Visual redesign: design system, refined ops console aesthetic
- Full component library (Button, Input, Label, Select, Modal, Table, Badge, Card, etc.) - Tailwind design tokens: IBM Plex Sans + JetBrains Mono, teal accent, semantic status colors - NavBar with logo, responsive hamburger menu, real logout - All pages redesigned: Login, Dashboard (KPI cards), Machines, SyncPairs, JobHistory, JobDetail, SSHKeys, Settings - Fixed: hover:bg-gray-750 dead class, window.location.href navigation bug - Replaced alert()/confirm() with sonner toasts and accessible modals - Added ErrorBoundary, skip link, accessible modal dialogs (Radix) - Icons: lucide-react throughout, copy/download buttons - 1.0.5 → 1.0.6
This commit is contained in:
+147
-61
@@ -1,18 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Server, Activity, HardDrive, Clock, Plus } from 'lucide-react';
|
||||
import { api, Machine, Job } from '../api/client';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardBody } from '@/components/ui/Card';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/Table';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { statusVariant, statusLabel } from '@/lib/status';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [machines, setMachines] = useState<Machine[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api<Machine[]>('/api/machines'),
|
||||
api<Job[]>('/api/jobs?limit=5'),
|
||||
]).then(([m, j]) => {
|
||||
setMachines(m);
|
||||
setJobs(j);
|
||||
}).catch(() => {});
|
||||
])
|
||||
.then(([m, j]) => {
|
||||
setMachines(m);
|
||||
setJobs(j);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const online = machines.filter(m => m.status.startsWith('online')).length;
|
||||
@@ -20,67 +35,138 @@ export default function Dashboard() {
|
||||
if (!j.started_at) return false;
|
||||
return j.started_at.startsWith(new Date().toISOString().split('T')[0]);
|
||||
}).length;
|
||||
const runningJobs = jobs.filter(j =>
|
||||
['running', 'queued', 'waking_up'].includes(j.status)
|
||||
).length;
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
label: 'Total Machines',
|
||||
value: machines.length,
|
||||
icon: Server,
|
||||
className: 'text-sky-400',
|
||||
bgClass: 'bg-sky-500/10',
|
||||
},
|
||||
{
|
||||
label: 'Online',
|
||||
value: online,
|
||||
icon: Activity,
|
||||
className: 'text-emerald-400',
|
||||
bgClass: 'bg-emerald-500/10',
|
||||
accent: online > 0,
|
||||
},
|
||||
{
|
||||
label: 'Jobs Today',
|
||||
value: todayJobs,
|
||||
icon: Clock,
|
||||
className: 'text-amber-400',
|
||||
bgClass: 'bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
label: 'Running',
|
||||
value: runningJobs,
|
||||
icon: HardDrive,
|
||||
className: 'text-accent',
|
||||
bgClass: 'bg-accent/10',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-gray-400 text-sm">Machines</div>
|
||||
<div className="text-3xl font-bold">{machines.length}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-gray-400 text-sm">Online</div>
|
||||
<div className="text-3xl font-bold text-green-500">{online}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-gray-400 text-sm">Jobs Today</div>
|
||||
<div className="text-3xl font-bold text-blue-500">{todayJobs}</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Overview of your sync infrastructure"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardBody>
|
||||
<Skeleton className="h-4 w-20 mb-3" />
|
||||
<Skeleton className="h-8 w-12" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
))
|
||||
: kpis.map(kpi => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<Card key={kpi.label} className="transition-shadow hover:shadow-card-hover">
|
||||
<CardBody>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider">
|
||||
{kpi.label}
|
||||
</span>
|
||||
<div className={`rounded-card p-1.5 ${kpi.bgClass}`}>
|
||||
<Icon className={`h-3.5 w-3.5 ${kpi.className}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-3xl font-bold ${kpi.className}`}>
|
||||
{kpi.value}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Recent Jobs</h2>
|
||||
{jobs.length === 0 ? <p className="text-gray-500">No jobs yet</p> : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-400 border-b border-gray-700">
|
||||
<th className="pb-2">ID</th>
|
||||
<th className="pb-2">Sync Pair</th>
|
||||
<th className="pb-2">Status</th>
|
||||
<th className="pb-2">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-b border-gray-700/50">
|
||||
<td className="py-2">{j.id}</td>
|
||||
<td className="py-2">{j.sync_pair_id}</td>
|
||||
<td className="py-2">
|
||||
<StatusBadge status={j.status} />
|
||||
</td>
|
||||
<td className="py-2">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
|
||||
<Card>
|
||||
<div className="p-5 pb-0 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-fg">Recent Jobs</h2>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/jobs">View all</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<HardDrive className="h-5 w-5" />}
|
||||
title="No jobs yet"
|
||||
description="Sync pairs will appear here once jobs are executed"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Sync Pair</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map(j => (
|
||||
<TableRow key={j.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/jobs/${j.id}`}
|
||||
className="text-accent hover:text-accent-hover font-mono text-xs"
|
||||
>
|
||||
#{j.id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted">
|
||||
Pair {j.sync_pair_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(j.status)} label={statusLabel(j.status)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{j.started_at ? formatRelativeTime(j.started_at) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
queued: 'bg-gray-600',
|
||||
waking_up: 'bg-yellow-600',
|
||||
running: 'bg-blue-600',
|
||||
success: 'bg-green-600',
|
||||
failed: 'bg-red-600',
|
||||
cancelled: 'bg-gray-600',
|
||||
};
|
||||
return (
|
||||
<span className={`${colors[status] || 'bg-gray-600'} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user