add server details page #1
@ -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
|
||||
|
||||
|
||||
10
src/App.tsx
10
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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/servers/:serverName"
|
||||
element={
|
||||
<Authenticated>
|
||||
<Server/>
|
||||
</Authenticated>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Redirect root to dashboard */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
464
src/routes/Authenticated/Servers/Server.scss
Normal file
464
src/routes/Authenticated/Servers/Server.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
350
src/routes/Authenticated/Servers/Server.tsx
Normal file
350
src/routes/Authenticated/Servers/Server.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user