Compare commits

...
2 Commits
Author SHA1 Message Date
bobbanandClaude Sonnet 5.5 447f33fff6 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>
2026-10-02 23:07:57 +02:00
bobbanandClaude Sonnet 5.5 ad1fb5338f Replace the browser's confirm() and prompt() pop-ups with in-app dialogs
All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two
"exclude a range" boxes, tag rename) now use the app's own Tabler-styled
modals, which follow the light/dark theme.

A small promise-based API (utils/dialogs.ts: confirmDialog, promptDialog)
and a single DialogHost mounted once in App mean call sites just await
it in place of the browser call - no hooks or per-page modal state. Every
call site was already in an async function, so each is a one-line swap.

Each dialog now has a title and a main button that names the action
("Delete", "Stop now", "Run now") instead of "OK", with destructive ones
in red. Escape cancels, Enter confirms, Tab stays inside the dialog, the
page behind stops scrolling, and focus returns to the button that was
clicked. Destructive dialogs start with focus on Cancel so a stray Enter
can't delete anything. Text prompts pre-select their default and disable
the main button until something is typed where it's required, and still
tell cancelling (null) apart from confirming an empty box (""). Clicking
the backdrop cancels, but releasing a text selection over it doesn't.
Dialogs asked for together appear one after another.

Verified in a browser on a test page using the real component and the
app's real stylesheet: Escape, Enter, Tab trapping, focus handling,
required and optional prompts, backdrop clicks, queuing, scroll lock and
dark mode. The 31 call sites themselves were checked by search and
typecheck rather than clicked through in the logged-in app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:07:43 +02:00
42 changed files with 1195 additions and 65 deletions

No files matched your search

+14
View File
@@ -89,6 +89,20 @@ schedule.
|---|---| |---|---|
| "Notifications from quiet hours" | Once, at quiet hours' configured end time, **only if enabled and only if at least one notification was held** during the window. Bundles every held notification's title and message into one message, then clears the queue. | | "Notifications from quiet hours" | Once, at quiet hours' configured end time, **only if enabled and only if at least one notification was held** during the window. Bundles every held notification's title and message into one message, then clears the queue. |
## The Alerts page
**Operations → Alerts** shows what these notifications are about — the problems that exist *right now* — as a list you can look at, filter, and
export. It uses the same checks as the notifications above, so a problem appears there for exactly the reason it would be notified, but it differs
in three ways:
- It ignores the "Notify on" toggles. Turning a notification off doesn't hide the problem from the page.
- Problems under a **maintenance window** are kept on the list, marked *silenced* and counted separately, instead of being dropped.
- It also lists things nothing notifies about: Uptime Kuma monitors that are down, osTicket tickets that are overdue, and any integration that's
failing its last few calls (before the threshold that triggers an "integration down" notification).
It runs the checks live when opened (a recent result is reused for a minute), and shows what it couldn't read at the top, so a missing section means
"couldn't check" and not "all clear".
## What does *not* send a notification ## What does *not* send a notification
Worth calling out explicitly, since it's easy to assume everything in Worth calling out explicitly, since it's easy to assume everything in
+15
View File
@@ -255,6 +255,21 @@ already failing, so old failures aren't announced. Toggle it under Settings →
Notifications. For Gitea this follows the repo's most recent run on any Notifications. For Gitea this follows the repo's most recent run on any
workflow or branch, the same as the Gitea page shows. workflow or branch, the same as the Gitea page shows.
**Alerts** (Operations → Alerts, visible to every role) lists everything that's
wrong right now in one place, 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,
secrets, domains and Tailscale keys that are expired or about to be, failed
Semaphore/Gitea runs, Uptime Kuma monitors that are down, overdue osTicket
tickets, and integrations whose calls keep failing. It runs the same checks that
send the notifications — so the two can't disagree — but ignores the on/off
toggles, since it's for looking at rather than being interrupted by. Problems
under a maintenance window stay listed, marked silenced and counted separately.
It checks live (a recent result is reused for a minute; "Check now" forces a
fresh one), and anything it couldn't read is called out at the top rather than
quietly treated as fine.
**Maintenance mode** silences alerts about one server, integration, or DNS **Maintenance mode** silences alerts about one server, integration, or DNS
provider while you work on it (server offline / disk, storage and Synology provider while you work on it (server offline / disk, storage and Synology
health, Proxmox backup alerts, Proxmox Backup Server verification alerts, and health, Proxmox backup alerts, Proxmox Backup Server verification alerts, and
+1
View File
@@ -37,6 +37,7 @@ page's own top-level link.
| Gitea | `/gitea` | ✅ | ✅ | ✅ | | Gitea | `/gitea` | ✅ | ✅ | ✅ |
| Secrets | `/secrets` | ✅ | ✅ | ✅ | | Secrets | `/secrets` | ✅ | ✅ | ✅ |
| **Operations** | | | | | | **Operations** | | | | |
| Alerts | `/alerts` | ✅ | ✅ | ✅ |
| Maintenance | `/maintenance` | ✅ | ✅ | ✅ | | Maintenance | `/maintenance` | ✅ | ✅ | ✅ |
| Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ | | Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ |
| osTicket | `/osticket` | ✅ | ✅ | ✅ | | osTicket | `/osticket` | ✅ | ✅ | ✅ |
+2
View File
@@ -29,6 +29,7 @@ import { consistencyRouter } from "./routes/consistency.js";
import { privacyRouter } from "./routes/privacy.js"; import { privacyRouter } from "./routes/privacy.js";
import { tagsRouter } from "./routes/tags.js"; import { tagsRouter } from "./routes/tags.js";
import { portsRouter } from "./routes/ports.js"; import { portsRouter } from "./routes/ports.js";
import { alertsRouter } from "./routes/alerts.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
@@ -104,6 +105,7 @@ app.use("/api/consistency", consistencyRouter);
app.use("/api/privacy", privacyRouter); app.use("/api/privacy", privacyRouter);
app.use("/api/tags", tagsRouter); app.use("/api/tags", tagsRouter);
app.use("/api/ports", portsRouter); app.use("/api/ports", portsRouter);
app.use("/api/alerts", alertsRouter);
if (existsSync(webDist)) { if (existsSync(webDist)) {
app.use(express.static(webDist)); app.use(express.static(webDist));
+14
View File
@@ -0,0 +1,14 @@
import { Router } from "express";
import { requireAuth } from "../auth/middleware.js";
import { getAlerts } from "../services/alerts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const alertsRouter = Router();
alertsRouter.use(requireAuth);
// Everyone signed in can see it — it's the same server, disk and backup status the other pages already show.
// `?refresh=1` asks for a fresh check instead of a recent one.
alertsRouter.get("/", asyncHandler(async (req, res) => {
res.json(await getAlerts(req.query.refresh === "1"));
}));
+28
View File
@@ -0,0 +1,28 @@
// Shared by the alerts page's collector (services/alerts.ts) and the scheduled checks it reuses, which can't import
// that file themselves without going round in a circle.
export type AlertSeverity = "critical" | "warning" | "info";
/** What kind of problem — drives the filter on the Alerts page. */
export type AlertCategory = "offline" | "disk" | "backup" | "updates" | "expiry" | "automation" | "integration" | "monitoring" | "tickets";
export interface Alert {
/** Stable, so the page can key rows on it. */
id: string;
severity: AlertSeverity;
category: AlertCategory;
/** Where it comes from, as a short name: "Server", "Proxmox", "Secrets", ... */
source: string;
message: string;
/** In-app page that shows more, if there is one. */
link: string | null;
/** Under an active maintenance window: still a real problem, but notifications for it are held back. */
silenced: boolean;
}
/** One integration that couldn't be read while collecting — so the page never mistakes "couldn't check" for "all clear". */
export interface SourceFailure {
integrationId: number;
integrationName: string;
message: string;
}
+387
View File
@@ -0,0 +1,387 @@
/**
* Everything that's wrong right now, in one list — the Alerts page. Nothing here decides what counts as a problem: it asks
* the same checks that send the notifications (health, backups, updates, expiry, automation, ...) and turns what they find
* into a flat list, so the page and the notifications can't disagree. Unlike the notifications it ignores the per-event
* on/off toggles (the page is for looking at, not for being interrupted by) and keeps problems that are under a
* maintenance window, flagged as silenced rather than dropped.
*
* It reads live (a handful of API calls per integration), so a result is kept for a short while rather than re-run for
* every viewer, and every source has a time limit so one hung integration can't hang the page. A source that can't be
* read is reported as such — silence from it must never look like "all clear".
*/
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { integrations, secrets } from "../db/schema.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js";
import { createOsTicketAdapter } from "../integrations/osticket/adapter.js";
import { activeSubjects, isSourceInMaintenance, subjectOfConditionKey } from "./maintenance.js";
import { collectSnapshot, evaluateHealth, startupGraceRemainingMs } from "./healthMonitor.js";
import { collectAutomation, evaluateAutomation } from "./automationMonitor.js";
import { collectDockerUpdates } from "./dockerUpdateScheduler.js";
import { collectProxmoxBackupProblems } from "./proxmoxBackupScheduler.js";
import { collectPbsProblems } from "./pbsVerificationScheduler.js";
import { collectTailscaleKeyExpiries } from "./tailscaleKeyExpiryScheduler.js";
import { collectDomainAlerts } from "./domainMonitor.js";
import { computeSecretStatus } from "./secretStatus.js";
import { getFailingSources } from "./integrationHealthMonitor.js";
import { getSettings } from "./settingsStore.js";
import { sourceLabel } from "./notify.js";
import type { Alert, AlertCategory, AlertSeverity, SourceFailure } from "./alertTypes.js";
export interface AlertsReport {
alerts: Alert[];
/** Active problems by severity — those under a maintenance window are counted apart, not in these. */
counts: { critical: number; warning: number; info: number; silenced: number };
/** Things that couldn't be checked, and which checks that leaves blind. */
couldntCheck: { name: string; error: string; affects: string[] }[];
/** Context worth knowing about how complete the picture is. */
notes: string[];
generatedAt: string;
}
/** Where each kind of source lives in the app, and what to call it. */
const SOURCES: Record<string, { label: string; link: string }> = {
server: { label: "Server", link: "/servers" },
proxmox: { label: "Proxmox", link: "/proxmox" },
synology: { label: "Synology", link: "/synology" },
semaphore: { label: "Semaphore", link: "/semaphore" },
gitea: { label: "Gitea", link: "/gitea" },
dockhand: { label: "Docker", link: "/docker" },
tailscale: { label: "Tailscale", link: "/tailscale" },
pbs: { label: "Proxmox Backup", link: "/pbs" },
uptimekuma: { label: "Uptime Kuma", link: "/uptime-kuma" },
osticket: { label: "osTicket", link: "/osticket" },
secrets: { label: "Secrets", link: "/secrets" },
domains: { label: "Domains", link: "/domains" },
};
const SOURCE_TIMEOUT_MS = 20_000;
/** How long a result is reused. */
const CACHE_MS = 60_000;
/** Pressing Refresh over and over shouldn't hammer every integration — a result younger than this is reused even then. */
const MIN_REFRESH_MS = 10_000;
const SEVERITY_ORDER: Record<AlertSeverity, number> = { critical: 0, warning: 1, info: 2 };
/** Which kind of source, and which one, a health/automation condition key belongs to. */
function originOfConditionKey(key: string): { type: string; id: number | null; category: AlertCategory } {
let m = /^(?:offline|disk):server:(\d+)/.exec(key);
if (m) return { type: "server", id: Number(m[1]), category: key.startsWith("offline") ? "offline" : "disk" };
m = /^disk:proxmox:(\d+):/.exec(key);
if (m) return { type: "proxmox", id: Number(m[1]), category: "disk" };
m = /^(?:synology-volume|synology-disk|disk:synology):(\d+):/.exec(key);
if (m) return { type: "synology", id: Number(m[1]), category: "disk" };
m = /^automation:(semaphore|gitea):(\d+):/.exec(key);
if (m) return { type: m[1], id: Number(m[2]), category: "automation" };
return { type: "server", id: null, category: "disk" };
}
function withTimeout<T>(work: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const limit = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
});
return Promise.race([work, limit]).finally(() => clearTimeout(timer));
}
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export async function collectAlerts(): Promise<AlertsReport> {
const alerts: Alert[] = [];
const notes: string[] = [];
const blind = new Map<string, { error: string; affects: Set<string> }>();
const subjects = await activeSubjects();
const intRows = await db.select({ id: integrations.id, name: integrations.name, type: integrations.type, enabled: integrations.enabled }).from(integrations);
const intById = new Map(intRows.map((r) => [r.id, r]));
function push(a: { category: AlertCategory; severity: AlertSeverity; type: string; message: string; key: string; link?: string | null; silenced?: boolean }) {
const meta = SOURCES[a.type];
alerts.push({
id: `${a.category}:${a.key}`,
severity: a.severity,
category: a.category,
source: meta?.label ?? sourceLabel(a.type),
message: a.message,
link: a.link === undefined ? (meta?.link ?? null) : a.link,
silenced: !!a.silenced,
});
}
function cannotCheck(name: string, error: string, check: string) {
const entry = blind.get(name) ?? { error, affects: new Set<string>() };
entry.affects.add(check);
blind.set(name, entry);
}
const cannotCheckIntegration = (f: SourceFailure, check: string) => {
const row = intById.get(f.integrationId);
cannotCheck(`${row ? (SOURCES[row.type]?.label ?? row.type) : "Integration"} “${f.integrationName}”`, f.message, check);
};
const silencedIntegration = (id: number) => subjects.has(`integration:${id}`);
// One entry per check. A check that blows up, or hangs, only costs its own section of the page.
async function check(name: string, work: () => Promise<void>) {
try {
await withTimeout(work());
} catch (err) {
cannotCheck(name, errorText(err), name);
}
}
await Promise.all([
check("Server and storage health", async () => {
const { healthChecks } = await getSettings();
const { snapshot, held } = await collectSnapshot();
const now = Date.now();
const grace = startupGraceRemainingMs(now);
if (grace > 0) {
notes.push(
`Server-offline and server-disk checks are paused for another ${Math.ceil(grace / 60_000)} min after the app restarted, so agents get a chance to report before any server is judged.`,
);
}
for (const c of evaluateHealth(snapshot, healthChecks, now, { skipServers: grace > 0 })) {
const origin = originOfConditionKey(c.key);
const subject = subjectOfConditionKey(c.key);
push({
category: origin.category,
severity: c.severity ?? "warning",
type: origin.type,
message: c.message,
key: c.key,
link: origin.type === "server" && origin.id !== null ? `/servers/${origin.id}` : undefined,
silenced: subject !== null && subjects.has(subject),
});
}
// Integrations the check couldn't read this time. (A whole-integration entry covers its nodes, so skip those.)
for (const h of held) {
const m = /^(proxmox|synology):(\d+)(?::(.+))?$/.exec(h);
if (!m || (m[3] && held.has(`${m[1]}:${m[2]}`))) continue;
const row = intById.get(Number(m[2]));
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”${m[3] ? ` (node ${m[3]})` : ""}`, "couldn't be read — see the Diagnostic Log", "Server and storage health");
}
}),
check("Automation runs", async () => {
const { items, held } = await collectAutomation();
for (const c of evaluateAutomation(items).conditions) {
const origin = originOfConditionKey(c.key);
const subject = subjectOfConditionKey(c.key);
push({ category: "automation", severity: "warning", type: origin.type, message: c.message, key: c.key, silenced: subject !== null && subjects.has(subject) });
}
for (const h of held) {
const m = /^(semaphore|gitea):(\d+)$/.exec(h);
if (!m) continue;
const row = intById.get(Number(m[2]));
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”`, "couldn't be read — see the Diagnostic Log", "Automation runs");
}
}),
check("Proxmox backups", async () => {
const { failures, uncovered, sourceFailures } = await collectProxmoxBackupProblems({ skipSilenced: false });
for (const f of failures) {
push({
category: "backup",
severity: "critical",
type: "proxmox",
message: `Latest backup on ${f.node}${f.guestId ? ` (guest ${f.guestId})` : ""} didn't succeed [${f.integrationName}]: ${f.status}`,
key: `proxmox-backup:${f.integrationId}:${f.node}`,
silenced: f.silenced,
});
}
for (const u of uncovered) {
push({
category: "backup",
severity: "warning",
type: "proxmox",
message: `${u.guestName} (#${u.vmid}) on ${u.node} isn't covered by any backup job [${u.integrationName}]`,
key: `proxmox-uncovered:${u.integrationId}:${u.vmid}`,
silenced: u.silenced,
});
}
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Proxmox backups"));
}),
check("Backup verification", async () => {
const { problems, sourceFailures } = await collectPbsProblems({ skipSilenced: false });
for (const p of problems) {
push({
category: "backup",
severity: p.error ? "warning" : "critical",
type: "pbs",
message: p.error
? `Datastore "${p.datastore}" couldn't be read [${p.integrationName}]: ${p.error}`
: `Datastore "${p.datastore}" has ${plural(p.failedCount, "snapshot")} that failed verification [${p.integrationName}]`,
key: `pbs:${p.integrationId}:${p.datastore}`,
silenced: p.silenced,
});
}
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Backup verification"));
}),
check("Image updates", async () => {
const { items, failures } = await collectDockerUpdates();
for (const u of items) {
push({
category: "updates",
severity: "info",
type: "dockhand",
message: `${u.containerName} [${u.environmentName}, ${u.integrationName}] has an image update available${u.newerVersion ? ` → ${u.newerVersion}` : ""}`,
key: `docker:${u.integrationId}:${u.environmentName}:${u.containerName}`,
silenced: silencedIntegration(u.integrationId),
});
}
failures.forEach((f) => cannotCheckIntegration(f, "Image updates"));
}),
check("Tailscale keys", async () => {
const { items, failures } = await collectTailscaleKeyExpiries();
for (const k of items) {
push({
category: "expiry",
severity: k.daysLeft < 0 ? "critical" : "warning",
type: "tailscale",
message: k.daysLeft < 0 ? `Key for ${k.deviceLabel} [${k.integrationName}] has expired` : `Key for ${k.deviceLabel} [${k.integrationName}] expires in ${plural(k.daysLeft, "day")}`,
key: `tailscale-key:${k.integrationId}:${k.deviceLabel}`,
silenced: silencedIntegration(k.integrationId),
});
}
failures.forEach((f) => cannotCheckIntegration(f, "Tailscale keys"));
}),
check("Uptime Kuma", async () => {
for (const row of intRows.filter((r) => r.type === "uptimekuma" && r.enabled)) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
for (const m of await createUptimeKumaAdapter(loaded.config as any).listMonitors()) {
if (m.status !== "down") continue;
push({
category: "monitoring",
severity: "critical",
type: "uptimekuma",
message: `Monitor "${m.name}" is down${m.target ? ` (${m.target}${m.port ? `:${m.port}` : ""})` : ""} [${row.name}]`,
key: `kuma:${row.id}:${m.id}`,
silenced: silencedIntegration(row.id),
});
}
} catch (err) {
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "Uptime Kuma");
}
}
}),
check("osTicket", async () => {
for (const row of intRows.filter((r) => r.type === "osticket" && r.enabled)) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
const overdue = (await createOsTicketAdapter(loaded.config as any).listOpenTickets()).filter((t) => t.isOverdue).length;
if (overdue > 0) {
push({
category: "tickets",
severity: "warning",
type: "osticket",
message: `${plural(overdue, "open ticket")} ${overdue === 1 ? "is" : "are"} overdue [${row.name}]`,
key: `osticket:${row.id}`,
silenced: silencedIntegration(row.id),
});
}
} catch (err) {
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "osTicket");
}
}
}),
check("Secrets", async () => {
for (const s of await db.select().from(secrets)) {
const status = computeSecretStatus(s.expiryDate, s.warnDays);
if (status.status === "expired") {
push({ category: "expiry", severity: "critical", type: "secrets", message: `Secret "${s.name}" expired ${plural(Math.abs(status.daysLeft), "day")} ago (${s.expiryDate})`, key: `secret:${s.id}` });
} else if (status.status === "expiring") {
push({ category: "expiry", severity: "warning", type: "secrets", message: `Secret "${s.name}" expires in ${plural(status.daysLeft, "day")} (${s.expiryDate})`, key: `secret:${s.id}` });
}
if (s.checkHost && s.lastCheckError) {
push({
category: "expiry",
severity: "warning",
type: "secrets",
message: `Couldn't read the live certificate for "${s.name}" (${s.checkHost}:${s.checkPort ?? 443}) — the expiry shown may be stale: ${s.lastCheckError}`,
key: `secret-check:${s.id}`,
});
}
}
}),
check("Domains", async () => {
const { expiring, staleChecks } = await collectDomainAlerts();
for (const d of expiring) {
push({
category: "expiry",
severity: d.status === "expired" ? "critical" : "warning",
type: "domains",
message: d.status === "expired" ? `Domain ${d.name} expired on ${d.expiresAt}` : `Domain ${d.name} expires in ${plural(d.daysLeft, "day")} (${d.expiresAt})`,
key: `domain:${d.name}`,
});
}
for (const s of staleChecks) {
push({ category: "expiry", severity: "info", type: "domains", message: `Couldn't refresh the registration for ${s.name} — the expiry shown may be stale: ${s.error}`, key: `domain-stale:${s.name}` });
}
}),
check("Integration failures", async () => {
const { notifications } = await getSettings();
for (const f of getFailingSources()) {
push({
category: "integration",
severity: f.alerted ? "critical" : "warning",
type: f.source,
message: `${sourceLabel(f.source)} has failed its last ${plural(f.consecutiveFailures, "call")} in a row${f.alerted ? "" : ` (a notification goes out after ${notifications.integrationFailureThreshold})`} — see the Diagnostic Log`,
key: `failing:${f.source}`,
link: null,
silenced: await isSourceInMaintenance(f.source),
});
}
}),
]);
alerts.sort(
(a, b) =>
Number(a.silenced) - Number(b.silenced) ||
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
a.source.localeCompare(b.source) ||
a.message.localeCompare(b.message),
);
const active = alerts.filter((a) => !a.silenced);
return {
alerts,
counts: {
critical: active.filter((a) => a.severity === "critical").length,
warning: active.filter((a) => a.severity === "warning").length,
info: active.filter((a) => a.severity === "info").length,
silenced: alerts.length - active.length,
},
couldntCheck: [...blind.entries()].map(([name, v]) => ({ name, error: v.error, affects: [...v.affects] })),
notes,
generatedAt: new Date().toISOString(),
};
}
let cache: { at: number; report: AlertsReport } | null = null;
let inFlight: Promise<AlertsReport> | null = null;
/** The current alerts, reusing a recent result unless `force` asks for a fresh one (and even then not more than once every few seconds). */
export async function getAlerts(force: boolean): Promise<AlertsReport & { cached: boolean }> {
const age = cache ? Date.now() - cache.at : Infinity;
if (cache && age < (force ? MIN_REFRESH_MS : CACHE_MS)) return { ...cache.report, cached: true };
inFlight ??= collectAlerts()
.then((report) => {
cache = { at: Date.now(), report };
return report;
})
.finally(() => {
inFlight = null;
});
return { ...(await inFlight), cached: false };
}
+2 -2
View File
@@ -62,7 +62,7 @@ export function evaluateAutomation(items: AutomationItem[]): { conditions: Healt
// ─── Collection (I/O) ─────────────────────────────────────────────────────── // ─── Collection (I/O) ───────────────────────────────────────────────────────
async function collect(): Promise<{ items: AutomationItem[]; held: Set<string>; readable: number }> { export async function collectAutomation(): Promise<{ items: AutomationItem[]; held: Set<string>; readable: number }> {
const items: AutomationItem[] = []; const items: AutomationItem[] = [];
const held = new Set<string>(); const held = new Set<string>();
let readable = 0; let readable = 0;
@@ -138,7 +138,7 @@ async function loadState(): Promise<ActiveState | null> {
* the first time this feature runs) that would otherwise be a wall of alerts about failures that are months old. * the first time this feature runs) that would otherwise be a wall of alerts about failures that are months old.
*/ */
export async function runAutomationCheck(now: number = Date.now()): Promise<{ added: number; resolved: number; baseline: boolean }> { export async function runAutomationCheck(now: number = Date.now()): Promise<{ added: number; resolved: number; baseline: boolean }> {
const { items, held: readHeld, readable } = await collect(); const { items, held: readHeld, readable } = await collectAutomation();
const stored = await loadState(); const stored = await loadState();
// Nothing could be read at all (or nothing is configured): don't spend the "first pass" on an empty picture. // Nothing could be read at all (or nothing is configured): don't spend the "first pass" on an empty picture.
+20 -3
View File
@@ -5,17 +5,28 @@ import { integrations } from "../db/schema.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js"; import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
import { notifyDockerUpdates } from "./notify.js"; import { notifyDockerUpdates } from "./notify.js";
import type { SourceFailure } from "./alertTypes.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
const LAST_RUN_FLAG = "dockerUpdateCheckLastRunDate"; const LAST_RUN_FLAG = "dockerUpdateCheckLastRunDate";
async function checkDockerUpdates(): Promise<void> { export interface DockerUpdate {
integrationId: number;
integrationName: string;
containerName: string;
environmentName: string;
newerVersion: string | null;
}
/** Every container with an image update waiting, across the enabled Dockhand integrations. Shared with the Alerts page. */
export async function collectDockerUpdates(): Promise<{ items: DockerUpdate[]; failures: SourceFailure[] }> {
const rows = await db const rows = await db
.select({ id: integrations.id, name: integrations.name }) .select({ id: integrations.id, name: integrations.name })
.from(integrations) .from(integrations)
.where(and(eq(integrations.type, "dockhand"), eq(integrations.enabled, true))); .where(and(eq(integrations.type, "dockhand"), eq(integrations.enabled, true)));
const updatesAvailable: { integrationName: string; containerName: string; environmentName: string; newerVersion: string | null }[] = []; const updatesAvailable: DockerUpdate[] = [];
const failures: SourceFailure[] = [];
for (const row of rows) { for (const row of rows) {
try { try {
@@ -28,6 +39,7 @@ async function checkDockerUpdates(): Promise<void> {
for (const c of containers) { for (const c of containers) {
if (!c.updateAvailable) continue; if (!c.updateAvailable) continue;
updatesAvailable.push({ updatesAvailable.push({
integrationId: row.id,
integrationName: row.name, integrationName: row.name,
containerName: c.name, containerName: c.name,
environmentName: c.environmentName, environmentName: c.environmentName,
@@ -36,10 +48,15 @@ async function checkDockerUpdates(): Promise<void> {
} }
} catch (err) { } catch (err) {
console.error(`[dockerUpdate] check failed for integration ${row.id}:`, err); console.error(`[dockerUpdate] check failed for integration ${row.id}:`, err);
failures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
} }
} }
await notifyDockerUpdates(updatesAvailable); return { items: updatesAvailable, failures };
}
async function checkDockerUpdates(): Promise<void> {
await notifyDockerUpdates((await collectDockerUpdates()).items);
} }
async function checkDockerUpdatesOnce(): Promise<void> { async function checkDockerUpdatesOnce(): Promise<void> {
+19 -1
View File
@@ -21,6 +21,8 @@ export interface HealthCondition {
/** Where the data came from, hierarchically ("server", "proxmox:3", "proxmox:3:pve1"). Used to hold a condition when its source can't be reached. */ /** Where the data came from, hierarchically ("server", "proxmox:3", "proxmox:3:pve1"). Used to hold a condition when its source can't be reached. */
source: string; source: string;
message: string; message: string;
/** How bad, for the Alerts page. Notifications don't use it. Left out means "warning". */
severity?: "critical" | "warning";
} }
export interface ServerSnapshot { export interface ServerSnapshot {
@@ -91,6 +93,8 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
const out: HealthCondition[] = []; const out: HealthCondition[] = [];
const limit = thresholds.diskUsagePercent; const limit = thresholds.diskUsagePercent;
const pct = (n: number) => `${Math.round(n)}%`; const pct = (n: number) => `${Math.round(n)}%`;
/** Over the configured threshold is a warning; practically out of room is critical. */
const fullness = (p: number): "critical" | "warning" => (p >= 95 ? "critical" : "warning");
if (!opts.skipServers) { if (!opts.skipServers) {
for (const s of snapshot.servers) { for (const s of snapshot.servers) {
@@ -103,6 +107,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
key: `offline:server:${s.id}`, key: `offline:server:${s.id}`,
source: "server", source: "server",
message: `${s.name} hasn't reported for ${formatDuration(age)}`, message: `${s.name} hasn't reported for ${formatDuration(age)}`,
severity: "critical",
}); });
} }
} }
@@ -115,6 +120,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
key: `disk:server:${s.id}:${d.mount}`, key: `disk:server:${s.id}:${d.mount}`,
source: "server", source: "server",
message: `${s.name}: ${d.mount} is ${pct(p)} full (${formatBytes(d.usedBytes)} of ${formatBytes(d.sizeBytes)})`, message: `${s.name}: ${d.mount} is ${pct(p)} full (${formatBytes(d.usedBytes)} of ${formatBytes(d.sizeBytes)})`,
severity: fullness(p),
}); });
} }
} }
@@ -132,6 +138,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
key: `disk:proxmox:${px.integrationId}:${node.node}:rootfs`, key: `disk:proxmox:${px.integrationId}:${node.node}:rootfs`,
source: `proxmox:${px.integrationId}:${node.node}`, source: `proxmox:${px.integrationId}:${node.node}`,
message: `${px.integrationName} / ${node.node}: root filesystem is ${pct(rootP)} full`, message: `${px.integrationName} / ${node.node}: root filesystem is ${pct(rootP)} full`,
severity: fullness(rootP),
}); });
} }
for (const st of node.storages) { for (const st of node.storages) {
@@ -146,12 +153,14 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
key: `disk:proxmox:${px.integrationId}:storage:${st.id}`, key: `disk:proxmox:${px.integrationId}:storage:${st.id}`,
source: `proxmox:${px.integrationId}:shared`, source: `proxmox:${px.integrationId}:shared`,
message: `${px.integrationName}: shared storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`, message: `${px.integrationName}: shared storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`,
severity: fullness(p),
}); });
} else { } else {
out.push({ out.push({
key: `disk:proxmox:${px.integrationId}:${node.node}:storage:${st.id}`, key: `disk:proxmox:${px.integrationId}:${node.node}:storage:${st.id}`,
source: `proxmox:${px.integrationId}:${node.node}`, source: `proxmox:${px.integrationId}:${node.node}`,
message: `${px.integrationName} / ${node.node}: storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`, message: `${px.integrationName} / ${node.node}: storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`,
severity: fullness(p),
}); });
} }
} }
@@ -166,6 +175,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
key: `synology-volume:${syn.integrationId}:${v.id}`, key: `synology-volume:${syn.integrationId}:${v.id}`,
source: src, source: src,
message: `${syn.integrationName}: volume ${v.id} status is "${v.status}"`, message: `${syn.integrationName}: volume ${v.id} status is "${v.status}"`,
severity: "critical",
}); });
} }
const p = usage(v.sizeUsed, v.sizeTotal); const p = usage(v.sizeUsed, v.sizeTotal);
@@ -174,6 +184,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
key: `disk:synology:${syn.integrationId}:${v.id}`, key: `disk:synology:${syn.integrationId}:${v.id}`,
source: src, source: src,
message: `${syn.integrationName}: volume ${v.id} is ${pct(p)} full (${formatBytes(v.sizeUsed ?? 0)} of ${formatBytes(v.sizeTotal ?? 0)})`, message: `${syn.integrationName}: volume ${v.id} is ${pct(p)} full (${formatBytes(v.sizeUsed ?? 0)} of ${formatBytes(v.sizeTotal ?? 0)})`,
severity: fullness(p),
}); });
} }
} }
@@ -188,6 +199,8 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
key: `synology-disk:${syn.integrationId}:${d.id}`, key: `synology-disk:${syn.integrationId}:${d.id}`,
source: src, source: src,
message: `${syn.integrationName}: disk ${d.name || d.id} — ${problems.join(", ")}`, message: `${syn.integrationName}: disk ${d.name || d.id} — ${problems.join(", ")}`,
// A disk that's no longer "normal" is failing; a SMART warning or a threshold crossing is the early notice.
severity: d.status && d.status.toLowerCase() !== "normal" ? "critical" : "warning",
}); });
} }
} }
@@ -247,7 +260,12 @@ export function diffConditions(
// ─── Collection (I/O) ─────────────────────────────────────────────────────── // ─── Collection (I/O) ───────────────────────────────────────────────────────
async function collectSnapshot(): Promise<{ snapshot: HealthSnapshot; held: Set<string> }> { /** How much longer, in ms, servers are exempt from being judged after a restart (their agents get a chance to report first). 0 once it's over. */
export function startupGraceRemainingMs(now: number = Date.now()): number {
return Math.max(0, STARTUP_GRACE_MS - (now - PROCESS_START));
}
export async function collectSnapshot(): Promise<{ snapshot: HealthSnapshot; held: Set<string> }> {
const held = new Set<string>(); const held = new Set<string>();
const snapshot: HealthSnapshot = { servers: [], proxmox: [], synology: [] }; const snapshot: HealthSnapshot = { servers: [], proxmox: [], synology: [] };
@@ -17,6 +17,13 @@ interface SourceHealth {
*/ */
const health = new Map<string, SourceHealth>(); const health = new Map<string, SourceHealth>();
/** Services whose most recent calls have been failing right now, for the Alerts page. `alerted` means a "down" notification has gone out for the streak. */
export function getFailingSources(): { source: string; consecutiveFailures: number; alerted: boolean }[] {
return [...health.entries()]
.filter(([, state]) => state.consecutiveFailures > 0)
.map(([source, state]) => ({ source, consecutiveFailures: state.consecutiveFailures, alerted: state.alerted }));
}
/** Called after every diagnostic-log entry is recorded, to track consecutive failures per source and alert on threshold-cross / recovery. */ /** Called after every diagnostic-log entry is recorded, to track consecutive failures per source and alert on threshold-cross / recovery. */
export async function trackIntegrationHealth(source: string, ok: boolean): Promise<void> { export async function trackIntegrationHealth(source: string, ok: boolean): Promise<void> {
const state = health.get(source) ?? { consecutiveFailures: 0, alerted: false }; const state = health.get(source) ?? { consecutiveFailures: 0, alerted: false };
+5 -1
View File
@@ -382,9 +382,13 @@ const SOURCE_LABELS: Record<string, string> = {
semaphore: "Semaphore", semaphore: "Semaphore",
gitea: "Gitea", gitea: "Gitea",
dockhand: "Dockhand", dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
osticket: "osTicket",
}; };
function sourceLabel(source: string): string { export function sourceLabel(source: string): string {
return SOURCE_LABELS[source] ?? source; return SOURCE_LABELS[source] ?? source;
} }
@@ -6,21 +6,39 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createPbsAdapter } from "../integrations/pbs/adapter.js"; import { createPbsAdapter } from "../integrations/pbs/adapter.js";
import { notifyPbsVerificationFailed } from "./notify.js"; import { notifyPbsVerificationFailed } from "./notify.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
import { isInMaintenance } from "./maintenance.js"; import { activeSubjects } from "./maintenance.js";
import type { SourceFailure } from "./alertTypes.js";
const LAST_RUN_FLAG = "pbsVerificationCheckLastRunDate"; const LAST_RUN_FLAG = "pbsVerificationCheckLastRunDate";
async function checkPbsVerification(): Promise<void> { export interface PbsProblem {
integrationId: number;
integrationName: string;
datastore: string;
failedCount: number;
error: string | null;
silenced: boolean;
}
/**
* Datastores with snapshots that failed verification, or that couldn't be read at all, across the enabled PBS
* integrations. The daily check skips integrations under a maintenance window altogether; the Alerts page passes
* skipSilenced: false to see them, flagged.
*/
export async function collectPbsProblems(opts: { skipSilenced: boolean }): Promise<{ problems: PbsProblem[]; sourceFailures: SourceFailure[] }> {
const rows = await db const rows = await db
.select({ id: integrations.id, name: integrations.name }) .select({ id: integrations.id, name: integrations.name })
.from(integrations) .from(integrations)
.where(and(eq(integrations.type, "pbs"), eq(integrations.enabled, true))); .where(and(eq(integrations.type, "pbs"), eq(integrations.enabled, true)));
const failures: { integrationName: string; datastore: string; failedCount: number; error: string | null }[] = []; const failures: PbsProblem[] = [];
const sourceFailures: SourceFailure[] = [];
const silencedSubjects = await activeSubjects();
for (const row of rows) { for (const row of rows) {
// A PBS host being worked on can't be reached reliably; this check is daily, so tomorrow's pass covers it. // A PBS host being worked on can't be reached reliably; this check is daily, so tomorrow's pass covers it.
if (await isInMaintenance("integration", row.id)) continue; const silenced = silencedSubjects.has(`integration:${row.id}`);
if (silenced && opts.skipSilenced) continue;
try { try {
const loaded = await loadIntegrationConfig(row.id); const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue; if (!loaded) continue;
@@ -29,17 +47,22 @@ async function checkPbsVerification(): Promise<void> {
for (const d of datastores) { for (const d of datastores) {
if (d.error) { if (d.error) {
failures.push({ integrationName: row.name, datastore: d.name, failedCount: 0, error: d.error }); failures.push({ integrationId: row.id, integrationName: row.name, datastore: d.name, failedCount: 0, error: d.error, silenced });
} else if (d.failedCount > 0) { } else if (d.failedCount > 0) {
failures.push({ integrationName: row.name, datastore: d.name, failedCount: d.failedCount, error: null }); failures.push({ integrationId: row.id, integrationName: row.name, datastore: d.name, failedCount: d.failedCount, error: null, silenced });
} }
} }
} catch (err) { } catch (err) {
console.error(`[pbsVerification] check failed for integration ${row.id}:`, err); console.error(`[pbsVerification] check failed for integration ${row.id}:`, err);
sourceFailures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
} }
} }
await notifyPbsVerificationFailed(failures); return { problems: failures, sourceFailures };
}
async function checkPbsVerification(): Promise<void> {
await notifyPbsVerificationFailed((await collectPbsProblems({ skipSilenced: true })).problems);
} }
async function checkPbsVerificationOnce(): Promise<void> { async function checkPbsVerificationOnce(): Promise<void> {
+42 -8
View File
@@ -6,22 +6,50 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, guestsWithoutBackupCoverage, type ProxmoxBackupTask } from "../integrations/proxmox/adapter.js"; import { createProxmoxAdapter, guestsWithoutBackupCoverage, type ProxmoxBackupTask } from "../integrations/proxmox/adapter.js";
import { notifyProxmoxBackupFailure, notifyProxmoxUncoveredGuests } from "./notify.js"; import { notifyProxmoxBackupFailure, notifyProxmoxUncoveredGuests } from "./notify.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
import { isInMaintenance } from "./maintenance.js"; import { activeSubjects } from "./maintenance.js";
import type { SourceFailure } from "./alertTypes.js";
const LAST_RUN_FLAG = "proxmoxBackupCheckLastRunDate"; const LAST_RUN_FLAG = "proxmoxBackupCheckLastRunDate";
async function checkProxmoxBackups(): Promise<void> { export interface BackupFailure {
integrationId: number;
integrationName: string;
node: string;
guestId: string | null;
status: string;
silenced: boolean;
}
export interface UncoveredGuest {
integrationId: number;
integrationName: string;
guestName: string;
vmid: number;
node: string;
silenced: boolean;
}
/**
* Failed latest backups and guests no backup job covers, across the enabled Proxmox integrations. The daily check skips
* integrations under a maintenance window altogether (a host being worked on can't run or report backups, and tomorrow's
* pass covers it); the Alerts page passes skipSilenced: false to see them, flagged.
*/
export async function collectProxmoxBackupProblems(opts: {
skipSilenced: boolean;
}): Promise<{ failures: BackupFailure[]; uncovered: UncoveredGuest[]; sourceFailures: SourceFailure[] }> {
const rows = await db const rows = await db
.select({ id: integrations.id, name: integrations.name }) .select({ id: integrations.id, name: integrations.name })
.from(integrations) .from(integrations)
.where(and(eq(integrations.type, "proxmox"), eq(integrations.enabled, true))); .where(and(eq(integrations.type, "proxmox"), eq(integrations.enabled, true)));
const failures: { integrationName: string; node: string; guestId: string | null; status: string }[] = []; const failures: BackupFailure[] = [];
const uncovered: { integrationName: string; guestName: string; vmid: number; node: string }[] = []; const uncovered: UncoveredGuest[] = [];
const sourceFailures: SourceFailure[] = [];
const silencedSubjects = await activeSubjects();
for (const row of rows) { for (const row of rows) {
// A host being worked on can't run or report backups; this check is daily, so tomorrow's pass covers it. const silenced = silencedSubjects.has(`integration:${row.id}`);
if (await isInMaintenance("integration", row.id)) continue; if (silenced && opts.skipSilenced) continue;
try { try {
const loaded = await loadIntegrationConfig(row.id); const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue; if (!loaded) continue;
@@ -44,18 +72,24 @@ async function checkProxmoxBackups(): Promise<void> {
for (const [node, task] of latestByNode) { for (const [node, task] of latestByNode) {
if (!task.ok && task.status !== "running") { if (!task.ok && task.status !== "running") {
failures.push({ integrationName: row.name, node, guestId: task.guestId, status: task.status }); failures.push({ integrationId: row.id, integrationName: row.name, node, guestId: task.guestId, status: task.status, silenced });
} }
} }
for (const g of guestsWithoutBackupCoverage(guests, jobs)) { for (const g of guestsWithoutBackupCoverage(guests, jobs)) {
uncovered.push({ integrationName: row.name, guestName: g.name, vmid: g.vmid, node: g.node }); uncovered.push({ integrationId: row.id, integrationName: row.name, guestName: g.name, vmid: g.vmid, node: g.node, silenced });
} }
} catch (err) { } catch (err) {
console.error(`[proxmoxBackup] check failed for integration ${row.id}:`, err); console.error(`[proxmoxBackup] check failed for integration ${row.id}:`, err);
sourceFailures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
} }
} }
return { failures, uncovered, sourceFailures };
}
async function checkProxmoxBackups(): Promise<void> {
const { failures, uncovered } = await collectProxmoxBackupProblems({ skipSilenced: true });
await notifyProxmoxBackupFailure(failures); await notifyProxmoxBackupFailure(failures);
await notifyProxmoxUncoveredGuests(uncovered); await notifyProxmoxUncoveredGuests(uncovered);
} }
@@ -5,17 +5,27 @@ import { integrations } from "../db/schema.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter, isKeyExpiringSoon } from "../integrations/tailscale/adapter.js"; import { createTailscaleAdapter, isKeyExpiringSoon } from "../integrations/tailscale/adapter.js";
import { notifyTailscaleKeyExpiry } from "./notify.js"; import { notifyTailscaleKeyExpiry } from "./notify.js";
import type { SourceFailure } from "./alertTypes.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
const LAST_RUN_FLAG = "tailscaleKeyCheckLastRunDate"; const LAST_RUN_FLAG = "tailscaleKeyCheckLastRunDate";
async function checkTailscaleKeyExpiry(): Promise<void> { export interface TailscaleKeyExpiry {
integrationId: number;
integrationName: string;
deviceLabel: string;
daysLeft: number;
}
/** Every device key that's expired or about to, across the enabled Tailscale integrations. Shared with the Alerts page. */
export async function collectTailscaleKeyExpiries(): Promise<{ items: TailscaleKeyExpiry[]; failures: SourceFailure[] }> {
const rows = await db const rows = await db
.select({ id: integrations.id, name: integrations.name }) .select({ id: integrations.id, name: integrations.name })
.from(integrations) .from(integrations)
.where(and(eq(integrations.type, "tailscale"), eq(integrations.enabled, true))); .where(and(eq(integrations.type, "tailscale"), eq(integrations.enabled, true)));
const expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[] = []; const expiring: TailscaleKeyExpiry[] = [];
const failures: SourceFailure[] = [];
const now = Date.now(); const now = Date.now();
for (const row of rows) { for (const row of rows) {
@@ -27,14 +37,19 @@ async function checkTailscaleKeyExpiry(): Promise<void> {
for (const d of devices) { for (const d of devices) {
if (!isKeyExpiringSoon(d, now)) continue; if (!isKeyExpiringSoon(d, now)) continue;
const daysLeft = Math.floor((new Date(d.keyExpiry!).getTime() - now) / 86_400_000); const daysLeft = Math.floor((new Date(d.keyExpiry!).getTime() - now) / 86_400_000);
expiring.push({ integrationName: row.name, deviceLabel: d.label || d.hostname, daysLeft }); expiring.push({ integrationId: row.id, integrationName: row.name, deviceLabel: d.label || d.hostname, daysLeft });
} }
} catch (err) { } catch (err) {
console.error(`[tailscaleKeyExpiry] check failed for integration ${row.id}:`, err); console.error(`[tailscaleKeyExpiry] check failed for integration ${row.id}:`, err);
failures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
} }
} }
await notifyTailscaleKeyExpiry(expiring); return { items: expiring, failures };
}
async function checkTailscaleKeyExpiry(): Promise<void> {
await notifyTailscaleKeyExpiry((await collectTailscaleKeyExpiries()).items);
} }
async function checkTailscaleKeyExpiryOnce(): Promise<void> { async function checkTailscaleKeyExpiryOnce(): Promise<void> {
+4
View File
@@ -24,6 +24,7 @@ import UptimeKuma from "./pages/UptimeKuma";
import PbsBackup from "./pages/PbsBackup"; import PbsBackup from "./pages/PbsBackup";
import OsTicket from "./pages/OsTicket"; import OsTicket from "./pages/OsTicket";
import AdminLinks from "./pages/AdminLinks"; import AdminLinks from "./pages/AdminLinks";
import Alerts from "./pages/Alerts";
import Generator from "./pages/Generator"; import Generator from "./pages/Generator";
import Maintenance from "./pages/Maintenance"; import Maintenance from "./pages/Maintenance";
import Domains from "./pages/Domains"; import Domains from "./pages/Domains";
@@ -38,6 +39,7 @@ import CacheSettings from "./pages/settings/CacheSettings";
import LogSettings from "./pages/settings/LogSettings"; import LogSettings from "./pages/settings/LogSettings";
import BackupSettings from "./pages/settings/BackupSettings"; import BackupSettings from "./pages/settings/BackupSettings";
import AppShell from "./layout/AppShell"; import AppShell from "./layout/AppShell";
import DialogHost from "./components/DialogHost";
import { setDateTimeSettings } from "./utils/date"; import { setDateTimeSettings } from "./utils/date";
import { setPageSize } from "./utils/pageSize"; import { setPageSize } from "./utils/pageSize";
@@ -113,6 +115,7 @@ export default function App() {
<Route path="/pbs" element={<PbsBackup user={user} />} /> <Route path="/pbs" element={<PbsBackup user={user} />} />
<Route path="/osticket" element={<OsTicket user={user} />} /> <Route path="/osticket" element={<OsTicket user={user} />} />
<Route path="/admin-links" element={<AdminLinks user={user} />} /> <Route path="/admin-links" element={<AdminLinks user={user} />} />
<Route path="/alerts" element={<Alerts user={user} />} />
<Route <Route
path="/users" path="/users"
element={ element={
@@ -163,6 +166,7 @@ export default function App() {
<Route path="backup" element={<BackupSettings />} /> <Route path="backup" element={<BackupSettings />} />
</Route> </Route>
</Routes> </Routes>
<DialogHost />
</AppShell> </AppShell>
); );
} }
+27
View File
@@ -397,6 +397,30 @@ export interface ServerDetail {
links: ServerLink[]; 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 { export interface AdminLink {
id: number; id: number;
serverId: number; serverId: number;
@@ -1006,6 +1030,9 @@ export const api = {
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }), syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }), syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
}, },
alerts: {
list: (refresh = false) => request<AlertsReport>(`/api/alerts${refresh ? "?refresh=1" : ""}`),
},
ports: { ports: {
agent: () => request<AgentPortsResponse>("/api/ports/agent"), agent: () => request<AgentPortsResponse>("/api/ports/agent"),
forwards: { forwards: {
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { settle, subscribe, type DialogRequest } from "../utils/dialogs";
const FOCUSABLE = "button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [href]";
/** Draws the confirm/prompt dialogs asked for through utils/dialogs. Mount once, near the root. */
export default function DialogHost() {
const [current, setCurrent] = useState<DialogRequest | null>(null);
useEffect(() => subscribe(setCurrent), []);
if (!current) return null;
// Keyed, so back-to-back dialogs each start fresh (an empty text box, the right button focused).
return createPortal(<DialogView key={current.id} request={current} />, document.body);
}
function DialogView({ request }: { request: DialogRequest }) {
const titleId = useId();
const inputId = useId();
const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const isPrompt = request.kind === "prompt";
const danger = request.kind === "confirm" && !!request.danger;
const [value, setValue] = useState(request.kind === "prompt" ? (request.defaultValue ?? "") : "");
const cancel = () => settle(request, false);
const blocked = request.kind === "prompt" && !!request.required && !value.trim();
// On open: stop the page behind from scrolling (without the scrollbar vanishing and shifting the layout), put the
// keyboard focus where it's useful, and on close hand it back to whatever had it — the button that was clicked.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const { overflow, paddingRight } = document.body.style;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden";
if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`;
if (isPrompt) {
inputRef.current?.focus();
inputRef.current?.select();
} else if (danger) {
cancelRef.current?.focus(); // the safe answer, so a stray Enter doesn't delete anything
} else {
confirmRef.current?.focus();
}
return () => {
document.body.style.overflow = overflow;
document.body.style.paddingRight = paddingRight;
previouslyFocused?.focus?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
cancel();
return;
}
if (e.key !== "Tab") return;
// Keep Tab inside the dialog.
const items = Array.from(modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
function onSubmit(e: FormEvent) {
e.preventDefault();
if (blocked) return;
settle(request, true, value);
}
const title = request.title ?? (isPrompt ? "Enter a value" : "Please confirm");
return (
<>
<div
ref={modalRef}
className="modal modal-blur fade show"
style={{ display: "block" }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={onKeyDown}
// mousedown rather than click, so selecting text in the box and letting go outside it doesn't close the dialog
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel();
}}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<form className="modal-content" onSubmit={onSubmit}>
{danger && <div className="modal-status bg-danger" />}
<div className="modal-header">
<h5 className="modal-title" id={titleId}>
{title}
</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={cancel} />
</div>
<div className="modal-body">
<div style={{ whiteSpace: "pre-line" }}>{request.message}</div>
{request.kind === "prompt" && (
<div className="mt-3">
{request.label && (
<label className="form-label" htmlFor={inputId}>
{request.label}
</label>
)}
<input
id={inputId}
ref={inputRef}
className="form-control"
value={value}
placeholder={request.placeholder}
onChange={(e) => setValue(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn me-auto" ref={cancelRef} onClick={cancel}>
{request.cancelLabel ?? "Cancel"}
</button>
<button type="submit" className={`btn ${danger ? "btn-danger" : "btn-primary"}`} ref={confirmRef} disabled={blocked}>
{request.confirmLabel ?? (isPrompt ? "OK" : "Confirm")}
</button>
</div>
</form>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
);
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { downloadCsv } from "../utils/csv";
import { readableError } from "../utils/errors"; import { readableError } from "../utils/errors";
import Pagination from "./Pagination"; import Pagination from "./Pagination";
import SortableTh from "./SortableTh"; import SortableTh from "./SortableTh";
import { confirmDialog } from "../utils/dialogs";
const PRESETS: { key: string; label: string; from: number; to: number }[] = [ const PRESETS: { key: string; label: string; from: number; to: number }[] = [
{ key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 }, { key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 },
@@ -188,7 +189,8 @@ export default function ServerPorts({
async function removeNote(entry: PortEntry) { async function removeNote(entry: PortEntry) {
if (entry.id === null) return; if (entry.id === null) return;
const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`; const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`;
if (!confirm(what)) return; const reserved = entry.state === "reserved";
if (!(await confirmDialog({ title: reserved ? "Remove reservation" : "Clear note", message: what, confirmLabel: reserved ? "Remove" : "Clear", danger: true }))) return;
try { try {
await api.servers.ports.remove(serverId, entry.id); await api.servers.ports.remove(serverId, entry.id);
setData(await api.servers.ports.list(serverId)); setData(await api.servers.ports.list(serverId));
+2
View File
@@ -32,6 +32,7 @@ import {
IconTicket, IconTicket,
IconPlug, IconPlug,
IconExternalLink, IconExternalLink,
IconAlertTriangle,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
import { formatRemaining } from "../utils/duration"; import { formatRemaining } from "../utils/duration";
@@ -101,6 +102,7 @@ const NAV: NavEntry[] = [
label: "Operations", label: "Operations",
icon: <IconTool size={20} />, icon: <IconTool size={20} />,
items: [ items: [
{ to: "/alerts", label: "Alerts", icon: <IconAlertTriangle size={20} /> },
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> }, { to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> }, { to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
{ to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> }, { to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> },
+2 -1
View File
@@ -5,6 +5,7 @@ import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl"; import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
import { confirmDialog } from "../utils/dialogs";
interface LinkForm { interface LinkForm {
serverId: number | ""; serverId: number | "";
@@ -133,7 +134,7 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
} }
async function remove(link: AdminLink) { async function remove(link: AdminLink) {
if (!confirm(`Remove the "${link.label}" link from ${link.serverName}?`)) return; if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link from ${link.serverName}?`, confirmLabel: "Remove", danger: true }))) return;
setError(null); setError(null);
try { try {
await api.servers.removeLink(link.serverId, link.id); await api.servers.removeLink(link.serverId, link.id);
+245
View File
@@ -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>
)}
</>
)}
</>
);
}
+15 -7
View File
@@ -5,6 +5,7 @@ import { downloadCsv } from "../utils/csv";
import { formatDateTime, parseDbTimestamp } from "../utils/date"; import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { formatAgo } from "../utils/duration"; import { formatAgo } from "../utils/duration";
import { readableError } from "../utils/errors"; import { readableError } from "../utils/errors";
import { promptDialog } from "../utils/dialogs";
const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [ const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [
{ kind: "ip_conflict", title: "Address conflicts", blurb: "The same address reported by more than one server." }, { kind: "ip_conflict", title: "Address conflicts", blurb: "The same address reported by more than one server." },
@@ -72,7 +73,12 @@ export default function Consistency({ user }: { user: CurrentUser }) {
} }
async function ignore(f: ConsistencyFinding) { async function ignore(f: ConsistencyFinding) {
const reason = window.prompt("Why is this fine? (optional — shown in the Ignored list)", ""); const reason = await promptDialog({
title: "Ignore this finding",
message: "Why is this fine? (optional — shown in the Ignored list)",
placeholder: "e.g. intentional, or a test machine",
confirmLabel: "Ignore",
});
if (reason === null) return; // cancelled if (reason === null) return; // cancelled
setBusyKey(f.key); setBusyKey(f.key);
setError(null); setError(null);
@@ -113,12 +119,14 @@ export default function Consistency({ user }: { user: CurrentUser }) {
async function excludeAround(f: ConsistencyFinding) { async function excludeAround(f: ConsistencyFinding) {
if (!report || !f.ip) return; if (!report || !f.ip) return;
const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`; const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`;
const range = window.prompt( const range = await promptDialog({
`Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike. title: "Exclude a range",
message: "Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike.",
Range (a network like 192.168.16.0/20, or a single address):`, label: "Range (a network like 192.168.16.0/20, or a single address)",
suggestion, defaultValue: suggestion,
); confirmLabel: "Exclude",
required: true,
});
if (range === null || !range.trim()) return; if (range === null || !range.trim()) return;
await saveRanges([...report.excludedRanges, range.trim()]); await saveRanges([...report.excludedRanges, range.trim()]);
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
cloudflare: "Cloudflare", cloudflare: "Cloudflare",
@@ -62,7 +63,7 @@ export default function DiagLog() {
} }
async function handleClear() { async function handleClear() {
if (!confirm("Clear the entire diagnostic log? This cannot be undone.")) return; if (!(await confirmDialog({ title: "Clear diagnostic log", message: "Clear the entire diagnostic log? This cannot be undone.", confirmLabel: "Clear log", danger: true }))) return;
setClearing(true); setClearing(true);
try { try {
await api.diagLog.clear(); await api.diagLog.clear();
+3 -2
View File
@@ -16,6 +16,7 @@ import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
const PROVIDER_LABELS: Record<DnsProviderType, string> = { const PROVIDER_LABELS: Record<DnsProviderType, string> = {
cloudflare: "Cloudflare", cloudflare: "Cloudflare",
@@ -182,7 +183,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
async function removeRecord(r: DnsRecord) { async function removeRecord(r: DnsRecord) {
if (!selectedProviderId || !selectedZone) return; if (!selectedProviderId || !selectedZone) return;
if (!confirm(`Delete ${r.type} record "${r.name}"?`)) return; if (!(await confirmDialog({ title: "Delete DNS record", message: `Delete ${r.type} record "${r.name}"?`, confirmLabel: "Delete", danger: true }))) return;
setError(null); setError(null);
try { try {
await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id); await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id);
@@ -202,7 +203,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
} }
async function removeProvider(p: DnsProviderSummary) { async function removeProvider(p: DnsProviderSummary) {
if (!confirm(`Delete provider "${p.name}"? This removes its cached zones and records too.`)) return; if (!(await confirmDialog({ title: "Delete DNS provider", message: `Delete provider "${p.name}"? This removes its cached zones and records too.`, confirmLabel: "Delete", danger: true }))) return;
try { try {
await api.dns.providers.remove(p.id); await api.dns.providers.remove(p.id);
if (selectedProviderId === p.id) setSelectedProviderId(null); if (selectedProviderId === p.id) setSelectedProviderId(null);
+2 -1
View File
@@ -12,6 +12,7 @@ import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
function updateBadge(c: DockhandContainer) { function updateBadge(c: DockhandContainer) {
if (c.updateAvailable === null) return <span className="text-secondary">—</span>; if (c.updateAvailable === null) return <span className="text-secondary">—</span>;
@@ -124,7 +125,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") { async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
if (!selectedId) return; if (!selectedId) return;
if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return; if (action === "stop" && !(await confirmDialog({ title: "Stop container", message: `Stop container "${c.name}"?`, confirmLabel: "Stop", danger: true }))) return;
setActingOnContainer(c.id); setActingOnContainer(c.id);
setError(null); setError(null);
try { try {
+2 -1
View File
@@ -8,6 +8,7 @@ import { useSortable } from "../hooks/useSortable";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { confirmDialog } from "../utils/dialogs";
function statusBadge(d: DomainRecord) { function statusBadge(d: DomainRecord) {
switch (d.status) { switch (d.status) {
@@ -87,7 +88,7 @@ export default function Domains({ user }: { user: CurrentUser }) {
} }
async function remove(d: DomainRecord) { async function remove(d: DomainRecord) {
if (!confirm(`Stop tracking ${d.name}?`)) return; if (!(await confirmDialog({ title: "Stop tracking domain", message: `Stop tracking ${d.name}?`, confirmLabel: "Stop tracking", danger: true }))) return;
setError(null); setError(null);
try { try {
await api.domains.remove(d.id); await api.domains.remove(d.id);
+2 -1
View File
@@ -6,6 +6,7 @@ import IntegrationEditForm from "../components/IntegrationEditForm";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
const TYPE_LABELS: Record<IntegrationType, string> = { const TYPE_LABELS: Record<IntegrationType, string> = {
tailscale: "Tailscale", tailscale: "Tailscale",
@@ -79,7 +80,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
} }
async function removeIntegration(i: IntegrationSummary) { async function removeIntegration(i: IntegrationSummary) {
if (!confirm(`Delete integration "${i.name}"?`)) return; if (!(await confirmDialog({ title: "Delete integration", message: `Delete integration "${i.name}"?`, confirmLabel: "Delete", danger: true }))) return;
try { try {
await api.integrations.remove(i.id); await api.integrations.remove(i.id);
loadIntegrations(); loadIntegrations();
+11 -6
View File
@@ -7,6 +7,7 @@ import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { useSelection } from "../hooks/useSelection"; import { useSelection } from "../hooks/useSelection";
import { confirmDialog, promptDialog } from "../utils/dialogs";
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" }; const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
@@ -74,10 +75,14 @@ export default function Ipam({ user }: { user: CurrentUser }) {
async function excludeAround(entry: IpamEntry) { async function excludeAround(entry: IpamEntry) {
const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`; const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`;
const range = window.prompt( const range = await promptDialog({
`Hide this range from IP Addresses and the Consistency report — every address in it, not just this one:`, title: "Exclude a range",
suggestion, message: "Hide this range from IP Addresses and the Consistency report — every address in it, not just this one.",
); label: "Range (a network like 172.17.0.0/16, or a single address)",
defaultValue: suggestion,
confirmLabel: "Exclude",
required: true,
});
if (range === null || !range.trim()) return; if (range === null || !range.trim()) return;
await saveRanges([...excludedRanges, range.trim()]); await saveRanges([...excludedRanges, range.trim()]);
} }
@@ -139,7 +144,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
} }
async function remove(entry: IpamEntry) { async function remove(entry: IpamEntry) {
if (!confirm(`Delete IP address "${entry.ipAddress}"?`)) return; if (!(await confirmDialog({ title: "Delete IP address", message: `Delete IP address "${entry.ipAddress}"?`, confirmLabel: "Delete", danger: true }))) return;
setError(null); setError(null);
try { try {
await api.ipam.remove(entry.id); await api.ipam.remove(entry.id);
@@ -152,7 +157,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
async function bulkDelete() { async function bulkDelete() {
const ids = Array.from(selection.selected); const ids = Array.from(selection.selected);
if (ids.length === 0) return; if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`)) return; if (!(await confirmDialog({ title: "Delete IP addresses", message: `Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`, confirmLabel: "Delete", danger: true }))) return;
setError(null); setError(null);
setBulkDeleting(true); setBulkDeleting(true);
try { try {
+2 -1
View File
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { formatAgo } from "../utils/duration"; import { formatAgo } from "../utils/duration";
import { confirmDialog } from "../utils/dialogs";
const emptyForm: PortForwardInput = { const emptyForm: PortForwardInput = {
label: "", label: "",
@@ -163,7 +164,7 @@ export default function Ports({ user }: { user: CurrentUser }) {
} }
async function remove(fwd: PortForward) { async function remove(fwd: PortForward) {
if (!confirm(`Delete port opening "${fwd.label}"?`)) return; if (!(await confirmDialog({ title: "Delete port opening", message: `Delete port opening "${fwd.label}"?`, confirmLabel: "Delete", danger: true }))) return;
setForwardError(null); setForwardError(null);
try { try {
await api.ports.forwards.remove(fwd.id); await api.ports.forwards.remove(fwd.id);
+11 -1
View File
@@ -15,6 +15,7 @@ import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
function proxmoxStatusBadge(status: string) { function proxmoxStatusBadge(status: string) {
return status === "running" ? ( return status === "running" ? (
@@ -164,7 +165,16 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") { async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
if (!selectedId) return; if (!selectedId) return;
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`)) const kind = g.type === "qemu" ? "VM" : "container";
if (
action === "stop" &&
!(await confirmDialog({
title: `Stop ${kind}`,
message: `Stop ${kind} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`,
confirmLabel: "Stop now",
danger: true,
}))
)
return; return;
setActingOnGuest(g.vmid); setActingOnGuest(g.vmid);
setError(null); setError(null);
+3 -2
View File
@@ -9,6 +9,7 @@ import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { useSelection } from "../hooks/useSelection"; import { useSelection } from "../hooks/useSelection";
import { confirmDialog } from "../utils/dialogs";
const TYPE_LABELS: Record<SecretRecord["type"], string> = { const TYPE_LABELS: Record<SecretRecord["type"], string> = {
api_token: "API Token", api_token: "API Token",
@@ -134,7 +135,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
} }
async function remove(s: SecretRecord) { async function remove(s: SecretRecord) {
if (!confirm(`Delete secret "${s.name}"?`)) return; if (!(await confirmDialog({ title: "Delete secret", message: `Delete secret "${s.name}"?`, confirmLabel: "Delete", danger: true }))) return;
setError(null); setError(null);
try { try {
await api.secrets.remove(s.id); await api.secrets.remove(s.id);
@@ -147,7 +148,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
async function bulkDelete() { async function bulkDelete() {
const ids = Array.from(selection.selected); const ids = Array.from(selection.selected);
if (ids.length === 0) return; if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`)) return; if (!(await confirmDialog({ title: "Delete secrets", message: `Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`, confirmLabel: "Delete", danger: true }))) return;
setError(null); setError(null);
setBulkDeleting(true); setBulkDeleting(true);
try { try {
+2 -1
View File
@@ -13,6 +13,7 @@ import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
function semaphoreStatusBadge(template: SemaphoreTemplate) { function semaphoreStatusBadge(template: SemaphoreTemplate) {
const status = template.lastTask?.status; const status = template.lastTask?.status;
@@ -85,7 +86,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) {
async function runTemplate(t: SemaphoreTemplate) { async function runTemplate(t: SemaphoreTemplate) {
if (!selectedId) return; if (!selectedId) return;
if (!confirm(`Run "${t.name}" now?`)) return; if (!(await confirmDialog({ title: "Run template", message: `Run "${t.name}" now?`, confirmLabel: "Run now" }))) return;
setRunningTemplate(t.id); setRunningTemplate(t.id);
setError(null); setError(null);
try { try {
+12 -3
View File
@@ -17,6 +17,7 @@ import ServerTags from "../components/ServerTags";
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable"; import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
import { formatDateTime } from "../utils/date"; import { formatDateTime } from "../utils/date";
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl"; import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
import { confirmDialog } from "../utils/dialogs";
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map( const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
([value, label]) => ({ value: value as ScheduleType, label }), ([value, label]) => ({ value: value as ScheduleType, label }),
@@ -258,7 +259,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
} }
async function deleteTask(task: TaskRecord) { async function deleteTask(task: TaskRecord) {
if (!confirm(`Delete "${task.name}"?`)) return; if (!(await confirmDialog({ title: "Delete task", message: `Delete "${task.name}"?`, confirmLabel: "Delete", danger: true }))) return;
try { try {
await api.tasks.remove(task.id); await api.tasks.remove(task.id);
loadTasks(); loadTasks();
@@ -330,7 +331,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
} }
async function removeAdminLink(link: ServerLink) { async function removeAdminLink(link: ServerLink) {
if (!confirm(`Remove the "${link.label}" link?`)) return; if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link?`, confirmLabel: "Remove", danger: true }))) return;
try { try {
await api.servers.removeLink(serverId, link.id); await api.servers.removeLink(serverId, link.id);
await loadDetail(); await loadDetail();
@@ -347,7 +348,15 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
async function guestAction(action: "start" | "stop" | "restart" | "shutdown") { async function guestAction(action: "start" | "stop" | "restart" | "shutdown") {
if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return; if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return;
if (action === "stop" && !confirm(`Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`)) if (
action === "stop" &&
!(await confirmDialog({
title: "Stop server",
message: `Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`,
confirmLabel: "Stop now",
danger: true,
}))
)
return; return;
setActingOnGuest(true); setActingOnGuest(true);
setGuestActionError(null); setGuestActionError(null);
+2 -1
View File
@@ -11,6 +11,7 @@ import { downloadCsv } from "../utils/csv";
import { TagBadges } from "../components/ServerTags"; import { TagBadges } from "../components/ServerTags";
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands"; import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
import { tagBadge, useTagColors } from "../utils/tags"; import { tagBadge, useTagColors } from "../utils/tags";
import { confirmDialog } from "../utils/dialogs";
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties { function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
const color = colors[type]; const color = colors[type];
@@ -85,7 +86,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
} }
async function deleteServer(id: number) { async function deleteServer(id: number) {
if (!confirm("Remove this server and all its tracked tasks?")) return; if (!(await confirmDialog({ title: "Remove server", message: "Remove this server and all its tracked tasks?", confirmLabel: "Remove", danger: true }))) return;
try { try {
await api.servers.remove(id); await api.servers.remove(id);
await loadServers(); await loadServers();
+2 -1
View File
@@ -4,6 +4,7 @@ import { formatDateTime } from "../utils/date";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
function friendlyUserAgent(ua: string | null): string { function friendlyUserAgent(ua: string | null): string {
if (!ua) return "—"; if (!ua) return "—";
@@ -54,7 +55,7 @@ export default function Sessions() {
const confirmMsg = isSelf const confirmMsg = isSelf
? `This is your current session — revoking it will sign you out immediately. Continue?` ? `This is your current session — revoking it will sign you out immediately. Continue?`
: `Revoke ${label}'s session? They'll be signed out immediately.`; : `Revoke ${label}'s session? They'll be signed out immediately.`;
if (!confirm(confirmMsg)) return; if (!(await confirmDialog({ title: isSelf ? "Sign out of this session" : "End session", message: confirmMsg, confirmLabel: isSelf ? "Sign me out" : "Revoke session", danger: true }))) return;
setError(null); setError(null);
setRevokingId(s.id); setRevokingId(s.id);
try { try {
+3 -2
View File
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { useSelection } from "../hooks/useSelection"; import { useSelection } from "../hooks/useSelection";
import { confirmDialog } from "../utils/dialogs";
const KEY_EXPIRY_WARN_DAYS = 30; const KEY_EXPIRY_WARN_DAYS = 30;
@@ -89,7 +90,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
async function removeDevice(device: TailscaleDevice) { async function removeDevice(device: TailscaleDevice) {
if (!selectedId) return; if (!selectedId) return;
if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return; if (!(await confirmDialog({ title: "Remove device", message: `Remove device "${device.label || device.hostname}" from the tailnet?`, confirmLabel: "Remove", danger: true }))) return;
try { try {
await api.integrations.tailscale.remove(selectedId, device.id); await api.integrations.tailscale.remove(selectedId, device.id);
loadDevices(selectedId); loadDevices(selectedId);
@@ -119,7 +120,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
if (!selectedId) return; if (!selectedId) return;
const ids = Array.from(selection.selected); const ids = Array.from(selection.selected);
if (ids.length === 0) return; if (ids.length === 0) return;
if (!confirm(`Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`)) return; if (!(await confirmDialog({ title: "Remove devices", message: `Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`, confirmLabel: "Remove", danger: true }))) return;
setError(null); setError(null);
setBulkActing(true); setBulkActing(true);
try { try {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { api, type EncryptedExportFile, type ImportResult } from "../../api/client"; import { api, type EncryptedExportFile, type ImportResult } from "../../api/client";
import { confirmDialog } from "../../utils/dialogs";
function downloadJson(filename: string, data: unknown) { function downloadJson(filename: string, data: unknown) {
const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
@@ -65,7 +66,7 @@ export default function BackupSettings() {
async function handleImport() { async function handleImport() {
if (!importFile) return; if (!importFile) return;
if (!confirm("Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.")) return; if (!(await confirmDialog({ title: "Import backup", message: "Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.", confirmLabel: "Import" }))) return;
setImporting(true); setImporting(true);
setImportError(null); setImportError(null);
setImportResult(null); setImportResult(null);
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { api } from "../../api/client"; import { api } from "../../api/client";
import { confirmDialog } from "../../utils/dialogs";
export default function CacheSettings() { export default function CacheSettings() {
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
@@ -7,7 +8,7 @@ export default function CacheSettings() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function handleClear() { async function handleClear() {
if (!confirm("Clear the DNS record cache? All zones will need to be re-synced afterwards.")) return; if (!(await confirmDialog({ title: "Clear DNS cache", message: "Clear the DNS record cache? All zones will need to be re-synced afterwards.", confirmLabel: "Clear cache" }))) return;
setClearing(true); setClearing(true);
setError(null); setError(null);
setCleared(false); setCleared(false);
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type AppSettings } from "../../api/client"; import { api, type AppSettings } from "../../api/client";
import { confirmDialog } from "../../utils/dialogs";
const INTERVAL_OPTIONS: { value: number; label: string }[] = [ const INTERVAL_OPTIONS: { value: number; label: string }[] = [
{ value: 1, label: "Every hour" }, { value: 1, label: "Every hour" },
@@ -51,7 +52,7 @@ export default function LogSettings() {
} }
async function handlePurgeNow() { async function handlePurgeNow() {
if (!confirm(`Delete diagnostic and audit log entries older than ${retentionDays} days now?`)) return; if (!(await confirmDialog({ title: "Delete old log entries", message: `Delete diagnostic and audit log entries older than ${retentionDays} days now?`, confirmLabel: "Delete", danger: true }))) return;
setPurging(true); setPurging(true);
setPurgeError(null); setPurgeError(null);
setPurgeResult(null); setPurgeResult(null);
+12 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api, type TagEntry } from "../../api/client"; import { api, type TagEntry } from "../../api/client";
import { readableError } from "../../utils/errors"; import { readableError } from "../../utils/errors";
import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags"; import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags";
import { confirmDialog, promptDialog } from "../../utils/dialogs";
const DEFAULT_PICK = "#3b82f6"; const DEFAULT_PICK = "#3b82f6";
@@ -64,12 +65,20 @@ export default function TagSettings() {
} }
async function rename(t: TagEntry) { async function rename(t: TagEntry) {
const input = window.prompt(`Rename “${t.name}” to:`, t.name); const input = await promptDialog({ title: "Rename tag", message: `Rename “${t.name}” to:`, defaultValue: t.name, confirmLabel: "Rename", required: true });
if (input === null) return; if (input === null) return;
const to = normalizeTagInput(input); const to = normalizeTagInput(input);
if (!to || to === t.name) return; if (!to || to === t.name) return;
const target = tags?.find((x) => x.name === to); const target = tags?.find((x) => x.name === to);
if (target && !window.confirm(`“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`)) return; if (
target &&
!(await confirmDialog({
title: "Merge tags",
message: `“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`,
confirmLabel: "Merge",
}))
)
return;
await run( await run(
() => api.tags.rename(t.name, to), () => api.tags.rename(t.name, to),
(res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`, (res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`,
@@ -78,7 +87,7 @@ export default function TagSettings() {
async function remove(t: TagEntry) { async function remove(t: TagEntry) {
const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : ""; const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : "";
if (!window.confirm(`Delete the tag “${t.name}”?${used}`)) return; if (!(await confirmDialog({ title: "Delete tag", message: `Delete the tag “${t.name}”?${used}`, confirmLabel: "Delete", danger: true }))) return;
await run( await run(
() => api.tags.remove(t.name), () => api.tags.remove(t.name),
(res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`, (res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`,
+69
View File
@@ -0,0 +1,69 @@
// In-app replacements for window.confirm() and window.prompt(). Call sites just await them, the same way they used to
// call the browser's — a single <DialogHost /> (mounted once, in App) draws whichever is asked for. Several asked for
// at once are shown one after another.
export interface ConfirmOptions {
title?: string;
message: string;
/** What the main button says — name the action ("Delete", "Stop") rather than leaving it as "OK". */
confirmLabel?: string;
cancelLabel?: string;
/** Destructive or hard to undo: red button, and the safe choice gets the keyboard focus. */
danger?: boolean;
}
export interface PromptOptions {
title?: string;
message: string;
/** Label for the text box itself, when the message above it isn't enough. */
label?: string;
defaultValue?: string;
placeholder?: string;
confirmLabel?: string;
cancelLabel?: string;
/** Disable the main button until something has been typed. */
required?: boolean;
}
export type DialogRequest =
| ({ kind: "confirm"; id: number; resolve: (accepted: boolean) => void } & ConfirmOptions)
| ({ kind: "prompt"; id: number; resolve: (value: string | null) => void } & PromptOptions);
let nextId = 1;
let queue: DialogRequest[] = [];
let listener: ((current: DialogRequest | null) => void) | null = null;
function emit() {
listener?.(queue[0] ?? null);
}
/** For the host: called with whatever should be on screen now (and straight away with what already is). */
export function subscribe(next: (current: DialogRequest | null) => void): () => void {
listener = next;
emit();
return () => {
if (listener === next) listener = null;
};
}
/** Resolves the dialog on screen. A cancelled confirm is false and a cancelled prompt is null — as with the browser's own. */
export function settle(request: DialogRequest, accepted: boolean, value = ""): void {
queue = queue.filter((r) => r !== request);
if (request.kind === "confirm") request.resolve(accepted);
else request.resolve(accepted ? value : null);
emit();
}
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
queue.push({ ...options, kind: "confirm", id: nextId++, resolve });
emit();
});
}
export function promptDialog(options: PromptOptions): Promise<string | null> {
return new Promise((resolve) => {
queue.push({ ...options, kind: "prompt", id: nextId++, resolve });
emit();
});
}