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:
+255
-79
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user