From 9e6c456956ba8de613ee474c3e3f2989f569ff14 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Sat, 17 Jan 2026 21:27:43 -0800 Subject: [PATCH 1/5] add server detail page and nav --- src/App.tsx | 10 + src/routes/Authenticated/Apps/App.tsx | 12 +- src/routes/Authenticated/Servers/Server.scss | 464 +++++++++++++++++++ src/routes/Authenticated/Servers/Server.tsx | 350 ++++++++++++++ 4 files changed, 827 insertions(+), 9 deletions(-) create mode 100644 src/routes/Authenticated/Servers/Server.scss create mode 100644 src/routes/Authenticated/Servers/Server.tsx diff --git a/src/App.tsx b/src/App.tsx index 4a4fe47..eea1522 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ 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'; function App() { return ( @@ -52,6 +53,15 @@ function App() { } /> + + + + } + /> + {/* Redirect root to dashboard */} } /> diff --git a/src/routes/Authenticated/Apps/App.tsx b/src/routes/Authenticated/Apps/App.tsx index 77fc491..a71b48d 100644 --- a/src/routes/Authenticated/Apps/App.tsx +++ b/src/routes/Authenticated/Apps/App.tsx @@ -102,7 +102,7 @@ export const AppDetail: React.FC = () => {
{error || 'App not found'}
@@ -130,7 +130,7 @@ export const AppDetail: React.FC = () => { {app.recipe} {app.status} {app.chaos === 'true' && ( - πŸ”¬ Chaos + Chaos )} @@ -254,7 +254,7 @@ export const AppDetail: React.FC = () => { {app.upgrade === 'latest' && (
- βœ… Running latest version + Running latest version
)} @@ -272,7 +272,6 @@ export const AppDetail: React.FC = () => { onClick={() => handleAction('start')} disabled={actionLoading === 'start'} > - ▢️ Start Application @@ -281,27 +280,22 @@ export const AppDetail: React.FC = () => { onClick={() => handleAction('stop')} disabled={actionLoading === 'stop'} > - ⏸️ Stop Application diff --git a/src/routes/Authenticated/Servers/Server.scss b/src/routes/Authenticated/Servers/Server.scss new file mode 100644 index 0000000..08560b7 --- /dev/null +++ b/src/routes/Authenticated/Servers/Server.scss @@ -0,0 +1,464 @@ +@use '../../../assets/scss/variables' as *; +@use '../../../assets/scss/mixins' as *; + +.server-detail-page { + min-height: 100vh; + background-color: $bg-secondary; +} + +.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-dark; + text-decoration: underline; + } + } + + .breadcrumb-separator { + color: $text-muted; + } + + .breadcrumb-current { + color: $text-primary; + font-weight: $font-weight-semibold; + } +} + +// Server header section +.server-header { + @include card; + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: $spacing-xl; + margin-bottom: $spacing-xl; + + .server-title-section { + flex: 1; + min-width: 300px; + + h1 { + margin: 0 0 $spacing-md 0; + font-size: $font-size-3xl; + color: $text-primary; + font-weight: $font-weight-bold; + } + + .server-meta { + display: flex; + gap: $spacing-sm; + flex-wrap: wrap; + } + } + + .server-actions { + display: flex; + gap: $spacing-md; + flex-wrap: wrap; + } +} + +// 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; + } + + &.primary { + background: $primary; + color: white; + border-color: $primary; + + &:hover:not(:disabled) { + background: $primary-dark; + border-color: $primary-dark; + } + } + + &.secondary { + background: $bg-secondary; + color: $text-primary; + border-color: $border-color; + + &:hover:not(:disabled) { + background: darken($bg-secondary, 5%); + } + } + + &.danger { + background: $error; + color: white; + border-color: $error; + + &:hover:not(:disabled) { + background: darken($error, 10%); + } + } +} + +.back-button { + @extend .action-btn; + @extend .secondary; +} + +// Badges +.host-badge { + display: inline-flex; + align-items: center; + gap: $spacing-xs; + padding: $spacing-xs $spacing-md; + background: $bg-secondary; + color: $text-secondary; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + font-family: monospace; +} + +.upgrade-badge { + display: inline-flex; + align-items: center; + gap: $spacing-xs; + padding: $spacing-xs $spacing-md; + background: rgba($warning, 0.1); + color: darken($warning, 20%); + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; +} + +.recipe-badge { + display: inline-block; + padding: $spacing-xs $spacing-sm; + background: rgba($primary, 0.1); + color: $primary-dark; + 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; + grid-template-columns: 2fr 1fr; + gap: $spacing-xl; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } +} + +// Info cards +.info-card { + @include card; + margin-bottom: $spacing-xl; + + &:last-child { + margin-bottom: 0; + } + + h2 { + margin: 0 0 $spacing-lg 0; + font-size: $font-size-xl; + color: $text-primary; + font-weight: $font-weight-bold; + } +} + +// Info grid +.info-grid { + 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; + } + + .stat-value { + font-size: $font-size-xl; + font-weight: $font-weight-bold; + color: $text-primary; + + &.warning { + color: darken($warning, 10%); + } + + &.chaos { + color: darken($info, 10%); + } + } + + .no-value { + color: $text-muted; + font-style: italic; + } +} + +// Apps list in server detail +.no-apps { + text-align: center; + padding: $spacing-2xl; + color: $text-secondary; + font-size: $font-size-base; +} + +.apps-list { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.app-item { + padding: $spacing-md; + background: $bg-secondary; + border-radius: $radius-md; + border: 2px solid transparent; + cursor: pointer; + transition: all $transition-base; + + &:hover { + border-color: $primary; + background: white; + transform: translateX(4px); + } + + .app-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: $spacing-md; + margin-bottom: $spacing-sm; + + .app-item-name { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-primary; + } + + .app-item-badges { + display: flex; + gap: $spacing-xs; + flex-wrap: wrap; + } + } + + .app-item-details { + display: flex; + align-items: center; + gap: $spacing-md; + font-size: $font-size-sm; + + .app-item-version { + color: $text-secondary; + font-family: monospace; + } + + .app-item-domain { + color: $primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +// Quick actions list +.action-list { + display: flex; + flex-direction: column; + gap: $spacing-sm; + + .action-list-item { + display: flex; + align-items: center; + gap: $spacing-md; + padding: $spacing-md; + background: $bg-secondary; + border: 2px solid transparent; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-base; + font-size: $font-size-sm; + color: $text-primary; + text-align: left; + width: 100%; + + &:hover:not(:disabled) { + background: white; + border-color: $primary; + transform: translateX(2px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.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; + } + } +} + +// Health status +.health-status { + display: flex; + flex-direction: column; + gap: $spacing-md; + + .health-item { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: $spacing-md; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .health-label { + font-size: $font-size-sm; + color: $text-secondary; + } + + .health-value { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-primary; + + &.status-online { + color: $success; + } + + &.status-offline { + color: $error; + } + } + } +} \ No newline at end of file diff --git a/src/routes/Authenticated/Servers/Server.tsx b/src/routes/Authenticated/Servers/Server.tsx new file mode 100644 index 0000000..6157623 --- /dev/null +++ b/src/routes/Authenticated/Servers/Server.tsx @@ -0,0 +1,350 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Header } from '../../../components/Header/Header'; +import { apiService } from '../../../services/api'; +import type { AbraServer, ServerAppsResponse } from '../../../types'; +import './Server.scss'; + +interface ServerWithApps extends AbraServer { + apps: any[]; + appCount: number; + upgradeCount: number; + versionCount: number; + unversionedCount: number; + latestCount: number; +} + +export const Server: React.FC = () => { + const { serverName } = useParams<{ serverName: string }>(); + const navigate = useNavigate(); + + const [server, setServer] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [actionLoading, setActionLoading] = useState(null); + + const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true'; + + useEffect(() => { + const fetchServer = async () => { + try { + if (isMockMode) { + const { mockApiService } = await import('../../../services/mockApi'); + 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 || '']; + + if (foundServer && serverApps) { + setServer({ + ...foundServer, + apps: serverApps.apps, + appCount: serverApps.appCount, + upgradeCount: serverApps.upgradeCount, + versionCount: serverApps.versionCount, + unversionedCount: serverApps.unversionedCount, + latestCount: serverApps.latestCount, + }); + } else { + setError('Server not found'); + } + } else { + // Real API call + const appsData = await apiService.getAppsGrouped(); + const serversData = await apiService.getServers(); + + const foundServer = serversData.find(s => s.name === serverName); + const serverApps = appsData[serverName || '']; + + if (foundServer && serverApps) { + setServer({ + ...foundServer, + apps: serverApps.apps, + appCount: serverApps.appCount, + upgradeCount: serverApps.upgradeCount, + versionCount: serverApps.versionCount, + unversionedCount: serverApps.unversionedCount, + latestCount: serverApps.latestCount, + }); + } else { + setError('Server not found'); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load server'); + } finally { + setLoading(false); + } + }; + + fetchServer(); + }, [serverName, isMockMode]); + + const handleAction = async (action: string) => { + if (!server) return; + + setActionLoading(action); + try { + console.log(`Action: ${action} on server ${server.name}`); + // Add actual server actions here + } catch (err) { + console.error('Action failed:', err); + } finally { + setActionLoading(null); + } + }; + + if (loading) { + return ( +
+
+
+
Loading server...
+
+
+ ); + } + + if (error || !server) { + return ( +
+
+
+
{error || 'Server not found'}
+ +
+
+ ); + } + + const chaosApps = server.apps.filter(app => app.chaos === 'true'); + const runningApps = server.apps.filter(app => app.status === 'deployed' || app.status === 'running'); + + return ( +
+
+
+
+ + / + {server.name} +
+ +
+
+

{server.name}

+
+ πŸ–₯️ {server.host} + {server.upgradeCount > 0 && ( + + ⬆️ {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''} + + )} +
+
+ +
+ + +
+
+ +
+ {/* Left Column - Main Info */} +
+
+

Server Details

+ +
+
+ + {server.name} +
+ +
+ + {server.host} +
+ +
+ + {server.appCount} +
+ +
+ + {runningApps.length} +
+ +
+ + 0 ? 'stat-value warning' : 'stat-value'}> + {server.upgradeCount} + +
+ +
+ + 0 ? 'stat-value chaos' : 'stat-value'}> + {chaosApps.length} + +
+ +
+ + {server.latestCount} +
+ +
+ + {server.versionCount} +
+
+
+ +
+

Applications on this Server

+ + {server.apps.length === 0 ? ( +
No applications deployed on this server
+ ) : ( +
+ {server.apps.map((app, idx) => ( +
navigate(`/apps/${server.name}/${app.appName}`)} + > +
+ {app.appName} +
+ {app.recipe} + + {app.status} + + {app.chaos === 'true' && ( + + )} + {app.upgrade !== 'latest' && ( + + )} +
+
+
+ v{app.version} + {app.domain && ( + e.stopPropagation()} + > + {app.domain} + + )} +
+
+ ))} +
+ )} +
+
+ + {/* Right Column - Actions & Stats */} +
+
+

Quick Actions

+ +
+ + + + + + + + + + + + + +
+
+ +
+

Server Health

+
+
+ Status + Online +
+
+ CPU Usage + β€” +
+
+ Memory + β€” +
+
+ Disk Space + β€” +
+
+ Uptime + β€” +
+
+
+
+
+
+
+ ); +}; \ No newline at end of file -- 2.49.0 From 43740d0357e3a53c6e32112e96b6e932d76a15fe Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Sat, 17 Jan 2026 21:28:56 -0800 Subject: [PATCH 2/5] fix nav clicks --- src/routes/Authenticated/Servers/Servers.tsx | 33 +++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/routes/Authenticated/Servers/Servers.tsx b/src/routes/Authenticated/Servers/Servers.tsx index b7c1cbb..95c369a 100644 --- a/src/routes/Authenticated/Servers/Servers.tsx +++ b/src/routes/Authenticated/Servers/Servers.tsx @@ -1,4 +1,5 @@ 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'; @@ -13,6 +14,7 @@ interface ServerWithStats extends AbraServer { } export const Servers: React.FC = () => { + const navigate = useNavigate(); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -199,7 +201,12 @@ export const Servers: React.FC = () => {
No servers found matching your search
) : ( filteredServers.map((server) => ( -
+
navigate(`/servers/${server.name}`)} + style={{ cursor: 'pointer' }} + >

{server.name}

@@ -226,7 +233,7 @@ export const Servers: React.FC = () => { {server.upgradeCount > 0 && (
- Need Upgrade + ⬆️ Need Upgrade {server.upgradeCount}
@@ -242,13 +249,29 @@ export const Servers: React.FC = () => {
- - + +
{server.upgradeCount > 0 && (
- + ⚠️ {server.upgradeCount} app{server.upgradeCount > 1 ? 's' : ''} can be upgraded -- 2.49.0 From a30bafaa1eb0962d44b6cdad19e1ff13bda333dc Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Sat, 17 Jan 2026 21:32:13 -0800 Subject: [PATCH 3/5] style pass --- src/routes/Authenticated/Servers/Server.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/routes/Authenticated/Servers/Server.tsx b/src/routes/Authenticated/Servers/Server.tsx index 6157623..0cab409 100644 --- a/src/routes/Authenticated/Servers/Server.tsx +++ b/src/routes/Authenticated/Servers/Server.tsx @@ -114,7 +114,7 @@ export const Server: React.FC = () => {
{error || 'Server not found'}
@@ -140,10 +140,10 @@ export const Server: React.FC = () => {

{server.name}

- πŸ–₯️ {server.host} + {server.host} {server.upgradeCount > 0 && ( - ⬆️ {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''} + {server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''} )}
@@ -155,14 +155,14 @@ export const Server: React.FC = () => { onClick={() => handleAction('refresh')} disabled={actionLoading === 'refresh'} > - {actionLoading === 'refresh' ? 'Refreshing...' : 'πŸ”„ Refresh'} + {actionLoading === 'refresh' ? 'Refreshing...' : 'Refresh'}
-- 2.49.0 From f188aa20d74e816cffe97acc2e2afd755ca45002 Mon Sep 17 00:00:00 2001 From: Matt Beaudoin Date: Sat, 17 Jan 2026 21:40:04 -0800 Subject: [PATCH 4/5] remove index usage --- README.md | 2 ++ src/routes/Authenticated/Apps/Apps.tsx | 6 +++--- src/routes/Authenticated/Dashboard/Dashboard.tsx | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a95d5d8..f99cd06 100644 --- a/README.md +++ b/README.md @@ -2,5 +2,7 @@ This is the frontend of a web wrapper for Coop Clouds abra CLI, letting users set up and manage VPSs and docker images through a web UI. +## Still a work in progess! + ## This is built with react, typescript, scss, and vite diff --git a/src/routes/Authenticated/Apps/Apps.tsx b/src/routes/Authenticated/Apps/Apps.tsx index fafe941..f0aadb0 100644 --- a/src/routes/Authenticated/Apps/Apps.tsx +++ b/src/routes/Authenticated/Apps/Apps.tsx @@ -200,9 +200,9 @@ export const Apps: React.FC = () => { ) : ( - filteredApps.map((app, index) => ( + filteredApps.map((app) => ( navigate(`/apps/${app.server}/${app.appName}`)} style={{ cursor: 'pointer' }} > @@ -234,7 +234,7 @@ export const Apps: React.FC = () => {
{app.version} {app.chaos === 'true' && ( - ☠️ + )} {app.upgrade !== 'latest' && ( diff --git a/src/routes/Authenticated/Dashboard/Dashboard.tsx b/src/routes/Authenticated/Dashboard/Dashboard.tsx index 67cd754..96b3317 100644 --- a/src/routes/Authenticated/Dashboard/Dashboard.tsx +++ b/src/routes/Authenticated/Dashboard/Dashboard.tsx @@ -90,7 +90,7 @@ export const Dashboard: React.FC = () => {
-