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 (
{s.icon}
{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)
);
}
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]) => (
))}
| when | type | detail |
{events.map((e, i) => (
| {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"}`}
|
))}
{events.length === 0 && (
| 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 (
{err &&
■ collector unreachable: {err}
}
{alerts.map((a, i) => (
{STATUS[a.severity]?.icon ?? "▲"}
{a.severity.toUpperCase()} · {a.machine_id} — {a.message}
))}
{machines.map((m) => (
))}
{machines.length === 0 && !err &&
no machines reporting yet…
}
);
}