84b185be39
Phase A - Stability: - Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash - Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits - Queue keyed by jobID (not syncPairID): cancel now targets exact job - Local rsync uses jobCtx (context.Background() replaced) - Migrations wrapped in transactions; checksums stored Phase B - Security: - admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run - Path validation: rejects .., leading -, null bytes in sync pair paths - Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from) - Shell concat in RunRemote replaced with proper sh -c escaping - knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts - RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role - deploy-keys: uses authorized_keys only (no private key upload) - Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir() Phase C - Operational: - /readyz health check: DB query + SSH dir accessibility - /metrics endpoint: Prometheus text format (jobs, queue, machines) - Event struct JSON tags: job_id, machine_id, type (snake_case) - EventBus broadcast: fanned out to all subscribers - SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set - Filesystem job log cleanup: removes .log files for purged jobs - Backup retention: old backups auto-purged Phase D - Frontend: - Schedules page: REST API + full CRUD UI for cron schedules - Dashboard: cancel button for running/queued jobs - JobDetail: server-side log download via API - Settings: displays data_dir from server - 404 page: proper NotFound component Phase E - Tests: - auth_test.go: JWT, bcrypt, middleware, seed (18 tests) - models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests) - go test -race: no data races found
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { api, apiRaw } 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 { Textarea } from '@/components/ui/Textarea';
|
|
import { Label } from '@/components/ui/Label';
|
|
import {
|
|
Modal,
|
|
ModalContent,
|
|
ModalHeader,
|
|
ModalTitle,
|
|
ModalDescription,
|
|
ModalBody,
|
|
ModalFooter,
|
|
} from '@/components/ui/Modal';
|
|
import { ErrorDetailsModal } from '@/components/ErrorDetailsModal';
|
|
import {
|
|
ArrowLeft,
|
|
Download,
|
|
XCircle,
|
|
ScrollText,
|
|
Terminal,
|
|
AlertCircle,
|
|
Ban,
|
|
ChevronDown,
|
|
Activity,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { statusVariant, statusLabel, getErrorCodeInfo } from '@/lib/status';
|
|
import { formatDuration } from '@/lib/utils';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SSEProgress {
|
|
file_bytes: number;
|
|
pct: number;
|
|
speed_bps: number;
|
|
eta_seconds: number;
|
|
xfr_done: number;
|
|
xfr_total: number;
|
|
}
|
|
|
|
interface SSEEvent {
|
|
type: string;
|
|
job_id: number;
|
|
status?: string;
|
|
line?: string;
|
|
stream?: string;
|
|
progress?: SSEProgress;
|
|
totalBytes?: number;
|
|
sentBytes?: number;
|
|
}
|
|
|
|
export default function JobDetail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [job, setJob] = useState<Job | null>(null);
|
|
const [pair, setPair] = useState<SyncPair | null>(null);
|
|
const [logs, setLogs] = useState<LogLine[]>([]);
|
|
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);
|
|
const [cancelReason, setCancelReason] = useState('');
|
|
const [errorModal, setErrorModal] = useState(false);
|
|
const [progress, setProgress] = useState<SSEProgress | null>(null);
|
|
const [finalTotals, setFinalTotals] = useState<{ totalBytes: number; sentBytes: number } | null>(null);
|
|
const [hasMoreLogs, setHasMoreLogs] = useState(false);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadJob();
|
|
if (jobId) {
|
|
loadLogs(0);
|
|
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') {
|
|
setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
|
|
}
|
|
if (evt.type === 'status') {
|
|
setJob(prev => {
|
|
if (!prev) return prev;
|
|
const updated = { ...prev, status: evt.status! };
|
|
if (evt.line) updated.error_message = evt.line;
|
|
return updated;
|
|
});
|
|
}
|
|
if (evt.type === 'progress' && evt.progress) {
|
|
setProgress(evt.progress);
|
|
}
|
|
if (evt.type === 'progress_total' && evt.totalBytes !== undefined && evt.sentBytes !== undefined) {
|
|
setFinalTotals({ totalBytes: evt.totalBytes, sentBytes: evt.sentBytes });
|
|
setProgress(null);
|
|
}
|
|
};
|
|
}
|
|
return () => esRef.current?.close();
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (autoScroll && logEndRef.current) {
|
|
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [liveLines, autoScroll]);
|
|
|
|
async function loadJob() {
|
|
try {
|
|
const j = await api<Job>(`/api/jobs/${id}`);
|
|
setJob(j);
|
|
const pairs = (await api<SyncPair[]>('/api/sync-pairs')) ?? [];
|
|
const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id);
|
|
setPair(p || null);
|
|
} catch {
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadLogs(offset: number) {
|
|
try {
|
|
const ls = (await api<LogLine[]>(
|
|
`/api/jobs/${id}/log?offset=${offset}&limit=1000`
|
|
)) ?? [];
|
|
if (offset === 0) {
|
|
setLogs(ls);
|
|
setHasMoreLogs(ls.length === 1000);
|
|
} else {
|
|
setLogs(prev => [...prev, ...ls]);
|
|
setHasMoreLogs(ls.length === 1000);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
async function cancel() {
|
|
try {
|
|
await api(`/api/jobs/${id}/cancel`, {
|
|
method: 'POST',
|
|
body: { reason: cancelReason.trim() || undefined },
|
|
});
|
|
toast.success('Job cancelled');
|
|
setCancelModal(false);
|
|
setCancelReason('');
|
|
loadJob();
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error).message);
|
|
}
|
|
}
|
|
|
|
async function downloadLog() {
|
|
try {
|
|
const resp = await apiRaw(`/api/jobs/${id}/log/download`);
|
|
if (resp.ok && resp.body) {
|
|
const blob = await resp.blob();
|
|
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);
|
|
} else {
|
|
fallbackDownload();
|
|
}
|
|
} catch {
|
|
fallbackDownload();
|
|
}
|
|
}
|
|
|
|
function fallbackDownload() {
|
|
const allLines = [
|
|
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
|
|
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
|
|
];
|
|
const blob = new Blob(allLines as string[], { 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);
|
|
}
|
|
|
|
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="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;
|
|
|
|
const fullErrorLog = [
|
|
...logs.filter(l => l.stream === 'stderr').map(l => l.content),
|
|
...liveLines.filter(l => l.stream === 'stderr').map(l => l.text),
|
|
].join('\n');
|
|
|
|
const errorInfo = getErrorCodeInfo(job.error_code ?? null);
|
|
|
|
return (
|
|
<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="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>
|
|
|
|
{(job.status === 'failed' || (job.status === 'cancelled' && job.error_message)) && (
|
|
<div className={cn(
|
|
"rounded-card border p-4 space-y-2",
|
|
job.status === 'failed'
|
|
? "border-rose-500/30 bg-rose-500/10"
|
|
: "border-zinc-500/30 bg-zinc-500/10"
|
|
)}>
|
|
<div className="flex items-start gap-2">
|
|
{job.status === 'failed'
|
|
? <AlertCircle className="h-4 w-4 text-rose-400 shrink-0 mt-0.5" />
|
|
: <Ban className="h-4 w-4 text-zinc-400 shrink-0 mt-0.5" />
|
|
}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className={cn(
|
|
"text-sm font-semibold",
|
|
job.status === 'failed' ? "text-rose-300" : "text-zinc-300"
|
|
)}>
|
|
{errorInfo?.title ?? (job.status === 'failed' ? 'Job failed' : 'Job cancelled')}
|
|
</span>
|
|
{job.error_code && (
|
|
<span className={cn(
|
|
"text-xs font-mono",
|
|
job.status === 'failed' ? "text-rose-400/60" : "text-zinc-400/60"
|
|
)}>
|
|
[{job.error_code}]
|
|
</span>
|
|
)}
|
|
</div>
|
|
{errorInfo?.hint && (
|
|
<p className={cn(
|
|
"text-xs mt-0.5",
|
|
job.status === 'failed' ? "text-rose-400/70" : "text-zinc-400/70"
|
|
)}>
|
|
{errorInfo.hint}
|
|
</p>
|
|
)}
|
|
<p className={cn(
|
|
"text-xs font-mono mt-1 truncate max-w-2xl",
|
|
job.status === 'failed' ? "text-rose-300/80" : "text-zinc-300/80"
|
|
)}>
|
|
{job.error_message}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setErrorModal(true)}
|
|
className={cn(
|
|
"shrink-0 border",
|
|
job.status === 'failed'
|
|
? "text-rose-300 hover:text-rose-200 border-rose-500/40 hover:border-rose-400/60"
|
|
: "text-zinc-300 hover:text-zinc-200 border-zinc-500/40 hover:border-zinc-400/60"
|
|
)}
|
|
>
|
|
{job.status === 'failed' ? <AlertCircle className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
|
|
View error
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{['queued', 'waking_up', 'running'].includes(job.status) && (
|
|
<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>
|
|
)}
|
|
|
|
{(progress || finalTotals) && (
|
|
<TransferProgress progress={progress} finalTotals={finalTotals} />
|
|
)}
|
|
|
|
<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 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>
|
|
) : (
|
|
<>
|
|
{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} />
|
|
{hasMoreLogs && (
|
|
<div className="flex justify-center py-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={async () => {
|
|
setLoadingMore(true);
|
|
await loadLogs(logs.length);
|
|
setLoadingMore(false);
|
|
}}
|
|
disabled={loadingMore}
|
|
>
|
|
<ChevronDown className="h-4 w-4" />
|
|
{loadingMore ? 'Loading...' : 'Load more'}
|
|
</Button>
|
|
</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>
|
|
<form onSubmit={e => { e.preventDefault(); cancel(); }}>
|
|
<ModalBody>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="cancel-reason">Reason (optional)</Label>
|
|
<Textarea
|
|
id="cancel-reason"
|
|
value={cancelReason}
|
|
onChange={e => setCancelReason(e.target.value)}
|
|
placeholder="e.g. wrong path, machine offline..."
|
|
rows={2}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<p className="text-xs text-fg-subtle">
|
|
Adding a reason helps track why jobs are cancelled
|
|
</p>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button variant="secondary" type="button" onClick={() => { setCancelModal(false); setCancelReason(''); }}>
|
|
Keep Running
|
|
</Button>
|
|
<Button variant="danger-solid" type="submit">
|
|
Cancel Job
|
|
</Button>
|
|
</ModalFooter>
|
|
</form>
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
<ErrorDetailsModal
|
|
open={errorModal}
|
|
onOpenChange={setErrorModal}
|
|
job={job}
|
|
fullLog={fullErrorLog}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
}
|
|
|
|
function formatSpeed(bps: number): string {
|
|
if (bps === 0) return '0 B/s';
|
|
const units = ['B/s', 'kB/s', 'MB/s', 'GB/s'];
|
|
const i = Math.floor(Math.log(bps) / Math.log(1000));
|
|
return `${(bps / Math.pow(1000, i)).toFixed(1)} ${units[i]}`;
|
|
}
|
|
|
|
function TransferProgress({
|
|
progress,
|
|
finalTotals,
|
|
}: {
|
|
progress: SSEProgress | null;
|
|
finalTotals: { totalBytes: number; sentBytes: number } | null;
|
|
}) {
|
|
const globalPct = finalTotals && finalTotals.totalBytes > 0
|
|
? Math.round((finalTotals.sentBytes / finalTotals.totalBytes) * 100)
|
|
: null;
|
|
|
|
return (
|
|
<Card>
|
|
<div className="p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="h-4 w-4 text-accent" />
|
|
<span className="text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
|
Transfer Progress
|
|
</span>
|
|
</div>
|
|
|
|
{progress && (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
<div className="flex justify-between text-xs text-fg-muted">
|
|
<span>Current file</span>
|
|
<span>{progress.pct}%</span>
|
|
</div>
|
|
<div className="h-2 bg-border rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent transition-all duration-300 rounded-full"
|
|
style={{ width: `${progress.pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between text-xs text-fg-muted">
|
|
<span className="font-mono">
|
|
xfr#{(progress.xfr_done).toLocaleString()}/{progress.xfr_total > 0 ? progress.xfr_total.toLocaleString() : '?'}
|
|
</span>
|
|
<span className="font-mono">
|
|
{formatSpeed(progress.speed_bps)}
|
|
</span>
|
|
<span className="font-mono">
|
|
ETA {progress.eta_seconds > 0 ? `${Math.floor(progress.eta_seconds / 60)}m ${progress.eta_seconds % 60}s` : '-'}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{finalTotals && (
|
|
<div className="space-y-1.5">
|
|
<div className="flex justify-between text-xs text-fg-muted">
|
|
<span>Total transferred</span>
|
|
<span>{globalPct}% — {formatBytes(finalTotals.sentBytes)} / {formatBytes(finalTotals.totalBytes)}</span>
|
|
</div>
|
|
<div className="h-2 bg-border rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-emerald-500 transition-all duration-300 rounded-full"
|
|
style={{ width: `${globalPct ?? 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|