261 lines
9.2 KiB
React
261 lines
9.2 KiB
React
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 (
|
||
<span className="badge" style={{ color: s.color }}>
|
||
<span aria-hidden="true">{s.icon}</span>
|
||
<span className="badge-text">{text ?? s.label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="pair-row">
|
||
<span className="pair-dir">{arrow} {p.direction}</span>
|
||
<span className="pair-label" title={p.pair}>{label}</span>
|
||
<span>
|
||
{p.pending_count > 0 && (
|
||
<span className="pending-chip mono" title="on disk, not yet on server">
|
||
{p.pending_count} pending{p.pending_bytes > 0 && ` · ${fmtBytes(p.pending_bytes)}`}
|
||
</span>
|
||
)}
|
||
{p.missing?.length > 0 && (
|
||
<span className="missing-chip mono" title={p.missing.join("\n")}>
|
||
{p.missing.length} missing!
|
||
</span>
|
||
)}
|
||
<StatusBadge
|
||
kind={p.missing?.length ? "serious" : kind}
|
||
text={p.last_status === "fail" ? `exit ${p.last_exit}` : p.last_status}
|
||
/>
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MachineCard({ m, selected, onSelect }) {
|
||
const kind = m.online ? "good" : "critical";
|
||
return (
|
||
<div className={"card" + (selected ? " card-selected" : "")} onClick={() => onSelect(m.machine_id)}>
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="card-title">{m.machine_id}</div>
|
||
<div className="card-sub">site {m.site} · seen {ago(m.last_seen)}</div>
|
||
</div>
|
||
<StatusBadge kind={kind} />
|
||
</div>
|
||
|
||
{!m.progress && m.last_transfer && (
|
||
<div className="progress-line">
|
||
last transfer · <span className="mono">{m.last_transfer.files}</span> files ·{" "}
|
||
<span className="mono">{fmtBytes(m.last_transfer.bytes)}</span> in{" "}
|
||
<span className="mono">{fmtDur(m.last_transfer.duration_s).replace("~", "")}</span>
|
||
{m.last_transfer.avg_bps != null && (
|
||
<> · <span className="mono">{fmtBytes(m.last_transfer.avg_bps)}/s</span></>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{m.progress && (
|
||
<div className="progress-line">
|
||
<span className="mono">{m.progress.percent}%</span> · {m.progress.speed} · ETA{" "}
|
||
{m.progress.eta} <span className="card-sub">({m.progress.direction} sync)</span>
|
||
<div className="progress-track">
|
||
<div className="progress-fill" style={{ width: `${m.progress.percent}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="tiles">
|
||
<div className="tile">
|
||
<div className="tile-num mono">{m.last_round ?? "–"}</div>
|
||
<div className="tile-label">last round · {ago(m.last_round_at)}</div>
|
||
</div>
|
||
<div className="tile">
|
||
<div className="tile-num mono">{m.files_1h}</div>
|
||
<div className="tile-label">files synced (1h)</div>
|
||
</div>
|
||
<div className="tile">
|
||
<div className="tile-num mono" style={m.pending_total > 0 ? { color: STATUS.warning.color } : {}}>
|
||
{m.pending_total ?? "–"}
|
||
</div>
|
||
<div className="tile-label">
|
||
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)}`}
|
||
</div>
|
||
</div>
|
||
<div className="tile">
|
||
<div className="tile-num mono" style={m.errors_1h ? { color: STATUS.serious.color } : {}}>
|
||
{m.errors_1h}
|
||
</div>
|
||
<div className="tile-label">errors (1h)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pairs">{m.pairs.map((p) => <PairRow key={p.pair} p={p} />)}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="panel">
|
||
<div className="panel-head">
|
||
<h2>{machineId} — recent activity</h2>
|
||
<div className="tabs">
|
||
{TABS.map(([k, label]) => (
|
||
<button key={k} className={tab === k ? "tab tab-on" : "tab"} onClick={() => setTab(k)}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<table className="events">
|
||
<thead>
|
||
<tr><th>when</th><th>type</th><th>detail</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{events.map((e, i) => (
|
||
<tr key={i}>
|
||
<td className="mono">{ago(e.received_at)}</td>
|
||
<td>{e.type}</td>
|
||
<td className="detail">
|
||
{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"}`}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{events.length === 0 && (
|
||
<tr><td colSpan="3" className="empty">no events yet — waiting for the next sync round</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="app">
|
||
<header>
|
||
<h1>Rclone Fleet Monitor</h1>
|
||
<div className="header-stats mono">
|
||
{online}/{machines.length} machines online
|
||
</div>
|
||
</header>
|
||
|
||
{err && <div className="alert alert-critical"><span aria-hidden="true">■</span> collector unreachable: {err}</div>}
|
||
|
||
{alerts.map((a, i) => (
|
||
<div key={i} className={`alert alert-${a.severity}`}>
|
||
<span aria-hidden="true">{STATUS[a.severity]?.icon ?? "▲"}</span>
|
||
<strong>{a.severity.toUpperCase()}</strong> · {a.machine_id} — {a.message}
|
||
</div>
|
||
))}
|
||
|
||
<div className="grid">
|
||
{machines.map((m) => (
|
||
<MachineCard key={m.machine_id} m={m} selected={selected === m.machine_id} onSelect={setSelected} />
|
||
))}
|
||
{machines.length === 0 && !err && <div className="empty">no machines reporting yet…</div>}
|
||
</div>
|
||
|
||
<EventsPanel machineId={selected} />
|
||
</div>
|
||
);
|
||
}
|