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:
+241
-102
@@ -1,6 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, Job, SyncPair } from '../api/client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
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 { History, XCircle, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { statusVariant, statusLabel } from '@/lib/status';
|
||||
import { formatDuration } from '@/lib/utils';
|
||||
|
||||
export default function JobHistory() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
@@ -10,6 +29,7 @@ export default function JobHistory() {
|
||||
const [filterRange, setFilterRange] = useState('7d');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const limit = 50;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,6 +41,7 @@ export default function JobHistory() {
|
||||
}, [filterStatus, filterPair, filterRange, page]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/jobs?limit=${limit}&offset=${page * limit}`;
|
||||
if (filterStatus) url += `&status=${filterStatus}`;
|
||||
@@ -40,19 +61,26 @@ export default function JobHistory() {
|
||||
if (totalCount) setTotal(Number(totalCount));
|
||||
const data = await res.json();
|
||||
setJobs(data);
|
||||
} catch {}
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPairs() {
|
||||
try { setPairs(await api<SyncPair[]>('/api/sync-pairs')); } catch {}
|
||||
try {
|
||||
setPairs(await api<SyncPair[]>('/api/sync-pairs'));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function cancel(id: number) {
|
||||
if (!confirm('Cancel this job?')) return;
|
||||
try {
|
||||
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||
toast.success('Job cancelled');
|
||||
load();
|
||||
} catch { alert('Cancel failed'); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function pairName(id: number) {
|
||||
@@ -60,110 +88,221 @@ export default function JobHistory() {
|
||||
return p ? p.name : `Pair ${id}`;
|
||||
}
|
||||
|
||||
function statusColor(s: string) {
|
||||
const map: 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 map[s] || 'bg-gray-600';
|
||||
}
|
||||
|
||||
function duration(j: Job) {
|
||||
if (!j.started_at) return '-';
|
||||
const start = new Date(j.started_at).getTime();
|
||||
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
|
||||
const secs = Math.round((end - start) / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const rem = secs % 60;
|
||||
if (mins < 60) return `${mins}m ${rem}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const FilterChip = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-auto min-w-[140px]">
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{label}</SelectItem>
|
||||
{label === 'All Pairs' &&
|
||||
pairs.map(p => (
|
||||
<SelectItem key={p.id} value={p.id.toString()}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{label === 'All Statuses' &&
|
||||
[
|
||||
{ value: 'queued', label: 'Queued' },
|
||||
{ value: 'waking_up', label: 'Waking Up' },
|
||||
{ value: 'running', label: 'Running' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
].map(s => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{label === 'Time Range' &&
|
||||
[
|
||||
{ value: '24h', label: 'Last 24h' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
].map(r => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Job History</h1>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<select value={filterPair} onChange={e => { setFilterPair(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Pairs</option>
|
||||
{pairs.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="waking_up">Waking Up</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select value={filterRange} onChange={e => { setFilterRange(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="24h">Last 24h</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Job History"
|
||||
description={`${total} job${total !== 1 ? 's' : ''} found`}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => load()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-700">
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">ID</th>
|
||||
<th className="p-3">Sync Pair</th>
|
||||
<th className="p-3">Trigger</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3">Duration</th>
|
||||
<th className="p-3">Started</th>
|
||||
<th className="p-3">Finished</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-t border-gray-700 hover:bg-gray-750 cursor-pointer"
|
||||
onClick={() => window.location.href = `/jobs/${j.id}`}>
|
||||
<td className="p-3 text-blue-400 hover:text-blue-300">
|
||||
<Link to={`/jobs/${j.id}`}>#{j.id}</Link>
|
||||
</td>
|
||||
<td className="p-3">{pairName(j.sync_pair_id)}</td>
|
||||
<td className="p-3">{j.trigger_type}</td>
|
||||
<td className="p-3">
|
||||
<span className={`${statusColor(j.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{j.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-gray-400 text-xs">{duration(j)}</td>
|
||||
<td className="p-3 text-xs">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3 text-xs">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3" onClick={e => e.stopPropagation()}>
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300 text-xs">Cancel</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && <tr><td colSpan={8} className="p-4 text-center text-gray-500">No jobs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-700">
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">← Previous</button>
|
||||
<span className="text-gray-400 text-sm">{page + 1} / {totalPages} ({total} total)</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">Next →</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FilterChip label="All Pairs" value={filterPair} onChange={v => { setFilterPair(v); setPage(0); }} />
|
||||
<FilterChip label="All Statuses" value={filterStatus} onChange={v => { setFilterStatus(v); setPage(0); }} />
|
||||
<FilterChip label="Time Range" value={filterRange} onChange={v => { setFilterRange(v); setPage(0); }} />
|
||||
{(filterStatus || filterPair || filterRange !== '7d') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setFilterStatus(''); setFilterPair(''); setFilterRange('7d'); setPage(0); }}
|
||||
className="text-fg-subtle"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-5 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<History className="h-5 w-5" />}
|
||||
title="No jobs found"
|
||||
description={
|
||||
filterStatus || filterPair || filterRange !== '7d'
|
||||
? 'Try adjusting your filters'
|
||||
: 'Sync pairs will appear here once jobs are executed'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Sync Pair</TableHead>
|
||||
<TableHead>Trigger</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Finished</TableHead>
|
||||
<TableHead className="w-20">Actions</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>
|
||||
<span className="text-xs text-fg-muted capitalize">
|
||||
{j.trigger_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={statusVariant(j.status)}
|
||||
label={statusLabel(j.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted font-mono text-xs">
|
||||
{j.started_at
|
||||
? formatDuration(
|
||||
(j.finished_at
|
||||
? new Date(j.finished_at).getTime()
|
||||
: Date.now()) -
|
||||
new Date(j.started_at).getTime()
|
||||
)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{j.started_at
|
||||
? new Date(j.started_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{j.finished_at
|
||||
? new Date(j.finished_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => cancel(j.id)}
|
||||
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
|
||||
title="Cancel job"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
|
||||
<div className="text-xs text-fg-muted">
|
||||
Page {page + 1} of {totalPages} ({total} total)
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-card border border-border bg-surface shadow-card', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user