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:
2026-07-07 23:12:34 -04:00
parent 93c22e844e
commit 9cef7173cb
36 changed files with 4069 additions and 652 deletions
+255 -79
View File
@@ -1,6 +1,32 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api, Job, LogLine, SyncPair } from '../api/client';
import { api } from '../api/client';
import type { Job, LogLine, SyncPair } from '../api/client';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import { Card } from '@/components/ui/Card';
import { Spinner } from '@/components/ui/Spinner';
import { CopyButton } from '@/components/ui/CopyButton';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
} from '@/components/ui/Modal';
import {
ArrowLeft,
Download,
XCircle,
ScrollText,
Terminal,
} from 'lucide-react';
import { toast } from 'sonner';
import { statusVariant, statusLabel } from '@/lib/status';
import { formatDuration } from '@/lib/utils';
import { cn } from '@/lib/utils';
interface SSEEvent {
type: string;
@@ -15,22 +41,26 @@ export default function JobDetail() {
const [job, setJob] = useState<Job | null>(null);
const [pair, setPair] = useState<SyncPair | null>(null);
const [logs, setLogs] = useState<LogLine[]>([]);
const [lines, setLines] = useState<{ stream: string; text: string }[]>([]);
const [liveLines, setLiveLines] = useState<{ stream: string; text: string }[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const logEndRef = useRef<HTMLDivElement>(null);
const esRef = useRef<EventSource | null>(null);
const jobId = Number(id);
const [loading, setLoading] = useState(true);
const [cancelModal, setCancelModal] = useState(false);
useEffect(() => {
loadJob();
if (jobId) {
loadLogs(0);
const es = new EventSource(`/api/jobs/${jobId}/log/stream?job_id=${jobId}`);
const es = new EventSource(
`/api/jobs/${jobId}/log/stream?job_id=${jobId}`
);
esRef.current = es;
es.onmessage = (e) => {
const evt: SSEEvent = JSON.parse(e.data);
if (evt.type === 'log') {
setLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
}
if (evt.type === 'status') {
setJob(prev => prev ? { ...prev, status: evt.status! } : prev);
@@ -44,7 +74,7 @@ export default function JobDetail() {
if (autoScroll && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [lines, autoScroll]);
}, [liveLines, autoScroll]);
async function loadJob() {
try {
@@ -53,12 +83,17 @@ export default function JobDetail() {
const pairs = await api<SyncPair[]>('/api/sync-pairs');
const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id);
setPair(p || null);
} catch {}
} catch {
} finally {
setLoading(false);
}
}
async function loadLogs(offset: number) {
try {
const ls = await api<LogLine[]>(`/api/jobs/${id}/log?offset=${offset}&limit=1000`);
const ls = await api<LogLine[]>(
`/api/jobs/${id}/log?offset=${offset}&limit=1000`
);
if (offset === 0) {
setLogs(ls);
} else {
@@ -68,100 +103,241 @@ export default function JobDetail() {
}
async function cancel() {
if (!confirm('Cancel this job?')) return;
try {
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
toast.success('Job cancelled');
setCancelModal(false);
loadJob();
} catch { alert('Cancel failed'); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
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 downloadLog() {
const allLines = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
];
const blob = new Blob([allLines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `job-${id}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
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 copyLog = useCallback(() => {
const text = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n');
navigator.clipboard.writeText(text);
toast.success('Log copied to clipboard');
}, [logs, liveLines]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Spinner size="lg" />
</div>
);
}
if (!job) return <div className="p-6 text-gray-400">Loading...</div>;
if (!job) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<p className="text-fg-muted">Job not found</p>
<Button variant="secondary" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Back to Jobs
</Link>
</Button>
</div>
);
}
const totalLines = logs.length + liveLines.length;
return (
<div className="p-6 h-screen flex flex-col">
<div className="flex items-center gap-3 mb-4">
<Link to="/jobs" className="text-gray-400 hover:text-white text-sm"> Job History</Link>
<h1 className="text-2xl font-bold">Job #{job.id}</h1>
<span className={`${statusColor(job.status)} text-white text-xs px-2 py-0.5 rounded`}>
{job.status}
</span>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Job History
</Link>
</Button>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-fg font-mono">#{job.id}</h1>
<Badge variant={statusVariant(job.status)} label={statusLabel(job.status)} />
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 mb-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-gray-400 text-xs">Sync Pair</div>
<div className="text-white font-medium">{pair?.name || `Pair ${job.sync_pair_id}`}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Trigger</div>
<div className="text-white">{job.trigger_type}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Duration</div>
<div className="text-white">{duration(job)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Started</div>
<div className="text-white text-xs">{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Sync Pair', value: pair?.name || `Pair ${job.sync_pair_id}` },
{ label: 'Trigger', value: job.trigger_type },
{
label: 'Duration',
value: job.started_at
? formatDuration(
(job.finished_at
? new Date(job.finished_at).getTime()
: Date.now()) -
new Date(job.started_at).getTime()
)
: '-',
},
{
label: 'Started',
value: job.started_at
? new Date(job.started_at).toLocaleString()
: '-',
},
].map(item => (
<Card key={item.label}>
<div className="p-4">
<div className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-1">
{item.label}
</div>
<div className="text-sm font-semibold text-fg truncate">
{item.value}
</div>
</div>
</Card>
))}
</div>
{['queued', 'waking_up', 'running'].includes(job.status) && (
<div className="flex gap-2 mb-4">
<button onClick={cancel} className="bg-red-600 hover:bg-red-700 text-white px-4 py-1.5 rounded text-sm">
Cancel
</button>
<label className="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} />
Auto-scroll
</label>
<div className="flex items-center gap-3">
<Button
variant="danger"
size="sm"
onClick={() => setCancelModal(true)}
>
<XCircle className="h-4 w-4" />
Cancel Job
</Button>
<div className="flex items-center gap-2 text-sm text-fg-muted">
<Switch
id="auto-scroll"
checked={autoScroll}
onCheckedChange={setAutoScroll}
/>
<label htmlFor="auto-scroll" className="cursor-pointer">
Auto-scroll
</label>
</div>
</div>
)}
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden flex flex-col min-h-0">
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between">
<span className="text-gray-400 text-xs font-mono">Output</span>
<span className="text-gray-500 text-xs">{lines.length + logs.length} lines</span>
<Card className="flex flex-col min-h-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-fg-subtle" />
<span className="text-xs font-semibold text-fg-muted uppercase tracking-wider">
Output
</span>
<span className="text-xs text-fg-subtle">
{totalLines} line{totalLines !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-1">
<CopyButton
text={[
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n')}
displayText="Copy log"
/>
<Button variant="ghost" size="icon-sm" onClick={downloadLog} title="Download log">
<Download className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
{logs.map(l => (
<div key={l.id} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
<span className="text-gray-600 mr-2">{((): string => {
const d = new Date(l.timestamp);
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`;
})()}</span>
{l.content}
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5 scrollbar-thin min-h-[300px] max-h-[60vh]">
{logs.length === 0 && liveLines.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-fg-subtle gap-2">
<ScrollText className="h-6 w-6" />
<p>No log output yet</p>
</div>
))}
{lines.map((l, i) => (
<div key={`live-${i}`} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
<span className="text-gray-600 mr-2">LIVE</span>
{l.text}
</div>
))}
) : (
<>
{logs.map(l => (
<LogLine
key={l.id}
stream={l.stream}
content={l.content}
timestamp={l.timestamp}
/>
))}
{liveLines.map((l, i) => (
<div
key={`live-${i}`}
className={cn(
'flex gap-2',
l.stream === 'stderr'
? 'text-rose-400'
: 'text-fg-muted'
)}
>
<span className="text-accent shrink-0">LIVE</span>
<span className="break-all">{l.text}</span>
</div>
))}
</>
)}
<div ref={logEndRef} />
</div>
</div>
</Card>
<Modal open={cancelModal} onOpenChange={setCancelModal}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Cancel Job</ModalTitle>
<ModalDescription>
Are you sure you want to cancel job #{job.id}? This action cannot
be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setCancelModal(false)}>
Keep Running
</Button>
<Button variant="danger-solid" onClick={cancel}>
Cancel Job
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
function LogLine({
stream,
content,
timestamp,
}: {
stream: string;
content: string;
timestamp: string;
}) {
const d = new Date(timestamp);
const timeStr = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
return (
<div
className={cn(
'flex gap-2',
stream === 'stderr' ? 'text-rose-400' : 'text-fg-muted'
)}
>
<span className="text-fg-subtle shrink-0">{timeStr}</span>
<span className="break-all">{content}</span>
</div>
);
}