add server route

This commit is contained in:
Matt Beaudoin
2025-11-14 13:11:49 -08:00
parent a84bd39a6d
commit 87855061fa
6 changed files with 621 additions and 7 deletions

View File

@ -4,6 +4,7 @@ import { LoginForm } from './routes/Login/LoginForm';
import { Authenticated } from './routes/Login/Authenticated';
import { Dashboard } from './routes/Authenticated/Dashboard/Dashboard';
import { Apps } from './routes/Authenticated/Apps/Apps';
import { Servers } from './routes/Authenticated/Servers/Servers';
function App() {
return (
@ -23,7 +24,6 @@ function App() {
}
/>
{/* Placeholder routes for navigation */}
<Route
path="/apps"
element={
@ -37,7 +37,7 @@ function App() {
path="/servers"
element={
<Authenticated>
<div style={{ padding: '2rem' }}>Servers page - Coming soon</div>
<Servers />
</Authenticated>
}
/>

View File

@ -62,5 +62,5 @@
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
background-color: rgba($color, 0.1);
color: darken($color, 20%);
// color: darken($color, 20%);
}

View File

@ -208,7 +208,7 @@
display: inline-block;
padding: $spacing-xs $spacing-sm;
background-color: rgba($info, 0.1);
color: darken($info, 20%);
// color: darken($info, 20%);
border-radius: $radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
@ -265,7 +265,7 @@
&.status-deployed {
background-color: rgba($success, 0.1);
color: darken($success, 20%);
// color: darken($success, 20%);
}
&.status-stopped {
@ -275,7 +275,7 @@
&.status-error {
background-color: rgba($error, 0.1);
color: darken($error, 10%);
// color: darken($error, 10%);
}
}

View File

@ -1,5 +1,5 @@
// routes/Apps/Apps.tsx
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Header } from '../../../components/Header/Header';
import { apiService } from '../../../services/api';
import type { AbraApp, AppWithServer, ServerAppsResponse } from '../../../types';

View File

@ -0,0 +1,345 @@
@use '../../../assets/scss/variables' as *;
@use '../../../assets/scss/mixins' as *;
.servers-page {
min-height: 100vh;
background-color: $bg-secondary;
}
.servers-content {
max-width: 1600px;
margin: 0 auto;
padding: $spacing-2xl $spacing-xl;
@media (max-width: 768px) {
padding: $spacing-xl $spacing-md;
}
}
.page-header {
margin-bottom: $spacing-2xl;
h1 {
font-size: $font-size-3xl;
margin: 0 0 $spacing-sm;
color: $text-primary;
}
.subtitle {
font-size: $font-size-lg;
color: $text-secondary;
margin: 0;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-lg;
margin-bottom: $spacing-2xl;
}
.stat-card {
@include card;
display: flex;
align-items: center;
gap: $spacing-lg;
padding: $spacing-xl;
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
&.upgrade {
border-left: 4px solid $warning;
}
&.chaos {
border-left: 4px solid $info;
}
.stat-icon {
font-size: 2rem;
}
.stat-info {
.stat-number {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: $text-primary;
margin: 0;
line-height: 1;
}
.stat-label {
font-size: $font-size-sm;
color: $text-secondary;
margin: $spacing-xs 0 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.filters {
@include card;
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
margin-bottom: $spacing-xl;
@media (max-width: 768px) {
flex-direction: column;
}
.search-input {
flex: 1;
min-width: 250px;
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-base;
transition: border-color $transition-base;
&:focus {
outline: none;
border-color: $primary;
}
}
select {
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
border-radius: $radius-md;
font-size: $font-size-base;
background-color: white;
cursor: pointer;
transition: border-color $transition-base;
&:focus {
outline: none;
border-color: $primary;
}
}
}
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: $spacing-xl;
margin-bottom: $spacing-xl;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.server-card {
@include card;
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;
.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;
}
}
.server-status {
.status-indicator {
font-size: $font-size-xl;
&.connected {
color: $success;
}
&.disconnected {
color: $error;
}
}
}
}
.server-stats {
flex: 1;
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;
}
&.highlight {
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 {
// color: darken($warning, 10%);
font-weight: $font-weight-semibold;
}
.stat-value {
// color: darken($warning, 10%);
font-weight: $font-weight-bold;
}
}
&.chaos-row {
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 {
// color: darken($info, 10%);
font-weight: $font-weight-semibold;
}
.stat-value {
// color: darken($info, 10%);
font-weight: $font-weight-bold;
}
}
.stat-label {
font-size: $font-size-sm;
color: $text-secondary;
display: flex;
align-items: center;
gap: $spacing-xs;
.upgrade-icon,
.chaos-icon {
font-size: $font-size-base;
}
}
.stat-value {
font-size: $font-size-lg;
color: $text-primary;
font-weight: $font-weight-semibold;
}
}
}
.server-actions {
display: flex;
gap: $spacing-sm;
margin-bottom: $spacing-md;
.action-btn {
flex: 1;
padding: $spacing-sm $spacing-md;
border: 2px solid $border-color;
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: $primary-dark;
transform: translateY(-1px);
}
&.primary {
color: white;
border-color: $primary;
&:hover {
background-color: $primary-dark;
border-color: $primary-dark;
}
}
}
}
.server-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;
}
.alert-text {
font-size: $font-size-sm;
// color: darken($warning, 20%);
font-weight: $font-weight-medium;
}
}
}
.no-results {
@include card;
text-align: center;
padding: $spacing-3xl;
color: $text-secondary;
grid-column: 1 / -1;
}
.results-count {
text-align: center;
color: $text-secondary;
font-size: $font-size-sm;
padding: $spacing-md;
}
.loading,
.error {
text-align: center;
padding: $spacing-3xl;
font-size: $font-size-lg;
}
.loading {
color: $text-secondary;
}
.error {
color: $error;
}

View File

@ -0,0 +1,269 @@
// routes/Servers/Servers.tsx
import React, { useEffect, useMemo, useState } from 'react';
import { Header } from '../../../components/Header/Header';
import { apiService } from '../../../services/api';
import type { AbraServer, ServerAppsResponse } from '../../../types';
import './Servers.scss';
interface ServerWithStats extends AbraServer {
appCount: number;
versionCount: number;
latestCount: number;
upgradeCount: number;
chaosCount: number;
}
export const Servers: React.FC = () => {
const [servers, setServers] = useState<ServerWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'apps' | 'upgrades'>('name');
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
const { mockApiService } = await import('../../../services/mockApi');
const [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([
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);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load servers');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
// Calculate overall stats
const stats = useMemo(() => {
const totalServers = servers.length;
const totalApps = servers.reduce((sum, s) => sum + s.appCount, 0);
const totalUpgrades = servers.reduce((sum, s) => sum + s.upgradeCount, 0);
const totalChaos = servers.reduce((sum, s) => sum + s.chaosCount, 0);
return { totalServers, totalApps, totalUpgrades, totalChaos };
}, [servers]);
// Filter and sort servers
const filteredServers = useMemo(() => {
const filtered = servers.filter(server =>
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.host.toLowerCase().includes(searchTerm.toLowerCase())
);
// 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);
}
});
return filtered;
}, [servers, searchTerm, sortBy]);
if (loading) {
return (
<div className="servers-page">
<Header />
<main className="servers-content">
<div className="loading">Loading servers...</div>
</main>
</div>
);
}
if (error) {
return (
<div className="servers-page">
<Header />
<main className="servers-content">
<div className="error">Error: {error}</div>
</main>
</div>
);
}
return (
<div className="servers-page">
<Header />
<main className="servers-content">
<div className="page-header">
<h1>Servers</h1>
<p className="subtitle">Managing {stats.totalServers} servers with {stats.totalApps} applications</p>
</div>
{/* Stats Overview */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalServers}</p>
<p className="stat-label">Total Servers</p>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalApps}</p>
<p className="stat-label">Total Apps</p>
</div>
</div>
<div className="stat-card upgrade">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalUpgrades}</p>
<p className="stat-label">Apps Need Upgrade</p>
</div>
</div>
<div className="stat-card chaos">
<div className="stat-icon"></div>
<div className="stat-info">
<p className="stat-number">{stats.totalChaos}</p>
<p className="stat-label">Chaos Apps</p>
</div>
</div>
</div>
{/* Filters */}
<div className="filters">
<input
type="text"
placeholder="Search servers by name or host..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
<option value="name">Sort by Name</option>
<option value="apps">Sort by App Count</option>
<option value="upgrades">Sort by Upgrades</option>
</select>
</div>
{/* Server Cards */}
<div className="servers-grid">
{filteredServers.length === 0 ? (
<div className="no-results">No servers found matching your search</div>
) : (
filteredServers.map((server) => (
<div key={server.name} className="server-card">
<div className="server-header">
<div className="server-title">
<h3>{server.name}</h3>
<span className="server-host">{server.host}</span>
</div>
<div className="server-status">
<span className="status-indicator connected" title="Connected"></span>
</div>
</div>
<div className="server-stats">
<div className="stat-row">
<span className="stat-label">Applications</span>
<span className="stat-value">{server.appCount}</span>
</div>
<div className="stat-row">
<span className="stat-label">Versioned</span>
<span className="stat-value">{server.versionCount}</span>
</div>
<div className="stat-row">
<span className="stat-label">Latest</span>
<span className="stat-value">{server.latestCount}</span>
</div>
{server.upgradeCount > 0 && (
<div className="stat-row highlight">
<span className="stat-label">
<span className="upgrade-icon"></span> Need Upgrade
</span>
<span className="stat-value">{server.upgradeCount}</span>
</div>
)}
{server.chaosCount > 0 && (
<div className="stat-row chaos-row">
<span className="stat-label">
<span className="chaos-icon"></span> Chaos Mode
</span>
<span className="stat-value">{server.chaosCount}</span>
</div>
)}
</div>
<div className="server-actions">
<button className="action-btn primary">View Apps</button>
<button className="action-btn">Manage</button>
</div>
{server.upgradeCount > 0 && (
<div className="server-alert">
<span className="alert-icon"></span>
<span className="alert-text">
{server.upgradeCount} app{server.upgradeCount > 1 ? 's' : ''} can be upgraded
</span>
</div>
)}
</div>
))
)}
</div>
<div className="results-count">
Showing {filteredServers.length} of {servers.length} servers
</div>
</main>
</div>
);
};