142 lines
5.4 KiB
TypeScript
142 lines
5.4 KiB
TypeScript
// Admin: the assets volume, the virus scanner and the open alerts (phase 4).
|
|||
|
|
import { useState } from "react";
|
||
|
|
import { storageApi } from "../../api/storage";
|
||
|
|
import type { AlertRow } from "../../api/types";
|
||
|
|
import { errorText, fmtBytes, fmtTime } from "../../lib/format";
|
||
|
|
import { useLoad } from "../../lib/useLoad";
|
||
|
|
|
||
|
|
const whoName = (a: AlertRow["acknowledged_by"]) => (a && typeof a === "object" ? a.display_name : (a ?? ""));
|
||
|
|
|
||
|
|
function alertText(a: AlertRow): string {
|
||
|
|
if (a.kind === "assets_quota") {
|
||
|
|
const pct = Number((a.detail as { pct?: unknown }).pct);
|
||
|
|
return Number.isFinite(pct) ? `The assets volume is ${Math.round(pct)}% full.` : "The assets volume is nearly full.";
|
||
|
|
}
|
||
|
|
return a.subject ? `${a.kind.replace(/_/g, " ")}: ${a.subject}` : a.kind.replace(/_/g, " ");
|
||
|
|
}
|
||
|
|
|
||
|
|
export function StorageTab() {
|
||
|
|
const [load, reload] = useLoad((s) => storageApi.storage(s));
|
||
|
|
const [alerts, reloadAlerts] = useLoad((s) => storageApi.alerts(s));
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||
|
|
|
||
|
|
const ack = async (id: number) => {
|
||
|
|
setBusy(true);
|
||
|
|
setMsg(null);
|
||
|
|
try {
|
||
|
|
await storageApi.ack(id);
|
||
|
|
setMsg({ ok: true, text: "Alert acknowledged." });
|
||
|
|
reload();
|
||
|
|
reloadAlerts();
|
||
|
|
} catch (err) {
|
||
|
|
setMsg({ ok: false, text: errorText(err) });
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (load.state === "loading") return <div className="empty slim">Loading</div>;
|
||
|
|
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
|
||
|
|
const s = load.data;
|
||
|
|
const u = s.usage;
|
||
|
|
const pct = Math.max(0, Math.min(100, Number(u.pct) || 0));
|
||
|
|
const scan = s.clamav;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="stack">
|
||
|
|
{msg ? (
|
||
|
|
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
|
||
|
|
{msg.text}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<section className="panel stack" aria-labelledby="stor-h">
|
||
|
|
<h3 id="stor-h">Assets volume</h3>
|
||
|
|
<div>
|
||
|
|
Used <b>{fmtBytes(u.used)}</b> of <b>{fmtBytes(u.total)}</b> ({Math.round(pct)}%). <span className="muted">{fmtBytes(u.free)} free.</span>
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
className="bar usage"
|
||
|
|
role="meter"
|
||
|
|
aria-label="Assets volume used"
|
||
|
|
aria-valuemin={0}
|
||
|
|
aria-valuemax={100}
|
||
|
|
aria-valuenow={Math.round(pct)}
|
||
|
|
>
|
||
|
|
<i className={u.warn ? "over" : undefined} style={{ width: `${pct}%` }} />
|
||
|
|
<span className="mark" style={{ left: `${s.quota_warn_pct}%` }} aria-hidden="true" />
|
||
|
|
</div>
|
||
|
|
<div className="small muted">Warning at {s.quota_warn_pct}%.</div>
|
||
|
|
{s.alert ? (
|
||
|
|
<div className="callout warn flush" role="status">
|
||
|
|
<b>{alertText(s.alert)}</b> Raised {fmtTime(s.alert.created_at)}.
|
||
|
|
{s.alert.acknowledged_at ? (
|
||
|
|
<> Acknowledged {fmtTime(s.alert.acknowledged_at)}{whoName(s.alert.acknowledged_by) ? ` by ${whoName(s.alert.acknowledged_by)}` : ""}.</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{" "}
|
||
|
|
<button className="btn sm" type="button" disabled={busy} onClick={() => void ack(s.alert?.id ?? 0)}>
|
||
|
|
Acknowledge
|
||
|
|
</button>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="panel" aria-label="Virus scanner">
|
||
|
|
<b>Virus scanner (ClamAV): </b>
|
||
|
|
{!scan.configured ? (
|
||
|
|
<span className="muted">Not configured</span>
|
||
|
|
) : (
|
||
|
|
<span className={scan.reachable ? "st-ok" : "st-bad"}>
|
||
|
|
{scan.host ?? "clamav"}: {scan.reachable ? "reachable" : "not reachable"}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="sec" aria-labelledby="alerts-h">
|
||
|
|
<div className="sec-h">
|
||
|
|
<h3 id="alerts-h">Open alerts</h3>
|
||
|
|
</div>
|
||
|
|
{alerts.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||
|
|
{alerts.state === "error" ? <div className="callout warn">{alerts.message}</div> : null}
|
||
|
|
{alerts.state === "ok" && !alerts.data.length ? <div className="empty slim">No open alerts.</div> : null}
|
||
|
|
{alerts.state === "ok" && alerts.data.length ? (
|
||
|
|
<div className="tw">
|
||
|
|
<table aria-label="Open alerts">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Raised</th>
|
||
|
|
<th>Severity</th>
|
||
|
|
<th>What</th>
|
||
|
|
<th>Acknowledged</th>
|
||
|
|
<th />
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{alerts.data.map((a) => (
|
||
|
|
<tr key={a.id}>
|
||
|
|
<td className="small mono nowrap">{fmtTime(a.created_at)}</td>
|
||
|
|
<td className="small">{a.severity}</td>
|
||
|
|
<td>{alertText(a)}</td>
|
||
|
|
<td className="small">{a.acknowledged_at ? `${fmtTime(a.acknowledged_at)} ${whoName(a.acknowledged_by)}` : "-"}</td>
|
||
|
|
<td>
|
||
|
|
{a.acknowledged_at ? null : (
|
||
|
|
<button className="btn sm" type="button" disabled={busy} onClick={() => void ack(a.id)}>
|
||
|
|
Acknowledge
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|