Files
R-clone-setup/.backups/App.jsx.bak
2026-08-18 11:31:53 +05:30

261 lines
9.2 KiB
React
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}