import React, { useEffect, useState } from "react"; const POLL_MS = 4000; // status palette (dataviz tokens) — always paired with icon+label, never color alone const STATUS = { good: { color: "#0ca30c", icon: "●", label: "online" }, warning: { color: "#fab219", icon: "▲", label: "warning" }, serious: { color: "#ec835a", icon: "▲", label: "failing" }, critical: { color: "#d03b3b", icon: "■", label: "offline" }, }; function fmtBytes(b) { if (b == null) return ""; if (b >= 1e9) return `${(b / 1e9).toFixed(1)} GB`; if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; if (b >= 1e3) return `${(b / 1e3).toFixed(0)} KB`; return `${b} B`; } function fmtDur(s) { if (s == null) return ""; if (s >= 3600) return `~${(s / 3600).toFixed(1)}h`; if (s >= 60) return `~${Math.round(s / 60)}m`; return `~${Math.round(s)}s`; } function ago(iso) { if (!iso) return "never"; const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); if (s < 60) return `${Math.round(s)}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; return `${(s / 3600).toFixed(1)}h ago`; } function StatusBadge({ kind, text }) { const s = STATUS[kind]; return ( {text ?? s.label} ); } function PairRow({ p }) { const kind = p.last_status === "fail" ? "serious" : p.last_status === "ok" ? "good" : "warning"; const arrow = p.direction === "up" ? "↑" : "↓"; // pair labels look like "/sources/x → server:path" — keep them compact const label = (p.pair || "").split("→")[0].trim().replace("server:", ""); return (
{arrow} {p.direction} {label} {p.pending_count > 0 && ( {p.pending_count} pending{p.pending_bytes > 0 && ` · ${fmtBytes(p.pending_bytes)}`} )} {p.missing?.length > 0 && ( {p.missing.length} missing! )}
); } function MachineCard({ m, selected, onSelect }) { const kind = m.online ? "good" : "critical"; return (
onSelect(m.machine_id)}>
{m.machine_id}
site {m.site} · seen {ago(m.last_seen)}
{!m.progress && m.last_transfer && (
last transfer · {m.last_transfer.files} files ·{" "} {fmtBytes(m.last_transfer.bytes)} in{" "} {fmtDur(m.last_transfer.duration_s).replace("~", "")} {m.last_transfer.avg_bps != null && ( <> · {fmtBytes(m.last_transfer.avg_bps)}/s )}
)} {m.progress && (
{m.progress.percent}% · {m.progress.speed} · ETA{" "} {m.progress.eta} ({m.progress.direction} sync)
)}
{m.last_round ?? "–"}
last round · {ago(m.last_round_at)}
{m.files_1h}
files synced (1h)
0 ? { color: STATUS.warning.color } : {}}> {m.pending_total ?? "–"}
pending → server {m.pending_total > 0 && m.pending_bytes != null && ` · ${fmtBytes(m.pending_bytes)}`} {m.pending_total > 0 && m.backlog_eta_s != null && ` · ${fmtDur(m.backlog_eta_s)}`}
{m.errors_1h}
errors (1h)
{m.pairs.map((p) => )}
); } function EventsPanel({ machineId }) { const [tab, setTab] = useState("file_synced"); const [events, setEvents] = useState([]); useEffect(() => { if (!machineId) return; let stop = false; const load = () => fetch(`/api/machines/${machineId}/events?type=${tab}&limit=40`) .then((r) => r.json()) .then((d) => !stop && setEvents(d.events)) .catch(() => {}); load(); const t = setInterval(load, POLL_MS); return () => { stop = true; clearInterval(t); }; }, [machineId, tab]); if (!machineId) return null; const TABS = [ ["file_synced", "Files"], ["error", "Errors"], ["round_summary", "Rounds"], ["pair_ok,pair_fail", "Pair results"], ]; return (

{machineId} — recent activity

{TABS.map(([k, label]) => ( ))}
{events.map((e, i) => ( ))} {events.length === 0 && ( )}
whentypedetail
{ago(e.received_at)} {e.type} {e.type === "file_synced" && `${e.file} — ${e.action} (${e.direction ?? "?"} · ${e.pair ?? ""})`} {e.type === "error" && e.message} {e.type === "round_summary" && (e.files ? `round #${e.round} — ${e.files} file(s), ${fmtBytes(e.bytes)} in ` + `${fmtDur(e.duration_s).replace("~", "")}` + (e.avg_bps != null ? ` · ${fmtBytes(e.avg_bps)}/s` : "") : `round #${e.round} — no transfers (${fmtDur(e.duration_s).replace("~", "")})`)} {(e.type === "round_start" || e.type === "round_end") && `round #${e.round} @ ${e.log_ts}`} {(e.type === "pair_ok" || e.type === "pair_fail") && `${e.pair}${e.exit_code != null ? ` — exit ${e.exit_code}` : " — ok"}`}
no events yet — waiting for the next sync round
); } export default function App() { const [data, setData] = useState(null); const [selected, setSelected] = useState(null); const [err, setErr] = useState(null); useEffect(() => { let stop = false; const load = () => fetch("/api/overview") .then((r) => r.json()) .then((d) => { if (!stop) { setData(d); setErr(null); } }) .catch((e) => !stop && setErr(String(e))); load(); const t = setInterval(load, POLL_MS); return () => { stop = true; clearInterval(t); }; }, []); const machines = data?.machines ?? []; const alerts = data?.alerts ?? []; const online = machines.filter((m) => m.online).length; return (

Rclone Fleet Monitor

{online}/{machines.length} machines online
{err &&
collector unreachable: {err}
} {alerts.map((a, i) => (
{a.severity.toUpperCase()} · {a.machine_id} — {a.message}
))}
{machines.map((m) => ( ))} {machines.length === 0 && !err &&
no machines reporting yet…
}
); }