From 89da98e35c0e1b7b281bf04fcb906adf12211ebb Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Thu, 5 Mar 2026 08:16:11 -0800 Subject: [PATCH 01/24] logs hooked up --- Netlify.toml | 14 + .../assets}/coopcloud_logo_grey.svg | 0 src/components/Authenticated.tsx | 2 +- src/components/Header/Header.tsx | 4 +- src/components/Terminal/Terminal.tsx | 86 +++++ src/components/Terminal/_Terminal.scss | 204 ++++++++++ src/context/AuthContext.tsx | 8 +- src/routes/Authenticated/Apps/App.scss | 348 +++++++----------- src/routes/Authenticated/Apps/App.tsx | 139 ++++--- src/routes/Authenticated/Servers/Server.scss | 117 +----- src/routes/Authenticated/Servers/Server.tsx | 93 +++-- src/services/api.ts | 107 +++++- src/services/mock-logs.json | 326 ++++++++++++++++ src/services/mockApi.ts | 132 ++++++- 14 files changed, 1148 insertions(+), 432 deletions(-) create mode 100644 Netlify.toml rename {public => src/assets}/coopcloud_logo_grey.svg (100%) create mode 100644 src/components/Terminal/Terminal.tsx create mode 100644 src/components/Terminal/_Terminal.scss create mode 100644 src/services/mock-logs.json diff --git a/Netlify.toml b/Netlify.toml new file mode 100644 index 0000000..8739f75 --- /dev/null +++ b/Netlify.toml @@ -0,0 +1,14 @@ +[build] + command = "npm run build" + publish = "dist" + +# Environment variables for build +[build.environment] + VITE_MOCK_AUTH = "true" + VITE_API_URL = "http://localhost:3000/api" + +# Redirect all requests to index.html for client-side routing +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/public/coopcloud_logo_grey.svg b/src/assets/coopcloud_logo_grey.svg similarity index 100% rename from public/coopcloud_logo_grey.svg rename to src/assets/coopcloud_logo_grey.svg diff --git a/src/components/Authenticated.tsx b/src/components/Authenticated.tsx index a66b1f1..7056f21 100644 --- a/src/components/Authenticated.tsx +++ b/src/components/Authenticated.tsx @@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext'; export const Authenticated: React.FC = () => { const { isAuthenticated, loading } = useAuth(); - console.log('πŸ›‘οΈ ProtectedRoute:', { isAuthenticated, loading }); + console.log('ProtectedRoute:', { isAuthenticated, loading }); if (loading) { return ( diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index ac9240c..107e2d1 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../hooks/useAuth'; import './_Header.scss'; +import logo from '../../assets/coopcloud_logo_grey.svg'; + interface HeaderProps { children: React.ReactNode; @@ -19,7 +21,7 @@ return(

navigate('/dashboard')} className="logo"> - +

-
+ {/* Terminal Component */} + setTerminalActive(false)} + /> +
{/* Left Column - Main Info */}
@@ -241,8 +276,8 @@ export const AppDetail: React.FC = () => { {version} @@ -254,14 +289,14 @@ export const AppDetail: React.FC = () => { {app.upgrade === 'latest' && (
- Running latest version + βœ“ Running latest version
)}
- {/* Right Column - Actions & Logs */} + {/* Right Column - Actions & Stats */}

Quick Actions

@@ -270,33 +305,37 @@ export const AppDetail: React.FC = () => { - - - - - -
@@ -323,4 +362,4 @@ export const AppDetail: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/src/routes/Authenticated/Servers/Server.scss b/src/routes/Authenticated/Servers/Server.scss index aba923f..9934b31 100644 --- a/src/routes/Authenticated/Servers/Server.scss +++ b/src/routes/Authenticated/Servers/Server.scss @@ -1,68 +1,13 @@ @use '../../../assets/scss/variables' as *; @use '../../../assets/scss/mixins' as *; +@use '../../../assets/scss/global' as *; .server-detail-page { - min-height: 100vh; - background-color: $bg-secondary; + @extend .page-wrapper; } .server-detail-content { - max-width: 1600px; - margin: 0 auto; - padding: $spacing-2xl $spacing-xl; - - @media (max-width: 768px) { - padding: $spacing-xl $spacing-md; - } - - .loading, - .error { - text-align: center; - padding: $spacing-3xl; - font-size: $font-size-lg; - } - - .loading { - color: $text-secondary; - } - - .error { - color: $error; - } -} - -// Breadcrumb navigation -.breadcrumb { - display: flex; - align-items: center; - gap: $spacing-sm; - margin-bottom: $spacing-xl; - font-size: $font-size-sm; - - .breadcrumb-link { - background: none; - border: none; - color: $primary; - cursor: pointer; - padding: 0; - font-size: $font-size-sm; - text-decoration: none; - transition: color $transition-base; - - &:hover { - color: $primary-light; - text-decoration: underline; - } - } - - .breadcrumb-separator { - color: $text-muted; - } - - .breadcrumb-current { - color: $text-primary; - font-weight: $font-weight-semibold; - } + @extend .page-content; } // Server header section @@ -100,7 +45,7 @@ } } -// Action buttons +// Action buttons (shared with App view, could be moved to global) .action-btn { padding: $spacing-sm $spacing-lg; border: 2px solid $border-color; @@ -159,7 +104,7 @@ @extend .secondary; } -// Badges +// Badges - host badge is specific to server view .host-badge { display: inline-flex; align-items: center; @@ -185,50 +130,6 @@ font-weight: $font-weight-medium; } -.recipe-badge { - display: inline-block; - padding: $spacing-xs $spacing-sm; - background: rgba($primary, 0.1); - color: $primary-light; - border-radius: $radius-sm; - font-size: $font-size-xs; - font-weight: $font-weight-medium; -} - -.status-badge { - display: inline-block; - padding: $spacing-xs $spacing-sm; - border-radius: $radius-sm; - font-size: $font-size-xs; - font-weight: $font-weight-medium; - - &.status-deployed, - &.status-running { - background: rgba($success, 0.1); - color: darken($success, 10%); - } - - &.status-stopped { - background: rgba($error, 0.1); - color: darken($error, 10%); - } - - &.status-unknown { - background: $bg-secondary; - color: $text-muted; - } -} - -.chaos-badge { - display: inline-block; - padding: $spacing-xs $spacing-sm; - background: rgba($info, 0.1); - color: darken($info, 10%); - border-radius: $radius-sm; - font-size: $font-size-xs; - font-weight: $font-weight-medium; -} - // Content grid layout .content-grid { display: grid; @@ -405,18 +306,12 @@ } &.danger { - color: $error; - &:hover:not(:disabled) { border-color: $error; background: rgba($error, 0.05); } } - .action-icon { - font-size: $font-size-lg; - } - .action-text { flex: 1; font-weight: $font-weight-medium; @@ -461,4 +356,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/routes/Authenticated/Servers/Server.tsx b/src/routes/Authenticated/Servers/Server.tsx index 0cab409..5f38523 100644 --- a/src/routes/Authenticated/Servers/Server.tsx +++ b/src/routes/Authenticated/Servers/Server.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Header } from '../../../components/Header/Header'; +import { Terminal } from '../../../components/Terminal/Terminal'; import { apiService } from '../../../services/api'; import type { AbraServer, ServerAppsResponse } from '../../../types'; +import type { LogEntry } from '../../../services/mockApi'; import './Server.scss'; interface ServerWithApps extends AbraServer { @@ -22,6 +24,10 @@ export const Server: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [actionLoading, setActionLoading] = useState(null); + + // Terminal state + const [terminalLogs, setTerminalLogs] = useState([]); + const [terminalActive, setTerminalActive] = useState(false); const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; @@ -33,7 +39,6 @@ export const Server: React.FC = () => { const appsData = await mockApiService.getAppsGrouped(); const serversData = await mockApiService.getServers(); - // Find the server const foundServer = serversData.find(s => s.name === serverName); const serverApps = appsData[serverName || '']; @@ -51,7 +56,6 @@ export const Server: React.FC = () => { setError('Server not found'); } } else { - // Real API call const appsData = await apiService.getAppsGrouped(); const serversData = await apiService.getServers(); @@ -86,11 +90,39 @@ export const Server: React.FC = () => { if (!server) return; setActionLoading(action); + setTerminalActive(true); + setTerminalLogs([]); + try { - console.log(`Action: ${action} on server ${server.name}`); - // Add actual server actions here + if (isMockMode) { + const { mockApiService } = await import('../../../services/mockApi'); + + const onLog = (log: LogEntry) => { + setTerminalLogs(prev => [...prev, log]); + }; + + switch (action) { + case 'refresh': + await mockApiService.refreshServer(server.name, onLog); + break; + case 'deploy-all': + await mockApiService.deployAllApps(server.name, server.appCount, onLog); + break; + case 'upgrade-all': + await mockApiService.upgradeAllApps(server.name, server.upgradeCount, onLog); + break; + } + } else { + // Real API calls + console.log(`Action: ${action} on server ${server.name}`); + } } catch (err) { console.error('Action failed:', err); + setTerminalLogs(prev => [...prev, { + type: 'error', + text: `❌ Error: ${err instanceof Error ? err.message : 'Action failed'}`, + timestamp: new Date() + }]); } finally { setActionLoading(null); } @@ -143,7 +175,7 @@ export const Server: React.FC = () => { {server.host} {server.upgradeCount > 0 && ( - {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''} + ⬆️ {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''} )} @@ -153,20 +185,27 @@ export const Server: React.FC = () => { + {/* Terminal Component */} + setTerminalActive(false)} + /> +
{/* Left Column - Main Info */}
@@ -241,10 +280,10 @@ export const Server: React.FC = () => { {app.status} {app.chaos === 'true' && ( - + πŸ”¬ )} {app.upgrade !== 'latest' && ( - + ⬆️ )}
@@ -278,41 +317,25 @@ export const Server: React.FC = () => { - - - - - - - - @@ -322,7 +345,7 @@ export const Server: React.FC = () => {
Status - Online + 🟒 Online
CPU Usage @@ -347,4 +370,4 @@ export const Server: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/services/api.ts b/src/services/api.ts index 2447568..0054dfc 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,12 +1,27 @@ -import type { AbraApp, AbraServer, AuthResponse, LoginCredentials, User } from '../types'; +import type { AbraServer, ServerAppsResponse, AuthResponse, LoginCredentials, User } from '../types'; + +// Log entry type - shared between mock and real API +export type LogEntry = { + type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output'; + text: string; + timestamp: Date; +}; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; +// Helper to process log JSON from API response +const processLogResponse = (logData: any[]): LogEntry[] => { + return logData.map(log => ({ + type: log.type, + text: log.text, + timestamp: new Date(log.timestamp || Date.now()), + })); +}; + class ApiService { private token: string | null = null; constructor() { - // Load token from localStorage on initialization this.token = localStorage.getItem('auth_token'); } @@ -66,23 +81,97 @@ class ApiService { return this.request('/abra/servers'); } - async deployApp(appName: string): Promise { - return this.request(`/abra/apps/${appName}/deploy`, { + // App actions with log streaming + async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/deploy`, { method: 'POST', }); + + // Process logs and stream to callback + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } } - async stopApp(appName: string): Promise { - return this.request(`/abra/apps/${appName}/stop`, { + async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/stop`, { method: 'POST', }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } } - async startApp(appName: string): Promise { - return this.request(`/abra/apps/${appName}/start`, { + async startApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/start`, { method: 'POST', }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/upgrade`, { + method: 'POST', + body: JSON.stringify({ version }), + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/remove`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + // Server actions with log streaming + async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/servers/${serverName}/refresh`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/servers/${serverName}/deploy-all`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/abra/servers/${serverName}/upgrade-all`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } } } -export const apiService = new ApiService(); \ No newline at end of file +export const apiService = new ApiService(); diff --git a/src/services/mock-logs.json b/src/services/mock-logs.json new file mode 100644 index 0000000..2c5b626 --- /dev/null +++ b/src/services/mock-logs.json @@ -0,0 +1,326 @@ +{ + "deploy": { + "logs": [ + { + "type": "info", + "text": "πŸ“¦ Deploying {appName}..." + }, + { + "type": "command", + "text": "$ abra app deploy {appName}" + }, + { + "type": "info", + "text": "Checking server connection..." + }, + { + "type": "success", + "text": "βœ“ Connected to server" + }, + { + "type": "info", + "text": "Pulling latest images..." + }, + { + "type": "output", + "text": "latest: Pulling from library/traefik" + }, + { + "type": "output", + "text": "a1b2c3d4: Pull complete" + }, + { + "type": "output", + "text": "e5f6g7h8: Pull complete" + }, + { + "type": "success", + "text": "βœ“ Images pulled successfully" + }, + { + "type": "info", + "text": "Creating containers..." + }, + { + "type": "output", + "text": "Creating {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Creating {appName}_db_1 ... done" + }, + { + "type": "info", + "text": "Starting services..." + }, + { + "type": "output", + "text": "Starting {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Starting {appName}_db_1 ... done" + }, + { + "type": "success", + "text": "βœ“ {appName} deployed successfully!" + } + ] + }, + "stop": { + "logs": [ + { + "type": "info", + "text": "πŸ›‘ Stopping {appName}..." + }, + { + "type": "command", + "text": "$ abra app stop {appName}" + }, + { + "type": "output", + "text": "Stopping {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Stopping {appName}_db_1 ... done" + }, + { + "type": "success", + "text": "βœ“ {appName} stopped" + } + ] + }, + "start": { + "logs": [ + { + "type": "info", + "text": "▢️ Starting {appName}..." + }, + { + "type": "command", + "text": "$ abra app start {appName}" + }, + { + "type": "output", + "text": "Starting {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Starting {appName}_db_1 ... done" + }, + { + "type": "success", + "text": "βœ“ {appName} started" + } + ] + }, + "upgrade": { + "logs": [ + { + "type": "info", + "text": "⬆️ Upgrading {appName} to {version}..." + }, + { + "type": "command", + "text": "$ abra app upgrade {appName}" + }, + { + "type": "info", + "text": "Pulling new version..." + }, + { + "type": "output", + "text": "{version}: Pulling from library/app" + }, + { + "type": "output", + "text": "x9y8z7w6: Pull complete" + }, + { + "type": "success", + "text": "βœ“ New version pulled" + }, + { + "type": "info", + "text": "Stopping old containers..." + }, + { + "type": "output", + "text": "Stopping {appName}_app_1 ... done" + }, + { + "type": "info", + "text": "Starting new containers..." + }, + { + "type": "output", + "text": "Starting {appName}_app_1 ... done" + }, + { + "type": "success", + "text": "βœ“ {appName} upgraded to {version}" + } + ] + }, + "remove": { + "logs": [ + { + "type": "warning", + "text": "⚠️ Removing {appName}..." + }, + { + "type": "command", + "text": "$ abra app remove {appName}" + }, + { + "type": "output", + "text": "Stopping {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Stopping {appName}_db_1 ... done" + }, + { + "type": "output", + "text": "Removing {appName}_app_1 ... done" + }, + { + "type": "output", + "text": "Removing {appName}_db_1 ... done" + }, + { + "type": "output", + "text": "Removing network {appName}_default" + }, + { + "type": "success", + "text": "βœ“ {appName} removed" + } + ] + }, + "serverRefresh": { + "logs": [ + { + "type": "info", + "text": "πŸ”„ Refreshing server {serverName}..." + }, + { + "type": "command", + "text": "$ abra server ls" + }, + { + "type": "info", + "text": "Fetching server status..." + }, + { + "type": "output", + "text": "Checking connection..." + }, + { + "type": "success", + "text": "βœ“ Connected" + }, + { + "type": "output", + "text": "Fetching app list..." + }, + { + "type": "success", + "text": "βœ“ Found apps on {serverName}" + }, + { + "type": "success", + "text": "βœ“ Server refreshed" + } + ] + }, + "deployAll": { + "logs": [ + { + "type": "info", + "text": "πŸ“¦ Deploying all apps on {serverName}..." + }, + { + "type": "command", + "text": "$ abra app deploy --all --server {serverName}" + }, + { + "type": "info", + "text": "Found {appCount} apps to deploy" + }, + { + "type": "output", + "text": "Deploying apps in parallel..." + }, + { + "type": "info", + "text": "[1/{appCount}] Deploying app_1..." + }, + { + "type": "success", + "text": "βœ“ app_1 deployed" + }, + { + "type": "info", + "text": "[2/{appCount}] Deploying app_2..." + }, + { + "type": "success", + "text": "βœ“ app_2 deployed" + }, + { + "type": "info", + "text": "[3/{appCount}] Deploying app_3..." + }, + { + "type": "success", + "text": "βœ“ app_3 deployed" + }, + { + "type": "success", + "text": "βœ“ All apps deployed on {serverName}" + } + ] + }, + "upgradeAll": { + "logs": [ + { + "type": "info", + "text": "⬆️ Upgrading all apps on {serverName}..." + }, + { + "type": "command", + "text": "$ abra app upgrade --all --server {serverName}" + }, + { + "type": "info", + "text": "Found {upgradeCount} apps with available upgrades" + }, + { + "type": "output", + "text": "Upgrading apps sequentially..." + }, + { + "type": "info", + "text": "[1/{upgradeCount}] Upgrading app_1..." + }, + { + "type": "success", + "text": "βœ“ app_1 upgraded" + }, + { + "type": "info", + "text": "[2/{upgradeCount}] Upgrading app_2..." + }, + { + "type": "success", + "text": "βœ“ app_2 upgraded" + }, + { + "type": "success", + "text": "βœ“ All apps upgraded on {serverName}" + } + ] + } +} \ No newline at end of file diff --git a/src/services/mockApi.ts b/src/services/mockApi.ts index 7e52479..ebe75f1 100644 --- a/src/services/mockApi.ts +++ b/src/services/mockApi.ts @@ -1,10 +1,67 @@ import type { AbraServer, ServerAppsResponse } from '../types'; import appsData from './mock-apps.json'; import serversData from './mock-servers.json'; +import logsData from './mock-logs.json'; + +// Log entry type +export type LogEntry = { + type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output'; + text: string; + timestamp: Date; +}; + +// Type for the imported JSON structure +type LogTemplate = { + type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output'; + text: string; +}; + +type LogsDataStructure = { + [key: string]: { + logs: LogTemplate[]; + }; +}; // Simulate API delay const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +// Helper to replace template variables like {appName}, {version} +const replaceVars = (text: string, vars: Record): string => { + let result = text; + Object.entries(vars).forEach(([key, value]) => { + result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)); + }); + return result; +}; + +// Helper to process log templates into LogEntry objects +const processLogs = ( + action: string, + vars: Record +): LogEntry[] => { + console.log('Processing logs for action:', action); + console.log('Loaded logsData:', logsData); + + const typedLogsData = logsData as LogsDataStructure; + const actionData = typedLogsData[action]; + + if (!actionData || !actionData.logs) { + console.error(`No logs found for action: ${action}`); + return []; + } + + console.log('Found logs:', actionData.logs); + + const templates = actionData.logs; + return templates + .filter(template => template && template.type && template.text) // Filter out any undefined/malformed entries + .map(template => ({ + type: template.type, + text: replaceVars(template.text, vars), + timestamp: new Date(), + })); +}; + export const mockApiService = { async getAppsGrouped(): Promise { await delay(500); @@ -16,18 +73,75 @@ export const mockApiService = { return serversData as AbraServer[]; }, - async deployApp(appName: string): Promise { - await delay(1000); - console.log(`Mock: Deploying app ${appName}`); + async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('deploy', { appName }); + + for (const log of logs) { + await delay(200); + onLog?.(log); + } }, - async stopApp(appName: string): Promise { - await delay(500); - console.log(`Mock: Stopping app ${appName}`); + async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('stop', { appName }); + + for (const log of logs) { + await delay(150); + onLog?.(log); + } }, - async startApp(appName: string): Promise { - await delay(500); - console.log(`Mock: Starting app ${appName}`); + async startApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('start', { appName }); + + for (const log of logs) { + await delay(150); + onLog?.(log); + } + }, + + async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('upgrade', { appName, version }); + + for (const log of logs) { + await delay(200); + onLog?.(log); + } + }, + + async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('remove', { appName }); + + for (const log of logs) { + await delay(180); + onLog?.(log); + } + }, + + async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('serverRefresh', { serverName }); + + for (const log of logs) { + await delay(200); + onLog?.(log); + } + }, + + async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('deployAll', { serverName, appCount }); + + for (const log of logs) { + await delay(250); + onLog?.(log); + } + }, + + async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise { + const logs = processLogs('upgradeAll', { serverName, upgradeCount }); + + for (const log of logs) { + await delay(250); + onLog?.(log); + } }, }; \ No newline at end of file -- 2.49.0 From a08f42b8af8e1ab9b78d83c51fd64fbdb0d79f2e Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Sun, 22 Mar 2026 14:15:59 -0700 Subject: [PATCH 02/24] api changes --- src/App.tsx | 2 +- src/components/Authenticated.tsx | 4 +- src/components/Header/Header.tsx | 2 +- src/context/AuthContext.tsx | 120 ++++++++++++++----------------- src/hooks/useAuth.ts | 12 ---- src/routes/Login/LoginForm.tsx | 2 +- src/services/api.ts | 85 +++++++--------------- 7 files changed, 86 insertions(+), 141 deletions(-) delete mode 100644 src/hooks/useAuth.ts diff --git a/src/App.tsx b/src/App.tsx index 30eaaee..483e2ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,4 +34,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/components/Authenticated.tsx b/src/components/Authenticated.tsx index 7056f21..4d877a3 100644 --- a/src/components/Authenticated.tsx +++ b/src/components/Authenticated.tsx @@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext'; export const Authenticated: React.FC = () => { const { isAuthenticated, loading } = useAuth(); - console.log('ProtectedRoute:', { isAuthenticated, loading }); + console.log('Authenticated:', { isAuthenticated, loading }); if (loading) { return ( @@ -21,4 +21,4 @@ export const Authenticated: React.FC = () => { } return isAuthenticated ? : ; -}; \ No newline at end of file +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 107e2d1..40f59cd 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../hooks/useAuth'; +import { useAuth } from '../../context/AuthContext'; import './_Header.scss'; import logo from '../../assets/coopcloud_logo_grey.svg'; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index c3f64df..de24c85 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,16 +1,24 @@ -import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { apiService } from '../services/api'; -import type { LoginCredentials, User } from '../types'; +import type { User } from '../types'; interface AuthContextType { user: User | null; - loading: boolean; - login: (credentials: LoginCredentials) => Promise; - logout: () => Promise; isAuthenticated: boolean; + loading: boolean; + login: (credentials: { username: string; password: string }) => Promise; + logout: () => void; } -export const AuthContext = createContext(undefined); +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; interface AuthProviderProps { children: ReactNode; @@ -18,72 +26,52 @@ interface AuthProviderProps { export const AuthProvider: React.FC = ({ children }) => { const [user, setUser] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(true); - const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; - + // Check for existing session on mount useEffect(() => { - const checkAuth = async () => { - console.log('checkAuth running, isMockMode:', isMockMode); + const storedUser = localStorage.getItem('user'); + + if (storedUser) { try { - if (isMockMode) { - console.log('Mock mode - setting mock user'); - setUser({ - id: 'mock-user-1', - username: 'developer', - email: 'dev@coopcloud.tech', - }); - setLoading(false); - console.log('Mock user set, loading set to false'); - return; - } - - const token = localStorage.getItem('auth_token'); - if (token) { - const currentUser = await apiService.getCurrentUser(); - setUser(currentUser); - } - } catch (error) { - console.error('Auth check failed:', error); - localStorage.removeItem('auth_token'); - } finally { - setLoading(false); + const parsedUser = JSON.parse(storedUser); + setUser(parsedUser); + setIsAuthenticated(true); + } catch (err) { + console.error('Failed to parse stored user:', err); + localStorage.removeItem('user'); } - }; - - checkAuth(); - }, [isMockMode]); - - const login = async (credentials: LoginCredentials) => { - try { - const response = await apiService.login(credentials); - setUser(response.user); - } catch (error) { - throw error; } - }; + + setLoading(false); + }, []); - const logout = async () => { - await apiService.logout(); - setUser(null); - }; - - const value: AuthContextType = { - user, - loading, - login, - logout, - isAuthenticated: !!user, - }; - console.log('AuthContext state:', { user, loading, isAuthenticated: !!user }); - - return {children}; +const login = async (credentials: { username: string; password: string }) => { + const response = await apiService.login(credentials); + setUser(response.user); + setIsAuthenticated(true); + localStorage.setItem('user', JSON.stringify(response.user)); }; -export function useAuth() { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -} \ No newline at end of file + const logout = () => { + apiService.logout(); + setUser(null); + setIsAuthenticated(false); + localStorage.removeItem('user'); + }; + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts deleted file mode 100644 index 4c697fa..0000000 --- a/src/hooks/useAuth.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from 'react'; -import { AuthContext } from '../context/AuthContext'; - -export const useAuth = () => { - const context = useContext(AuthContext); - - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - - return context; -}; \ No newline at end of file diff --git a/src/routes/Login/LoginForm.tsx b/src/routes/Login/LoginForm.tsx index d35e525..b9cdc3b 100644 --- a/src/routes/Login/LoginForm.tsx +++ b/src/routes/Login/LoginForm.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../hooks/useAuth'; +import { useAuth } from '../../context/AuthContext'; import './LoginForm.scss'; export const LoginForm: React.FC = () => { diff --git a/src/services/api.ts b/src/services/api.ts index 0054dfc..4ac47da 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,6 +1,6 @@ -import type { AbraServer, ServerAppsResponse, AuthResponse, LoginCredentials, User } from '../types'; +import type { AbraServer, ServerAppsResponse } from '../types'; -// Log entry type - shared between mock and real API +// Log entry type export type LogEntry = { type: 'info' | 'success' | 'error' | 'warning' | 'command' | 'output'; text: string; @@ -19,12 +19,6 @@ const processLogResponse = (logData: any[]): LogEntry[] => { }; class ApiService { - private token: string | null = null; - - constructor() { - this.token = localStorage.getItem('auth_token'); - } - private async request( endpoint: string, options: RequestInit = {} @@ -34,10 +28,6 @@ class ApiService { ...options.headers, }; - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } - const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, @@ -51,51 +41,19 @@ class ApiService { return response.json(); } - // Auth methods - async login(credentials: LoginCredentials): Promise { - const response = await this.request('/auth/login', { - method: 'POST', - body: JSON.stringify(credentials), - }); - - this.token = response.token; - localStorage.setItem('auth_token', response.token); - return response; - } - - async logout(): Promise { - this.token = null; - localStorage.removeItem('auth_token'); - } - - async getCurrentUser(): Promise { - return this.request('/auth/me'); - } - - // Abra CLI wrapper methods + // Get all apps grouped by server async getAppsGrouped(): Promise { - return this.request('/abra/apps'); + return this.request('/apps'); } + // Get all servers async getServers(): Promise { - return this.request('/abra/servers'); + return this.request('/servers'); } - // App actions with log streaming + // App actions with log streaming (websocket future endpoint) async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/deploy`, { - method: 'POST', - }); - - // Process logs and stream to callback - if (onLog && response.logs) { - const logs = processLogResponse(response.logs); - logs.forEach(log => onLog(log)); - } - } - - async stopApp(appName: string, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/stop`, { + const response = await this.request<{ logs: any[] }>(`/apps/${appName}/deploy`, { method: 'POST', }); @@ -105,8 +63,19 @@ class ApiService { } } - async startApp(appName: string, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/start`, { + async undeployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/apps/${appName}/undeploy`, { + method: 'POST', + }); + + if (onLog && response.logs) { + const logs = processLogResponse(response.logs); + logs.forEach(log => onLog(log)); + } + } + + async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { + const response = await this.request<{ logs: any[] }>(`/apps/${appName}/deploy`, { method: 'POST', }); @@ -117,7 +86,7 @@ class ApiService { } async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/upgrade`, { + const response = await this.request<{ logs: any[] }>(`/apps/${appName}/upgrade`, { method: 'POST', body: JSON.stringify({ version }), }); @@ -129,7 +98,7 @@ class ApiService { } async removeApp(appName: string, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/apps/${appName}/remove`, { + const response = await this.request<{ logs: any[] }>(`/apps/${appName}/remove`, { method: 'POST', }); @@ -141,7 +110,7 @@ class ApiService { // Server actions with log streaming async refreshServer(serverName: string, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/servers/${serverName}/refresh`, { + const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/refresh`, { method: 'POST', }); @@ -152,7 +121,7 @@ class ApiService { } async deployAllApps(serverName: string, appCount: number, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/servers/${serverName}/deploy-all`, { + const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/deploy-all`, { method: 'POST', }); @@ -163,7 +132,7 @@ class ApiService { } async upgradeAllApps(serverName: string, upgradeCount: number, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/abra/servers/${serverName}/upgrade-all`, { + const response = await this.request<{ logs: any[] }>(`/servers/${serverName}/upgrade-all`, { method: 'POST', }); @@ -174,4 +143,4 @@ class ApiService { } } -export const apiService = new ApiService(); +export const apiService = new ApiService(); \ No newline at end of file -- 2.49.0 From 9cde325a022f55a76c12a06c5ef62f0c78523904 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Tue, 24 Mar 2026 21:16:06 -0700 Subject: [PATCH 03/24] remove duplicate function --- src/services/api.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 4ac47da..9afed3b 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -51,7 +51,7 @@ class ApiService { return this.request('/servers'); } - // App actions with log streaming (websocket future endpoint) + // App actions with log streaming (websocket future) async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { const response = await this.request<{ logs: any[] }>(`/apps/${appName}/deploy`, { method: 'POST', @@ -74,17 +74,6 @@ class ApiService { } } - async deployApp(appName: string, onLog?: (log: LogEntry) => void): Promise { - const response = await this.request<{ logs: any[] }>(`/apps/${appName}/deploy`, { - method: 'POST', - }); - - if (onLog && response.logs) { - const logs = processLogResponse(response.logs); - logs.forEach(log => onLog(log)); - } - } - async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise { const response = await this.request<{ logs: any[] }>(`/apps/${appName}/upgrade`, { method: 'POST', -- 2.49.0 From 9be82e9e95a67045c71ff769c2fcf49938bcc7ec Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Tue, 24 Mar 2026 21:22:39 -0700 Subject: [PATCH 04/24] remove user / auth --- .env.example | 2 +- Netlify.toml | 14 --- src/App.tsx | 35 +++---- src/components/Authenticated.tsx | 24 ----- src/components/Header/Header.tsx | 14 --- src/components/Header/_Header.scss | 46 --------- src/context/AuthContext.tsx | 77 --------------- src/routes/{Authenticated => }/Apps/App.scss | 0 src/routes/{Authenticated => }/Apps/App.tsx | 14 +-- src/routes/{Authenticated => }/Apps/Apps.scss | 0 src/routes/{Authenticated => }/Apps/Apps.tsx | 8 +- .../Dashboard/Dashboard.tsx | 8 +- .../Dashboard/_Dashboard.scss | 0 src/routes/Login/LoginForm.scss | 94 ------------------- src/routes/Login/LoginForm.tsx | 72 -------------- .../{Authenticated => }/Servers/Server.scss | 0 .../{Authenticated => }/Servers/Server.tsx | 14 +-- .../{Authenticated => }/Servers/Servers.scss | 0 .../{Authenticated => }/Servers/Servers.tsx | 8 +- src/types/index.ts | 16 ---- 20 files changed, 39 insertions(+), 407 deletions(-) delete mode 100644 Netlify.toml delete mode 100644 src/components/Authenticated.tsx delete mode 100644 src/context/AuthContext.tsx rename src/routes/{Authenticated => }/Apps/App.scss (100%) rename src/routes/{Authenticated => }/Apps/App.tsx (96%) rename src/routes/{Authenticated => }/Apps/Apps.scss (100%) rename src/routes/{Authenticated => }/Apps/Apps.tsx (97%) rename src/routes/{Authenticated => }/Dashboard/Dashboard.tsx (94%) rename src/routes/{Authenticated => }/Dashboard/_Dashboard.scss (100%) delete mode 100644 src/routes/Login/LoginForm.scss delete mode 100644 src/routes/Login/LoginForm.tsx rename src/routes/{Authenticated => }/Servers/Server.scss (100%) rename src/routes/{Authenticated => }/Servers/Server.tsx (96%) rename src/routes/{Authenticated => }/Servers/Servers.scss (100%) rename src/routes/{Authenticated => }/Servers/Servers.tsx (97%) diff --git a/.env.example b/.env.example index b87d9f0..f21daf3 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # API base URL for the backend that wraps the abra CLI VITE_API_URL=http://localhost:3000/api -# Set to 'true' to bypass authentication for development +# Set to 'true' to use mock data VITE_MOCK_AUTH=true \ No newline at end of file diff --git a/Netlify.toml b/Netlify.toml deleted file mode 100644 index 8739f75..0000000 --- a/Netlify.toml +++ /dev/null @@ -1,14 +0,0 @@ -[build] - command = "npm run build" - publish = "dist" - -# Environment variables for build -[build.environment] - VITE_MOCK_AUTH = "true" - VITE_API_URL = "http://localhost:3000/api" - -# Redirect all requests to index.html for client-side routing -[[redirects]] - from = "/*" - to = "/index.html" - status = 200 diff --git a/src/App.tsx b/src/App.tsx index 483e2ea..d424307 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,24 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { AuthProvider } from './context/AuthContext'; -import { LoginForm } from './routes/Login/LoginForm'; -import { Authenticated } from './components/Authenticated'; -import { Dashboard } from './routes/Authenticated/Dashboard/Dashboard'; -import { Apps } from './routes/Authenticated/Apps/Apps'; -import { AppDetail } from './routes/Authenticated/Apps/App'; -import { Servers } from './routes/Authenticated/Servers/Servers'; -import { Server } from './routes/Authenticated/Servers/Server'; +import { Dashboard } from './routes/Dashboard/Dashboard'; +import { Apps } from './routes/Apps/Apps'; +import { AppDetail } from './routes/Apps/App'; +import { Servers } from './routes/Servers/Servers'; +import { Server } from './routes/Servers/Server'; function App() { return ( - - - {/* Public routes */} - } /> - - {/* Protected routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - + + } /> + } /> + } /> + } /> + } /> + } /> {/* 404 catch-all */} } /> - ); } diff --git a/src/components/Authenticated.tsx b/src/components/Authenticated.tsx deleted file mode 100644 index 4d877a3..0000000 --- a/src/components/Authenticated.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { Navigate, Outlet } from 'react-router-dom'; -import { useAuth } from '../context/AuthContext'; - -export const Authenticated: React.FC = () => { - const { isAuthenticated, loading } = useAuth(); - - console.log('Authenticated:', { isAuthenticated, loading }); - - if (loading) { - return ( -
-

Loading...

-
- ); - } - - return isAuthenticated ? : ; -}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 40f59cd..3a8fc9b 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../context/AuthContext'; import './_Header.scss'; import logo from '../../assets/coopcloud_logo_grey.svg'; @@ -10,13 +9,7 @@ interface HeaderProps { } export const Header: React.FC = ({ children }) => { - const { user, logout } = useAuth(); const navigate = useNavigate(); - - const handleLogout = async () => { - await logout(); - navigate('/login'); - }; return(
@@ -34,13 +27,6 @@ return( Servers - -
- {user?.username} - -
)} \ No newline at end of file diff --git a/src/components/Header/_Header.scss b/src/components/Header/_Header.scss index 9b8f02b..3219edb 100644 --- a/src/components/Header/_Header.scss +++ b/src/components/Header/_Header.scss @@ -78,50 +78,4 @@ font-size: $font-size-xl; } } - - .user-menu { - display: flex; - align-items: center; - gap: $spacing-lg; - - @media (max-width: 768px) { - width: 100%; - justify-content: space-between; - } - - .username { - font-weight: $font-weight-medium; - font-size: $font-size-base; - - @media (max-width: 480px) { - font-size: $font-size-sm; - } - } - - .logout-button { - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.4); - color: $text-primary; - padding: $spacing-sm $spacing-lg; - border-radius: $radius-md; - cursor: pointer; - transition: all $transition-base; - font-weight: $font-weight-medium; - - &:hover { - background: rgba(255, 255, 255, 0.3); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - } - - &:active { - transform: translateY(0); - } - - @media (max-width: 480px) { - padding: $spacing-sm $spacing-md; - font-size: $font-size-sm; - } - } - } } \ No newline at end of file diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx deleted file mode 100644 index de24c85..0000000 --- a/src/context/AuthContext.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { apiService } from '../services/api'; -import type { User } from '../types'; - -interface AuthContextType { - user: User | null; - isAuthenticated: boolean; - loading: boolean; - login: (credentials: { username: string; password: string }) => Promise; - logout: () => void; -} - -const AuthContext = createContext(undefined); - -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -}; - -interface AuthProviderProps { - children: ReactNode; -} - -export const AuthProvider: React.FC = ({ children }) => { - const [user, setUser] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [loading, setLoading] = useState(true); - - // Check for existing session on mount - useEffect(() => { - const storedUser = localStorage.getItem('user'); - - if (storedUser) { - try { - const parsedUser = JSON.parse(storedUser); - setUser(parsedUser); - setIsAuthenticated(true); - } catch (err) { - console.error('Failed to parse stored user:', err); - localStorage.removeItem('user'); - } - } - - setLoading(false); - }, []); - -const login = async (credentials: { username: string; password: string }) => { - const response = await apiService.login(credentials); - setUser(response.user); - setIsAuthenticated(true); - localStorage.setItem('user', JSON.stringify(response.user)); -}; - - const logout = () => { - apiService.logout(); - setUser(null); - setIsAuthenticated(false); - localStorage.removeItem('user'); - }; - - return ( - - {children} - - ); -}; diff --git a/src/routes/Authenticated/Apps/App.scss b/src/routes/Apps/App.scss similarity index 100% rename from src/routes/Authenticated/Apps/App.scss rename to src/routes/Apps/App.scss diff --git a/src/routes/Authenticated/Apps/App.tsx b/src/routes/Apps/App.tsx similarity index 96% rename from src/routes/Authenticated/Apps/App.tsx rename to src/routes/Apps/App.tsx index 4467a7a..636e15a 100644 --- a/src/routes/Authenticated/Apps/App.tsx +++ b/src/routes/Apps/App.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Header } from '../../../components/Header/Header'; -import { Terminal } from '../../../components/Terminal/Terminal'; -import { apiService } from '../../../services/api'; -import type { AbraApp } from '../../../types'; -import type { LogEntry } from '../../../services/mockApi'; +import { Header } from '../../components/Header/Header'; +import { Terminal } from '../../components/Terminal/Terminal'; +import { apiService } from '../../services/api'; +import type { AbraApp } from '../../types'; +import type { LogEntry } from '../../services/mockApi'; import './App.scss'; export const AppDetail: React.FC = () => { @@ -26,7 +26,7 @@ export const AppDetail: React.FC = () => { const fetchApp = async () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const appsData = await mockApiService.getAppsGrouped(); const serverApps = appsData[server || '']; @@ -67,7 +67,7 @@ export const AppDetail: React.FC = () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const onLog = (log: LogEntry) => { setTerminalLogs(prev => [...prev, log]); diff --git a/src/routes/Authenticated/Apps/Apps.scss b/src/routes/Apps/Apps.scss similarity index 100% rename from src/routes/Authenticated/Apps/Apps.scss rename to src/routes/Apps/Apps.scss diff --git a/src/routes/Authenticated/Apps/Apps.tsx b/src/routes/Apps/Apps.tsx similarity index 97% rename from src/routes/Authenticated/Apps/Apps.tsx rename to src/routes/Apps/Apps.tsx index f0aadb0..1901f5a 100644 --- a/src/routes/Authenticated/Apps/Apps.tsx +++ b/src/routes/Apps/Apps.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Header } from '../../../components/Header/Header'; -import { apiService } from '../../../services/api'; -import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../../types'; +import { Header } from '../../components/Header/Header'; +import { apiService } from '../../services/api'; +import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../types'; import './Apps.scss'; export const Apps: React.FC = () => { @@ -21,7 +21,7 @@ export const Apps: React.FC = () => { const fetchData = async () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const data = await mockApiService.getAppsGrouped(); setAppsData(data); } else { diff --git a/src/routes/Authenticated/Dashboard/Dashboard.tsx b/src/routes/Dashboard/Dashboard.tsx similarity index 94% rename from src/routes/Authenticated/Dashboard/Dashboard.tsx rename to src/routes/Dashboard/Dashboard.tsx index 3788542..c8bd091 100644 --- a/src/routes/Authenticated/Dashboard/Dashboard.tsx +++ b/src/routes/Dashboard/Dashboard.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react'; -import { Header } from '../../../components/Header/Header'; +import { Header } from '../../components/Header/Header'; import { useNavigate } from 'react-router-dom'; -import { apiService } from '../../../services/api'; -import type { AbraApp, AbraServer } from '../../../types'; +import { apiService } from '../../services/api'; +import type { AbraApp, AbraServer } from '../../types'; import './_Dashboard.scss'; export const Dashboard: React.FC = () => { @@ -19,7 +19,7 @@ export const Dashboard: React.FC = () => { try { if (isMockMode) { // Use mock API in development - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const [appsData, serversData] = await Promise.all([ mockApiService.getAppsGrouped(), mockApiService.getServers(), diff --git a/src/routes/Authenticated/Dashboard/_Dashboard.scss b/src/routes/Dashboard/_Dashboard.scss similarity index 100% rename from src/routes/Authenticated/Dashboard/_Dashboard.scss rename to src/routes/Dashboard/_Dashboard.scss diff --git a/src/routes/Login/LoginForm.scss b/src/routes/Login/LoginForm.scss deleted file mode 100644 index 90e259b..0000000 --- a/src/routes/Login/LoginForm.scss +++ /dev/null @@ -1,94 +0,0 @@ -@use '../../assets/scss/variables' as *; -@use '../../assets/scss/mixins' as *; - -.login-container { - @include flex-center; - min-height: 100vh; - width: 100%; - // @include gradient-primary; - background-color: $primary-light; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; -} - -.login-card { - @include card; - width: 100%; - max-width: 400px; - - h1 { - margin: 0 0 $spacing-sm; - font-size: $font-size-3xl; - color: $text-primary; - text-align: center; - } - - .login-subtitle { - text-align: center; - color: $text-secondary; - margin: 0 0 $spacing-2xl; - font-size: $font-size-sm; - } -} - -.form-group { - margin-bottom: $spacing-lg; - - label { - display: block; - margin-bottom: $spacing-sm; - color: $text-primary; - font-weight: $font-weight-medium; - font-size: $font-size-sm; - } - - input { - width: 100%; - padding: $spacing-md; - border: 2px solid $border-color; - border-radius: $radius-md; - font-size: $font-size-base; - transition: border-color $transition-base; - box-sizing: border-box; - - &:focus { - outline: none; - border-color: $primary; - } - - &:disabled { - background-color: $bg-secondary; - cursor: not-allowed; - } - } -} - -.error-message { - // background-color: rgba($error, 0.1); - // color: darken($error, 10%); - padding: $spacing-md; - border-radius: $radius-md; - margin-bottom: $spacing-md; - font-size: $font-size-sm; - // border-left: 4px solid $error; -} - -.login-button { - @include button-base; - @include gradient-primary; - width: 100%; - color: white; - font-size: $font-size-base; - - &:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 8px 16px rgba($primary, 0.4); - } - - &:active:not(:disabled) { - transform: translateY(0); - } -} \ No newline at end of file diff --git a/src/routes/Login/LoginForm.tsx b/src/routes/Login/LoginForm.tsx deleted file mode 100644 index b9cdc3b..0000000 --- a/src/routes/Login/LoginForm.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../context/AuthContext'; -import './LoginForm.scss'; - -export const LoginForm: React.FC = () => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const { login } = useAuth(); - const navigate = useNavigate(); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - await login({ username, password }); - navigate('/dashboard'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed'); - } finally { - setLoading(false); - } - }; - - return ( -
-
-

Coop Cloud

-

Sign in to manage your applications

- -
- {error &&
{error}
} - -
- - setUsername(e.target.value)} - required - autoComplete="username" - disabled={loading} - /> -
- -
- - setPassword(e.target.value)} - required - autoComplete="current-password" - disabled={loading} - /> -
- - -
-
-
- ); -}; \ No newline at end of file diff --git a/src/routes/Authenticated/Servers/Server.scss b/src/routes/Servers/Server.scss similarity index 100% rename from src/routes/Authenticated/Servers/Server.scss rename to src/routes/Servers/Server.scss diff --git a/src/routes/Authenticated/Servers/Server.tsx b/src/routes/Servers/Server.tsx similarity index 96% rename from src/routes/Authenticated/Servers/Server.tsx rename to src/routes/Servers/Server.tsx index 5f38523..7c85bd4 100644 --- a/src/routes/Authenticated/Servers/Server.tsx +++ b/src/routes/Servers/Server.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Header } from '../../../components/Header/Header'; -import { Terminal } from '../../../components/Terminal/Terminal'; -import { apiService } from '../../../services/api'; -import type { AbraServer, ServerAppsResponse } from '../../../types'; -import type { LogEntry } from '../../../services/mockApi'; +import { Header } from '../../components/Header/Header'; +import { Terminal } from '../../components/Terminal/Terminal'; +import { apiService } from '../../services/api'; +import type { AbraServer, ServerAppsResponse } from '../../types'; +import type { LogEntry } from '../../services/mockApi'; import './Server.scss'; interface ServerWithApps extends AbraServer { @@ -35,7 +35,7 @@ export const Server: React.FC = () => { const fetchServer = async () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const appsData = await mockApiService.getAppsGrouped(); const serversData = await mockApiService.getServers(); @@ -95,7 +95,7 @@ export const Server: React.FC = () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const onLog = (log: LogEntry) => { setTerminalLogs(prev => [...prev, log]); diff --git a/src/routes/Authenticated/Servers/Servers.scss b/src/routes/Servers/Servers.scss similarity index 100% rename from src/routes/Authenticated/Servers/Servers.scss rename to src/routes/Servers/Servers.scss diff --git a/src/routes/Authenticated/Servers/Servers.tsx b/src/routes/Servers/Servers.tsx similarity index 97% rename from src/routes/Authenticated/Servers/Servers.tsx rename to src/routes/Servers/Servers.tsx index 95c369a..714ad89 100644 --- a/src/routes/Authenticated/Servers/Servers.tsx +++ b/src/routes/Servers/Servers.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Header } from '../../../components/Header/Header'; -import { apiService } from '../../../services/api'; -import type { AbraServer, ServerAppsResponse } from '../../../types'; +import { Header } from '../../components/Header/Header'; +import { apiService } from '../../services/api'; +import type { AbraServer, ServerAppsResponse } from '../../types'; import './Servers.scss'; interface ServerWithStats extends AbraServer { @@ -27,7 +27,7 @@ export const Servers: React.FC = () => { const fetchData = async () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const [serversData, appsData] = await Promise.all([ mockApiService.getServers(), mockApiService.getAppsGrouped(), diff --git a/src/types/index.ts b/src/types/index.ts index a3ab2ac..b4e1d1d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,19 +1,3 @@ -export interface User { - id: string; - username: string; - email: string; -} - -export interface AuthResponse { - user: User; - token: string; -} - -export interface LoginCredentials { - username: string; - password: string; -} - export interface ApiError { message: string; status: number; -- 2.49.0 From a588a16b96fddc18ce2c8f8796b6b71068d52189 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Tue, 24 Mar 2026 21:25:19 -0700 Subject: [PATCH 05/24] fix scss imports --- src/routes/Apps/App.scss | 6 +++--- src/routes/Apps/Apps.scss | 6 +++--- src/routes/Dashboard/_Dashboard.scss | 6 +++--- src/routes/Servers/Server.scss | 6 +++--- src/routes/Servers/Servers.scss | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/routes/Apps/App.scss b/src/routes/Apps/App.scss index a7ac02c..c0b528b 100644 --- a/src/routes/Apps/App.scss +++ b/src/routes/Apps/App.scss @@ -1,6 +1,6 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; -@use '../../../assets/scss/global' as *; +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; .app-detail-page { @extend .page-wrapper; diff --git a/src/routes/Apps/Apps.scss b/src/routes/Apps/Apps.scss index b1fdd1c..a301935 100644 --- a/src/routes/Apps/Apps.scss +++ b/src/routes/Apps/Apps.scss @@ -1,6 +1,6 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; -@use '../../../assets/scss/global' as *; +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; // Extend global page wrapper .apps-page { diff --git a/src/routes/Dashboard/_Dashboard.scss b/src/routes/Dashboard/_Dashboard.scss index 8032066..77e9a4e 100644 --- a/src/routes/Dashboard/_Dashboard.scss +++ b/src/routes/Dashboard/_Dashboard.scss @@ -1,6 +1,6 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; -@use '../../../assets/scss/global' as *; +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; // Extend global page wrapper .dashboard-page { diff --git a/src/routes/Servers/Server.scss b/src/routes/Servers/Server.scss index 9934b31..c379ff6 100644 --- a/src/routes/Servers/Server.scss +++ b/src/routes/Servers/Server.scss @@ -1,6 +1,6 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; -@use '../../../assets/scss/global' as *; +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; .server-detail-page { @extend .page-wrapper; diff --git a/src/routes/Servers/Servers.scss b/src/routes/Servers/Servers.scss index d7224ae..ab719ab 100644 --- a/src/routes/Servers/Servers.scss +++ b/src/routes/Servers/Servers.scss @@ -1,6 +1,6 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; -@use '../../../assets/scss/global' as *; +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; // Extend global page wrapper .servers-page { -- 2.49.0 From b1863f8dcf446ebbb4cc720d38fff480c68a527d Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Tue, 24 Mar 2026 21:27:12 -0700 Subject: [PATCH 06/24] remove duplicate start --- src/routes/Apps/App.tsx | 10 ++-------- src/services/mockApi.ts | 9 --------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/routes/Apps/App.tsx b/src/routes/Apps/App.tsx index 636e15a..5b4af58 100644 --- a/src/routes/Apps/App.tsx +++ b/src/routes/Apps/App.tsx @@ -74,15 +74,12 @@ export const AppDetail: React.FC = () => { }; switch (action) { - case 'start': - await mockApiService.startApp(app.appName, onLog); + case 'deploy': + await mockApiService.deployApp(app.appName, onLog); break; case 'stop': await mockApiService.stopApp(app.appName, onLog); break; - case 'deploy': - await mockApiService.deployApp(app.appName, onLog); - break; case 'upgrade': if (version) { await mockApiService.upgradeApp(app.appName, version, onLog); @@ -95,9 +92,6 @@ export const AppDetail: React.FC = () => { } else { // Real API calls switch (action) { - case 'start': - await apiService.startApp(app.appName); - break; case 'stop': await apiService.stopApp(app.appName); break; diff --git a/src/services/mockApi.ts b/src/services/mockApi.ts index ebe75f1..709fe1d 100644 --- a/src/services/mockApi.ts +++ b/src/services/mockApi.ts @@ -91,15 +91,6 @@ export const mockApiService = { } }, - async startApp(appName: string, onLog?: (log: LogEntry) => void): Promise { - const logs = processLogs('start', { appName }); - - for (const log of logs) { - await delay(150); - onLog?.(log); - } - }, - async upgradeApp(appName: string, version: string, onLog?: (log: LogEntry) => void): Promise { const logs = processLogs('upgrade', { appName, version }); -- 2.49.0 From 10acf1f0f2d9ac27fb7d8f1082d2b081cc205151 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Tue, 24 Mar 2026 21:30:59 -0700 Subject: [PATCH 07/24] style pass --- src/routes/Apps/App.tsx | 17 +++++------------ src/routes/Servers/Server.tsx | 6 +++--- src/routes/Servers/Servers.tsx | 8 ++------ 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/routes/Apps/App.tsx b/src/routes/Apps/App.tsx index 5b4af58..efc8d76 100644 --- a/src/routes/Apps/App.tsx +++ b/src/routes/Apps/App.tsx @@ -296,12 +296,13 @@ export const AppDetail: React.FC = () => {

Quick Actions

+ - -
diff --git a/src/routes/Servers/Server.tsx b/src/routes/Servers/Server.tsx index 7c85bd4..e2970ac 100644 --- a/src/routes/Servers/Server.tsx +++ b/src/routes/Servers/Server.tsx @@ -319,7 +319,7 @@ export const Server: React.FC = () => { onClick={() => handleAction('refresh')} disabled={!!actionLoading} > - πŸ”„ Refresh Server Info + Refresh Server Info
diff --git a/src/routes/Servers/Servers.tsx b/src/routes/Servers/Servers.tsx index 714ad89..5c0170a 100644 --- a/src/routes/Servers/Servers.tsx +++ b/src/routes/Servers/Servers.tsx @@ -232,17 +232,13 @@ export const Servers: React.FC = () => { {server.upgradeCount > 0 && (
- - ⬆️ Need Upgrade - + Need Upgrade {server.upgradeCount}
)} {server.chaosCount > 0 && (
- - ☠️ Chaos Mode - + Chaos Mode {server.chaosCount}
)} -- 2.49.0 From a9a3b0c4e6b85e33b46fa5332e7df0e4318908b7 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Tue, 24 Mar 2026 21:36:22 -0700 Subject: [PATCH 08/24] add local websocket url --- .env | 1 + 1 file changed, 1 insertion(+) diff --git a/.env b/.env index ff9c79c..a53bee2 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ VITE_API_URL=http://localhost:3000/api +VITE_WS_URL=ws://localhost:3000 VITE_MOCK_AUTH=true \ No newline at end of file -- 2.49.0 From 21825ee009a6dd4b09865ff116fdb4a112e1a4b0 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 17 Apr 2026 12:34:40 -0700 Subject: [PATCH 09/24] add recipe from jjsfunhouse --- src/App.tsx | 2 + src/routes/Recipes/Recipe.scss | 0 src/routes/Recipes/Recipe.tsx | 0 src/routes/Recipes/RecipeForm.scss | 0 src/routes/Recipes/RecipeForm.tsx | 112 ++ src/routes/Recipes/Recipes.scss | 177 +++ src/routes/Recipes/Recipes.tsx | 192 ++++ src/services/mock-catalogue.json | 1726 ++++++++++++++++++++++++++++ 8 files changed, 2209 insertions(+) create mode 100644 src/routes/Recipes/Recipe.scss create mode 100644 src/routes/Recipes/Recipe.tsx create mode 100644 src/routes/Recipes/RecipeForm.scss create mode 100644 src/routes/Recipes/RecipeForm.tsx create mode 100644 src/routes/Recipes/Recipes.scss create mode 100644 src/routes/Recipes/Recipes.tsx create mode 100644 src/services/mock-catalogue.json diff --git a/src/App.tsx b/src/App.tsx index d424307..bfa3113 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { Apps } from './routes/Apps/Apps'; import { AppDetail } from './routes/Apps/App'; import { Servers } from './routes/Servers/Servers'; import { Server } from './routes/Servers/Server'; +import { Recipes } from './routes/Authenticated/Recipes/Recipes'; function App() { return ( @@ -15,6 +16,7 @@ function App() { } /> } /> } /> + } /> {/* 404 catch-all */} } /> diff --git a/src/routes/Recipes/Recipe.scss b/src/routes/Recipes/Recipe.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/Recipes/Recipe.tsx b/src/routes/Recipes/Recipe.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/Recipes/RecipeForm.scss b/src/routes/Recipes/RecipeForm.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/Recipes/RecipeForm.tsx b/src/routes/Recipes/RecipeForm.tsx new file mode 100644 index 0000000..4c29f00 --- /dev/null +++ b/src/routes/Recipes/RecipeForm.tsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from "react"; +import { apiService } from '../../../services/api'; +import type { AbraServer } from '../../../types'; + + + +function RecipeForm({ recipe, onClose }) { + const [loading, setLoading] = useState(true); + const [servers, setServers] = useState([]); + const [selectedServer, setSelectedServer] = useState(""); // ❌ only one value + const [chaos, setChaos] = useState(false); + const [secrets, setSecrets] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + const fetchData = async () => { + try { + const [serversData] = await Promise.all([ + apiService.getServers(), + ]); + + setServers(serversData); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load servers'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }); + const [formData, setFormData] = useState({ + domain: "", + chaos: false, + secrets: true, + }); + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + console.log("Submitting:", formData); + onClose(); + }; + + return ( +
+

{recipe.name}

+ { loading ? (

Loading servers...

+ ) : ( +
+ +
+ )} +
+ +
+
+ +
+ +
+ +
+ + + +
+ ); +} + +export default RecipeForm; \ No newline at end of file diff --git a/src/routes/Recipes/Recipes.scss b/src/routes/Recipes/Recipes.scss new file mode 100644 index 0000000..b2082bb --- /dev/null +++ b/src/routes/Recipes/Recipes.scss @@ -0,0 +1,177 @@ +@use '../../../assets/scss/variables' as *; +@use '../../../assets/scss/mixins' as *; +@use '../../../assets/scss/global' as *; + +// Extend global page wrapper +.recipes-page { + @extend .page-wrapper; +} + +.recipes-content { + @extend .page-content; +} + +// Servers grid +.recipes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: $spacing-xl; + margin-bottom: $spacing-xl; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +// Server card +.recipe-card { + @include card; + display: grid; + grid-template-rows: 1fr 2fr auto 0.9fr; + transition: transform $transition-base, box-shadow $transition-base; + position: relative; + overflow: hidden; + + &:hover { + transform: translateY(-4px); + box-shadow: $shadow-xl; + } + + .recipe-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: $spacing-lg; + padding-bottom: $spacing-md; + border-bottom: 2px solid $bg-secondary; + + .recipe-title { + h3 { + margin: 0 0 $spacing-xs; + font-size: $font-size-xl; + color: $text-primary; + font-weight: $font-weight-bold; + } + + .recipe-host { + font-size: $font-size-sm; + color: $text-muted; + font-family: monospace; + } + } + img { + width: clamp(20px, 3vw, 32px); + height: clamp(20px, 3vw, 32px); + object-fit: contain; + } + } + .card-tags { + display: flex; + gap: 6px; + margin-top: $spacing-sm; + height: $spacing-md; + flex-wrap: wrap; + } + .tag-category { + font-size: 12px; + padding: 2px 8px; + border-radius: 5px; + color: rgb(255, 255, 255); + font-weight: 700; + background-color: rgb(111, 128, 255); + + } + .tag-feature { + font-size: 12px; + padding: 2px 8px; + border-radius: 5px; + color: rgb(255, 255, 255); + font-weight: 700; + background-color: rgb(22, 180, 22); + + } + .recipe-stats { + flex-grow: 1; + flex: 1; + margin-bottom: $spacing-lg; + + .stat-row { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + + } + } + + .recipe-actions { + display: flex; + gap: $spacing-sm; + .action-btn { + flex: 1; + padding: $spacing-sm $spacing-md; + border: 2px solid $border-color; + background: none; + color: $text-primary; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + cursor: pointer; + transition: all $transition-base; + + &:hover { + background-color: rgba($primary, 0.1); + border-color: $primary; + transform: translateY(-1px); + } + + &.primary { + background-color: $primary; + color: white; + border-color: $primary; + + &:hover { + background-color: $primary-light; + border-color: $primary-light; + } + } + } + } + + .recipe-alert { + background-color: rgba($warning, 0.1); + border-left: 4px solid $warning; + padding: $spacing-sm $spacing-md; + margin: 0 (-$spacing-xl) (-$spacing-xl); + display: flex; + align-items: center; + gap: $spacing-sm; + + .alert-icon { + font-size: $font-size-lg; + color: $warning; + } + + .alert-text { + font-size: $font-size-sm; + color: $text-primary; + font-weight: $font-weight-medium; + } + } +} +.modal-overlay { +position: fixed; +inset: 0; +background: rgba(0, 0, 0, 0.4); +display: flex; +justify-content: center; +align-items: center; +} + +.modal { + background: white; + padding: 24px; + border-radius: 8px; + width: min(90%, 500px); +} diff --git a/src/routes/Recipes/Recipes.tsx b/src/routes/Recipes/Recipes.tsx new file mode 100644 index 0000000..d32946c --- /dev/null +++ b/src/routes/Recipes/Recipes.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Header } from '../../../components/Header/Header'; +import { apiService } from '../../../services/api'; +import type { AbraApp, AppWithServer, AbraRecipe } from '../../../types'; +import RecipeForm from './RecipeForm.tsx' +import './Recipes.scss'; + +export const Recipes: React.FC = () => { + const navigate = useNavigate(); + const [recipesData, setRecipesData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [filterServer, setFilterServer] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [selectedRecipe, setSelectedRecipe] = useState(null); + const [showForm, setShowForm] = useState(false); + + + + const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; + + useEffect(() => { + const fetchData = async () => { + try { + if (isMockMode) { + const { mockApiService } = await import('../../../services/mockApi'); + const data = await mockApiService.getRecipes(); + console.log(data) + setRecipesData(data); + } else { + const data = await apiService.getRecipes(); + setRecipesData(data); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load apps'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [isMockMode]); + + // Flatten and enrich apps data + const allRecipes: AbraRecipe[] = useMemo(() => { + if (!recipesData) return []; + + return recipesData; + }, [recipesData]); + + + // Filter apps + const filteredRecipes = useMemo(() => { + return allRecipes.filter(recipe => { + const matchesSearch = + recipe.name.toLowerCase().includes(searchTerm.toLowerCase()); + + return matchesSearch; + }); + }, [allRecipes, searchTerm, filterServer, filterStatus]); + + const stats = useMemo(() => { + const total = allRecipes.length; + + return { total }; + }, [allRecipes]); + + if (loading) { + return ( +
+
+
+
Loading applications...
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
Error: {error}
+
+
+ ); + } + + return ( +
+
+
+
+

Applications

+

{stats.total} recipes

+
+ + {/* Stats Overview */} +
+
+
+

{stats.total}

+

Total Recipes

+
+
+
+ + {/* Filters */} +
+ setSearchTerm(e.target.value)} + className="search-input" + /> + + + +
+ + {/* Server Cards */} +
+ {filteredRecipes.length === 0 ? ( +
No recipes found matching your search
+ ) : ( + filteredRecipes.map((recipe) => ( +
+
+
+

{recipe.name}

+
+
+ {recipe.icon.length > 0 ? : null } +
+
+ +
+
+ {recipe.description} +
+
+ +
+ +
+
+ {recipe.features.backups.toLowerCase().includes("yes") ? Backups : null} + {recipe.features.healthcheck.toLowerCase().includes("yes") ? Healthcheck : null} + {recipe.category.length > 0 ? {recipe.category} : null} +
+
+ )) + )} +
+ {selectedRecipe && ( +
setSelectedRecipe(null)}> +
e.stopPropagation()}> + {} + setSelectedRecipe(null)} /> +
+
+ )} + +
+ Showing {filteredRecipes.length} of {allRecipes.length} apps +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/services/mock-catalogue.json b/src/services/mock-catalogue.json new file mode 100644 index 0000000..3d11e40 --- /dev/null +++ b/src/services/mock-catalogue.json @@ -0,0 +1,1726 @@ +[ + { + "category": "Apps", + "default_branch": "main", + "description": "An awesome, Online Office suite image suitable for home use.", + "features": { + "backups": "No", + "email": "3", + "healthcheck": "No", + "image": { + "image": "collabora", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/collabora/code" + }, + "status": 3, + "tests": "2", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/249-0937028d43099df58d85bc41a7e69fbd", + "name": "collabora", + "repository": "https://git.coopcloud.tech/coop-cloud/collabora.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/collabora.git", + "versions": [ + { + "1.0.0+6.4.9.3": { + "app": { + "image": "collabora/code", + "tag": "6.4.9.3" + }, + "web": { + "image": "nginx", + "tag": "1.21.4" + } + } + }, + { + "2.0.0+21.11.0.5.1": { + "app": { + "image": "collabora/code", + "tag": "21.11.0.5.1" + }, + "web": { + "image": "nginx", + "tag": "1.21.4" + } + } + }, + { + "2.1.0+21.11.1.4.1": { + "app": { + "image": "collabora/code", + "tag": "21.11.1.4.1" + }, + "web": { + "image": "nginx", + "tag": "1.21.4" + } + } + }, + { + "2.1.2+22.05.8.2.1": { + "app": { + "image": "collabora/code", + "tag": "22.05.8.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.22.1" + } + } + }, + { + "2.2.0+22.05.10.1.1": { + "app": { + "image": "collabora/code", + "tag": "22.05.10.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.23.3" + } + } + }, + { + "2.3.0+22.05.14.3.1": { + "app": { + "image": "collabora/code", + "tag": "22.05.14.3.1" + }, + "web": { + "image": "nginx", + "tag": "1.24.0" + } + } + }, + { + "2.4.0+23.05.2.2.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.2.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.2" + } + } + }, + { + "2.5.0+23.05.3.1.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.3.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.2" + } + } + }, + { + "2.6.0+23.05.5.4.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.5.4.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.3" + } + } + }, + { + "2.7.0+23.05.8.2.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.8.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.4" + } + } + }, + { + "2.7.1+23.05.10.1.1": { + "app": { + "image": "collabora/code", + "tag": "23.05.10.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.25.4" + } + } + }, + { + "3.0.0+24.04.6.1.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.6.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.27.0" + } + } + }, + { + "3.1.0+24.04.10.2.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.10.2.1" + }, + "web": { + "image": "nginx", + "tag": "1.27.3" + } + } + }, + { + "3.2.0+24.04.12.3.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.12.3.1" + }, + "web": { + "image": "nginx", + "tag": "1.27.4" + } + } + }, + { + "3.3.0+24.04.13.3.1": { + "app": { + "image": "collabora/code", + "tag": "24.04.13.3.1" + }, + "web": { + "image": "nginx", + "tag": "1.28.0" + } + } + }, + { + "4.0.0+25.04.4.1.1": { + "app": { + "image": "collabora/code", + "tag": "25.04.4.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.29.0" + } + } + }, + { + "4.0.1+25.04.9.1.1": { + "app": { + "image": "collabora/code", + "tag": "25.04.9.1.1" + }, + "web": { + "image": "nginx", + "tag": "1.29.4" + } + } + }, + { + "4.0.2+25.04.9.4.1": { + "app": { + "image": "collabora/code", + "tag": "25.04.9.4.1" + }, + "web": { + "image": "nginx", + "tag": "1.29.7" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "HTTP/HTTPS compression proxy ", + "features": { + "backups": "No", + "email": "N/A", + "healthcheck": "No", + "image": { + "image": "compy", + "rating": "0", + "source": "own", + "url": "https://hub.docker.com/r/thecoopcloud/compy" + }, + "status": 1, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/435-127e3f583ac79a3b71f8fbd9c1cf5ba1", + "name": "compy", + "repository": "https://git.coopcloud.tech/coop-cloud/compy.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/compy.git", + "versions": [], + "website": "" + }, + { + "category": "Development", + "default_branch": "main", + "description": "A painless self-hosted Git service", + "features": { + "backups": "Yes", + "email": "Yes", + "healthcheck": "Yes", + "image": { + "image": "forgejo/forgejo", + "rating": "4", + "source": "upstream", + "url": "https://codeberg.org/forgejo/-/packages/container/forgejo/13-rootless" + }, + "status": 5, + "tests": "2", + "sso": "3 (OAuth)" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/eb3fb3ee823159765f746a75128143f3cd9b11c3f074ff6043ceeac8cdc434a9", + "name": "forgejo", + "repository": "https://git.coopcloud.tech/coop-cloud/forgejo.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/forgejo.git", + "versions": [ + { + "1.0.0+1.14.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.14.5-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.0+1.15.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.0-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.1+1.15.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.3-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.2+1.15.6-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.6-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.3+1.15.10-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.15.10-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.2.0+1.16.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.16.3-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.2.1+1.16.8-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.16.8-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.3.0+1.17.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.17.2-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.3.1+1.17.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.17.3-rootless" + }, + "db": { + "image": "mariadb", + "tag": "10.9" + } + } + }, + { + "2.0.0+1.18.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.18.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "9.6" + } + } + }, + { + "2.0.1+1.18.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.18.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "9.6" + } + } + }, + { + "2.1.0+1.18.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.18.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "9.6" + } + } + }, + { + "2.1.2+1.19.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.19.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.2" + } + } + }, + { + "2.2.0+1.19.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.19.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.3" + } + } + }, + { + "2.3.0+1.20.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.3" + } + } + }, + { + "2.3.1+1.20.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.3" + } + } + }, + { + "2.3.2+1.20.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.4" + } + } + }, + { + "2.3.3+1.20.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.20.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.4" + } + } + }, + { + "2.4.0+1.21.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.4" + } + } + }, + { + "2.5.0+1.21.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.5.1+1.21.4-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.4-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.5.2+1.21.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.6.0+1.21.5-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.5-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.6.1+1.21.10-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.10-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.6.2+1.21.10-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.10-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.5" + } + } + }, + { + "2.7.0+1.21.11-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.11-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.6" + } + } + }, + { + "2.8.0+1.21.11-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.21.11-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.9.0+1.22.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.9.1+1.22.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.10.0+1.22.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.7" + } + } + }, + { + "2.10.1+1.22.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "2.11.0+1.22.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "3.0.0+1.22.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "3.0.1+1.22.3-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.8" + } + } + }, + { + "3.0.2+1.22.6-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.6-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.0.3+1.22.6-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.22.6-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.1.0+1.23.0-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.0-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.1.1+1.23.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.2.0+1.23.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.3.0+1.23.1-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.3.1+1.23.8-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.23.8-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.10" + } + } + }, + { + "3.4.0+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "3.5.0+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "3.5.1+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "3.5.2+1.24.2-rootless": { + "app": { + "image": "gitea/gitea", + "tag": "1.24.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "4.0.0+12.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "12.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "4.0.1+12.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "12.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "4.0.2+12.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "12.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.0+13.0.2-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.2-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.1+13.0.3-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.2+13.0.4-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.4-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.0.3+13.0.4-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "13.0.4-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.1.0+14.0.1-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "14.0.1-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + }, + { + "5.1.1+14.0.3-rootless": { + "app": { + "image": "forgejo/forgejo", + "tag": "14.0.3-rootless" + }, + "db": { + "image": "postgres", + "tag": "15.13" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "A shared agenda for local communities", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "gancio", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/cisti/gancio" + }, + "status": 0, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/485-f4a447197439adc13c2440728f1ba8b6", + "name": "gancio", + "repository": "https://git.coopcloud.tech/coop-cloud/gancio.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/gancio.git", + "versions": [ + { + "0.1.0+1.25.1": { + "app": { + "image": "cisti/gancio", + "tag": "1.25.1" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "A federated link aggregator in Rust", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "Yes", + "image": { + "image": "lemmy", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/dessalines/lemmy" + }, + "status": 1, + "tests": "2", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/432-5751cb07a13ead1bea8249a8f3b5ae68", + "name": "lemmy", + "repository": "https://git.coopcloud.tech/coop-cloud/lemmy.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/lemmy.git", + "versions": [ + { + "0.1.0+0.16.6": { + "app": { + "image": "dessalines/lemmy", + "tag": "0.16.6" + }, + "db": { + "image": "postgres", + "tag": "12-alpine" + }, + "pictrs": { + "image": "asonix/pictrs", + "tag": "0.3.0-beta.12-r1" + }, + "ui": { + "image": "dessalines/lemmy-ui", + "tag": "0.16.6" + }, + "web": { + "image": "nginx", + "tag": "1.20.0" + } + } + }, + { + "0.1.0+0.16.1": { + "app": { + "image": "dessalines/lemmy", + "tag": "0.16.1" + }, + "db": { + "image": "postgres", + "tag": "12-alpine" + }, + "pictrs": { + "image": "asonix/pictrs", + "tag": "0.3.0-beta.12-r1" + }, + "ui": { + "image": "dessalines/lemmy-ui", + "tag": "0.16.1" + }, + "web": { + "image": "nginx", + "tag": "1.20.0" + } + } + }, + { + "0.2.0+0.18.3": { + "app": { + "image": "dessalines/lemmy", + "tag": "0.18.3" + }, + "db": { + "image": "postgres", + "tag": "15-alpine" + }, + "pictrs": { + "image": "asonix/pictrs", + "tag": "0.4.0-beta.19" + }, + "ui": { + "image": "dessalines/lemmy-ui", + "tag": "0.18.3" + }, + "web": { + "image": "nginx", + "tag": "1.20.0" + } + } + } + ], + "website": "" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Spin up an onion service for your Co-op Cloud applications", + "features": { + "backups": "Yes", + "email": "N/A", + "healthcheck": "Yes", + "image": { + "image": "onimages/tor:alpine", + "rating": "4", + "source": "upstream", + "url": "https://onionservices.torproject.org/apps/base/containers" + }, + "status": 0, + "tests": "No", + "sso": "N/A" + }, + "icon": "", + "name": "onion", + "repository": "https://git.coopcloud.tech/coop-cloud/onion.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/onion.git", + "versions": [ + { + "1.0.0+alpine": { + "app": { + "image": "tpo/onion-services/onimages/tor", + "tag": "alpine" + } + } + }, + { + "1.0.1+alpine": { + "app": { + "image": "tpo/onion-services/onimages/tor", + "tag": "alpine" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "", + "features": { + "backups": "Yes", + "email": "Yes", + "healthcheck": "3", + "image": { + "image": "vaultwarden/server", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/vaultwarden/server" + }, + "status": 2, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/379-697fbe9f0f9481f250fde86e811c1e34", + "name": "vaultwarden", + "repository": "https://git.coopcloud.tech/coop-cloud/vaultwarden.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/vaultwarden.git", + "versions": [ + { + "0.1.0+1.25.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.25.0" + } + } + }, + { + "0.1.1+1.26.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.26.0" + } + } + }, + { + "0.2.0+1.26.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.26.0" + } + } + }, + { + "0.3.0+1.26.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.26.0" + } + } + }, + { + "0.4.0+1.28.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.28.1" + } + } + }, + { + "0.5.0+1.29.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.29.0" + } + } + }, + { + "0.5.1+1.29.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.29.1" + } + } + }, + { + "0.6.0+1.29.2": { + "app": { + "image": "vaultwarden/server", + "tag": "1.29.2" + } + } + }, + { + "0.7.0+1.30.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.30.0" + } + } + }, + { + "0.7.1+1.30.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.30.1" + } + } + }, + { + "0.7.2+1.30.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.30.3" + } + } + }, + { + "0.8.0+1.31.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.31.0" + } + } + }, + { + "0.9.0+1.32.0": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.0" + } + } + }, + { + "0.9.1+1.32.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.3" + } + } + }, + { + "1.0.0+1.32.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.3" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.1+1.32.5": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.5" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.2+1.32.5": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.5" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.3+1.32.5": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.5" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.0.4+1.32.7": { + "app": { + "image": "vaultwarden/server", + "tag": "1.32.7" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "1.1.0+1.33.2": { + "app": { + "image": "vaultwarden/server", + "tag": "1.33.2" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "2.0.0+1.33.2": { + "app": { + "image": "vaultwarden/server", + "tag": "1.33.2" + }, + "db": { + "image": "mariadb", + "tag": "10.6" + } + } + }, + { + "2.1.0+1.34.1": { + "app": { + "image": "vaultwarden/server", + "tag": "1.34.1" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + }, + { + "2.1.1+1.34.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.34.3" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + }, + { + "2.1.2+1.35.3": { + "app": { + "image": "vaultwarden/server", + "tag": "1.35.3" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + }, + { + "2.1.3+1.35.4": { + "app": { + "image": "vaultwarden/server", + "tag": "1.35.4" + }, + "db": { + "image": "mariadb", + "tag": "10.11" + } + } + } + ], + "website": "" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Easily and securely send things from one computer to another", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "croc", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/schollz/croc" + }, + "status": 3, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/365-d44b58c8c77808fb82c2d9fdfee17800", + "name": "croc", + "repository": "https://git.coopcloud.tech/coop-cloud/croc.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/croc.git", + "versions": [], + "website": "https://github.com/schollz/croc" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Community Keycloak SSO user management.", + "features": { + "backups": "No", + "email": "N/A", + "healthcheck": "Yes", + "image": { + "image": "", + "rating": "", + "source": "", + "url": "" + }, + "status": 1, + "tests": "Yes", + "sso": "N/A" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/281-ae6d28ba5409a428cbb3a49d5ef6d739", + "name": "keycloak-collective-portal", + "repository": "https://git.coopcloud.tech/coop-cloud/keycloak-collective-portal.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/keycloak-collective-portal.git", + "versions": [], + "website": "" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "Manage a node in a local resilient mesh, see https://lores.tech/", + "features": { + "backups": "No", + "email": "N/A", + "healthcheck": "No", + "image": { + "image": "lores-node", + "rating": "4", + "source": "upstream", + "url": "https://ghcr.io/local-resilience-tech/lores-node:latest" + }, + "status": 1, + "tests": "0", + "sso": "N/A" + }, + "icon": "", + "name": "lores-node", + "repository": "https://git.coopcloud.tech/coop-cloud/lores-node.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/lores-node.git", + "versions": [ + { + "0.1.0+0.15.3": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.15.3" + } + } + }, + { + "0.2.0+v0.16.0": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.16.0" + } + } + }, + { + "0.2.1+v0.16.1": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.16.1" + } + } + }, + { + "0.2.2+v0.16.2": { + "app": { + "image": "local-resilience-tech/lores-node", + "tag": "v0.16.2" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "", + "features": { + "backups": "Yes", + "email": "No", + "healthcheck": "", + "image": { + "image": "plausible/analytics", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/plausible/analytics" + }, + "status": 1, + "tests": "", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/ba858ce9af5a5e69074b125fd4f99ac7b43fd1ad4fb82d926541175826037921", + "name": "plausible", + "repository": "https://git.coopcloud.tech/coop-cloud/plausible.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/plausible.git", + "versions": [ + { + "1.1.0+v1.5.1": { + "app": { + "image": "plausible/analytics", + "tag": "v1.5.1" + }, + "db": { + "image": "postgres", + "tag": "13.11-alpine" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + }, + { + "2.0.0+v1.5.1": { + "app": { + "image": "plausible/analytics", + "tag": "v1.5.1" + }, + "db": { + "image": "postgres", + "tag": "13.11" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + }, + { + "3.0.0+v2.0.0": { + "app": { + "image": "plausible/analytics", + "tag": "v2.0.0" + }, + "db": { + "image": "postgres", + "tag": "13.12" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + }, + { + "3.0.1+v2.0.0": { + "app": { + "image": "plausible/analytics", + "tag": "v2.0.0" + }, + "db": { + "image": "postgres", + "tag": "13.12" + }, + "plausible_events_db": { + "image": "clickhouse/clickhouse-server", + "tag": "23.4.2.11-alpine" + } + } + } + ], + "website": "https://github.com/plausible/analytics/" + }, + { + "category": "Utilities", + "default_branch": "main", + "description": "RSS feed for Docker Hub images", + "features": { + "backups": "?", + "email": "?", + "healthcheck": "?", + "image": { + "image": "docker-hub-rss", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/theconnman/docker-hub-rss/" + }, + "status": 0, + "tests": "?", + "sso": "?" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/210-09cd28347983de7287a1af0792371087", + "name": "docker-hub-rss", + "repository": "https://git.coopcloud.tech/coop-cloud/docker-hub-rss.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/docker-hub-rss.git", + "versions": [ + { + "0.1.0+0.4.2": { + "app": { + "image": "theconnman/docker-hub-rss", + "tag": "0.4.2" + } + } + } + ], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "A web-based farm record keeping application. ", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "farmos", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/farmos" + }, + "status": 0, + "tests": "No", + "sso": "No" + }, + "icon": "", + "name": "farmos", + "repository": "https://git.coopcloud.tech/coop-cloud/farmos.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/farmos.git", + "versions": [], + "website": "" + }, + { + "category": "Apps", + "default_branch": "main", + "description": "Funkwhale is a self-hosted, modern free and open-source music server, heavily inspired by Grooveshark.", + "features": { + "backups": "No", + "email": "No", + "healthcheck": "No", + "image": { + "image": "funkwhale", + "rating": "4", + "source": "upstream", + "url": "https://hub.docker.com/r/funkwhale" + }, + "status": 0, + "tests": "No", + "sso": "No" + }, + "icon": "https://git.coopcloud.tech/repo-avatars/455-5c7ee2a7d5ca18dbd1fba168994f9196", + "name": "funkwhale", + "repository": "https://git.coopcloud.tech/coop-cloud/funkwhale.git", + "ssh_url": "ssh://git@git.coopcloud.tech:2222/coop-cloud/funkwhale.git", + "versions": [ + { + "0.1.0+1.25.3": { + "api": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "app": { + "image": "nginx", + "tag": "1.25.3" + }, + "cache": { + "image": "redis", + "tag": "7-alpine" + }, + "celerybeat": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "celeryworker": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "db": { + "image": "postgres", + "tag": "10-alpine" + } + } + }, + { + "0.1.1+1.27.1": { + "api": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "app": { + "image": "nginx", + "tag": "1.27.1" + }, + "cache": { + "image": "redis", + "tag": "7-alpine" + }, + "celerybeat": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "celeryworker": { + "image": "funkwhale/funkwhale", + "tag": "1.2" + }, + "db": { + "image": "postgres", + "tag": "10-alpine" + } + } + } + ], + "website": "" + } +] \ No newline at end of file -- 2.49.0 From f85b453b55afde6863ffc74d0840417ae808ba0b Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 17 Apr 2026 13:06:15 -0700 Subject: [PATCH 10/24] basic functionality working --- src/App.tsx | 4 ++-- src/routes/Recipes/RecipeForm.tsx | 4 ++-- src/routes/Recipes/Recipes.scss | 6 +++--- src/routes/Recipes/Recipes.tsx | 8 ++++---- src/services/api.ts | 7 ++++++- src/services/mockApi.ts | 9 ++++++++- src/types/index.ts | 32 +++++++++++++++++++++++++++++++ 7 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bfa3113..6d37791 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { Apps } from './routes/Apps/Apps'; import { AppDetail } from './routes/Apps/App'; import { Servers } from './routes/Servers/Servers'; import { Server } from './routes/Servers/Server'; -import { Recipes } from './routes/Authenticated/Recipes/Recipes'; +import { Recipes } from './routes/Recipes/Recipes'; function App() { return ( @@ -17,7 +17,7 @@ function App() { } /> } /> } /> - + {/* 404 catch-all */} } /> diff --git a/src/routes/Recipes/RecipeForm.tsx b/src/routes/Recipes/RecipeForm.tsx index 4c29f00..dc3c7f9 100644 --- a/src/routes/Recipes/RecipeForm.tsx +++ b/src/routes/Recipes/RecipeForm.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import { apiService } from '../../../services/api'; -import type { AbraServer } from '../../../types'; +import { apiService } from '../../services/api'; +import type { AbraServer } from '../../types'; diff --git a/src/routes/Recipes/Recipes.scss b/src/routes/Recipes/Recipes.scss index b2082bb..ec084d7 100644 --- a/src/routes/Recipes/Recipes.scss +++ b/src/routes/Recipes/Recipes.scss @@ -1,6 +1,6 @@ -@use '../../../assets/scss/variables' as *; -@use '../../../assets/scss/mixins' as *; -@use '../../../assets/scss/global' as *; +@use '../../assets/scss/variables' as *; +@use '../../assets/scss/mixins' as *; +@use '../../assets/scss/global' as *; // Extend global page wrapper .recipes-page { diff --git a/src/routes/Recipes/Recipes.tsx b/src/routes/Recipes/Recipes.tsx index d32946c..f8b9005 100644 --- a/src/routes/Recipes/Recipes.tsx +++ b/src/routes/Recipes/Recipes.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Header } from '../../../components/Header/Header'; -import { apiService } from '../../../services/api'; -import type { AbraApp, AppWithServer, AbraRecipe } from '../../../types'; +import { Header } from '../../components/Header/Header'; +import { apiService } from '../../services/api'; +import type { AbraApp, AppWithServer, AbraRecipe } from '../../types'; import RecipeForm from './RecipeForm.tsx' import './Recipes.scss'; @@ -25,7 +25,7 @@ export const Recipes: React.FC = () => { const fetchData = async () => { try { if (isMockMode) { - const { mockApiService } = await import('../../../services/mockApi'); + const { mockApiService } = await import('../../services/mockApi'); const data = await mockApiService.getRecipes(); console.log(data) setRecipesData(data); diff --git a/src/services/api.ts b/src/services/api.ts index 9afed3b..2f55c2d 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,4 +1,4 @@ -import type { AbraServer, ServerAppsResponse } from '../types'; +import type { AbraServer, AbraRecipe, ServerAppsResponse } from '../types'; // Log entry type export type LogEntry = { @@ -130,6 +130,11 @@ class ApiService { logs.forEach(log => onLog(log)); } } + // recipe catalog imports + async getRecipes(): Promise { + return this.request('/abra/catalogue'); + } } + export const apiService = new ApiService(); \ No newline at end of file diff --git a/src/services/mockApi.ts b/src/services/mockApi.ts index 709fe1d..71b776b 100644 --- a/src/services/mockApi.ts +++ b/src/services/mockApi.ts @@ -1,7 +1,9 @@ -import type { AbraServer, ServerAppsResponse } from '../types'; +import type { AbraServer, AbraRecipe, ServerAppsResponse } from '../types'; import appsData from './mock-apps.json'; import serversData from './mock-servers.json'; import logsData from './mock-logs.json'; +import catalogue from './mock-catalogue.json'; + // Log entry type export type LogEntry = { @@ -135,4 +137,9 @@ export const mockApiService = { onLog?.(log); } }, + // recipe catalog imports + async getRecipes(): Promise { + await delay(300); + return catalogue as AbraRecipe[]; + } }; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index b4e1d1d..8e2727f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -36,4 +36,36 @@ export interface AppWithServer extends AbraApp { appCount: number; upgradeCount: number; }; +} +export interface Image { + image: string; + rating: string + source: string + url: string +} +type RecipeVersions = Record>[]; +export interface ServiceMeta { + image: string; + tag: string +} +export interface Features { + backups: string; + email: string; + healthcheck: string; + image: Image; + status: number; + tests: string; + sso: string; +} +export interface AbraRecipe { + category: string; + default_branch: string; + description: string; + features: Features; + icon: string; + name: string; + repository: string; + ssh_url: string; + versions: RecipeVersions; + website: string; } \ No newline at end of file -- 2.49.0 From a2efaf279d0e55e8ead8cfd006bd06c6c05326a0 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 24 Apr 2026 15:43:18 -0700 Subject: [PATCH 11/24] smaller stat cards --- src/routes/Apps/Apps.scss | 87 ++++++++++++++- src/routes/Apps/Apps.tsx | 106 +++++++++++------- src/routes/Servers/Server.tsx | 6 +- src/routes/Servers/Servers.tsx | 193 +++++++++++++++++---------------- src/services/mock-logs.json | 4 +- 5 files changed, 251 insertions(+), 145 deletions(-) diff --git a/src/routes/Apps/Apps.scss b/src/routes/Apps/Apps.scss index a301935..360285f 100644 --- a/src/routes/Apps/Apps.scss +++ b/src/routes/Apps/Apps.scss @@ -11,6 +11,76 @@ @extend .page-content; } +// Compact stats row +.stats-row { + display: flex; + align-items: center; + gap: $spacing-md; + margin-bottom: $spacing-xl; + flex-wrap: wrap; +} + +.stat-chip { + display: flex; + align-items: center; + gap: $spacing-sm; + padding: $spacing-sm $spacing-md; + background: white; + border: 2px solid $border-color; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-base; + font-size: $font-size-base; + + &:hover:not(:disabled) { + border-color: $primary; + transform: translateY(-2px); + box-shadow: $shadow-sm; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &.active { + border-color: $primary; + background: rgba($primary, 0.05); + } + + .stat-label { + color: $text-secondary; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + } + + .stat-value { + color: $text-primary; + font-size: $font-size-xl; + font-weight: $font-weight-bold; + min-width: 24px; + text-align: center; + } +} + +.reset-filters-btn { + padding: $spacing-sm $spacing-md; + background: $bg-secondary; + border: 2px solid $border-color; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-base; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-secondary; + + &:hover { + background: white; + color: $text-primary; + border-color: $primary; + } +} + // Apps table specific styles .apps-table-container { @include card; @@ -114,21 +184,28 @@ .action-btn { background: none; border: 1px solid $border-color; - padding: $spacing-xs $spacing-sm; + padding: $spacing-xs $spacing-md; border-radius: $radius-sm; cursor: pointer; - font-size: $font-size-base; + font-size: $font-size-sm; color: $text-primary; + font-weight: $font-weight-medium; transition: all $transition-base; &:hover { - background-color: $bg-tertiary; - transform: scale(1.1); + background-color: $primary; + color: white; + border-color: $primary; } &.upgrade { border-color: $warning; color: $warning; + + &:hover { + background-color: $warning; + color: white; + } } } -} +} \ No newline at end of file diff --git a/src/routes/Apps/Apps.tsx b/src/routes/Apps/Apps.tsx index 1901f5a..9ca5703 100644 --- a/src/routes/Apps/Apps.tsx +++ b/src/routes/Apps/Apps.tsx @@ -14,7 +14,8 @@ export const Apps: React.FC = () => { const [filterServer, setFilterServer] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); - + const [showChaosOnly, setShowChaosOnly] = useState(false); + const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; useEffect(() => { @@ -86,6 +87,28 @@ export const Apps: React.FC = () => { return { total, needsUpgrade, chaosApps, totalServers }; }, [allApps, servers]); + const resetFilters = () => { + setSearchTerm(''); + setFilterServer('all'); + setShowChaosOnly(false); + setShowUpgradesOnly(false); + }; + + const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev); + const toggleChaos = () => setShowChaosOnly(prev => !prev); + + // Show only apps that need upgrades + const filterByUpgrades = () => { + setShowUpgradesOnly(true); + setFilterStatus('all'); + }; + + // Show only chaos apps + const filterByChaos = () => { + setFilterStatus('chaos'); + setShowUpgradesOnly(false); + }; + if (loading) { return (
@@ -117,32 +140,46 @@ export const Apps: React.FC = () => {

{stats.total} apps across {stats.totalServers} servers

- {/* Stats Overview */} -
-
-
-

{stats.total}

-

Total Apps

-
-
-
-
-

{stats.needsUpgrade}

-

Upgrades Available

-
-
-
-
-

{stats.chaosApps}

-

Chaos Mode

-
-
-
-
-

{stats.totalServers}

-

Servers

-
-
+ {/* Compact Stats Overview */} +
+ + + + + + + +
{/* Filters */} @@ -161,21 +198,6 @@ export const Apps: React.FC = () => { ))} - - - -
{/* Apps Table */} diff --git a/src/routes/Servers/Server.tsx b/src/routes/Servers/Server.tsx index e2970ac..94e7d88 100644 --- a/src/routes/Servers/Server.tsx +++ b/src/routes/Servers/Server.tsx @@ -175,7 +175,7 @@ export const Server: React.FC = () => { {server.host} {server.upgradeCount > 0 && ( - ⬆️ {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''} + {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''} )} @@ -187,14 +187,14 @@ export const Server: React.FC = () => { onClick={() => handleAction('refresh')} disabled={!!actionLoading} > - {actionLoading === 'refresh' ? 'Refreshing...' : 'πŸ”„ Refresh'} + {actionLoading === 'refresh' ? 'Refreshing...' : 'Refresh'} diff --git a/src/routes/Servers/Servers.tsx b/src/routes/Servers/Servers.tsx index 5c0170a..ac26ed5 100644 --- a/src/routes/Servers/Servers.tsx +++ b/src/routes/Servers/Servers.tsx @@ -20,59 +20,46 @@ export const Servers: React.FC = () => { const [error, setError] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name'); + const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; useEffect(() => { const fetchData = async () => { try { + let serversData, appsData; + if (isMockMode) { const { mockApiService } = await import('../../services/mockApi'); - const [serversData, appsData] = await Promise.all([ + [serversData, appsData] = await Promise.all([ mockApiService.getServers(), mockApiService.getAppsGrouped(), ]); - - // Enrich servers with stats from apps data - const enrichedServers = serversData.map(server => { - const serverStats = appsData[server.name]; - const chaosCount = serverStats?.apps.filter(app => app.chaos === 'true').length || 0; - - return { - ...server, - appCount: serverStats?.appCount || 0, - versionCount: serverStats?.versionCount || 0, - latestCount: serverStats?.latestCount || 0, - upgradeCount: serverStats?.upgradeCount || 0, - chaosCount, - }; - }); - - setServers(enrichedServers); } else { - const [serversData, appsData] = await Promise.all([ + [serversData, appsData] = await Promise.all([ apiService.getServers(), apiService.getAppsGrouped(), ]); - - // Enrich servers with stats from apps data - const enrichedServers = serversData.map(server => { - const serverStats = appsData[server.name]; - const chaosCount = serverStats?.apps.filter(app => app.chaos === 'true').length || 0; - - return { - ...server, - appCount: serverStats?.appCount || 0, - versionCount: serverStats?.versionCount || 0, - latestCount: serverStats?.latestCount || 0, - upgradeCount: serverStats?.upgradeCount || 0, - chaosCount, - }; - }); - - setServers(enrichedServers); } + + // Enrich servers with stats from apps data + const enrichedServers = serversData.map(server => { + const serverStats = appsData[server.name]; + const chaosCount = serverStats?.apps.filter(app => app.chaos === 'true').length || 0; + + return { + ...server, + appCount: serverStats?.appCount || 0, + versionCount: serverStats?.versionCount || 0, + latestCount: serverStats?.latestCount || 0, + upgradeCount: serverStats?.upgradeCount || 0, + chaosCount, + }; + }); + + setServers(enrichedServers); } catch (err) { + console.error('Error loading servers:', err); setError(err instanceof Error ? err.message : 'Failed to load servers'); } finally { setLoading(false); @@ -94,26 +81,32 @@ export const Servers: React.FC = () => { // Filter and sort servers const filteredServers = useMemo(() => { - const filtered = servers.filter(server => + const filtered = servers.filter(server => { + const matchesSearch = server.name.toLowerCase().includes(searchTerm.toLowerCase()) || - server.host.toLowerCase().includes(searchTerm.toLowerCase()) - ); + server.host.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0; + return matchesSearch && matchesUpgrades; + }); - // Sort - filtered.sort((a, b) => { - switch (sortBy) { - case 'apps': - return b.appCount - a.appCount; - case 'upgrades': - return b.upgradeCount - a.upgradeCount; - case 'name': - default: - return a.name.localeCompare(b.name); - } - }); + filtered.sort((a, b) => { + switch (sortBy) { + case 'apps': + return b.appCount - a.appCount; + case 'name': + default: + return a.name.localeCompare(b.name); + } + }); - return filtered; - }, [servers, searchTerm, sortBy]); + return filtered; +}, [servers, searchTerm, sortBy, showUpgradesOnly]); + +const resetFilters = () => { + setSearchTerm(''); + setSortBy('name'); + setShowUpgradesOnly(false); +}; if (loading) { return ( @@ -146,36 +139,53 @@ export const Servers: React.FC = () => {

Managing {stats.totalServers} servers with {stats.totalApps} applications

- {/* Stats Overview */} -
-
-
-
-

{stats.totalServers}

-

Total Servers

-
-
-
-
-
-

{stats.totalApps}

-

Total Apps

-
-
-
-
-
-

{stats.totalUpgrades}

-

Apps Need Upgrade

-
-
-
-
-
-

{stats.totalChaos}

-

Chaos Apps

-
-
+ {/* Compact Stats Row */} +
+ + + + + + + + + {(searchTerm || sortBy !== 'name' || showUpgradesOnly) && ( + + )}
{/* Filters */} @@ -187,12 +197,6 @@ export const Servers: React.FC = () => { onChange={(e) => setSearchTerm(e.target.value)} className="search-input" /> - -
{/* Server Cards */} @@ -232,13 +236,17 @@ export const Servers: React.FC = () => { {server.upgradeCount > 0 && (
- Need Upgrade + + Need Upgrade + {server.upgradeCount}
)} {server.chaosCount > 0 && (
- Chaos Mode + + Chaos Mode + {server.chaosCount}
)} @@ -267,7 +275,6 @@ export const Servers: React.FC = () => { {server.upgradeCount > 0 && (
- ⚠️ {server.upgradeCount} app{server.upgradeCount > 1 ? 's' : ''} can be upgraded @@ -284,4 +291,4 @@ export const Servers: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/src/services/mock-logs.json b/src/services/mock-logs.json index 2c5b626..5c19923 100644 --- a/src/services/mock-logs.json +++ b/src/services/mock-logs.json @@ -119,7 +119,7 @@ "logs": [ { "type": "info", - "text": "⬆️ Upgrading {appName} to {version}..." + "text": "Upgrading {appName} to {version}..." }, { "type": "command", @@ -287,7 +287,7 @@ "logs": [ { "type": "info", - "text": "⬆️ Upgrading all apps on {serverName}..." + "text": "Upgrading all apps on {serverName}..." }, { "type": "command", -- 2.49.0 From 27bc8de54b869987ef1850d060ebb3b1dec0f5d7 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 24 Apr 2026 15:50:48 -0700 Subject: [PATCH 12/24] remove silly emoji xd --- src/routes/Servers/Server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/Servers/Server.tsx b/src/routes/Servers/Server.tsx index 94e7d88..7e4797e 100644 --- a/src/routes/Servers/Server.tsx +++ b/src/routes/Servers/Server.tsx @@ -335,7 +335,7 @@ export const Server: React.FC = () => { onClick={() => handleAction('upgrade-all')} disabled={!!actionLoading || server.upgradeCount === 0} > - ⬆Upgrade All Apps + Upgrade All Apps -- 2.49.0 From 08017ad6bee32ebe6cce7f87fa172fcc595e153b Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 24 Apr 2026 15:55:04 -0700 Subject: [PATCH 13/24] swap skull for microscope --- src/routes/Apps/App.tsx | 4 ++-- src/routes/Servers/Server.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/Apps/App.tsx b/src/routes/Apps/App.tsx index efc8d76..ea7210d 100644 --- a/src/routes/Apps/App.tsx +++ b/src/routes/Apps/App.tsx @@ -158,7 +158,7 @@ export const AppDetail: React.FC = () => { {app.recipe} {app.status} {app.chaos === 'true' && ( - πŸ”¬ Chaos + Chaos )} @@ -236,7 +236,7 @@ export const AppDetail: React.FC = () => {
- {app.chaos === 'true' ? 'πŸ”¬ Enabled' : 'Disabled'} + {app.chaos === 'true' ? '☠️ Enabled' : 'Disabled'}
diff --git a/src/routes/Servers/Server.tsx b/src/routes/Servers/Server.tsx index 7e4797e..bf28050 100644 --- a/src/routes/Servers/Server.tsx +++ b/src/routes/Servers/Server.tsx @@ -280,7 +280,7 @@ export const Server: React.FC = () => { {app.status} {app.chaos === 'true' && ( - πŸ”¬ + ☠️ )} {app.upgrade !== 'latest' && ( ⬆️ -- 2.49.0 From a8826853e7a74d136627d6fa72995e2b0440c543 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 24 Apr 2026 18:31:39 -0700 Subject: [PATCH 14/24] tweak button style and redundant data --- src/assets/scss/_global.scss | 1 + src/assets/scss/_mixins.scss | 1 - src/routes/Apps/App.scss | 16 +++++++--------- src/routes/Dashboard/Dashboard.tsx | 2 -- src/routes/Servers/Server.scss | 11 ++++++----- src/routes/Servers/Server.tsx | 2 +- src/routes/Servers/Servers.scss | 6 +++--- src/routes/Servers/Servers.tsx | 21 --------------------- 8 files changed, 18 insertions(+), 42 deletions(-) diff --git a/src/assets/scss/_global.scss b/src/assets/scss/_global.scss index 9fbde7a..e870dbb 100644 --- a/src/assets/scss/_global.scss +++ b/src/assets/scss/_global.scss @@ -1,6 +1,7 @@ @use './variables' as *; @use './mixins' as *; + body { margin: 0; padding: 0; diff --git a/src/assets/scss/_mixins.scss b/src/assets/scss/_mixins.scss index ab73615..dc22d7f 100644 --- a/src/assets/scss/_mixins.scss +++ b/src/assets/scss/_mixins.scss @@ -62,5 +62,4 @@ font-size: $font-size-sm; font-weight: $font-weight-semibold; background-color: rgba($color, 0.1); - // color: darken($color, 20%); } \ No newline at end of file diff --git a/src/routes/Apps/App.scss b/src/routes/Apps/App.scss index c0b528b..ef9e7ec 100644 --- a/src/routes/Apps/App.scss +++ b/src/routes/Apps/App.scss @@ -1,6 +1,8 @@ @use '../../assets/scss/variables' as *; @use '../../assets/scss/mixins' as *; @use '../../assets/scss/global' as *; +@use "sass:color"; + .app-detail-page { @extend .page-wrapper; @@ -69,7 +71,7 @@ &.primary { background: $primary; - color: white; + color: $text-primary; border-color: $primary; &:hover:not(:disabled) { @@ -82,10 +84,6 @@ background: $bg-secondary; color: $text-primary; border-color: $border-color; - - &:hover:not(:disabled) { - background: darken($bg-secondary, 5%); - } } &.danger { @@ -94,7 +92,7 @@ border-color: $error; &:hover:not(:disabled) { - background: darken($error, 10%); + background: color.adjust($error, $lightness: -10%); } } } @@ -163,7 +161,7 @@ } .chaos-active { - color: darken($info, 10%); + background: color.adjust($info, $lightness: -10%); font-weight: $font-weight-medium; } } @@ -286,7 +284,7 @@ transition: all $transition-base; &:hover:not(:disabled) { - background: darken($warning, 10%); + background: color.adjust($warning, $lightness: -10%); transform: translateY(-1px); } @@ -301,7 +299,7 @@ .version-latest { padding: $spacing-md; background: rgba($success, 0.1); - color: darken($success, 10%); + background: color.adjust($success, $lightness: -10%); border-radius: $radius-md; text-align: center; font-size: $font-size-base; diff --git a/src/routes/Dashboard/Dashboard.tsx b/src/routes/Dashboard/Dashboard.tsx index c8bd091..b8737f4 100644 --- a/src/routes/Dashboard/Dashboard.tsx +++ b/src/routes/Dashboard/Dashboard.tsx @@ -89,7 +89,6 @@ export const Dashboard: React.FC = () => { - - - {server.upgradeCount > 0 && (
-- 2.49.0 From d300b6353ec9e6129b0775983e5633305b6e05ab Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 24 Apr 2026 18:45:30 -0700 Subject: [PATCH 15/24] style pass on bad element colorings --- src/routes/Apps/App.scss | 20 ++------------------ src/routes/Apps/App.tsx | 17 +++++++++-------- src/routes/Apps/Apps.scss | 2 +- src/routes/Apps/Apps.tsx | 6 +++--- 4 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/routes/Apps/App.scss b/src/routes/Apps/App.scss index ef9e7ec..53fb950 100644 --- a/src/routes/Apps/App.scss +++ b/src/routes/Apps/App.scss @@ -166,8 +166,8 @@ } } -.domain-link { - color: $primary; +.link { + color: $primary-dark; text-decoration: none; font-size: $font-size-base; transition: color $transition-base; @@ -178,22 +178,6 @@ } } -.server-link { - background: none; - border: none; - color: $primary; - cursor: pointer; - padding: 0; - font-size: $font-size-base; - text-align: left; - transition: color $transition-base; - - &:hover { - color: $primary-light; - text-decoration: underline; - } -} - // Version information .version-info { display: flex; diff --git a/src/routes/Apps/App.tsx b/src/routes/Apps/App.tsx index ea7210d..c67cbf8 100644 --- a/src/routes/Apps/App.tsx +++ b/src/routes/Apps/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router-dom'; import { Header } from '../../components/Header/Header'; import { Terminal } from '../../components/Terminal/Terminal'; import { apiService } from '../../services/api'; @@ -203,7 +203,7 @@ export const AppDetail: React.FC = () => {
{app.domain ? ( - + {app.domain} β†— ) : ( @@ -212,13 +212,14 @@ export const AppDetail: React.FC = () => {
- - + {app.server} β†— +
diff --git a/src/routes/Apps/Apps.scss b/src/routes/Apps/Apps.scss index 360285f..01eb1eb 100644 --- a/src/routes/Apps/Apps.scss +++ b/src/routes/Apps/Apps.scss @@ -144,7 +144,7 @@ } .domain-link { - color: $primary; + color: $primary-dark; text-decoration: none; transition: color $transition-base; diff --git a/src/routes/Apps/Apps.tsx b/src/routes/Apps/Apps.tsx index 9ca5703..faf161c 100644 --- a/src/routes/Apps/Apps.tsx +++ b/src/routes/Apps/Apps.tsx @@ -211,7 +211,7 @@ export const Apps: React.FC = () => { Server Version Status - Actions + {/* Actions */} @@ -269,7 +269,7 @@ export const Apps: React.FC = () => { {app.status} - + {/*
)}
- + */} )) )} -- 2.49.0 From b7e8bab6d1715fcafb45bea172001b54470faf88 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Thu, 14 May 2026 22:19:15 -0700 Subject: [PATCH 16/24] Add card mixins for consistent styling across server and app components --- src/assets/scss/_mixins.scss | 78 ++++++++++++++++++++++++++++ src/routes/Apps/Apps.scss | 3 +- src/routes/Dashboard/_Dashboard.scss | 11 +--- src/routes/Servers/Server.scss | 4 +- src/routes/Servers/Servers.scss | 48 +++-------------- 5 files changed, 88 insertions(+), 56 deletions(-) diff --git a/src/assets/scss/_mixins.scss b/src/assets/scss/_mixins.scss index ab73615..bbbe9ce 100644 --- a/src/assets/scss/_mixins.scss +++ b/src/assets/scss/_mixins.scss @@ -28,6 +28,84 @@ padding: $spacing-xl; } +/// Standard vertical stack for card-style list rows (dashboard recent apps, server detail apps, etc.) +@mixin card-list-vertical($gap: $spacing-md) { + display: flex; + flex-direction: column; + gap: $gap; +} + +/// Hover lift used by dashboard list cards, server grid cards, and similar surfaces +@mixin card-hover-lift($translate-y: -2px, $hover-shadow: $shadow-lg) { + transition: transform $transition-base, box-shadow $transition-base; + + &:hover { + transform: translateY($translate-y); + box-shadow: $hover-shadow; + } +} + +/// Card shell with horizontal scroll (e.g. data tables) +@mixin scrollable-card { + @include card; + overflow-x: auto; +} + +/// Header block inside a card: title area with a bottom rule (server cards, etc.) +@mixin card-header-rule { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: $spacing-lg; + padding-bottom: $spacing-md; + border-bottom: 2px solid $bg-secondary; +} + +/// Primary title + muted monospace/meta line (server cards, resource headers) +@mixin card-title-stack( + $title-size: $font-size-xl, + $title-weight: $font-weight-bold, + $meta-size: $font-size-sm +) { + h3 { + margin: 0 0 $spacing-xs; + font-size: $title-size; + color: $text-primary; + font-weight: $title-weight; + } + + .server-host { + font-size: $meta-size; + color: $text-muted; + font-family: monospace; + } +} + +/// Label + value row inside a card body (server stats, etc.) +@mixin card-stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-sm 0; + border-bottom: 1px solid $bg-secondary; + + &:last-child { + border-bottom: none; + } +} + +/// Full-width edge bleed for highlighted rows inside padded cards (matches card horizontal padding) +@mixin card-row-highlight-bleed($clear-bottom-border: true) { + padding: $spacing-sm $spacing-md; + margin: $spacing-sm (-$spacing-xl); + padding-left: calc($spacing-xl + $spacing-md); + padding-right: calc($spacing-xl + $spacing-md); + + @if $clear-bottom-border { + border-bottom: none; + } +} + // Button base @mixin button-base { padding: $spacing-sm $spacing-lg; diff --git a/src/routes/Apps/Apps.scss b/src/routes/Apps/Apps.scss index 360285f..b3b966d 100644 --- a/src/routes/Apps/Apps.scss +++ b/src/routes/Apps/Apps.scss @@ -83,8 +83,7 @@ // Apps table specific styles .apps-table-container { - @include card; - overflow-x: auto; + @include scrollable-card; margin-bottom: $spacing-lg; } diff --git a/src/routes/Dashboard/_Dashboard.scss b/src/routes/Dashboard/_Dashboard.scss index 77e9a4e..f804d61 100644 --- a/src/routes/Dashboard/_Dashboard.scss +++ b/src/routes/Dashboard/_Dashboard.scss @@ -23,24 +23,17 @@ } .apps-list { - display: flex; - flex-direction: column; - gap: $spacing-md; + @include card-list-vertical; } .app-item { @include card; + @include card-hover-lift(-2px, $shadow-lg); display: flex; justify-content: space-between; align-items: center; - transition: transform $transition-base, box-shadow $transition-base; cursor: pointer; - &:hover { - transform: translateY(-2px); - box-shadow: $shadow-lg; - } - .app-info { flex: 1; diff --git a/src/routes/Servers/Server.scss b/src/routes/Servers/Server.scss index c379ff6..0976afd 100644 --- a/src/routes/Servers/Server.scss +++ b/src/routes/Servers/Server.scss @@ -212,9 +212,7 @@ } .apps-list { - display: flex; - flex-direction: column; - gap: $spacing-md; + @include card-list-vertical; } .app-item { diff --git a/src/routes/Servers/Servers.scss b/src/routes/Servers/Servers.scss index ab719ab..5ba2c7f 100644 --- a/src/routes/Servers/Servers.scss +++ b/src/routes/Servers/Servers.scss @@ -26,38 +26,17 @@ // Server card .server-card { @include card; + @include card-hover-lift(-4px, $shadow-xl); display: flex; flex-direction: column; - transition: transform $transition-base, box-shadow $transition-base; position: relative; overflow: hidden; - &:hover { - transform: translateY(-4px); - box-shadow: $shadow-xl; - } - .server-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: $spacing-lg; - padding-bottom: $spacing-md; - border-bottom: 2px solid $bg-secondary; + @include card-header-rule; .server-title { - h3 { - margin: 0 0 $spacing-xs; - font-size: $font-size-xl; - color: $text-primary; - font-weight: $font-weight-bold; - } - - .server-host { - font-size: $font-size-sm; - color: $text-muted; - font-family: monospace; - } + @include card-title-stack; } .server-status { @@ -80,24 +59,12 @@ margin-bottom: $spacing-lg; .stat-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: $spacing-sm 0; - border-bottom: 1px solid $bg-secondary; - - &:last-child { - border-bottom: none; - } + @include card-stat-row; // Highlighted rows &.highlight { + @include card-row-highlight-bleed; background-color: rgba($warning, 0.05); - padding: $spacing-sm $spacing-md; - margin: $spacing-sm (-$spacing-xl); - padding-left: calc($spacing-xl + $spacing-md); - padding-right: calc($spacing-xl + $spacing-md); - border-bottom: none; .stat-label { font-weight: $font-weight-semibold; @@ -109,11 +76,8 @@ } &.chaos-row { + @include card-row-highlight-bleed(false); background-color: rgba($info, 0.05); - padding: $spacing-sm $spacing-md; - margin: $spacing-sm (-$spacing-xl); - padding-left: calc($spacing-xl + $spacing-md); - padding-right: calc($spacing-xl + $spacing-md); .stat-label { font-weight: $font-weight-semibold; -- 2.49.0 From e6f159bbea50cee20bbfc6bc7a54a9e101b7d02b Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Fri, 15 May 2026 20:00:29 -0700 Subject: [PATCH 17/24] first big style pass, consolidate to mixins --- src/assets/scss/_mixins.scss | 69 ++++++++++++++++++++++++++++ src/routes/Apps/App.scss | 20 +------- src/routes/Apps/Apps.scss | 55 +++------------------- src/routes/Apps/Apps.tsx | 23 +++++----- src/routes/Dashboard/_Dashboard.scss | 1 - src/routes/Recipes/Recipes.scss | 19 ++------ src/routes/Servers/Server.scss | 20 +------- src/routes/Servers/Servers.scss | 21 ++------- src/routes/Servers/Servers.tsx | 24 +--------- 9 files changed, 98 insertions(+), 154 deletions(-) diff --git a/src/assets/scss/_mixins.scss b/src/assets/scss/_mixins.scss index bbbe9ce..e5189ee 100644 --- a/src/assets/scss/_mixins.scss +++ b/src/assets/scss/_mixins.scss @@ -28,6 +28,16 @@ padding: $spacing-xl; } +// Ensure cards occupy consistent vertical space and can stretch horizontally +@mixin card-dimensions($min-width: 320px, $min-height: 180px) { + min-width: $min-width; + min-height: $min-height; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} + /// Standard vertical stack for card-style list rows (dashboard recent apps, server detail apps, etc.) @mixin card-list-vertical($gap: $spacing-md) { display: flex; @@ -141,4 +151,63 @@ font-weight: $font-weight-semibold; background-color: rgba($color, 0.1); // color: darken($color, 20%); +} + +// Compact chip used for stat chips and small interactive chips +@mixin chip( + $bg: white, + $border-color: $border-color, + $radius: $radius-md, + $padding: $spacing-md $spacing-lg, + $gap: $spacing-md, + $hover-translate: -2px, + $hover-shadow: $shadow-sm, + $font-size: $font-size-base +) { + display: flex; + align-items: center; + gap: $gap; + padding: $padding; + background: $bg; + border: 2px solid $border-color; + border-radius: $radius; + cursor: pointer; + transition: all $transition-base; + font-size: $font-size; + + &:hover:not(:disabled) { + border-color: $primary; + transform: translateY($hover-translate); + box-shadow: $hover-shadow; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +// Reusable action button styles; accepts border width and radius +@mixin action-btn($border-width: 1px, $radius: $radius-sm, $padding: $spacing-xs $spacing-md, $font-size: $font-size-sm) { + background: none; + border: $border-width solid $border-color; + padding: $padding; + border-radius: $radius; + cursor: pointer; + font-size: $font-size; + color: $text-primary; + font-weight: $font-weight-medium; + transition: all $transition-base; + + &:hover { + background-color: rgba($primary, 0.05); + color: $text-primary; + transform: translateY(-1px); + border-color: $primary; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } } \ No newline at end of file diff --git a/src/routes/Apps/App.scss b/src/routes/Apps/App.scss index c0b528b..aa45b8f 100644 --- a/src/routes/Apps/App.scss +++ b/src/routes/Apps/App.scss @@ -47,25 +47,7 @@ // Action buttons .action-btn { - padding: $spacing-sm $spacing-lg; - border: 2px solid $border-color; - border-radius: $radius-md; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - cursor: pointer; - transition: all $transition-base; - background: white; - color: $text-primary; - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: $shadow-md; - } + @include action-btn(2px, $radius-md, $spacing-sm $spacing-lg, $font-size-sm); &.primary { background: $primary; diff --git a/src/routes/Apps/Apps.scss b/src/routes/Apps/Apps.scss index b3b966d..78bb452 100644 --- a/src/routes/Apps/Apps.scss +++ b/src/routes/Apps/Apps.scss @@ -21,31 +21,12 @@ } .stat-chip { - display: flex; - align-items: center; - gap: $spacing-sm; - padding: $spacing-sm $spacing-md; - background: white; - border: 2px solid $border-color; - border-radius: $radius-md; - cursor: pointer; - transition: all $transition-base; - font-size: $font-size-base; - - &:hover:not(:disabled) { - border-color: $primary; - transform: translateY(-2px); - box-shadow: $shadow-sm; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } + @include chip(); &.active { - border-color: $primary; - background: rgba($primary, 0.05); + border-color: $primary-dark; + background: $bg-primary; + box-shadow: 0 0 0 3px rgba($primary-dark, 0.08); } .stat-label { @@ -63,23 +44,7 @@ } } -.reset-filters-btn { - padding: $spacing-sm $spacing-md; - background: $bg-secondary; - border: 2px solid $border-color; - border-radius: $radius-md; - cursor: pointer; - transition: all $transition-base; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - color: $text-secondary; - - &:hover { - background: white; - color: $text-primary; - border-color: $primary; - } -} +/* reset-filters-btn removed β€” outline on stat-chip indicates active filters */ // Apps table specific styles .apps-table-container { @@ -181,15 +146,7 @@ gap: $spacing-sm; .action-btn { - background: none; - border: 1px solid $border-color; - padding: $spacing-xs $spacing-md; - border-radius: $radius-sm; - cursor: pointer; - font-size: $font-size-sm; - color: $text-primary; - font-weight: $font-weight-medium; - transition: all $transition-base; + @include action-btn(1px, $radius-sm, $spacing-xs $spacing-md, $font-size-sm); &:hover { background-color: $primary; diff --git a/src/routes/Apps/Apps.tsx b/src/routes/Apps/Apps.tsx index 9ca5703..b49d4c5 100644 --- a/src/routes/Apps/Apps.tsx +++ b/src/routes/Apps/Apps.tsx @@ -94,19 +94,19 @@ export const Apps: React.FC = () => { setShowUpgradesOnly(false); }; - const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev); - const toggleChaos = () => setShowChaosOnly(prev => !prev); - - // Show only apps that need upgrades - const filterByUpgrades = () => { - setShowUpgradesOnly(true); - setFilterStatus('all'); + const toggleUpgrades = () => { + setShowUpgradesOnly(!showUpgradesOnly); + if (!showUpgradesOnly) { + setFilterStatus('all'); + setShowChaosOnly(false); + } }; - // Show only chaos apps - const filterByChaos = () => { - setFilterStatus('chaos'); - setShowUpgradesOnly(false); + const toggleChaos = () => { + const newChaosState = !showChaosOnly; + setShowChaosOnly(newChaosState); + setFilterStatus(newChaosState ? 'chaos' : 'all'); + if (newChaosState) setShowUpgradesOnly(false); }; if (loading) { @@ -150,7 +150,6 @@ export const Apps: React.FC = () => { Apps {stats.total} - - - {(searchTerm || sortBy !== 'name' || showUpgradesOnly) && ( - - )} + {/* Clear filters button removed β€” use stat-chip outlines for active filters */}
{/* Filters */} -- 2.49.0 From 1b5907be78267d56f8975cff9a7f7d6417d9b1f5 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Sat, 16 May 2026 12:18:51 -0700 Subject: [PATCH 18/24] fix filter toggles, fix bad color declarations --- src/assets/scss/_global.scss | 9 +++++ src/routes/Apps/App.scss | 60 +++++++++++++++++----------------- src/routes/Apps/Apps.scss | 20 ++++++------ src/routes/Apps/Apps.tsx | 30 ++++++----------- src/routes/Servers/Servers.tsx | 13 +++++--- 5 files changed, 67 insertions(+), 65 deletions(-) diff --git a/src/assets/scss/_global.scss b/src/assets/scss/_global.scss index 9fbde7a..529d799 100644 --- a/src/assets/scss/_global.scss +++ b/src/assets/scss/_global.scss @@ -234,6 +234,15 @@ body { color: $text-secondary; } +// Ensure stat-chip shows primary-dark outline on hover and when active +.stat-chip { + &:hover:not(:disabled), + &.active { + border-color: $primary-dark; + box-shadow: 0 0 0 3px rgba($primary-dark, 0.08); + } +} + // Results count .results-count { text-align: center; diff --git a/src/routes/Apps/App.scss b/src/routes/Apps/App.scss index aa45b8f..3d6d302 100644 --- a/src/routes/Apps/App.scss +++ b/src/routes/Apps/App.scss @@ -119,39 +119,39 @@ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: $spacing-lg; -} - -.info-item { - display: flex; - flex-direction: column; - gap: $spacing-xs; - - label { - font-size: $font-size-xs; - color: $text-secondary; - font-weight: $font-weight-medium; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - span { - font-size: $font-size-base; - color: $text-primary; - } - - .no-value { - color: $text-muted; - font-style: italic; - } - - .chaos-active { - color: darken($info, 10%); - font-weight: $font-weight-medium; + + .info-item { + display: flex; + flex-direction: column; + gap: $spacing-xs; + + label { + font-size: $font-size-xs; + color: $text-secondary; + font-weight: $font-weight-medium; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + span { + font-size: $font-size-base; + color: $text-primary; + } + + .no-value { + color: $text-muted; + font-style: italic; + } + + .chaos-active { + color: darken($info, 10%); + font-weight: $font-weight-medium; + } } } .domain-link { - color: $primary; + color: $primary-dark; text-decoration: none; font-size: $font-size-base; transition: color $transition-base; @@ -165,7 +165,7 @@ .server-link { background: none; border: none; - color: $primary; + color: $primary-dark; cursor: pointer; padding: 0; font-size: $font-size-base; diff --git a/src/routes/Apps/Apps.scss b/src/routes/Apps/Apps.scss index 78bb452..ead1337 100644 --- a/src/routes/Apps/Apps.scss +++ b/src/routes/Apps/Apps.scss @@ -105,19 +105,19 @@ font-weight: $font-weight-medium; color: $text-primary; } -} - -.domain-link { - color: $primary; - text-decoration: none; - transition: color $transition-base; - - &:hover { - color: $primary-light; - text-decoration: underline; + .domain-link { + color: $primary-dark; + text-decoration: none; + transition: color $transition-base; + + &:hover { + color: $primary-light; + text-decoration: underline; + } } } + .no-domain { color: $text-muted; font-style: italic; diff --git a/src/routes/Apps/Apps.tsx b/src/routes/Apps/Apps.tsx index b49d4c5..654f30d 100644 --- a/src/routes/Apps/Apps.tsx +++ b/src/routes/Apps/Apps.tsx @@ -1,3 +1,8 @@ + +// TODOS: +// make the two filters non-exlusive + + import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Header } from '../../components/Header/Header'; @@ -12,7 +17,6 @@ export const Apps: React.FC = () => { const [error, setError] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [filterServer, setFilterServer] = useState('all'); - const [filterStatus, setFilterStatus] = useState('all'); const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); const [showChaosOnly, setShowChaosOnly] = useState(false); @@ -60,7 +64,7 @@ export const Apps: React.FC = () => { return Object.keys(appsData); }, [appsData]); - // Filter apps + // Filter apps (additive filters: upgrades AND chaos can be applied together) const filteredApps = useMemo(() => { return allApps.filter(app => { const matchesSearch = @@ -69,14 +73,12 @@ export const Apps: React.FC = () => { (app.domain || '').toLowerCase().includes(searchTerm.toLowerCase()); const matchesServer = filterServer === 'all' || app.server === filterServer; - const matchesChaos = filterStatus === 'all' || - (filterStatus === 'chaos' && app.chaos === 'true') || - (filterStatus === 'stable' && app.chaos === 'false'); + const matchesChaos = !showChaosOnly || app.chaos === 'true'; const matchesUpgrade = !showUpgradesOnly || app.upgrade !== 'latest'; return matchesSearch && matchesServer && matchesChaos && matchesUpgrade; }); - }, [allApps, searchTerm, filterServer, filterStatus, showUpgradesOnly]); + }, [allApps, searchTerm, filterServer, showUpgradesOnly, showChaosOnly]); const stats = useMemo(() => { const total = allApps.length; @@ -94,20 +96,8 @@ export const Apps: React.FC = () => { setShowUpgradesOnly(false); }; - const toggleUpgrades = () => { - setShowUpgradesOnly(!showUpgradesOnly); - if (!showUpgradesOnly) { - setFilterStatus('all'); - setShowChaosOnly(false); - } - }; - - const toggleChaos = () => { - const newChaosState = !showChaosOnly; - setShowChaosOnly(newChaosState); - setFilterStatus(newChaosState ? 'chaos' : 'all'); - if (newChaosState) setShowUpgradesOnly(false); - }; + const toggleUpgrades = () => setShowUpgradesOnly(prev => !prev); + const toggleChaos = () => setShowChaosOnly(prev => !prev); if (loading) { return ( diff --git a/src/routes/Servers/Servers.tsx b/src/routes/Servers/Servers.tsx index f6c9da4..7cc6233 100644 --- a/src/routes/Servers/Servers.tsx +++ b/src/routes/Servers/Servers.tsx @@ -21,6 +21,7 @@ export const Servers: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name'); const [showUpgradesOnly, setShowUpgradesOnly] = useState(false); + const [showChaosOnly, setShowChaosOnly] = useState(false); const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; @@ -79,14 +80,15 @@ export const Servers: React.FC = () => { return { totalServers, totalApps, totalUpgrades, totalChaos }; }, [servers]); - // Filter and sort servers + // Filter and sort servers (additive filters allowed) const filteredServers = useMemo(() => { const filtered = servers.filter(server => { const matchesSearch = server.name.toLowerCase().includes(searchTerm.toLowerCase()) || server.host.toLowerCase().includes(searchTerm.toLowerCase()); const matchesUpgrades = !showUpgradesOnly || server.upgradeCount > 0; - return matchesSearch && matchesUpgrades; + const matchesChaos = !showChaosOnly || server.chaosCount > 0; + return matchesSearch && matchesUpgrades && matchesChaos; }); filtered.sort((a, b) => { @@ -157,9 +159,10 @@ export const Servers: React.FC = () => { +
diff --git a/src/components/Header/_Header.scss b/src/components/Header/_Header.scss index 3219edb..480d9ec 100644 --- a/src/components/Header/_Header.scss +++ b/src/components/Header/_Header.scss @@ -27,12 +27,14 @@ margin: 0; display: inline-block; max-width: 100%; + cursor: pointer; } .nav { display: flex; gap: $spacing-sm; flex: 1; + text-align: center; @media (max-width: 768px) { width: 100%; -- 2.49.0 From e4e9eea438efda90858866f0ec10a3ea2c0be883 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Sat, 16 May 2026 14:11:20 -0700 Subject: [PATCH 24/24] default pointer for filters --- src/assets/scss/_global.scss | 3 +++ src/routes/Apps/Apps.tsx | 4 ++-- src/routes/Servers/Servers.tsx | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/assets/scss/_global.scss b/src/assets/scss/_global.scss index a4ceef5..612f936 100644 --- a/src/assets/scss/_global.scss +++ b/src/assets/scss/_global.scss @@ -244,6 +244,9 @@ body { box-shadow: 0 0 0 3px rgba($primary-dark, 0.08); } } +.filter-chip { + cursor: default; +} // Results count .results-count { diff --git a/src/routes/Apps/Apps.tsx b/src/routes/Apps/Apps.tsx index ad2e066..0fa9c0f 100644 --- a/src/routes/Apps/Apps.tsx +++ b/src/routes/Apps/Apps.tsx @@ -135,7 +135,7 @@ export const Apps: React.FC = () => {