Compare commits
2
Commits
88d9c8e097
...
447f33fff6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
447f33fff6 | ||
|
|
ad1fb5338f |
@@ -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. |
|
||||
|
||||
## 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
|
||||
|
||||
Worth calling out explicitly, since it's easy to assume everything in
|
||||
|
||||
@@ -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
|
||||
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
|
||||
provider while you work on it (server offline / disk, storage and Synology
|
||||
health, Proxmox backup alerts, Proxmox Backup Server verification alerts, and
|
||||
|
||||
@@ -37,6 +37,7 @@ page's own top-level link.
|
||||
| Gitea | `/gitea` | ✅ | ✅ | ✅ |
|
||||
| Secrets | `/secrets` | ✅ | ✅ | ✅ |
|
||||
| **Operations** | | | | |
|
||||
| Alerts | `/alerts` | ✅ | ✅ | ✅ |
|
||||
| Maintenance | `/maintenance` | ✅ | ✅ | ✅ |
|
||||
| Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ |
|
||||
| osTicket | `/osticket` | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -29,6 +29,7 @@ import { consistencyRouter } from "./routes/consistency.js";
|
||||
import { privacyRouter } from "./routes/privacy.js";
|
||||
import { tagsRouter } from "./routes/tags.js";
|
||||
import { portsRouter } from "./routes/ports.js";
|
||||
import { alertsRouter } from "./routes/alerts.js";
|
||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||
@@ -104,6 +105,7 @@ app.use("/api/consistency", consistencyRouter);
|
||||
app.use("/api/privacy", privacyRouter);
|
||||
app.use("/api/tags", tagsRouter);
|
||||
app.use("/api/ports", portsRouter);
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -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"));
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function evaluateAutomation(items: AutomationItem[]): { conditions: Healt
|
||||
|
||||
// ─── 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 held = new Set<string>();
|
||||
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.
|
||||
*/
|
||||
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();
|
||||
|
||||
// Nothing could be read at all (or nothing is configured): don't spend the "first pass" on an empty picture.
|
||||
|
||||
@@ -5,17 +5,28 @@ import { integrations } from "../db/schema.js";
|
||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||
import { notifyDockerUpdates } from "./notify.js";
|
||||
import type { SourceFailure } from "./alertTypes.js";
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
|
||||
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
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.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) {
|
||||
try {
|
||||
@@ -28,6 +39,7 @@ async function checkDockerUpdates(): Promise<void> {
|
||||
for (const c of containers) {
|
||||
if (!c.updateAvailable) continue;
|
||||
updatesAvailable.push({
|
||||
integrationId: row.id,
|
||||
integrationName: row.name,
|
||||
containerName: c.name,
|
||||
environmentName: c.environmentName,
|
||||
@@ -36,10 +48,15 @@ async function checkDockerUpdates(): Promise<void> {
|
||||
}
|
||||
} catch (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> {
|
||||
|
||||
@@ -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. */
|
||||
source: string;
|
||||
message: string;
|
||||
/** How bad, for the Alerts page. Notifications don't use it. Left out means "warning". */
|
||||
severity?: "critical" | "warning";
|
||||
}
|
||||
|
||||
export interface ServerSnapshot {
|
||||
@@ -91,6 +93,8 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
const out: HealthCondition[] = [];
|
||||
const limit = thresholds.diskUsagePercent;
|
||||
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) {
|
||||
for (const s of snapshot.servers) {
|
||||
@@ -103,6 +107,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `offline:server:${s.id}`,
|
||||
source: "server",
|
||||
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}`,
|
||||
source: "server",
|
||||
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`,
|
||||
source: `proxmox:${px.integrationId}:${node.node}`,
|
||||
message: `${px.integrationName} / ${node.node}: root filesystem is ${pct(rootP)} full`,
|
||||
severity: fullness(rootP),
|
||||
});
|
||||
}
|
||||
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}`,
|
||||
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)})`,
|
||||
severity: fullness(p),
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
key: `disk:proxmox:${px.integrationId}:${node.node}:storage:${st.id}`,
|
||||
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)})`,
|
||||
severity: fullness(p),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -166,6 +175,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `synology-volume:${syn.integrationId}:${v.id}`,
|
||||
source: src,
|
||||
message: `${syn.integrationName}: volume ${v.id} status is "${v.status}"`,
|
||||
severity: "critical",
|
||||
});
|
||||
}
|
||||
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}`,
|
||||
source: src,
|
||||
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}`,
|
||||
source: src,
|
||||
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) ───────────────────────────────────────────────────────
|
||||
|
||||
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 snapshot: HealthSnapshot = { servers: [], proxmox: [], synology: [] };
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@ interface 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. */
|
||||
export async function trackIntegrationHealth(source: string, ok: boolean): Promise<void> {
|
||||
const state = health.get(source) ?? { consecutiveFailures: 0, alerted: false };
|
||||
|
||||
@@ -382,9 +382,13 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,39 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createPbsAdapter } from "../integrations/pbs/adapter.js";
|
||||
import { notifyPbsVerificationFailed } from "./notify.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";
|
||||
|
||||
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
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.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) {
|
||||
// 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 {
|
||||
const loaded = await loadIntegrationConfig(row.id);
|
||||
if (!loaded) continue;
|
||||
@@ -29,17 +47,22 @@ async function checkPbsVerification(): Promise<void> {
|
||||
|
||||
for (const d of datastores) {
|
||||
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) {
|
||||
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) {
|
||||
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> {
|
||||
|
||||
@@ -6,22 +6,50 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createProxmoxAdapter, guestsWithoutBackupCoverage, type ProxmoxBackupTask } from "../integrations/proxmox/adapter.js";
|
||||
import { notifyProxmoxBackupFailure, notifyProxmoxUncoveredGuests } from "./notify.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";
|
||||
|
||||
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
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, "proxmox"), eq(integrations.enabled, true)));
|
||||
|
||||
const failures: { integrationName: string; node: string; guestId: string | null; status: string }[] = [];
|
||||
const uncovered: { integrationName: string; guestName: string; vmid: number; node: string }[] = [];
|
||||
const failures: BackupFailure[] = [];
|
||||
const uncovered: UncoveredGuest[] = [];
|
||||
const sourceFailures: SourceFailure[] = [];
|
||||
const silencedSubjects = await activeSubjects();
|
||||
|
||||
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.
|
||||
if (await isInMaintenance("integration", row.id)) continue;
|
||||
const silenced = silencedSubjects.has(`integration:${row.id}`);
|
||||
if (silenced && opts.skipSilenced) continue;
|
||||
try {
|
||||
const loaded = await loadIntegrationConfig(row.id);
|
||||
if (!loaded) continue;
|
||||
@@ -44,18 +72,24 @@ async function checkProxmoxBackups(): Promise<void> {
|
||||
|
||||
for (const [node, task] of latestByNode) {
|
||||
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)) {
|
||||
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) {
|
||||
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 notifyProxmoxUncoveredGuests(uncovered);
|
||||
}
|
||||
|
||||
@@ -5,17 +5,27 @@ import { integrations } from "../db/schema.js";
|
||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createTailscaleAdapter, isKeyExpiringSoon } from "../integrations/tailscale/adapter.js";
|
||||
import { notifyTailscaleKeyExpiry } from "./notify.js";
|
||||
import type { SourceFailure } from "./alertTypes.js";
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
|
||||
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
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.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();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -27,14 +37,19 @@ async function checkTailscaleKeyExpiry(): Promise<void> {
|
||||
for (const d of devices) {
|
||||
if (!isKeyExpiringSoon(d, now)) continue;
|
||||
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) {
|
||||
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> {
|
||||
|
||||
@@ -24,6 +24,7 @@ import UptimeKuma from "./pages/UptimeKuma";
|
||||
import PbsBackup from "./pages/PbsBackup";
|
||||
import OsTicket from "./pages/OsTicket";
|
||||
import AdminLinks from "./pages/AdminLinks";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import Generator from "./pages/Generator";
|
||||
import Maintenance from "./pages/Maintenance";
|
||||
import Domains from "./pages/Domains";
|
||||
@@ -38,6 +39,7 @@ import CacheSettings from "./pages/settings/CacheSettings";
|
||||
import LogSettings from "./pages/settings/LogSettings";
|
||||
import BackupSettings from "./pages/settings/BackupSettings";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import DialogHost from "./components/DialogHost";
|
||||
import { setDateTimeSettings } from "./utils/date";
|
||||
import { setPageSize } from "./utils/pageSize";
|
||||
|
||||
@@ -113,6 +115,7 @@ export default function App() {
|
||||
<Route path="/pbs" element={<PbsBackup user={user} />} />
|
||||
<Route path="/osticket" element={<OsTicket user={user} />} />
|
||||
<Route path="/admin-links" element={<AdminLinks user={user} />} />
|
||||
<Route path="/alerts" element={<Alerts user={user} />} />
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
@@ -163,6 +166,7 @@ export default function App() {
|
||||
<Route path="backup" element={<BackupSettings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<DialogHost />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -397,6 +397,30 @@ export interface ServerDetail {
|
||||
links: ServerLink[];
|
||||
}
|
||||
|
||||
export type AlertSeverity = "critical" | "warning" | "info";
|
||||
export type AlertCategory = "offline" | "disk" | "backup" | "updates" | "expiry" | "automation" | "integration" | "monitoring" | "tickets";
|
||||
|
||||
export interface AlertItem {
|
||||
id: string;
|
||||
severity: AlertSeverity;
|
||||
category: AlertCategory;
|
||||
source: string;
|
||||
message: string;
|
||||
link: string | null;
|
||||
/** Under a maintenance window: still a real problem, but its notifications are held back. */
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
export interface AlertsReport {
|
||||
alerts: AlertItem[];
|
||||
counts: { critical: number; warning: number; info: number; silenced: number };
|
||||
couldntCheck: { name: string; error: string; affects: string[] }[];
|
||||
notes: string[];
|
||||
generatedAt: string;
|
||||
/** This is a recent result being reused, not a fresh check. */
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export interface AdminLink {
|
||||
id: number;
|
||||
serverId: number;
|
||||
@@ -1006,6 +1030,9 @@ export const api = {
|
||||
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
||||
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
|
||||
},
|
||||
alerts: {
|
||||
list: (refresh = false) => request<AlertsReport>(`/api/alerts${refresh ? "?refresh=1" : ""}`),
|
||||
},
|
||||
ports: {
|
||||
agent: () => request<AgentPortsResponse>("/api/ports/agent"),
|
||||
forwards: {
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { downloadCsv } from "../utils/csv";
|
||||
import { readableError } from "../utils/errors";
|
||||
import Pagination from "./Pagination";
|
||||
import SortableTh from "./SortableTh";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const PRESETS: { key: string; label: string; from: number; to: number }[] = [
|
||||
{ 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) {
|
||||
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}?`;
|
||||
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 {
|
||||
await api.servers.ports.remove(serverId, entry.id);
|
||||
setData(await api.servers.ports.list(serverId));
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
IconTicket,
|
||||
IconPlug,
|
||||
IconExternalLink,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
@@ -101,6 +102,7 @@ const NAV: NavEntry[] = [
|
||||
label: "Operations",
|
||||
icon: <IconTool size={20} />,
|
||||
items: [
|
||||
{ to: "/alerts", label: "Alerts", icon: <IconAlertTriangle size={20} /> },
|
||||
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
|
||||
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
|
||||
{ to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
interface LinkForm {
|
||||
serverId: number | "";
|
||||
@@ -133,7 +134,7 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await api.servers.removeLink(link.serverId, link.id);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { downloadCsv } from "../utils/csv";
|
||||
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
||||
import { formatAgo } from "../utils/duration";
|
||||
import { readableError } from "../utils/errors";
|
||||
import { promptDialog } from "../utils/dialogs";
|
||||
|
||||
const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [
|
||||
{ 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) {
|
||||
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
|
||||
setBusyKey(f.key);
|
||||
setError(null);
|
||||
@@ -113,12 +119,14 @@ export default function Consistency({ user }: { user: CurrentUser }) {
|
||||
async function excludeAround(f: ConsistencyFinding) {
|
||||
if (!report || !f.ip) return;
|
||||
const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`;
|
||||
const range = window.prompt(
|
||||
`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):`,
|
||||
suggestion,
|
||||
);
|
||||
const range = await promptDialog({
|
||||
title: "Exclude a range",
|
||||
message: "Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike.",
|
||||
label: "Range (a network like 192.168.16.0/20, or a single address)",
|
||||
defaultValue: suggestion,
|
||||
confirmLabel: "Exclude",
|
||||
required: true,
|
||||
});
|
||||
if (range === null || !range.trim()) return;
|
||||
await saveRanges([...report.excludedRanges, range.trim()]);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
@@ -62,7 +63,7 @@ export default function DiagLog() {
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await api.diagLog.clear();
|
||||
|
||||
@@ -16,6 +16,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
@@ -182,7 +183,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function removeRecord(r: DnsRecord) {
|
||||
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);
|
||||
try {
|
||||
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) {
|
||||
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 {
|
||||
await api.dns.providers.remove(p.id);
|
||||
if (selectedProviderId === p.id) setSelectedProviderId(null);
|
||||
|
||||
@@ -12,6 +12,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function updateBadge(c: DockhandContainer) {
|
||||
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") {
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSortable } from "../hooks/useSortable";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function statusBadge(d: DomainRecord) {
|
||||
switch (d.status) {
|
||||
@@ -87,7 +88,7 @@ export default function Domains({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await api.domains.remove(d.id);
|
||||
|
||||
@@ -6,6 +6,7 @@ import IntegrationEditForm from "../components/IntegrationEditForm";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
tailscale: "Tailscale",
|
||||
@@ -79,7 +80,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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 {
|
||||
await api.integrations.remove(i.id);
|
||||
loadIntegrations();
|
||||
|
||||
+11
-6
@@ -7,6 +7,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { useSelection } from "../hooks/useSelection";
|
||||
import { confirmDialog, promptDialog } from "../utils/dialogs";
|
||||
|
||||
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
|
||||
|
||||
@@ -74,10 +75,14 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function excludeAround(entry: IpamEntry) {
|
||||
const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`;
|
||||
const range = window.prompt(
|
||||
`Hide this range from IP Addresses and the Consistency report — every address in it, not just this one:`,
|
||||
suggestion,
|
||||
);
|
||||
const range = await promptDialog({
|
||||
title: "Exclude a range",
|
||||
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;
|
||||
await saveRanges([...excludedRanges, range.trim()]);
|
||||
}
|
||||
@@ -139,7 +144,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await api.ipam.remove(entry.id);
|
||||
@@ -152,7 +157,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
async function bulkDelete() {
|
||||
const ids = Array.from(selection.selected);
|
||||
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);
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { formatAgo } from "../utils/duration";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const emptyForm: PortForwardInput = {
|
||||
label: "",
|
||||
@@ -163,7 +164,7 @@ export default function Ports({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await api.ports.forwards.remove(fwd.id);
|
||||
|
||||
@@ -15,6 +15,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function proxmoxStatusBadge(status: string) {
|
||||
return status === "running" ? (
|
||||
@@ -164,7 +165,16 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
||||
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;
|
||||
setActingOnGuest(g.vmid);
|
||||
setError(null);
|
||||
|
||||
@@ -9,6 +9,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { useSelection } from "../hooks/useSelection";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
|
||||
api_token: "API Token",
|
||||
@@ -134,7 +135,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await api.secrets.remove(s.id);
|
||||
@@ -147,7 +148,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
||||
async function bulkDelete() {
|
||||
const ids = Array.from(selection.selected);
|
||||
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);
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function semaphoreStatusBadge(template: SemaphoreTemplate) {
|
||||
const status = template.lastTask?.status;
|
||||
@@ -85,7 +86,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function runTemplate(t: SemaphoreTemplate) {
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@ import ServerTags from "../components/ServerTags";
|
||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
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(
|
||||
([value, label]) => ({ value: value as ScheduleType, label }),
|
||||
@@ -258,7 +259,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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 {
|
||||
await api.tasks.remove(task.id);
|
||||
loadTasks();
|
||||
@@ -330,7 +331,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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 {
|
||||
await api.servers.removeLink(serverId, link.id);
|
||||
await loadDetail();
|
||||
@@ -347,7 +348,15 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function guestAction(action: "start" | "stop" | "restart" | "shutdown") {
|
||||
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;
|
||||
setActingOnGuest(true);
|
||||
setGuestActionError(null);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { downloadCsv } from "../utils/csv";
|
||||
import { TagBadges } from "../components/ServerTags";
|
||||
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
|
||||
import { tagBadge, useTagColors } from "../utils/tags";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
||||
const color = colors[type];
|
||||
@@ -85,7 +86,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
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 {
|
||||
await api.servers.remove(id);
|
||||
await loadServers();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatDateTime } from "../utils/date";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function friendlyUserAgent(ua: string | null): string {
|
||||
if (!ua) return "—";
|
||||
@@ -54,7 +55,7 @@ export default function Sessions() {
|
||||
const confirmMsg = isSelf
|
||||
? `This is your current session — revoking it will sign you out immediately. Continue?`
|
||||
: `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);
|
||||
setRevokingId(s.id);
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { useSelection } from "../hooks/useSelection";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const KEY_EXPIRY_WARN_DAYS = 30;
|
||||
|
||||
@@ -89,7 +90,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function removeDevice(device: TailscaleDevice) {
|
||||
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 {
|
||||
await api.integrations.tailscale.remove(selectedId, device.id);
|
||||
loadDevices(selectedId);
|
||||
@@ -119,7 +120,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
if (!selectedId) return;
|
||||
const ids = Array.from(selection.selected);
|
||||
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);
|
||||
setBulkActing(true);
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { api, type EncryptedExportFile, type ImportResult } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
|
||||
function downloadJson(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||
@@ -65,7 +66,7 @@ export default function BackupSettings() {
|
||||
|
||||
async function handleImport() {
|
||||
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);
|
||||
setImportError(null);
|
||||
setImportResult(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
|
||||
export default function CacheSettings() {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
@@ -7,7 +8,7 @@ export default function CacheSettings() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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);
|
||||
setError(null);
|
||||
setCleared(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AppSettings } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
|
||||
const INTERVAL_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: "Every hour" },
|
||||
@@ -51,7 +52,7 @@ export default function LogSettings() {
|
||||
}
|
||||
|
||||
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);
|
||||
setPurgeError(null);
|
||||
setPurgeResult(null);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { api, type TagEntry } from "../../api/client";
|
||||
import { readableError } from "../../utils/errors";
|
||||
import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags";
|
||||
import { confirmDialog, promptDialog } from "../../utils/dialogs";
|
||||
|
||||
const DEFAULT_PICK = "#3b82f6";
|
||||
|
||||
@@ -64,12 +65,20 @@ export default function TagSettings() {
|
||||
}
|
||||
|
||||
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;
|
||||
const to = normalizeTagInput(input);
|
||||
if (!to || to === t.name) return;
|
||||
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(
|
||||
() => 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.`,
|
||||
@@ -78,7 +87,7 @@ export default function TagSettings() {
|
||||
|
||||
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"}.` : "";
|
||||
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(
|
||||
() => api.tags.remove(t.name),
|
||||
(res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user