Files
move-data-nas/web/src/pages/Dashboard.tsx
T
darroyo 1798fc6804 Bump version to 1.0.11
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
2026-07-08 21:44:50 -04:00

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