Compare commits
3
Commits
88d9c8e097
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3035d7fc08 | ||
|
|
447f33fff6 | ||
|
|
ad1fb5338f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -206,6 +206,18 @@ offered as one-click suggestions when tagging), give any tag a colour of your ch
|
||||
that already exists merges the two, and both rename and delete rewrite every server
|
||||
that carries the tag.
|
||||
|
||||
**Name generator** — Operations → Generator suggests server names (and usernames and
|
||||
passwords, which are made in your browser and never stored). Server names are picked from
|
||||
name lists: Swedish girl and boy names, Disney and Pixar characters, Norse mythology and
|
||||
Astrid Lindgren to start with, or "Mixed" for all of them together. A name already used
|
||||
by a server isn't suggested. Admins edit the lists under Settings → Names — add and remove
|
||||
names, rename, delete or create lists, put a built-in list back as it was — and can
|
||||
import the most common Swedish names from Skatteverket's open name statistics for
|
||||
girls or boys over the latest one to five years. The import is shown to you first and only
|
||||
changes a list once you add it and save. (Statistics Sweden used to publish this but
|
||||
stopped after 2023.) Names are kept to letters, digits and hyphens so they work as
|
||||
hostnames; å, ä and ö become a, a and o.
|
||||
|
||||
**Privacy** — a page every signed-in user can open that says what this
|
||||
installation stores (accounts, sign-in sessions with their IP and browser, the audit
|
||||
and diagnostic logs, server reports, the secrets tracker, credentials), where data
|
||||
@@ -255,6 +267,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` | ✅ | ✅ | ✅ |
|
||||
@@ -78,6 +79,9 @@ even further, to admin only:
|
||||
- **Admin Links**: everyone can see and open every server's admin
|
||||
bookmarks, from that server's own page or the summary page; adding,
|
||||
editing, or removing one needs operator, from either place.
|
||||
- **Generator**: everyone can use it; the name lists it picks server names from
|
||||
are edited under Settings → Names, which is admin-only (and so is importing
|
||||
names from Skatteverket).
|
||||
- Everything under **Settings** (notification channels, badge colors,
|
||||
display prefs, log retention, backup/restore) is admin-only, matching
|
||||
the page itself being admin-only.
|
||||
|
||||
@@ -29,6 +29,8 @@ 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 { generatorRouter } from "./routes/generator.js";
|
||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||
@@ -104,6 +106,8 @@ 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);
|
||||
app.use("/api/generator", generatorRouter);
|
||||
|
||||
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,128 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
||||
import {
|
||||
DEFAULT_THEME_IDS,
|
||||
InvalidThemesError,
|
||||
MAX_NAME_LENGTH,
|
||||
cleanThemes,
|
||||
defaultThemes,
|
||||
importSkatteverketNames,
|
||||
readStoredThemes,
|
||||
type NameTheme,
|
||||
type NameThemeSource,
|
||||
} from "../services/nameThemes.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const generatorRouter = Router();
|
||||
generatorRouter.use(requireAuth);
|
||||
|
||||
async function currentThemes(): Promise<NameTheme[]> {
|
||||
return readStoredThemes((await getSettings()).nameGenerator.themes);
|
||||
}
|
||||
|
||||
// Read by everyone signed in — the Generator page needs the lists to pick from. Editing them is a Settings matter.
|
||||
generatorRouter.get("/themes", asyncHandler(async (_req, res) => {
|
||||
res.json({ themes: await currentThemes(), builtinIds: DEFAULT_THEME_IDS });
|
||||
}));
|
||||
|
||||
generatorRouter.get("/themes/defaults", requireRole("admin"), (_req, res) => {
|
||||
res.json({ themes: defaultThemes() });
|
||||
});
|
||||
|
||||
const sourceSchema = z.object({
|
||||
kind: z.literal("skatteverket"),
|
||||
sex: z.enum(["girls", "boys"]),
|
||||
years: z.array(z.number().int()).max(10),
|
||||
count: z.number().int(),
|
||||
importedAt: z.string().max(40),
|
||||
});
|
||||
|
||||
const saveSchema = z.object({
|
||||
themes: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(40).optional(),
|
||||
label: z.string().max(200),
|
||||
names: z.array(z.string().max(MAX_NAME_LENGTH * 3)).max(10000),
|
||||
lastImport: sourceSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.max(100),
|
||||
});
|
||||
|
||||
/** A short, readable account of what an edit changed, for the audit log. */
|
||||
function describeThemeChanges(before: NameTheme[], after: NameTheme[]) {
|
||||
const beforeById = new Map(before.map((t) => [t.id, t]));
|
||||
const afterIds = new Set(after.map((t) => t.id));
|
||||
const created = after.filter((t) => !beforeById.has(t.id)).map((t) => `${t.label} (${t.names.length} names)`);
|
||||
const deleted = before.filter((t) => !afterIds.has(t.id)).map((t) => t.label);
|
||||
const edited: { list: string; renamedFrom?: string; added: number; removed: number }[] = [];
|
||||
for (const t of after) {
|
||||
const old = beforeById.get(t.id);
|
||||
if (!old) continue;
|
||||
const had = new Set(old.names);
|
||||
const has = new Set(t.names);
|
||||
const added = t.names.filter((n) => !had.has(n)).length;
|
||||
const removed = old.names.filter((n) => !has.has(n)).length;
|
||||
if (added || removed || old.label !== t.label) edited.push({ list: t.label, ...(old.label !== t.label ? { renamedFrom: old.label } : {}), added, removed });
|
||||
}
|
||||
return { created, deleted, edited };
|
||||
}
|
||||
|
||||
generatorRouter.put("/themes", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
const parsed = saveSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "That doesn't look like a set of name lists.", details: parsed.error.flatten() });
|
||||
|
||||
let themes: NameTheme[];
|
||||
try {
|
||||
themes = cleanThemes(parsed.data.themes);
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidThemesError) return res.status(400).json({ error: "invalid_names", message: err.message, invalid: err.invalid });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const before = await currentThemes();
|
||||
const changes = describeThemeChanges(before, themes);
|
||||
await updateSettings({ nameGenerator: { themes } });
|
||||
|
||||
if (changes.created.length || changes.deleted.length || changes.edited.length) {
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "settings",
|
||||
action: "update_name_lists",
|
||||
targetType: "name_generator",
|
||||
detail: changes,
|
||||
});
|
||||
}
|
||||
res.json({ themes });
|
||||
}));
|
||||
|
||||
const importSchema = z.object({
|
||||
sex: z.enum(["girls", "boys"]),
|
||||
years: z.number().int().min(1).max(5),
|
||||
count: z.number().int().min(10).max(500),
|
||||
});
|
||||
|
||||
// Only fetches and returns a preview — nothing is stored until the editor's own Save, so an import can be looked at (and
|
||||
// thrown away) first. The address is fixed; none of the request's values go into it unchecked.
|
||||
generatorRouter.post("/themes/import", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
const parsed = importSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Pick girls or boys, 1–5 years and 10–500 names.", details: parsed.error.flatten() });
|
||||
|
||||
try {
|
||||
const result = await importSkatteverketNames(parsed.data.sex, parsed.data.years, parsed.data.count);
|
||||
const source: NameThemeSource = {
|
||||
kind: "skatteverket",
|
||||
sex: parsed.data.sex,
|
||||
years: result.years,
|
||||
count: parsed.data.count,
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
res.json({ names: result.names, source, missingYears: result.missingYears, skipped: result.skipped });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: "import_failed", message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* The name lists behind Operations → Generator's server-name picker.
|
||||
*
|
||||
* The built-in lists below are hand-picked, not taken from any official source — they're a starting point. Admins can
|
||||
* edit every list under Settings → Names, and can replace a Swedish list with real statistics from Skatteverket.
|
||||
*/
|
||||
|
||||
export interface NameThemeSource {
|
||||
kind: "skatteverket";
|
||||
sex: "girls" | "boys";
|
||||
/** Birth years that were combined. */
|
||||
years: number[];
|
||||
/** How many names the import asked for. */
|
||||
count: number;
|
||||
importedAt: string;
|
||||
}
|
||||
|
||||
export interface NameTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
names: string[];
|
||||
/** The last import into this list, if there's been one. Editing the list by hand doesn't clear it. */
|
||||
lastImport?: NameThemeSource;
|
||||
}
|
||||
|
||||
export const MAX_THEMES = 20;
|
||||
export const MAX_NAMES_PER_THEME = 2000;
|
||||
export const MAX_LABEL_LENGTH = 40;
|
||||
export const MAX_NAME_LENGTH = 30;
|
||||
|
||||
/**
|
||||
* Names end up as server names and hostnames, so they're kept to lowercase a–z, digits and inner hyphens. Accents are
|
||||
* folded away first (Åsa → asa, José → jose) so a pasted Swedish name isn't rejected over its å, ä or ö.
|
||||
*/
|
||||
export function normalizeName(raw: string): string | null {
|
||||
const folded = raw
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(folded) ? folded : null;
|
||||
}
|
||||
|
||||
export function slugifyId(label: string): string {
|
||||
return (
|
||||
label
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40) || "list"
|
||||
);
|
||||
}
|
||||
|
||||
function words(text: string): string[] {
|
||||
return text.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
const SWEDISH_GIRLS = words(`
|
||||
freja elsa alice maja wilma alma ebba lilly ella saga agnes stella selma vera ingrid astrid linnea nova sara emma
|
||||
julia olivia isabelle klara nellie elin signe tuva moa tyra hedda nora amanda anna elvira iris matilda molly sofia
|
||||
thea vilma cornelia filippa livia meja ronja sigrid tilde greta hilda leia lovisa siri jasmine felicia ida lina
|
||||
mira mimmi lykke ellen ellie emelie hanna jenny josefin kajsa karin lisa lotta maria märta nathalie pia
|
||||
rebecka sanna sally stina svea tindra ylva yvonne
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const SWEDISH_BOYS = words(`
|
||||
noah liam hugo lucas oliver elias oscar william adam alfred nils axel arvid vincent theo leo ludvig filip viktor
|
||||
albin gustav melvin sixten love ivar edvin otto wilmer malte valter folke sigge algot ebbe noel benjamin felix isak
|
||||
jonathan anton erik emil johan karl lars anders mattias henrik jakob sebastian daniel samuel alex max rasmus
|
||||
tage olle tobias simon kasper julius ture vidar viggo vilgot ville lennart gunnar bertil sten stig bo björn
|
||||
rolf ulf kurt mats magnus mikael peter per pontus
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const DISNEY = words(`
|
||||
moana elsa anna belle ariel jasmine aurora cinderella rapunzel mulan tiana merida pocahontas mickey minnie simba
|
||||
nala stitch lilo olaf baymax dory nemo woody buzz genie aladdin eric flynn kristoff sven pumbaa timon mufasa scar
|
||||
ursula maleficent gaston hercules meg tinkerbell wendy pinocchio bambi dumbo thumper flounder sebastian iago abu
|
||||
pascal maximus heihei goofy donald daisy pluto chip dale bagheera baloo mowgli rafiki zazu piglet tigger eeyore
|
||||
pooh hades phil jafar rajah tarzan jane kida milo pacha kuzco yzma bolt judy nick gazelle mirabel bruno luisa
|
||||
isabela cruella dodger tramp lady jiminy figaro cleo shenzi banzai kronk vanellope ralph esmeralda quasimodo
|
||||
megara belle gus jaq
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const PIXAR = words(`
|
||||
jessie rex hamm slinky bo lotso flik heimlich mike sulley boo randall marlin crush bruce gill remy linguini colette
|
||||
walle eve carl russell dug kevin lightning mater sally doc luigi guido fillmore joy sadness anger fear disgust bing
|
||||
arlo spot miguel hector dante ian barley luca alberto giulia mei ming elastigirl dash violet jack edna syndrome
|
||||
forky gabby ducky bunny duke ember wade bing-bong riley
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const NORSE = words(`
|
||||
odin thor loki freya frigg baldur tyr heimdall idun bragi njord freyr sif hel ymir fenrir sleipnir ran aegir skadi
|
||||
vidar vali ullr forseti hermod sigyn nanna jord eir fulla gefjon mimir yggdrasil asgard midgard valhalla bifrost
|
||||
ragnarok jormungandr hugin munin audhumla surtr
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const LINDGREN = words(`
|
||||
pippi emil ida lotta madicken mio ronja birk mattis borka karlsson lillebror rasmus tengil katla skorpan jonatan
|
||||
nangijala bullerbyn vimmerby lonneberga junibacken villekulla kalle
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
function dedupe(list: string[]): string[] {
|
||||
return [...new Set(list)];
|
||||
}
|
||||
|
||||
/** What a fresh install starts with, and what "restore" puts back. */
|
||||
export function defaultThemes(): NameTheme[] {
|
||||
return [
|
||||
{ id: "swedish-girls", label: "Swedish girl names", names: dedupe(SWEDISH_GIRLS) },
|
||||
{ id: "swedish-boys", label: "Swedish boy names", names: dedupe(SWEDISH_BOYS) },
|
||||
{ id: "disney", label: "Disney characters", names: dedupe(DISNEY) },
|
||||
{ id: "pixar", label: "Pixar characters", names: dedupe(PIXAR) },
|
||||
{ id: "norse", label: "Norse mythology", names: dedupe(NORSE) },
|
||||
{ id: "lindgren", label: "Astrid Lindgren", names: dedupe(LINDGREN) },
|
||||
];
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_IDS = defaultThemes().map((t) => t.id);
|
||||
|
||||
// ─── Validating an edit ──────────────────────────────────────────────────────
|
||||
|
||||
export interface InvalidName {
|
||||
theme: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class InvalidThemesError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly invalid: InvalidName[] = [],
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever the editor sent into clean themes, or throws with a message that says what to fix. Names are folded
|
||||
* and de-duplicated; a name that can't be made into a hostname-safe one is reported, never silently dropped.
|
||||
*/
|
||||
export function cleanThemes(input: { id?: string; label: string; names: string[]; lastImport?: NameThemeSource }[]): NameTheme[] {
|
||||
if (input.length > MAX_THEMES) throw new InvalidThemesError(`At most ${MAX_THEMES} lists.`);
|
||||
const usedIds = new Set<string>();
|
||||
const invalid: InvalidName[] = [];
|
||||
const out: NameTheme[] = [];
|
||||
|
||||
for (const raw of input) {
|
||||
const label = raw.label.trim();
|
||||
if (!label) throw new InvalidThemesError("Every list needs a name.");
|
||||
if (label.length > MAX_LABEL_LENGTH) throw new InvalidThemesError(`“${label}” — list names can be at most ${MAX_LABEL_LENGTH} characters.`);
|
||||
|
||||
let id = raw.id && /^[a-z0-9-]{1,40}$/.test(raw.id) ? raw.id : slugifyId(label);
|
||||
for (let n = 2; usedIds.has(id); n++) id = `${slugifyId(label)}-${n}`;
|
||||
usedIds.add(id);
|
||||
|
||||
const names: string[] = [];
|
||||
for (const entry of raw.names) {
|
||||
if (!entry.trim()) continue;
|
||||
const clean = normalizeName(entry);
|
||||
if (clean === null) invalid.push({ theme: label, name: entry.trim() });
|
||||
else names.push(clean);
|
||||
}
|
||||
const unique = dedupe(names);
|
||||
if (unique.length > MAX_NAMES_PER_THEME) throw new InvalidThemesError(`“${label}” has ${unique.length} names — at most ${MAX_NAMES_PER_THEME} per list.`);
|
||||
out.push({ id, label, names: unique, ...(raw.lastImport ? { lastImport: raw.lastImport } : {}) });
|
||||
}
|
||||
|
||||
if (invalid.length > 0) {
|
||||
const shown = invalid.slice(0, 5).map((i) => `“${i.name}”`).join(", ");
|
||||
throw new InvalidThemesError(
|
||||
`${invalid.length} name${invalid.length === 1 ? " isn't" : "s aren't"} usable as a server name (${shown}${invalid.length > 5 ? ", …" : ""}). Use letters, digits and hyphens, no spaces.`,
|
||||
invalid,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads themes back out of settings defensively — a backup restore or hand-edited row must not be able to break the Generator. */
|
||||
export function readStoredThemes(stored: unknown): NameTheme[] {
|
||||
if (!Array.isArray(stored)) return defaultThemes();
|
||||
const themes: NameTheme[] = [];
|
||||
for (const t of stored) {
|
||||
if (!t || typeof t !== "object") continue;
|
||||
const { id, label, names, lastImport } = t as Partial<NameTheme>;
|
||||
if (typeof id !== "string" || typeof label !== "string" || !Array.isArray(names)) continue;
|
||||
themes.push({
|
||||
id,
|
||||
label,
|
||||
names: names.filter((n): n is string => typeof n === "string"),
|
||||
...(lastImport && typeof lastImport === "object" ? { lastImport } : {}),
|
||||
});
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
// ─── Importing from Skatteverket ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Skatteverket's open-data API for "Namn på nyfödda": the most common given names of babies, by sex and birth year,
|
||||
* for the whole country ("Total"). It needs no key. Statistics Sweden (SCB), which used to publish this, stopped
|
||||
* after 2023 and points to Skatteverket.
|
||||
*/
|
||||
const SKATTEVERKET_DATASET = "https://skatteverket.entryscape.net/rowstore/dataset/da2556d0-c717-45e8-a1d8-3320161d3a7d";
|
||||
const PAGE_SIZE = 500;
|
||||
const MAX_PAGES = 4;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
export interface NameImport {
|
||||
names: string[];
|
||||
years: number[];
|
||||
/** Years that had no data (yet) and were left out. */
|
||||
missingYears: number[];
|
||||
/** Names Skatteverket lists that can't be used as a server name (a space, an unusual letter) and were left out. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
interface Row {
|
||||
namn?: string;
|
||||
antal?: string;
|
||||
rangordning?: string;
|
||||
}
|
||||
|
||||
async function fetchYear(sex: "girls" | "boys", year: number): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const url = `${SKATTEVERKET_DATASET}?gruppering=Total&fodelsear=${year}&k%C3%B6n=${sex === "girls" ? "Kvinna" : "Man"}&_limit=${PAGE_SIZE}&_offset=${page * PAGE_SIZE}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Skatteverket's name service answered ${res.status}.`);
|
||||
const body = (await res.json()) as { results?: Row[] };
|
||||
const results = Array.isArray(body.results) ? body.results : [];
|
||||
rows.push(...results);
|
||||
if (results.length < PAGE_SIZE) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most common names over the last `yearCount` full calendar years (the running year is only partly counted, so it's
|
||||
* skipped). Counts are added up across years, so one unusually popular year doesn't decide the list.
|
||||
*/
|
||||
export async function importSkatteverketNames(sex: "girls" | "boys", yearCount: number, count: number, now = new Date()): Promise<NameImport> {
|
||||
const wanted = Array.from({ length: yearCount }, (_, i) => now.getUTCFullYear() - 1 - i);
|
||||
const totals = new Map<string, number>();
|
||||
const skipped = new Set<string>();
|
||||
const years: number[] = [];
|
||||
const missingYears: number[] = [];
|
||||
|
||||
for (const year of wanted) {
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await fetchYear(sex, year);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "TimeoutError") throw new Error("Skatteverket's name service didn't answer in time.");
|
||||
throw err;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
missingYears.push(year);
|
||||
continue;
|
||||
}
|
||||
years.push(year);
|
||||
for (const row of rows) {
|
||||
const raw = (row.namn ?? "").trim();
|
||||
const amount = Number(row.antal);
|
||||
if (!raw || !Number.isFinite(amount)) continue;
|
||||
const clean = normalizeName(raw);
|
||||
if (clean === null) {
|
||||
skipped.add(raw);
|
||||
continue;
|
||||
}
|
||||
totals.set(clean, (totals.get(clean) ?? 0) + amount);
|
||||
}
|
||||
}
|
||||
|
||||
if (years.length === 0) throw new Error("Skatteverket has no name statistics for those years.");
|
||||
|
||||
const names = [...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "sv"))
|
||||
.slice(0, count)
|
||||
.map(([name]) => name);
|
||||
return { names, years: years.sort((a, b) => a - b), missingYears, skipped: [...skipped].sort((a, b) => a.localeCompare(b, "sv")) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { settings } from "../db/schema.js";
|
||||
import { defaultThemes, type NameTheme } from "./nameThemes.js";
|
||||
|
||||
export interface GotifySettings {
|
||||
enabled: boolean;
|
||||
@@ -99,6 +100,11 @@ export interface ConsistencySettings {
|
||||
excludedRanges: string[];
|
||||
}
|
||||
|
||||
export interface NameGeneratorSettings {
|
||||
/** The lists the Generator picks server names from — edited under Settings → Names. */
|
||||
themes: NameTheme[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
gotify: GotifySettings;
|
||||
ntfy: NtfySettings;
|
||||
@@ -112,6 +118,7 @@ export interface AppSettings {
|
||||
quietHours: QuietHoursSettings;
|
||||
healthChecks: HealthCheckSettings;
|
||||
consistency: ConsistencySettings;
|
||||
nameGenerator: NameGeneratorSettings;
|
||||
}
|
||||
|
||||
const DEFAULTS: AppSettings = {
|
||||
@@ -145,6 +152,7 @@ const DEFAULTS: AppSettings = {
|
||||
// Docker's default bridge (172.17.0.0/16) and the pool it hands custom networks from (172.18–31) live here, and every
|
||||
// Docker host repeats them. Shown on the Consistency page, where it can be removed if 172.16/12 is used as a real LAN.
|
||||
consistency: { excludedRanges: ["172.16.0.0/12"] },
|
||||
nameGenerator: { themes: defaultThemes() },
|
||||
};
|
||||
|
||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+7
-1
@@ -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";
|
||||
@@ -37,7 +38,9 @@ import TagSettings from "./pages/settings/TagSettings";
|
||||
import CacheSettings from "./pages/settings/CacheSettings";
|
||||
import LogSettings from "./pages/settings/LogSettings";
|
||||
import BackupSettings from "./pages/settings/BackupSettings";
|
||||
import NameSettings from "./pages/settings/NameSettings";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import DialogHost from "./components/DialogHost";
|
||||
import { setDateTimeSettings } from "./utils/date";
|
||||
import { setPageSize } from "./utils/pageSize";
|
||||
|
||||
@@ -98,7 +101,7 @@ export default function App() {
|
||||
<Route path="/ports" element={<Ports user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
<Route path="/integrations" element={<Integrations user={user} />} />
|
||||
<Route path="/generator" element={<Generator />} />
|
||||
<Route path="/generator" element={<Generator user={user} />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/consistency" element={<Consistency user={user} />} />
|
||||
<Route path="/domains" element={<Domains user={user} />} />
|
||||
@@ -113,6 +116,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={
|
||||
@@ -158,11 +162,13 @@ export default function App() {
|
||||
<Route path="badges" element={<BadgeSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="tags" element={<TagSettings />} />
|
||||
<Route path="names" element={<NameSettings />} />
|
||||
<Route path="cache" element={<CacheSettings />} />
|
||||
<Route path="logs" element={<LogSettings />} />
|
||||
<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;
|
||||
@@ -627,6 +651,28 @@ export interface PrivacyOverview {
|
||||
};
|
||||
}
|
||||
|
||||
export interface NameThemeSource {
|
||||
kind: "skatteverket";
|
||||
sex: "girls" | "boys";
|
||||
years: number[];
|
||||
count: number;
|
||||
importedAt: string;
|
||||
}
|
||||
|
||||
export interface NameTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
names: string[];
|
||||
lastImport?: NameThemeSource;
|
||||
}
|
||||
|
||||
export interface NameImportPreview {
|
||||
names: string[];
|
||||
source: NameThemeSource;
|
||||
missingYears: number[];
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export interface TagEntry {
|
||||
name: string;
|
||||
/** "#rrggbb", or null for the automatic colour. */
|
||||
@@ -1006,6 +1052,16 @@ export const api = {
|
||||
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
||||
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
|
||||
},
|
||||
generator: {
|
||||
themes: () => request<{ themes: NameTheme[]; builtinIds: string[] }>("/api/generator/themes"),
|
||||
defaults: () => request<{ themes: NameTheme[] }>("/api/generator/themes/defaults"),
|
||||
save: (themes: (Omit<NameTheme, "id"> & { id?: string })[]) => request<{ themes: NameTheme[] }>("/api/generator/themes", { method: "PUT", body: JSON.stringify({ themes }) }),
|
||||
importNames: (sex: "girls" | "boys", years: number, count: number) =>
|
||||
request<NameImportPreview>("/api/generator/themes/import", { method: "POST", body: JSON.stringify({ sex, years, count }) }),
|
||||
},
|
||||
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);
|
||||
|
||||
+46
-13
@@ -1,21 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type CurrentUser, type NameTheme } from "../api/client";
|
||||
import CopyButton from "../components/CopyButton";
|
||||
import { readableError } from "../utils/errors";
|
||||
import {
|
||||
generateServerName,
|
||||
generateUsername,
|
||||
generatePassword,
|
||||
passwordEntropyBits,
|
||||
passwordStrengthLabel,
|
||||
type ServerNameTheme,
|
||||
type UsernameOptions,
|
||||
type PasswordOptions,
|
||||
} from "../utils/generators";
|
||||
|
||||
export default function Generator() {
|
||||
const MIXED = "mixed";
|
||||
|
||||
export default function Generator({ user }: { user: CurrentUser }) {
|
||||
// ─── Server name ───────────────────────────────────────────────────────
|
||||
const [existingServerNames, setExistingServerNames] = useState<string[]>([]);
|
||||
const [theme, setTheme] = useState<ServerNameTheme>("mixed");
|
||||
const [themes, setThemes] = useState<NameTheme[] | null>(null);
|
||||
const [themesError, setThemesError] = useState<string | null>(null);
|
||||
const [theme, setTheme] = useState<string>(MIXED);
|
||||
const [serverName, setServerName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,8 +32,22 @@ export default function Generator() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.generator
|
||||
.themes()
|
||||
.then((res) => setThemes(res.themes))
|
||||
.catch((err) => setThemesError(readableError(err)));
|
||||
}, []);
|
||||
|
||||
// "Mixed" is every list together, each name once even if it appears in several.
|
||||
const pool = useMemo(() => {
|
||||
if (!themes) return [];
|
||||
if (theme === MIXED) return [...new Set(themes.flatMap((t) => t.names))];
|
||||
return themes.find((t) => t.id === theme)?.names ?? [];
|
||||
}, [themes, theme]);
|
||||
|
||||
function rollServerName() {
|
||||
setServerName(generateServerName(theme, existingServerNames));
|
||||
setServerName(generateServerName(pool, existingServerNames));
|
||||
}
|
||||
|
||||
// ─── Username ────────────────────────────────────────────────────────
|
||||
@@ -64,7 +83,8 @@ export default function Generator() {
|
||||
<>
|
||||
<h2 className="page-title mb-3">Generator</h2>
|
||||
<div className="text-secondary mb-3">
|
||||
Everything here is generated locally in your browser — nothing is sent to or stored on the server.
|
||||
Usernames and passwords are generated locally in your browser — nothing is sent to or stored on the server. Server names are
|
||||
picked in your browser too, from name lists the server hands out.
|
||||
</div>
|
||||
|
||||
<div className="row row-cards">
|
||||
@@ -74,20 +94,33 @@ export default function Generator() {
|
||||
<h3 className="card-title">Server name</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">Picks from Swedish girl names and Disney characters, avoiding names already in use.</p>
|
||||
<p className="text-secondary">
|
||||
Picks from a list of names, avoiding names already in use.
|
||||
{user.role === "admin" && (
|
||||
<>
|
||||
{" "}
|
||||
<Link to="/settings/names">Edit the lists</Link>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{themesError && <div className="alert alert-danger py-2">{themesError}</div>}
|
||||
<label className="form-label">Theme</label>
|
||||
<select className="form-select mb-3" value={theme} onChange={(e) => setTheme(e.target.value as ServerNameTheme)}>
|
||||
<option value="mixed">Mixed</option>
|
||||
<option value="swedish">Swedish girl names</option>
|
||||
<option value="disney">Disney characters</option>
|
||||
<select className="form-select mb-1" value={theme} onChange={(e) => setTheme(e.target.value)} disabled={!themes}>
|
||||
<option value={MIXED}>Mixed — all lists</option>
|
||||
{(themes ?? []).map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label} ({t.names.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-hint mb-3">{themes ? `${pool.length} name${pool.length === 1 ? "" : "s"} to pick from.` : "Loading the lists…"}</div>
|
||||
<div className="input-group">
|
||||
<input type="text" className="form-control" readOnly value={serverName} placeholder="Click Generate…" />
|
||||
{serverName && <CopyButton text={serverName} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button className="btn btn-primary" onClick={rollServerName}>
|
||||
<button className="btn btn-primary" onClick={rollServerName} disabled={pool.length === 0}>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -251,6 +251,11 @@ export default function Privacy() {
|
||||
<strong>Certificate checks</strong> — for {outbound?.tlsCertificateChecks ?? 0} secret{outbound?.tlsCertificateChecks === 1 ? "" : "s"} with
|
||||
a host to check, the app connects to that host to read its certificate.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Skatteverket</strong> — only when an admin clicks "Import from Skatteverket" under Settings → Names, the app asks
|
||||
Skatteverket's open name statistics for the most common given names. The request carries a sex and a birth year, nothing about you
|
||||
or your servers.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Servers and agents</strong> — {outbound?.serversWithAgent ?? 0} of {outbound?.servers ?? 0} servers have reported in. Agents push
|
||||
their reports to the app; the app doesn't connect out to them, apart from port scans, which run only when an operator starts one
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ const SUB_NAV = [
|
||||
{ to: "/settings/badges", label: "Badges" },
|
||||
{ to: "/settings/display", label: "Display" },
|
||||
{ to: "/settings/tags", label: "Tags" },
|
||||
{ to: "/settings/names", label: "Names" },
|
||||
{ to: "/settings/cache", label: "Cache" },
|
||||
{ to: "/settings/logs", label: "Logs" },
|
||||
{ to: "/settings/backup", label: "Backup" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type NameImportPreview, type NameTheme, type NameThemeSource } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
import { formatDateTime } from "../../utils/date";
|
||||
import { readableError } from "../../utils/errors";
|
||||
import { parseNames } from "../../utils/nameLists";
|
||||
|
||||
/** One list as it's being edited. The names are kept as the text in the box, so typing stays natural. */
|
||||
interface Draft {
|
||||
/** Stable for the life of the page; a list that hasn't been saved yet has no `id`. */
|
||||
key: string;
|
||||
id?: string;
|
||||
label: string;
|
||||
text: string;
|
||||
lastImport?: NameThemeSource;
|
||||
}
|
||||
|
||||
const MAX_LABEL = 40;
|
||||
|
||||
function toDraft(t: NameTheme): Draft {
|
||||
return { key: t.id, id: t.id, label: t.label, text: t.names.join("\n"), lastImport: t.lastImport };
|
||||
}
|
||||
|
||||
/** What identifies the content of a list, for telling whether anything changed. */
|
||||
function snapshot(id: string | undefined, label: string, names: string[], lastImport: NameThemeSource | undefined): string {
|
||||
return JSON.stringify({ id, label, names, lastImport });
|
||||
}
|
||||
|
||||
function describeImport(s: NameThemeSource): string {
|
||||
const years = s.years.length === 0 ? "" : s.years.length === 1 ? String(s.years[0]) : `${s.years[0]}–${s.years[s.years.length - 1]}`;
|
||||
return `${s.sex === "girls" ? "girls" : "boys"}, ${years}, top ${s.count} — ${formatDateTime(new Date(s.importedAt))}`;
|
||||
}
|
||||
|
||||
export default function NameSettings() {
|
||||
const [saved, setSaved] = useState<NameTheme[] | null>(null);
|
||||
const [builtinIds, setBuiltinIds] = useState<string[]>([]);
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
|
||||
// Skatteverket import panel
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [sex, setSex] = useState<"girls" | "boys">("girls");
|
||||
const [years, setYears] = useState(3);
|
||||
const [count, setCount] = useState(100);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<NameImportPreview | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.generator
|
||||
.themes()
|
||||
.then((res) => {
|
||||
setSaved(res.themes);
|
||||
setBuiltinIds(res.builtinIds);
|
||||
const next = res.themes.map(toDraft);
|
||||
setDrafts(next);
|
||||
setSelectedKey(next[0]?.key ?? null);
|
||||
})
|
||||
.catch((err) => setError(readableError(err)));
|
||||
}, []);
|
||||
|
||||
const parsed = useMemo(() => drafts.map((d) => parseNames(d.text)), [drafts]);
|
||||
const selectedIndex = Math.max(0, drafts.findIndex((d) => d.key === selectedKey));
|
||||
const selected = drafts[selectedIndex];
|
||||
const selectedParsed = parsed[selectedIndex];
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!saved) return false;
|
||||
const now = drafts.map((d, i) => snapshot(d.id, d.label.trim(), parsed[i].names, d.lastImport));
|
||||
const before = saved.map((t) => snapshot(t.id, t.label, t.names, t.lastImport));
|
||||
return JSON.stringify(now) !== JSON.stringify(before);
|
||||
}, [saved, drafts, parsed]);
|
||||
|
||||
const problem = useMemo(() => {
|
||||
for (let i = 0; i < drafts.length; i++) {
|
||||
if (!drafts[i].label.trim()) return "Every list needs a name.";
|
||||
if (parsed[i].invalid.length > 0) return `“${drafts[i].label.trim() || "A list"}” has names that can't be used — see the list.`;
|
||||
}
|
||||
return null;
|
||||
}, [drafts, parsed]);
|
||||
|
||||
function update(key: string, patch: Partial<Draft>) {
|
||||
setDrafts((all) => all.map((d) => (d.key === key ? { ...d, ...patch } : d)));
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
function select(key: string) {
|
||||
setSelectedKey(key);
|
||||
setImportOpen(false);
|
||||
setPreview(null);
|
||||
setImportError(null);
|
||||
}
|
||||
|
||||
function addList() {
|
||||
const key = `new-${newCount}`;
|
||||
setNewCount((n) => n + 1);
|
||||
setDrafts((all) => [...all, { key, label: "New list", text: "" }]);
|
||||
select(key);
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
async function removeList(d: Draft) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Delete list",
|
||||
message: `Delete “${d.label.trim() || "this list"}”? It's removed when you save${d.id && builtinIds.includes(d.id) ? ", and can be brought back with “Restore built-in lists”" : ""}.`,
|
||||
confirmLabel: "Delete",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const index = drafts.findIndex((x) => x.key === d.key);
|
||||
const rest = drafts.filter((x) => x.key !== d.key);
|
||||
setDrafts(rest);
|
||||
select(rest[Math.min(index, rest.length - 1)]?.key ?? "");
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
async function resetToBuiltIn(d: Draft) {
|
||||
if (!d.id) return;
|
||||
const ok = await confirmDialog({
|
||||
title: "Reset list",
|
||||
message: `Put “${d.label.trim() || d.id}” back to its built-in names? Your edits to this list are lost (once you save).`,
|
||||
confirmLabel: "Reset",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const { themes } = await api.generator.defaults();
|
||||
const original = themes.find((t) => t.id === d.id);
|
||||
if (!original) throw new Error("There's no built-in version of this list.");
|
||||
update(d.key, { label: original.label, text: original.names.join("\n"), lastImport: undefined });
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
}
|
||||
}
|
||||
|
||||
const missingBuiltIns = builtinIds.filter((id) => !drafts.some((d) => d.id === id));
|
||||
|
||||
async function restoreBuiltIns() {
|
||||
try {
|
||||
const { themes } = await api.generator.defaults();
|
||||
const missing = themes.filter((t) => missingBuiltIns.includes(t.id));
|
||||
setDrafts((all) => [...all, ...missing.map(toDraft)]);
|
||||
setNotice(`Brought back ${missing.length} built-in list${missing.length === 1 ? "" : "s"} — save to keep ${missing.length === 1 ? "it" : "them"}.`);
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await api.generator.save(
|
||||
drafts.map((d, i) => ({ id: d.id, label: d.label.trim(), names: parsed[i].names, lastImport: d.lastImport })),
|
||||
);
|
||||
const keepAt = selectedIndex;
|
||||
setSaved(res.themes);
|
||||
const next = res.themes.map(toDraft);
|
||||
setDrafts(next);
|
||||
setSelectedKey(next[Math.min(keepAt, next.length - 1)]?.key ?? null);
|
||||
setNotice("Saved. The Generator uses these lists from now on.");
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (!saved) return;
|
||||
const next = saved.map(toDraft);
|
||||
setDrafts(next);
|
||||
setSelectedKey(next[0]?.key ?? null);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
setImportOpen(false);
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
async function fetchPreview() {
|
||||
setFetching(true);
|
||||
setImportError(null);
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await api.generator.importNames(sex, years, count));
|
||||
} catch (err) {
|
||||
setImportError(readableError(err));
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreview(mode: "add" | "replace") {
|
||||
if (!preview || !selected) return;
|
||||
const existing = mode === "add" ? selectedParsed.names : [];
|
||||
const have = new Set(existing);
|
||||
const fresh = preview.names.filter((n) => !have.has(n));
|
||||
update(selected.key, { text: [...existing, ...fresh].join("\n"), lastImport: preview.source });
|
||||
setNotice(
|
||||
mode === "add"
|
||||
? `Added ${fresh.length} new name${fresh.length === 1 ? "" : "s"} (${preview.names.length - fresh.length} were already there) — save to keep ${fresh.length === 1 ? "it" : "them"}.`
|
||||
: `Replaced the list with ${preview.names.length} names — save to keep them.`,
|
||||
);
|
||||
setPreview(null);
|
||||
setImportOpen(false);
|
||||
}
|
||||
|
||||
function sortList() {
|
||||
if (!selected) return;
|
||||
update(selected.key, { text: [...selectedParsed.names].sort((a, b) => a.localeCompare(b, "sv")).join("\n") });
|
||||
}
|
||||
|
||||
if (!saved) {
|
||||
return error ? <div className="alert alert-danger">{error}</div> : <div className="text-secondary">Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{notice && <div className="alert alert-success">{notice}</div>}
|
||||
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Name lists</h3>
|
||||
<div className="card-actions btn-list">
|
||||
{missingBuiltIns.length > 0 && (
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => void restoreBuiltIns()}>
|
||||
Restore built-in lists ({missingBuiltIns.length})
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={addList} disabled={drafts.length >= 20}>
|
||||
New list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="text-secondary small mb-3">
|
||||
Operations → Generator picks server names from these lists. A name is letters, digits and hyphens (å, ä and ö become a, a and o), so
|
||||
it works as a hostname. Changes apply when you save.
|
||||
</div>
|
||||
{drafts.length === 0 ? (
|
||||
<div className="text-secondary">No lists. Add one, or restore the built-in ones.</div>
|
||||
) : (
|
||||
<ul className="nav nav-pills gap-1">
|
||||
{drafts.map((d, i) => (
|
||||
<li className="nav-item" key={d.key}>
|
||||
<button className={`nav-link ${d.key === selected?.key ? "active" : ""}`} onClick={() => select(d.key)}>
|
||||
{d.label.trim() || "(unnamed)"} <span className="ms-1 opacity-75">{parsed[i].names.length}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && selectedParsed && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">List name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
maxLength={MAX_LABEL}
|
||||
value={selected.label}
|
||||
onChange={(e) => update(selected.key, { label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6 d-flex align-items-end justify-content-md-end">
|
||||
<div className="btn-list">
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => setImportOpen((v) => !v)}>
|
||||
Import from Skatteverket…
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={sortList} disabled={selectedParsed.names.length < 2}>
|
||||
Sort A–Z
|
||||
</button>
|
||||
{selected.id && builtinIds.includes(selected.id) && (
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => void resetToBuiltIn(selected)}>
|
||||
Reset to built-in
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-outline-danger btn-sm" onClick={() => void removeList(selected)}>
|
||||
Delete list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importOpen && (
|
||||
<div className="border rounded p-3 mt-3">
|
||||
<div className="fw-bold mb-1">Import the most common names from Skatteverket</div>
|
||||
<div className="text-secondary small mb-3">
|
||||
Skatteverket publishes the given names of newborns in Sweden, by sex and birth year, as open data. This fetches the most common ones
|
||||
across the years you pick (the running year isn't counted — it isn't complete). You see them first; nothing changes until you add
|
||||
them to this list and save.
|
||||
</div>
|
||||
<div className="d-flex flex-wrap align-items-end gap-2">
|
||||
<div>
|
||||
<label className="form-label mb-1">Names for</label>
|
||||
<select className="form-select" value={sex} onChange={(e) => setSex(e.target.value as "girls" | "boys")}>
|
||||
<option value="girls">Girls</option>
|
||||
<option value="boys">Boys</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label mb-1">Latest years</label>
|
||||
<select className="form-select" value={years} onChange={(e) => setYears(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} year{n === 1 ? "" : "s"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label mb-1">How many names</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
style={{ width: 120 }}
|
||||
min={10}
|
||||
max={500}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Math.min(500, Math.max(10, Number(e.target.value) || 10)))}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => void fetchPreview()} disabled={fetching}>
|
||||
{fetching ? "Fetching…" : "Fetch names"}
|
||||
</button>
|
||||
</div>
|
||||
{importError && <div className="alert alert-danger mt-3 mb-0">{importError}</div>}
|
||||
{preview && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2">
|
||||
Found <strong>{preview.names.length}</strong> names for {preview.source.sex === "girls" ? "girls" : "boys"}, born{" "}
|
||||
{preview.source.years.join(", ")}.
|
||||
{preview.missingYears.length > 0 && <span className="text-secondary"> No data yet for {preview.missingYears.join(", ")}.</span>}
|
||||
{preview.skipped.length > 0 && (
|
||||
<span className="text-secondary">
|
||||
{" "}
|
||||
{preview.skipped.length} left out because they can't be used as a server name ({preview.skipped.slice(0, 5).join(", ")}
|
||||
{preview.skipped.length > 5 ? ", …" : ""}).
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-secondary small mb-3" style={{ maxHeight: 96, overflow: "auto" }}>
|
||||
{preview.names.join(", ")}
|
||||
</div>
|
||||
<div className="btn-list">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => applyPreview("add")}>
|
||||
Add to this list
|
||||
</button>
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={() => applyPreview("replace")}>
|
||||
Replace this list
|
||||
</button>
|
||||
<button className="btn btn-link btn-sm" onClick={() => setPreview(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="form-label mt-3">
|
||||
Names <span className="text-secondary fw-normal">— one per line, or separated by commas or spaces</span>
|
||||
</label>
|
||||
<textarea
|
||||
className={`form-control font-monospace ${selectedParsed.invalid.length > 0 ? "is-invalid" : ""}`}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
value={selected.text}
|
||||
onChange={(e) => update(selected.key, { text: e.target.value })}
|
||||
/>
|
||||
{selectedParsed.invalid.length > 0 && (
|
||||
<div className="invalid-feedback d-block">
|
||||
Can't be used as a server name: {selectedParsed.invalid.slice(0, 8).map((n) => `“${n}”`).join(", ")}
|
||||
{selectedParsed.invalid.length > 8 ? `, and ${selectedParsed.invalid.length - 8} more` : ""}. Use letters, digits and hyphens.
|
||||
</div>
|
||||
)}
|
||||
<div className="text-secondary small mt-2">
|
||||
{selectedParsed.names.length} name{selectedParsed.names.length === 1 ? "" : "s"}
|
||||
{selected.id && builtinIds.includes(selected.id) ? " · built-in list" : ""}
|
||||
{selected.lastImport ? ` · last imported from Skatteverket (${describeImport(selected.lastImport)})` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||
<button className="btn btn-primary" onClick={() => void save()} disabled={busy || !dirty || problem !== null}>
|
||||
{busy ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={discard} disabled={busy || !dirty}>
|
||||
Discard changes
|
||||
</button>
|
||||
{dirty && !problem && <span className="text-secondary small">You have unsaved changes.</span>}
|
||||
{problem && <span className="text-danger small">{problem}</span>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -16,41 +16,19 @@ function pick<T>(list: T[]): T {
|
||||
|
||||
// ─── Server names ───────────────────────────────────────────────────────────
|
||||
|
||||
export type ServerNameTheme = "swedish" | "disney" | "mixed";
|
||||
|
||||
// Common Swedish girl names, per SCB/Skatteverket's own published
|
||||
// name-popularity statistics for recent birth cohorts.
|
||||
const SWEDISH_GIRL_NAMES = [
|
||||
"freja", "elsa", "alice", "maja", "wilma", "alma", "ebba", "lilly", "ella", "saga",
|
||||
"agnes", "stella", "selma", "vera", "ingrid", "astrid", "linnea", "nova", "sara", "emma",
|
||||
"julia", "olivia", "isabelle", "klara", "nellie", "elin", "signe", "tuva", "moa", "tyra",
|
||||
"hedda", "nora", "amanda", "anna", "elvira", "iris", "matilda", "molly", "sofia", "thea",
|
||||
"vilma", "cornelia", "filippa", "livia", "meja", "ronja", "sigrid", "tilde", "greta", "hilda",
|
||||
];
|
||||
|
||||
// Single-word Disney character names (kept single-word so they slug into a
|
||||
// hostname cleanly without a judgment call on how to join "Snow White").
|
||||
const DISNEY_CHARACTERS = [
|
||||
"moana", "elsa", "anna", "belle", "ariel", "jasmine", "aurora", "cinderella", "rapunzel", "mulan",
|
||||
"tiana", "merida", "pocahontas", "mickey", "minnie", "simba", "nala", "stitch", "lilo", "olaf",
|
||||
"baymax", "dory", "nemo", "woody", "buzz", "genie", "aladdin", "eric", "flynn", "kristoff",
|
||||
"sven", "pumbaa", "timon", "mufasa", "scar", "ursula", "maleficent", "gaston", "hercules", "meg",
|
||||
"tinkerbell", "wendy", "pinocchio", "bambi", "dumbo", "thumper", "flounder", "sebastian", "iago", "abu",
|
||||
"pascal", "maximus", "heihei", "goofy", "donald", "daisy", "pluto", "chip", "dale", "bagheera",
|
||||
"baloo", "mowgli", "rafiki", "zazu", "piglet", "tigger", "eeyore", "pooh",
|
||||
];
|
||||
|
||||
/** Picks a name from the given theme not already present (case-insensitively) in `existing`, appending -2/-3/... only if the whole list is exhausted. */
|
||||
export function generateServerName(theme: ServerNameTheme, existing: string[] = []): string {
|
||||
const pool = theme === "swedish" ? SWEDISH_GIRL_NAMES : theme === "disney" ? DISNEY_CHARACTERS : [...SWEDISH_GIRL_NAMES, ...DISNEY_CHARACTERS];
|
||||
/**
|
||||
* Picks a name from `pool` that isn't already in `existing` (case-insensitively). Only if the whole pool is taken does it
|
||||
* fall back to numbering one — maja2, maja3, …. The pools themselves are the name lists under Settings → Names.
|
||||
*/
|
||||
export function generateServerName(pool: string[], existing: string[] = []): string {
|
||||
if (pool.length === 0) return "";
|
||||
const used = new Set(existing.map((n) => n.toLowerCase()));
|
||||
const available = pool.filter((n) => !used.has(n));
|
||||
const available = pool.filter((n) => !used.has(n.toLowerCase()));
|
||||
if (available.length > 0) return pick(available);
|
||||
// Every name in the theme is already taken — fall back to numbering a random one.
|
||||
const base = pick(pool);
|
||||
for (let suffix = 2; suffix < 1000; suffix++) {
|
||||
const candidate = `${base}${suffix}`;
|
||||
if (!used.has(candidate)) return candidate;
|
||||
if (!used.has(candidate.toLowerCase())) return candidate;
|
||||
}
|
||||
return `${base}${randomInt(100000)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The same rule the server applies to every name in the Generator's lists (server/src/services/nameThemes.ts), so the
|
||||
* editor can point out a bad name while it's being typed. The server checks again on save and is the one that decides.
|
||||
*/
|
||||
export function normalizeNameInput(raw: string): string | null {
|
||||
const folded = raw
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(folded) ? folded : null;
|
||||
}
|
||||
|
||||
/** Names are separated by line breaks, commas, semicolons or spaces. */
|
||||
export function splitNames(text: string): string[] {
|
||||
return text.split(/[\s,;]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The usable names in `text` (folded and de-duplicated, in the order written) and whatever couldn't be used. */
|
||||
export function parseNames(text: string): { names: string[]; invalid: string[] } {
|
||||
const names: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of splitNames(text)) {
|
||||
const clean = normalizeNameInput(token);
|
||||
if (clean === null) invalid.push(token);
|
||||
else if (!seen.has(clean)) {
|
||||
seen.add(clean);
|
||||
names.push(clean);
|
||||
}
|
||||
}
|
||||
return { names, invalid };
|
||||
}
|
||||
Reference in new issue
Block a user