Add an Alerts page under Operations listing everything that's wrong now
One list of the current problems across servers and integrations, instead of waiting for a notification or visiting each page: servers that stopped reporting, full or nearly full disks and volumes (critical from 95%), Synology volume/disk problems, failed or uncovered Proxmox backups, failed Proxmox Backup Server verifications, container image updates, expired or expiring secrets/domains/Tailscale keys, failed Semaphore and Gitea runs, Uptime Kuma monitors that are down, overdue osTicket tickets, and integrations whose calls keep failing. Visible to every role, with severity and kind filters, search, sorting, CSV export and "Check now". It runs the same checks that send the notifications rather than a second copy of them: the detection in the health, automation, Proxmox backup, PBS, Docker update and Tailscale key checks is pulled out into shared collectors that both the schedulers and the page call, so the two can't disagree about what counts as a problem. Notification behaviour is unchanged, including the scheduled backup checks skipping integrations under a maintenance window. Unlike the notifications the page ignores the on/off toggles, and keeps problems under a maintenance window, marked silenced and counted apart. It reads live, so a result is reused for a minute (and Refresh can't re-run everything more than once every ten seconds), and every source has a 20 s limit so one hung integration can't hang the page. Anything it couldn't read is called out at the top instead of looking like all clear, and server checks pause for the same 20 minutes after a restart as the notifications do, with a note saying so. Also gives the newer integrations (PBS, osTicket, Uptime Kuma, phpIPAM) proper names in "integration down" notifications instead of their ids. Verified through the real routes against a scratch database with fake backends (offline and full-disk servers, secrets and domains, a silenced server, a fake PBS with failed verification, a hanging integration, a refused one, a failing-calls streak, caching, the restart grace period, auth), and by rendering the real page against that data in a browser: filters, search, silenced toggle, sorting, Check now, dark mode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad1fb5338f
commit
447f33fff6
19 files changed
+883
-28
No files matched your search
+4
-2
@@ -24,6 +24,7 @@ import UptimeKuma from "./pages/UptimeKuma";
|
||||
import PbsBackup from "./pages/PbsBackup";
|
||||
import OsTicket from "./pages/OsTicket";
|
||||
import AdminLinks from "./pages/AdminLinks";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import Generator from "./pages/Generator";
|
||||
import Maintenance from "./pages/Maintenance";
|
||||
import Domains from "./pages/Domains";
|
||||
@@ -38,8 +39,8 @@ import CacheSettings from "./pages/settings/CacheSettings";
|
||||
import LogSettings from "./pages/settings/LogSettings";
|
||||
import BackupSettings from "./pages/settings/BackupSettings";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import { setDateTimeSettings } from "./utils/date";
|
||||
import DialogHost from "./components/DialogHost";
|
||||
import { setDateTimeSettings } from "./utils/date";
|
||||
import { setPageSize } from "./utils/pageSize";
|
||||
|
||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||
@@ -114,6 +115,7 @@ export default function App() {
|
||||
<Route path="/pbs" element={<PbsBackup user={user} />} />
|
||||
<Route path="/osticket" element={<OsTicket user={user} />} />
|
||||
<Route path="/admin-links" element={<AdminLinks user={user} />} />
|
||||
<Route path="/alerts" element={<Alerts user={user} />} />
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
@@ -164,7 +166,7 @@ export default function App() {
|
||||
<Route path="backup" element={<BackupSettings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<DialogHost />
|
||||
</AppShell>
|
||||
);
|
||||
<DialogHost />
|
||||
}
|
||||
@@ -397,6 +397,30 @@ export interface ServerDetail {
|
||||
links: ServerLink[];
|
||||
}
|
||||
|
||||
export type AlertSeverity = "critical" | "warning" | "info";
|
||||
export type AlertCategory = "offline" | "disk" | "backup" | "updates" | "expiry" | "automation" | "integration" | "monitoring" | "tickets";
|
||||
|
||||
export interface AlertItem {
|
||||
id: string;
|
||||
severity: AlertSeverity;
|
||||
category: AlertCategory;
|
||||
source: string;
|
||||
message: string;
|
||||
link: string | null;
|
||||
/** Under a maintenance window: still a real problem, but its notifications are held back. */
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
export interface AlertsReport {
|
||||
alerts: AlertItem[];
|
||||
counts: { critical: number; warning: number; info: number; silenced: number };
|
||||
couldntCheck: { name: string; error: string; affects: string[] }[];
|
||||
notes: string[];
|
||||
generatedAt: string;
|
||||
/** This is a recent result being reused, not a fresh check. */
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export interface AdminLink {
|
||||
id: number;
|
||||
serverId: number;
|
||||
@@ -1006,6 +1030,9 @@ export const api = {
|
||||
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
||||
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
|
||||
},
|
||||
alerts: {
|
||||
list: (refresh = false) => request<AlertsReport>(`/api/alerts${refresh ? "?refresh=1" : ""}`),
|
||||
},
|
||||
ports: {
|
||||
agent: () => request<AgentPortsResponse>("/api/ports/agent"),
|
||||
forwards: {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
IconTicket,
|
||||
IconPlug,
|
||||
IconExternalLink,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
@@ -101,6 +102,7 @@ const NAV: NavEntry[] = [
|
||||
label: "Operations",
|
||||
icon: <IconTool size={20} />,
|
||||
items: [
|
||||
{ to: "/alerts", label: "Alerts", icon: <IconAlertTriangle size={20} /> },
|
||||
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
|
||||
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
|
||||
{ to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> },
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type AlertCategory, type AlertItem, type AlertSeverity, type AlertsReport, type CurrentUser } from "../api/client";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import { formatAgo } from "../utils/duration";
|
||||
import { readableError } from "../utils/errors";
|
||||
|
||||
const SEVERITY: Record<AlertSeverity, { label: string; badge: string; rank: number }> = {
|
||||
critical: { label: "Critical", badge: "bg-red-lt text-red", rank: 0 },
|
||||
warning: { label: "Warning", badge: "bg-yellow-lt text-yellow", rank: 1 },
|
||||
info: { label: "Info", badge: "bg-blue-lt text-blue", rank: 2 },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<AlertCategory, string> = {
|
||||
offline: "Server down",
|
||||
disk: "Disk & storage",
|
||||
backup: "Backups",
|
||||
updates: "Updates",
|
||||
expiry: "Expiry",
|
||||
automation: "Automation",
|
||||
integration: "Integration failing",
|
||||
monitoring: "Monitoring",
|
||||
tickets: "Tickets",
|
||||
};
|
||||
|
||||
type Row = AlertItem & { rank: number };
|
||||
|
||||
export default function Alerts(_props: { user: CurrentUser }) {
|
||||
const [report, setReport] = useState<AlertsReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [severity, setSeverity] = useState<"all" | AlertSeverity>("all");
|
||||
const [category, setCategory] = useState<"all" | AlertCategory>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showSilenced, setShowSilenced] = useState(true);
|
||||
|
||||
function load(refresh = false) {
|
||||
setLoading(true);
|
||||
return api.alerts
|
||||
.list(refresh)
|
||||
.then((res) => {
|
||||
setReport(res);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(readableError(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!report) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return report.alerts
|
||||
.filter((a) => (severity === "all" || a.severity === severity) && (category === "all" || a.category === category) && (showSilenced || !a.silenced))
|
||||
.filter((a) => !q || [a.message, a.source, CATEGORY_LABELS[a.category]].some((v) => v.toLowerCase().includes(q)))
|
||||
.map((a) => ({ ...a, rank: SEVERITY[a.severity].rank }));
|
||||
}, [report, severity, category, search, showSilenced]);
|
||||
|
||||
// No initial sort: the server already puts active problems first, worst first.
|
||||
const { sorted, sortKey, sortDir, requestSort } = useSortable(rows);
|
||||
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
||||
|
||||
const presentCategories = useMemo(() => [...new Set((report?.alerts ?? []).map((a) => a.category))], [report]);
|
||||
|
||||
function exportCsv() {
|
||||
downloadCsv(
|
||||
"alerts.csv",
|
||||
["Severity", "Category", "Source", "Alert", "Silenced"],
|
||||
(sorted ?? []).map((a) => [SEVERITY[a.severity].label, CATEGORY_LABELS[a.category], a.source, a.message, a.silenced ? "yes" : "no"]),
|
||||
);
|
||||
}
|
||||
|
||||
const counts = report?.counts;
|
||||
const nothingFound = report !== null && report.alerts.length === 0;
|
||||
const cards: { label: string; value: number | undefined; color: string }[] = [
|
||||
{ label: "Critical", value: counts?.critical, color: "text-red" },
|
||||
{ label: "Warnings", value: counts?.warning, color: "text-yellow" },
|
||||
{ label: "Info", value: counts?.info, color: "text-blue" },
|
||||
{ label: "Silenced (maintenance)", value: counts?.silenced, color: "text-secondary" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex flex-wrap align-items-center gap-2 mb-1">
|
||||
<h2 className="page-title mb-0">Alerts</h2>
|
||||
<div className="ms-auto btn-list">
|
||||
<button className="btn btn-outline-secondary" onClick={exportCsv} disabled={!sorted?.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={() => void load(true)} disabled={loading}>
|
||||
{loading ? "Checking…" : "Check now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-secondary mb-3">
|
||||
What's wrong right now across your servers and integrations — full disks, servers that stopped reporting, failed backups, updates waiting,
|
||||
things about to expire. It runs the same checks that send notifications, whether or not those notifications are switched on.
|
||||
{report && (
|
||||
<>
|
||||
{" "}
|
||||
<span title={formatDateTime(new Date(report.generatedAt))}>
|
||||
Checked {formatAgo(report.generatedAt)}
|
||||
{report.cached ? " (a recent result, reused)" : ""}.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{!report && !error && (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">Checking everything — this can take a few seconds…</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<div className="row g-3 mb-3">
|
||||
{cards.map((c) => (
|
||||
<div className="col-6 col-md-3" key={c.label}>
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="text-secondary">{c.label}</div>
|
||||
<div className={`h2 mb-0 ${c.value ? c.color : ""}`}>{c.value ?? "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{report.couldntCheck.length > 0 && (
|
||||
<div className="alert alert-warning">
|
||||
<div>
|
||||
<div className="fw-bold mb-1">Couldn't check everything — what's missing below isn't necessarily fine</div>
|
||||
<ul className="mb-0">
|
||||
{report.couldntCheck.map((c) => (
|
||||
<li key={c.name}>
|
||||
<strong>{c.name}</strong> — {c.error}
|
||||
{c.affects.length > 0 && <span className="text-secondary"> (affects: {c.affects.join(", ")})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{report.notes.map((n) => (
|
||||
<div className="alert alert-info" key={n}>
|
||||
{n}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{nothingFound ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-5">
|
||||
{report.couldntCheck.length === 0 ? (
|
||||
<>
|
||||
<div className="h3 text-green mb-1">All clear</div>
|
||||
<div className="text-secondary">Nothing needs attention right now.</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-secondary">No problems found in what could be checked — see the warning above for what couldn't.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
|
||||
<input className="form-control" style={{ maxWidth: 260 }} placeholder="Search alerts…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<select className="form-select w-auto" value={severity} onChange={(e) => setSeverity(e.target.value as "all" | AlertSeverity)}>
|
||||
<option value="all">All severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select className="form-select w-auto" value={category} onChange={(e) => setCategory(e.target.value as "all" | AlertCategory)}>
|
||||
<option value="all">All kinds</option>
|
||||
{presentCategories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CATEGORY_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(counts?.silenced ?? 0) > 0 && (
|
||||
<label className="form-check mb-0">
|
||||
<input type="checkbox" className="form-check-input" checked={showSilenced} onChange={(e) => setShowSilenced(e.target.checked)} />
|
||||
<span className="form-check-label">Show silenced</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<Row> label="Severity" sortKeyName="rank" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<Row> label="Kind" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<Row> label="Source" sortKeyName="source" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<Row> label="Alert" sortKeyName="message" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(pageItems ?? []).map((a) => (
|
||||
<tr key={a.id} className={a.silenced ? "text-secondary" : undefined} style={a.silenced ? { opacity: 0.65 } : undefined}>
|
||||
<td>
|
||||
<span className={`badge ${SEVERITY[a.severity].badge}`}>{SEVERITY[a.severity].label}</span>
|
||||
</td>
|
||||
<td>{CATEGORY_LABELS[a.category]}</td>
|
||||
<td>{a.link ? <Link to={a.link}>{a.source}</Link> : a.source}</td>
|
||||
<td>
|
||||
{a.message}
|
||||
{a.silenced && (
|
||||
<span className="badge bg-secondary-lt text-secondary ms-2" title="Under a maintenance window — notifications for this are held back">
|
||||
silenced
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(sorted ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
Nothing matches these filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user