add server details page #1

Merged
BornDeleuze merged 5 commits from dev into main 2026-01-18 05:51:38 +00:00
9 changed files with 864 additions and 47 deletions

View File

@ -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

View File

@ -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() {
}
/>
<Route
path="/servers/:serverName"
element={
<Authenticated>
<Server/>
</Authenticated>
}
/>
{/* Redirect root to dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@ -102,7 +102,7 @@ export const AppDetail: React.FC = () => {
<main className="app-detail-content">
<div className="error">{error || 'App not found'}</div>
<button onClick={() => navigate('/apps')} className="back-button">
Back to Apps
Back to Apps
</button>
</main>
</div>
@ -130,7 +130,7 @@ export const AppDetail: React.FC = () => {
<span className="recipe-badge">{app.recipe}</span>
<span className={`status-badge status-${app.status}`}>{app.status}</span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode enabled">🔬 Chaos</span>
<span className="chaos-badge" title="Chaos mode enabled">Chaos</span>
)}
</div>
</div>
@ -254,7 +254,7 @@ export const AppDetail: React.FC = () => {
{app.upgrade === 'latest' && (
<div className="version-latest">
Running latest version
Running latest version
</div>
)}
</div>
@ -272,7 +272,6 @@ export const AppDetail: React.FC = () => {
onClick={() => handleAction('start')}
disabled={actionLoading === 'start'}
>
<span className="action-icon"></span>
<span className="action-text">Start Application</span>
</button>
@ -281,27 +280,22 @@ export const AppDetail: React.FC = () => {
onClick={() => handleAction('stop')}
disabled={actionLoading === 'stop'}
>
<span className="action-icon"></span>
<span className="action-text">Stop Application</span>
</button>
<button className="action-list-item">
<span className="action-icon">🔄</span>
<span className="action-text">Restart Application</span>
</button>
<button className="action-list-item">
<span className="action-icon">📊</span>
<span className="action-text">View Logs</span>
</button>
<button className="action-list-item">
<span className="action-icon"></span>
<span className="action-text">Edit Configuration</span>
</button>
<button className="action-list-item danger">
<span className="action-icon">🗑</span>
<span className="action-text">Delete Application</span>
</button>
</div>

View File

@ -200,9 +200,9 @@ export const Apps: React.FC = () => {
</td>
</tr>
) : (
filteredApps.map((app, index) => (
filteredApps.map((app) => (
<tr
key={`${app.server}-${app.appName}-${index}`}
key={`${app.server}/${app.appName}`}
onClick={() => navigate(`/apps/${app.server}/${app.appName}`)}
style={{ cursor: 'pointer' }}
>
@ -234,7 +234,7 @@ export const Apps: React.FC = () => {
<div className="version-cell">
<span className="version">{app.version}</span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode enabled"></span>
<span className="chaos-badge" title="Chaos mode enabled"></span>
)}
{app.upgrade !== 'latest' && (
<span className="upgrade-available" title="Upgrade available">

View File

@ -90,7 +90,7 @@ export const Dashboard: React.FC = () => {
</div>
</button>
<button onClick={() => navigate('/apps')} className="nav-link bland-button">
<button onClick={() => navigate('/servers')} className="nav-link bland-button">
<div className="stat-card">
<h3>Servers</h3>
<p className="stat-number">{servers.length}</p>

View File

@ -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;
}
}
}
}

View File

@ -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<ServerWithApps | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(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 (
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="loading">Loading server...</div>
</main>
</div>
);
}
if (error || !server) {
return (
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="error">{error || 'Server not found'}</div>
<button onClick={() => navigate('/servers')} className="back-button">
Back to Servers
</button>
</main>
</div>
);
}
const chaosApps = server.apps.filter(app => app.chaos === 'true');
const runningApps = server.apps.filter(app => app.status === 'deployed' || app.status === 'running');
return (
<div className="server-detail-page">
<Header />
<main className="server-detail-content">
<div className="breadcrumb">
<button onClick={() => navigate('/servers')} className="breadcrumb-link">
Servers
</button>
<span className="breadcrumb-separator">/</span>
<span className="breadcrumb-current">{server.name}</span>
</div>
<div className="server-header">
<div className="server-title-section">
<h1>{server.name}</h1>
<div className="server-meta">
<span className="host-badge">{server.host}</span>
{server.upgradeCount > 0 && (
<span className="upgrade-badge">
{server.upgradeCount} upgrade{server.upgradeCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="server-actions">
<button
className="action-btn secondary"
onClick={() => handleAction('refresh')}
disabled={actionLoading === 'refresh'}
>
{actionLoading === 'refresh' ? 'Refreshing...' : 'Refresh'}
</button>
<button
className="action-btn primary"
onClick={() => handleAction('deploy-all')}
disabled={actionLoading === 'deploy-all'}
>
{actionLoading === 'deploy-all' ? 'Deploying...' : 'Deploy All'}
</button>
</div>
</div>
<div className="content-grid">
{/* Left Column - Main Info */}
<div className="main-column">
<section className="info-card">
<h2>Server Details</h2>
<div className="info-grid">
<div className="info-item">
<label>Server Name</label>
<span>{server.name}</span>
</div>
<div className="info-item">
<label>Host</label>
<span>{server.host}</span>
</div>
<div className="info-item">
<label>Total Apps</label>
<span className="stat-value">{server.appCount}</span>
</div>
<div className="info-item">
<label>Running Apps</label>
<span className="stat-value">{runningApps.length}</span>
</div>
<div className="info-item">
<label>Upgrades Available</label>
<span className={server.upgradeCount > 0 ? 'stat-value warning' : 'stat-value'}>
{server.upgradeCount}
</span>
</div>
<div className="info-item">
<label>Chaos Mode Apps</label>
<span className={chaosApps.length > 0 ? 'stat-value chaos' : 'stat-value'}>
{chaosApps.length}
</span>
</div>
<div className="info-item">
<label>Latest Versions</label>
<span className="stat-value">{server.latestCount}</span>
</div>
<div className="info-item">
<label>Versioned Apps</label>
<span className="stat-value">{server.versionCount}</span>
</div>
</div>
</section>
<section className="info-card">
<h2>Applications on this Server</h2>
{server.apps.length === 0 ? (
<div className="no-apps">No applications deployed on this server</div>
) : (
<div className="apps-list">
{server.apps.map((app, idx) => (
<div
key={`${app.appName}-${idx}`}
className="app-item"
onClick={() => navigate(`/apps/${server.name}/${app.appName}`)}
>
<div className="app-item-header">
<span className="app-item-name">{app.appName}</span>
<div className="app-item-badges">
<span className="recipe-badge">{app.recipe}</span>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
{app.chaos === 'true' && (
<span className="chaos-badge" title="Chaos mode"></span>
)}
{app.upgrade !== 'latest' && (
<span className="upgrade-badge" title="Upgrade available"></span>
)}
</div>
</div>
<div className="app-item-details">
<span className="app-item-version">v{app.version}</span>
{app.domain && (
<a
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
className="app-item-domain"
onClick={(e) => e.stopPropagation()}
>
{app.domain}
</a>
)}
</div>
</div>
))}
</div>
)}
</section>
</div>
{/* Right Column - Actions & Stats */}
<div className="sidebar-column">
<section className="info-card">
<h2>Quick Actions</h2>
<div className="action-list">
<button
className="action-list-item"
onClick={() => handleAction('refresh')}
disabled={actionLoading === 'refresh'}
>
<span className="action-text">Refresh Server Info</span>
</button>
<button
className="action-list-item"
onClick={() => handleAction('deploy-all')}
disabled={actionLoading === 'deploy-all'}
>
<span className="action-text">Deploy All Apps</span>
</button>
<button
className="action-list-item"
onClick={() => handleAction('upgrade-all')}
disabled={actionLoading === 'upgrade-all' || server.upgradeCount === 0}
>
<span className="action-text">Upgrade All Apps</span>
</button>
<button className="action-list-item">
<span className="action-text">View Metrics</span>
</button>
<button className="action-list-item">
<span className="action-text">View Logs</span>
</button>
<button className="action-list-item">
<span className="action-text">Server Settings</span>
</button>
<button className="action-list-item danger">
<span className="action-text">Remove Server</span>
</button>
</div>
</section>
<section className="info-card">
<h2>Server Health</h2>
<div className="health-status">
<div className="health-item">
<span className="health-label">Status</span>
<span className="health-value status-online">Online</span>
</div>
<div className="health-item">
<span className="health-label">CPU Usage</span>
<span className="health-value"></span>
</div>
<div className="health-item">
<span className="health-label">Memory</span>
<span className="health-value"></span>
</div>
<div className="health-item">
<span className="health-label">Disk Space</span>
<span className="health-value"></span>
</div>
<div className="health-item">
<span className="health-label">Uptime</span>
<span className="health-value"></span>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
);
};

View File

@ -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<ServerWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@ -199,7 +201,12 @@ export const Servers: React.FC = () => {
<div className="no-results">No servers found matching your search</div>
) : (
filteredServers.map((server) => (
<div key={server.name} className="server-card">
<div
key={server.name}
className="server-card"
onClick={() => navigate(`/servers/${server.name}`)}
style={{ cursor: 'pointer' }}
>
<div className="server-header">
<div className="server-title">
<h3>{server.name}</h3>
@ -226,7 +233,7 @@ export const Servers: React.FC = () => {
{server.upgradeCount > 0 && (
<div className="stat-row highlight">
<span className="stat-label">
<span className="upgrade-icon"></span> Need Upgrade
<span className="upgrade-icon"></span> Need Upgrade
</span>
<span className="stat-value">{server.upgradeCount}</span>
</div>
@ -242,13 +249,29 @@ export const Servers: React.FC = () => {
</div>
<div className="server-actions">
<button className="action-btn primary">View Apps</button>
<button className="action-btn">Manage</button>
<button
className="action-btn primary"
onClick={(e) => {
e.stopPropagation();
navigate(`/servers/${server.name}`);
}}
>
View Apps
</button>
<button
className="action-btn"
onClick={(e) => {
e.stopPropagation();
navigate(`/servers/${server.name}`);
}}
>
Manage
</button>
</div>
{server.upgradeCount > 0 && (
<div className="server-alert">
<span className="alert-icon"></span>
<span className="alert-icon"></span>
<span className="alert-text">
{server.upgradeCount} app{server.upgradeCount > 1 ? 's' : ''} can be upgraded
</span>

View File

@ -27,17 +27,6 @@ export const mockAppsData: ServerAppsResponse = {
version: "3.4.2+v3.4.5",
upgrade: "3.6.2+v3.4.5\n3.6.1+v3.4.5"
},
{
server: "mydomain.com",
recipe: "authentik",
appName: "accounts.mydomain.com",
domain: "accounts.mydomain.com",
status: "deployed",
chaos: "false",
chaosVersion: "unknown",
version: "7.4.0+2025.6.3",
upgrade: "9.0.1+2025.8.1\n9.0.0+2025.8.1"
},
{
server: "mydomain.com",
recipe: "authentik",
@ -72,21 +61,10 @@ export const mockAppsData: ServerAppsResponse = {
upgrade: "4.0.0+25.04.4.1.1\n3.3.0+24.04.13.3.1"
},
{
server: "mydomain.com",
recipe: "nextcloud",
appName: "nc.mydomain.com",
domain: "nc.mydomain.com",
status: "deployed",
chaos: "false",
chaosVersion: "unknown",
version: "12.0.1+31.0.6-fpm",
upgrade: "latest"
},
{
server: "mydomain.com",
server: "myotherdomain.com",
recipe: "traefik",
appName: "traefik.mydomain.com",
domain: "traefik.mydomain.com",
appName: "traefik.myotherdomain.com",
domain: "traefik.myotherdomain.com",
status: "deployed",
chaos: "false",
chaosVersion: "unknown",
@ -134,10 +112,6 @@ export const mockAppsData: ServerAppsResponse = {
};
export const mockServers: AbraServer[] = [
{
name: 'mydomain.com',
host: 'mydomain.com',
},
{
name: 'test.coop',
host: 'test.coop',