1798fc6804
Machine status probe on page load: - POST /api/machines/refresh probes all machines in parallel (max 20 concurrent, 1.5s timeout) - Updates DB status and broadcasts via SSE to all connected browser tabs - Server-side throttle: ignores refresh requests within 10s - Machines.tsx and Dashboard.tsx fire probe on mount - Visual "Checking machine status..." indicator in Machines table - MachineHandler now accepts *Engine for ProbeAllMachines access
191 lines
6.3 KiB
TypeScript
191 lines
6.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Server, Activity, HardDrive, Clock, Plus } from 'lucide-react';
|
|
import { api, Machine, Job, SyncPair } 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';
|
|
import { subscribeMachineStatus } from '@/lib/sse';
|
|
|
|
export default function Dashboard() {
|
|
const [machines, setMachines] = useState<Machine[]>([]);
|
|
const [jobs, setJobs] = useState<Job[]>([]);
|
|
const [pairs, setPairs] = useState<SyncPair[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
api<Machine[]>('/api/machines'),
|
|
api<Job[]>('/api/jobs?limit=5'),
|
|
api<SyncPair[]>('/api/sync-pairs'),
|
|
])
|
|
.then(([m, j, p]) => {
|
|
setMachines(m);
|
|
setJobs(j);
|
|
setPairs(p);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
api('/api/machines/refresh', { method: 'POST' }).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const unsub = subscribeMachineStatus((evt) => {
|
|
setMachines((prev) =>
|
|
prev.map((m) =>
|
|
m.id === evt.machine_id ? { ...m, status: evt.status } : m
|
|
)
|
|
);
|
|
});
|
|
return unsub;
|
|
}, []);
|
|
|
|
const pairName = (id: number) => pairs.find(p => p.id === id)?.name ?? `Pair ${id}`;
|
|
|
|
const online = machines.filter(m => m.status.startsWith('online')).length;
|
|
const todayJobs = jobs.filter(j => {
|
|
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="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>
|
|
|
|
<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" />
|
|
))}
|
|
</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">
|
|
{pairName(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>
|
|
);
|
|
}
|