Compare commits

..
6 Commits
Author SHA1 Message Date
bobbanandClaude Sonnet 5.5 fae7089448 Document the database schema in DATABASE.md
Every table, column, foreign key and unique index (checked against a database built
from the migrations), the JSON stored in text columns, the settings keys, delete
behaviour, retention and backups, and how to change the schema. Linked from the
README alongside the other documents.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:53:08 +02:00
bobbanandClaude Sonnet 5.5 f246410f24 Encrypt the notification channels' credentials at rest
The Gotify and ntfy tokens, the SMTP password and the webhook secret were stored
as plain text in the settings table. They are now encrypted with the same key as
integration credentials (CREDENTIALS_ENCRYPTION_KEY), marked with an "enc:v1:"
prefix. Settings are decrypted when read and encrypted when written, so nothing
else changes; values saved before this are converted at startup.

An edit that doesn't touch a credential keeps its stored ciphertext, so a wrong or
missing key (which reads as empty) can't be made permanent by an unrelated edit.
Without a key new credentials fall back to plain storage, and the startup warning,
.env.example and the Privacy page say so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:53:07 +02:00
bobbanandClaude Sonnet 5.5 86dfa9ae2e Unlink servers before deleting a Proxmox integration
servers.proxmox_integration_id was created (migration 0001) without an ON DELETE
rule, although schema.ts asks for "set null", so with foreign keys on, deleting
an integration that a server was linked to failed with a constraint error.
The delete route now clears the four proxmox_* columns on those servers first
and records how many it unlinked in the audit entry. schema.ts notes the gap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:53:06 +02:00
bobbanandClaude Sonnet 5.5 3035d7fc08 Make the Generator's server-name lists editable, add themes, import names from Skatteverket
Name lists now live in settings instead of the web bundle. Admins edit them under
Settings -> Names (add/remove names, create/rename/delete lists, reset a built-in
list). Names are folded to hostname-safe form (a-z, 0-9, hyphen) and validated on
the server; saves are audited.

Adds Swedish boy names, Pixar, Norse mythology and Astrid Lindgren lists, and
lengthens the Swedish girl and Disney lists. "Mixed" is now every list with each
name counted once.

Admins can preview and import the most common Swedish names from Skatteverket's
open "Namn pa nyfodda" data (girls or boys, latest 1-5 full years); nothing is
stored until the editor is saved. The old comment that credited SCB statistics is
gone: SCB stopped publishing name statistics after 2023.

Privacy page, README and ROLES updated for the new outbound call and page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 01:53:31 +02:00
bobbanandClaude Sonnet 5.5 447f33fff6 Add an Alerts page under Operations listing everything that's wrong now
One list of the current problems across servers and integrations, instead
of waiting for a notification or visiting each page: servers that stopped
reporting, full or nearly full disks and volumes (critical from 95%),
Synology volume/disk problems, failed or uncovered Proxmox backups,
failed Proxmox Backup Server verifications, container image updates,
expired or expiring secrets/domains/Tailscale keys, failed Semaphore and
Gitea runs, Uptime Kuma monitors that are down, overdue osTicket tickets,
and integrations whose calls keep failing. Visible to every role, with
severity and kind filters, search, sorting, CSV export and "Check now".

It runs the same checks that send the notifications rather than a second
copy of them: the detection in the health, automation, Proxmox backup,
PBS, Docker update and Tailscale key checks is pulled out into shared
collectors that both the schedulers and the page call, so the two can't
disagree about what counts as a problem. Notification behaviour is
unchanged, including the scheduled backup checks skipping integrations
under a maintenance window. Unlike the notifications the page ignores the
on/off toggles, and keeps problems under a maintenance window, marked
silenced and counted apart.

It reads live, so a result is reused for a minute (and Refresh can't
re-run everything more than once every ten seconds), and every source has
a 20 s limit so one hung integration can't hang the page. Anything it
couldn't read is called out at the top instead of looking like all clear,
and server checks pause for the same 20 minutes after a restart as the
notifications do, with a note saying so.

Also gives the newer integrations (PBS, osTicket, Uptime Kuma, phpIPAM)
proper names in "integration down" notifications instead of their ids.

Verified through the real routes against a scratch database with fake
backends (offline and full-disk servers, secrets and domains, a silenced
server, a fake PBS with failed verification, a hanging integration, a
refused one, a failing-calls streak, caching, the restart grace period,
auth), and by rendering the real page against that data in a browser:
filters, search, silenced toggle, sorting, Check now, dark mode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:07:57 +02:00
bobbanandClaude Sonnet 5.5 ad1fb5338f Replace the browser's confirm() and prompt() pop-ups with in-app dialogs
All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two
"exclude a range" boxes, tag rename) now use the app's own Tabler-styled
modals, which follow the light/dark theme.

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

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

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

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

No files matched your search

+4 -3
View File
@@ -5,9 +5,10 @@ APP_BASE_URL=https://homelab.example.lan
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=change-me-to-a-random-64-char-hex-string
# 32-byte (64 hex char) key used to encrypt stored integration API tokens at
# rest (AES-256-GCM). Generate the same way as SESSION_SECRET. Losing/changing
# this key makes previously-stored integration credentials unreadable.
# 32-byte (64 hex char) key used to encrypt stored integration API tokens, and the
# notification channels' credentials (Gotify/ntfy tokens, SMTP password, webhook
# secret), at rest (AES-256-GCM). Generate the same way as SESSION_SECRET.
# Losing/changing this key makes those stored credentials unreadable.
CREDENTIALS_ENCRYPTION_KEY=change-me-to-a-random-64-char-hex-string
# Port docker-compose publishes on the host (container always listens on 3000).
+553
View File
@@ -0,0 +1,553 @@
# Database schema
Homelab Manager keeps its data in one SQLite file, accessed through
[drizzle-orm](https://orm.drizzle.team) and the libSQL client. The schema is
defined in one place — [`server/src/db/schema.ts`](server/src/db/schema.ts) —
and this document describes it. It was checked against a fresh database built
from the migrations, so the tables, columns, foreign keys and indexes below are
what the app actually creates.
- [The basics](#the-basics)
- [How the tables relate](#how-the-tables-relate)
- [Tables](#tables)
- [What's stored inside the JSON columns](#whats-stored-inside-the-json-columns)
- [Settings keys](#settings-keys)
- [What happens on delete](#what-happens-on-delete)
- [The Proxmox link's foreign key](#the-proxmox-links-foreign-key)
- [Retention and backups](#retention-and-backups)
- [Changing the schema](#changing-the-schema)
## The basics
| | |
|---|---|
| **File** | `DATABASE_PATH`, default `../data/homelab-manager.sqlite` (relative to `server/`; `/app/data/...` in Docker). The folder is created if missing. |
| **Foreign keys** | Switched on for every connection (`PRAGMA foreign_keys = ON`), so the `ON DELETE` rules below are enforced. |
| **Migrations** | SQL files in [`server/drizzle/`](server/drizzle) (`0000` … `0014`), applied automatically when the server starts. Which ones have run is recorded in the table `__drizzle_migrations`. |
| **Tables** | 21 application tables, plus drizzle's own `__drizzle_migrations`. |
**Not in the database:**
- **Login sessions** are files in `SESSION_DIR` (default `../data/sessions`), not rows.
- **The encryption key** for integration credentials (`CREDENTIALS_ENCRYPTION_KEY`) and the session secret live in the environment, never in the file.
- **Agent tokens** are never stored: only a SHA-256 hash and a short prefix of each (`servers.api_token_hash`, `api_token_prefix`). The token itself is shown once, when the server is registered.
### Conventions
- **Primary keys** are `INTEGER PRIMARY KEY AUTOINCREMENT` named `id` — except `settings`, which uses its text `key`.
- **Booleans** are `INTEGER` 0/1 (shown as `bool` below).
- **Timestamps are text, in two formats**, so read them with care:
- Columns the database fills in itself (`created_at`, and most `updated_at`) use SQLite's `current_timestamp`: `2026-10-03 14:05:09`, **UTC, with no zone marker**. Treat it as UTC, not local time.
- Columns the app fills in (`last_seen_at`, `last_login_at`, `synced_at`, `last_checked_at`, …) use ISO 8601: `2026-10-03T14:05:09.123Z`.
- Dates without a time (`secrets.expiry_date`, `domains.expires_at`) are `YYYY-MM-DD`.
- **JSON columns** are `TEXT` holding JSON; see [what's inside](#whats-stored-inside-the-json-columns).
- **Enumerations** (roles, types) are plain text — SQLite doesn't enforce the allowed values; the app does.
- **Indexes:** besides primary keys, the only indexes are the unique ones listed per table. There are no secondary indexes; the data sets here (hundreds to a few thousand rows) don't need them.
## How the tables relate
```mermaid
erDiagram
users ||--o{ audit_log : "actor (set null)"
integration_credentials ||--o{ integrations : "credential (set null)"
integration_credentials ||--o{ dns_providers : "credential (set null)"
dns_providers ||--o{ dns_zones_cache : "cascade"
dns_providers ||--o{ dns_records_cache : "cascade"
servers ||--o{ scheduled_tasks : "cascade"
servers ||--o{ server_links : "cascade"
servers ||--o{ server_ports : "cascade"
servers ||--o{ port_forwards : "optional (set null)"
integrations ||--o{ servers : "proxmox link (app clears it)"
```
Eight tables stand alone, with no foreign keys: `settings`, `secrets`,
`ipam_entries`, `domains`, `diag_log`, `notification_queue`,
`consistency_ignores`, `tag_definitions`. `maintenance_windows` also has no
foreign key — see [soft references](#soft-references-not-foreign-keys).
## Tables
Grouped by what they're for. "Null" in the notes means the column allows NULL;
everything not marked **NOT NULL** may be empty.
### People and activity
#### `users`
Everyone who has signed in through Authentik. The first one becomes admin.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `oidc_sub` | text NOT NULL, **unique** | The user's stable ID from Authentik (`sub` claim). This is what a session is matched against. |
| `email`, `name` | text | From the sign-in; may be empty. |
| `role` | text NOT NULL, default `viewer` | `admin` \| `operator` \| `viewer`. |
| `created_at` | text NOT NULL | SQLite UTC. |
| `last_login_at` | text | ISO. |
#### `audit_log`
Who changed what. Written by the app on every change (see
[ROLES.md](ROLES.md) for who can read it).
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `actor_user_id` | integer → `users.id`, **set null** on delete | Null for automatic actions, and for entries whose user was deleted. |
| `actor_label` | text | The user's name/email as it was at the time (or `system`), so an entry still reads correctly after the account is gone. |
| `category` | text NOT NULL | Free text. In use: `server`, `integration`, `dns`, `settings`, `ipam`, `secret`, `domain`, `tag`, `task`, `session`, `network`, `maintenance`, `consistency`, `user`, `privacy`, `diag_log`. |
| `action` | text NOT NULL | `create`, `update`, `delete`, `start`, `stop`, … — free text. |
| `target_type`, `target_id` | text | What it happened to. `target_id` is text so it can hold any kind of ID; there's no foreign key. |
| `detail` | text | JSON, free-form context (what changed, names, counts). Secrets are never put here. |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `diag_log`
One row per outbound call to an integration or DNS provider — the source of
the Diagnostic Log page and of the "integration down" alert.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `source` | text NOT NULL | The integration/provider type, e.g. `proxmox`, `cloudflare`. |
| `operation` | text NOT NULL | The adapter method, e.g. `listZones`. |
| `ok` | bool NOT NULL | |
| `latency_ms` | integer NOT NULL | |
| `error` | text | Message when `ok` is false. |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `notification_queue`
Notifications held back during quiet hours, delivered as one digest and then
cleared.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `title`, `message` | text NOT NULL | |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `maintenance_windows`
While a window is open, alerts about its target are silenced. An end time is
required, so a forgotten window can't silence real problems forever.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `target_type` | text NOT NULL | `server` \| `integration` \| `dns_provider`. |
| `target_id` | integer NOT NULL | The ID in the table `target_type` names. **Not a foreign key**; a window whose target was deleted is simply ignored. |
| `reason` | text | |
| `started_at`, `ends_at` | text NOT NULL | ISO. |
| `created_by` | text | Name of the person who opened it. |
### Servers
#### `servers`
A machine that reports in through the agent (or is registered by hand), plus
what it last reported.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL | Not unique. |
| `hostname` | text | |
| `os_type` | text NOT NULL, default `linux` | |
| `description` | text | |
| `api_token_hash` | text NOT NULL | SHA-256 of the agent's token. |
| `api_token_prefix` | text NOT NULL | First characters of the token, to tell tokens apart in the UI. |
| `created_at` | text NOT NULL | SQLite UTC. |
| `last_seen_at` | text | ISO; when the agent last reported. Drives "server offline". |
| `ip_addresses` | text | JSON `string[]`. Agent-reported. |
| `cpu_model` | text | Agent-reported. |
| `cpu_cores` | integer | |
| `cpu_load_percent` | real | Load average ÷ cores × 100 — an approximation, not instantaneous usage. |
| `mem_total_bytes`, `mem_used_bytes` | integer | |
| `disks` | text | JSON, see below. Agent-reported. |
| `listening_ports` | text | JSON, see below. What the agent sees bound on the host. |
| `last_port_scan` | text | JSON summary of the latest network scan *from this app*. |
| `tags` | text | JSON `string[]` of normalised tag names. |
| `proxmox_integration_id` | integer → `integrations.id` | The database has no `ON DELETE` rule here; the app clears the link itself — see [below](#the-proxmox-links-foreign-key). |
| `proxmox_node` | text | |
| `proxmox_guest_type` | text | `qemu` \| `lxc`. |
| `proxmox_vmid` | integer | The four `proxmox_*` columns are set together or cleared together (enforced by the API), by an admin — never by the agent. |
| `hide_proxmox_link` | bool NOT NULL, default 0 | Hides the "Proxmox link" card for servers that aren't Proxmox guests. Ignored while the server is actually linked. |
#### `scheduled_tasks`
Cron jobs, systemd timers and Windows tasks the agent found on a server, plus
tasks added by hand.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `server_id` | integer NOT NULL → `servers.id`, **cascade** | |
| `schedule_type` | text NOT NULL | `cron` \| `systemd_timer` \| `windows_task` from agents; manual tasks may use others (`docker`, `backup`, `update`, `n8n_workflow`, `manual`). |
| `origin` | text NOT NULL, default `agent` | `agent` \| `manual`. Manual rows are never touched by agent sync. |
| `name` | text NOT NULL | |
| `command`, `schedule_expression`, `source` | text | |
| `enabled` | bool NOT NULL, default 1 | |
| `next_run_at` | text | |
| `raw_metadata` | text | JSON as the agent reported it. |
| `is_stale` | bool NOT NULL, default 0 | Set when the agent stops reporting the task. |
| `first_seen_at`, `last_seen_at` | text NOT NULL | SQLite UTC at first insert; later updates are written by the app. |
#### `server_links`
Admin-page bookmarks for a server (Dockge, Webmin, Cockpit, …). Shown on the
server's page and summarised under Operations → Admin Links.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `server_id` | integer NOT NULL → `servers.id`, **cascade** | |
| `label`, `url` | text NOT NULL | |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `server_ports`
A port on one server that's been seen open by a scan, or that someone wrote a
note about. Rows exist only while they carry information. What the *agent*
sees is stored on the server row instead (`servers.listening_ports`).
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `server_id` | integer NOT NULL → `servers.id`, **cascade** | |
| `port` | integer NOT NULL | |
| `protocol` | text NOT NULL, default `tcp` | `tcp` \| `udp`. |
| `label`, `comment` | text | |
| `open` | bool NOT NULL, default 0 | True when the last scan connected to it. |
| `last_seen_open_at` | text | |
| `updated_at` | text NOT NULL | |
Unique index **`server_ports_unique`** on (`server_id`, `port`, `protocol`).
#### `port_forwards`
Manually recorded port openings on something this app doesn't monitor — a
router's port forward, an edge firewall rule, a cloud security group. It
records them; it can't check or change them.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `label` | text NOT NULL | |
| `external_port` | integer NOT NULL | |
| `protocol` | text NOT NULL, default `tcp` | `tcp` \| `udp`. |
| `server_id` | integer → `servers.id`, **set null** | Optional: the tracked server it points at. |
| `destination` | text | Anything else — a bare IP, an untracked device — or detail alongside `server_id`. |
| `internal_port` | integer | When NAT changes the port. |
| `source` | text | Free text: where the rule lives ("Home router", "OPNsense WAN rule"). |
| `comment` | text | |
| `created_at`, `updated_at` | text NOT NULL | |
### Integrations and credentials
#### `integrations`
A connected system: Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand,
Uptime Kuma, phpIPAM, Proxmox Backup Server, osTicket.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `type` | text NOT NULL | `proxmox` \| `synology` \| `semaphore` \| `tailscale` \| `gitea` \| `dockhand` \| `uptimekuma` \| `phpipam` \| `pbs` \| `osticket`. |
| `name` | text NOT NULL | |
| `base_url` | text NOT NULL | For osTicket (a direct database connection) this holds the database host. |
| `credential_id` | integer → `integration_credentials.id`, **set null** | |
| `config` | text | JSON of the *non-secret* settings, see below. |
| `enabled` | bool NOT NULL, default 1 | |
| `created_at` | text NOT NULL | |
#### `integration_credentials`
The secret half of an integration or DNS provider, encrypted at rest.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL | `"<type>:<name>"`, for recognising a row when looking at the file. |
| `encrypted_secret` | text NOT NULL | `iv:authTag:ciphertext`, each in hex — AES-256-GCM with `CREDENTIALS_ENCRYPTION_KEY`. The plaintext is a JSON object of the secret fields (an API token, a password). Without the same key it can't be read. |
| `created_at` | text NOT NULL | |
The notification channels' credentials aren't in this table; they're encrypted in place in `settings` — see [Retention and backups](#retention-and-backups).
### DNS
#### `dns_providers`
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `provider_type` | text NOT NULL | `cloudflare` \| `loopia` \| `pihole` \| `azure` \| `cpanel` \| `technitium`. |
| `name` | text NOT NULL | |
| `credential_id` | integer → `integration_credentials.id`, **set null** | |
| `config` | text | JSON, non-secret provider config (base URL, zone list, …). |
| `enabled` | bool NOT NULL, default 1 | |
| `created_at` | text NOT NULL | |
#### `dns_zones_cache` and `dns_records_cache`
Local copies of what the providers returned at the last sync, so pages load
without calling every provider. The providers remain the source of truth.
`dns_zones_cache`
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `provider_id` | integer NOT NULL → `dns_providers.id`, **cascade** | |
| `zone_id` | text NOT NULL | The provider's own identifier for the zone. |
| `zone_name` | text NOT NULL | |
| `synced_at` | text | ISO. |
Unique index **`dns_zones_cache_provider_zone_idx`** on (`provider_id`, `zone_id`).
`dns_records_cache`
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `provider_id` | integer NOT NULL → `dns_providers.id`, **cascade** | |
| `zone_id` | text NOT NULL | The provider's zone identifier — matches `dns_zones_cache.zone_id` for the same provider, but is **not a foreign key**. |
| `record_id` | text NOT NULL | The provider's own record identifier. |
| `type` | text NOT NULL | `A`, `AAAA`, `CNAME`, `TXT`, `MX`, … |
| `name`, `content` | text NOT NULL | |
| `ttl`, `priority` | integer | |
| `proxied` | bool | Cloudflare only. |
### Address and name tracking
#### `ipam_entries`
IP addresses with a label and notes, entered by hand or synced.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `ip_address` | text NOT NULL, **unique** | |
| `label`, `vendor`, `location`, `notes` | text | |
| `source` | text | Null = typed in by hand; otherwise the sync that created it: `tailscale`, `proxmox` or `phpipam`. A sync only updates rows it created itself and never overwrites a manual one. |
| `created_at`, `updated_at` | text NOT NULL | |
#### `domains`
Registered domains whose expiry is tracked.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL, **unique** | The registrable domain, lowercase ASCII. |
| `origin` | text NOT NULL, default `manual` | `manual` (typed in) or `zone` (created from a synced DNS zone, removed again when the zone goes away). |
| `expires_at` | text | `YYYY-MM-DD`, as the registry reports it. Null for registries that don't publish one. |
| `registrar` | text | |
| `lookup_source` | text | `rdap` \| `whois`. |
| `last_checked_at` | text | Last attempt. |
| `last_checked_ok_at` | text | Last *successful* attempt. |
| `last_check_error` | text | |
| `created_at` | text NOT NULL | |
#### `secrets`
The expiry tracker for API tokens, certificates, passwords and the like. It
tracks *when* something expires; it does not store the secret itself.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL | |
| `type` | text NOT NULL, default `generic` | `api_token` \| `ssl_certificate` \| `password` \| `generic`. |
| `description`, `notes` | text | |
| `expiry_date` | text NOT NULL | `YYYY-MM-DD`. For a certificate with a host to check, overwritten from the live certificate. |
| `warn_days` | integer NOT NULL, default 30 | How long before expiry to start reminding. |
| `check_host`, `check_port` | text / integer | Certificates only: read the expiry from the live certificate at this host:port. |
| `last_checked_at`, `last_check_error` | text | Result of the last live check. |
| `created_at`, `updated_at` | text NOT NULL | |
### Housekeeping
#### `settings`
Key/value store for app settings. One row per settings section (the value is
JSON) plus a few internal bookkeeping rows. Details under
[Settings keys](#settings-keys).
| Column | Type | Notes |
|---|---|---|
| `key` | text PK | |
| `value` | text NOT NULL | JSON (or a plain date/time for internal flags). |
| `updated_at` | text NOT NULL | |
#### `tag_definitions`
Tags themselves live on the servers that carry them (`servers.tags`). A row
here adds what a server can't: a tag that exists before anything uses it, and a
chosen colour. A tag with no row is simply one in use with an automatic colour.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL, **unique** | Normalised. |
| `color` | text | `#rrggbb`, or null for automatic. |
| `created_at` | text NOT NULL | |
#### `consistency_ignores`
Consistency findings someone looked at and decided are fine.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `key` | text NOT NULL, **unique** | The finding's stable key, so it stays ignored across runs. |
| `title` | text NOT NULL | What the finding said when it was ignored, so the list still reads sensibly after it's gone. |
| `reason`, `created_by` | text | |
| `created_at` | text NOT NULL | |
## What's stored inside the JSON columns
| Column | Shape |
|---|---|
| `servers.ip_addresses` | `["10.0.0.5", "fd00::5"]` |
| `servers.disks` | `[{ "mount": "/", "sizeBytes": 32000000000, "usedBytes": 9000000000 }]` |
| `servers.listening_ports` | `[{ "protocol": "tcp", "port": 22, "address": "0.0.0.0", "process": "sshd" }]` |
| `servers.last_port_scan` | `{ "at": "<ISO>", "address": "10.0.0.5", "from": 1, "to": 1024, "open": 3, "refused": 1010, "filtered": 11, "responded": true }` |
| `servers.tags` | `["prod", "media"]` |
| `audit_log.detail` | Free-form per action — e.g. `{ "name": "pve1" }`, or for settings `{ "sections": [...], "changes": { "<section>": { "<field>": { "from": …, "to": … } } } }`. For the notification channels (Gotify, ntfy, SMTP, webhook) only the field *names* that changed are recorded, never values. |
| `integrations.config` | The non-secret fields for that integration type (table below). |
| `dns_providers.config` | Non-secret provider settings (base URL, zone list, …). |
**`integrations.config` by type** (the secret fields go to `integration_credentials` instead):
| Type | In `config` | Encrypted separately |
|---|---|---|
| `proxmox` | `url`, `tokenId`, `insecure` | `tokenSecret` |
| `pbs` | `url`, `tokenId`, `insecure` | `tokenSecret` |
| `synology` | `url`, `username`, `insecure` | `password` |
| `semaphore`, `gitea`, `dockhand` | `url` | `token` |
| `tailscale` | `tailnet` | `apiKey` |
| `uptimekuma` | `url`, `username` | `password` (an API key, or the password on old installs) |
| `phpipam` | `url`, `appId`, `insecure` | `token` |
| `osticket` | `host`, `port`, `database`, `username`, `tablePrefix` | `password` |
## Settings keys
Each section is one row in `settings`, with a JSON object as its value. Fields
that were never changed aren't stored; the app fills in defaults when reading.
| Key | Holds |
|---|---|
| `gotify`, `ntfy`, `smtp`, `webhook` | The four notification channels: enabled, address, and credentials (token / password / secret). The credential fields are stored encrypted (prefix `enc:v1:`) — see [Retention and backups](#retention-and-backups). |
| `notifications` | Which events notify (`dnsAdd`, `healthAlerts`, …), the daily-reminder time (`secretCheckTime`, default `08:00`) and `timezone` (default `UTC`), and the integration-failure threshold (default 3). |
| `quietHours` | `enabled`, `start`, `end`. |
| `healthChecks` | `serverOfflineMinutes` (60), `diskUsagePercent` (90), `domainWarnDays` (30). |
| `logRetention` | `enabled`, `retentionDays` (90), `intervalHours` (24). |
| `display` | `dateFormat`, `timeFormat`, `pageSize`. |
| `providerColors`, `integrationColors` | Badge colours, by provider / integration type. |
| `consistency` | `excludedRanges` — addresses the Consistency and IP Addresses pages ignore. Default `["172.16.0.0/12"]` (Docker's networks). |
| `nameGenerator` | `themes` — the Generator's server-name lists: `[{ "id", "label", "names": [...], "lastImport"? }]`. Edited under Settings → Names. |
Rows whose key starts with **`_internal:`** are scheduler bookkeeping, not
settings, and aren't part of the app's settings object:
| Key | Value |
|---|---|
| `_internal:secretCheckLastRunDate`, `tailscaleKeyCheckLastRunDate`, `dockerUpdateCheckLastRunDate`, `proxmoxBackupCheckLastRunDate`, `pbsVerificationCheckLastRunDate` | The date a daily check last ran, so a restart doesn't repeat it (or skip it). |
| `_internal:logRetentionLastRunAt` | When the log purge last ran. |
| `_internal:healthActiveConditions`, `_internal:automationActiveConditions` | JSON: the problems currently active, so each is announced once and again when it clears, and survives a restart. |
## What happens on delete
| Deleting… | Effect |
|---|---|
| a **server** | Its `scheduled_tasks`, `server_links` and `server_ports` are deleted with it. Port forwards that pointed at it stay, with `server_id` cleared. |
| a **DNS provider** | Its cached zones and records are deleted. |
| an **integration** or **DNS provider** | The credential row it used is deleted by the app (not by the database). |
| an **integration credential** | The integration / provider using it keeps existing, with `credential_id` cleared. |
| a **user** | Their audit entries stay; `actor_user_id` is cleared and `actor_label` keeps the name. |
| a **Proxmox integration** that servers are linked to | Those servers keep existing and lose their Proxmox link (all four `proxmox_*` columns). The app does this before deleting; the database alone would refuse — see the next section. |
### Soft references (not foreign keys)
These point at other rows by ID or name without the database enforcing it, so
a stale value is possible and the app treats it as "nothing there":
- `maintenance_windows.target_id` (→ a server, integration or DNS provider, by `target_type`)
- `audit_log.target_id`
- `dns_records_cache.zone_id` (→ `dns_zones_cache.zone_id`, same provider)
- `servers.tags` (→ `tag_definitions.name`)
- `consistency_ignores.key`
## The Proxmox link's foreign key
`servers.proxmox_integration_id` is meant to clear itself when its integration
is deleted: `schema.ts` says `onDelete: "set null"`. The database doesn't do
that. Migration `0001` added the column as a plain
`REFERENCES integrations(id)`, and SQLite can't change a foreign key afterwards
without rebuilding the table, so the rule *in the database* is **no action**:
with foreign keys on, deleting an integration that a server points at is refused
with `FOREIGN KEY constraint failed`.
The app works around it rather than rebuilding the table. The delete route in
[`server/src/routes/integrations.ts`](server/src/routes/integrations.ts) first
clears the four `proxmox_*` columns on every server linked to that integration,
then deletes it, and the audit entry records how many servers were unlinked
(`unlinkedServers`). Deleting an integration therefore works whether or not
servers are linked to it. Anything that deletes integrations some other way —
a hand-written SQL statement, say — has to do the same first.
## Retention and backups
**Retention.** Only the two logs are trimmed: `audit_log` and `diag_log` entries
older than `logRetention.retentionDays` are deleted by the purge job (off by
default), and `notification_queue` is emptied each time the quiet-hours digest is
sent. Everything else stays until someone deletes it.
**Credentials at rest.** Integration and DNS provider credentials are
encrypted in `integration_credentials` (above). The notification channels'
credentials — the Gotify and ntfy tokens, the SMTP password and the webhook
secret — are in the `settings` rows, and are encrypted in place with the same key:
each is stored as `enc:v1:` followed by the same `iv:authTag:ciphertext` form, and the
rest of the row (URLs, topics, priorities) stays readable. The app decrypts them when
settings are read and encrypts them when they're written, so nothing else sees the
difference.
- A value saved by an older version (plain text) still works, and is encrypted the
next time the server starts.
- Without `CREDENTIALS_ENCRYPTION_KEY`, new notification credentials can only be
stored as plain text, and the server warns about it at startup. They are encrypted
at the next start once the key is set.
- If the key is changed or lost, the stored credentials can't be read: they show as
empty, and the server logs which ones. Enter them again under Settings →
Notifications. Editing a *different* field of the same channel meanwhile doesn't
overwrite the old ciphertext, so putting the right key back restores them.
Everything else in the file — IP addresses, hostnames, secret *names* and expiry
dates, the audit log — is readable by anyone who can read the file, so treat the
file and its backups as sensitive anyway.
**Backups.**
- **Settings → Backup** exports the settings, the integrations and the DNS
providers (with their credentials decrypted, then wrapped in a file encrypted
with a passphrase you choose). Importing merges the settings over the current ones, and adds
integrations and providers that don't exist yet (matched by type and name) — it never
overwrites an existing integration. It does **not** include servers, tasks,
secrets, IP addresses, domains, ports, tags, maintenance windows or logs.
- **A full backup** is a copy of the SQLite file, together with the value of
`CREDENTIALS_ENCRYPTION_KEY` — without that key the stored integration
credentials can't be decrypted. Copy the file while the app is stopped, or use
SQLite's `.backup` command, so you don't capture it mid-write.
## Changing the schema
1. Edit [`server/src/db/schema.ts`](server/src/db/schema.ts).
2. From the repo root run `npm run db:generate`; it writes a new numbered SQL
file to `server/drizzle/` (and updates `server/drizzle/meta/`).
3. Read the generated SQL. SQLite can add a column but can't change or drop a
foreign key, so some changes become a table rebuild — which is also why the
[Proxmox link](#the-proxmox-links-foreign-key) described above is the way it is.
4. Start the server (or run `npm run db:migrate`); the migration is applied and
recorded in `__drizzle_migrations`.
5. Commit the schema, the SQL file and the `meta/` changes together, and update
this document.
+14
View File
@@ -89,6 +89,20 @@ schedule.
|---|---|
| "Notifications from quiet hours" | Once, at quiet hours' configured end time, **only if enabled and only if at least one notification was held** during the window. Bundles every held notification's title and message into one message, then clears the queue. |
## 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
+32
View File
@@ -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
@@ -300,6 +327,11 @@ needs the site to be served over HTTPS (browsers only offer install on secure
origins; `localhost` also counts). The bundled service worker deliberately
caches nothing, so an installed copy always shows the current build.
**More documentation**: [ROLES.md](ROLES.md) — who can see and do what;
[NOTIFICATIONS.md](NOTIFICATIONS.md) — every notification the app sends and when;
[INTEGRATIONS.md](INTEGRATIONS.md) — the credentials each integration needs;
[DATABASE.md](DATABASE.md) — the database tables, columns and relationships.
## Requirements
- Node.js 20+
+4
View File
@@ -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.
+3 -1
View File
@@ -224,7 +224,9 @@ export const servers = sqliteTable("servers", {
lastPortScan: text("last_port_scan"), // JSON string: summary of the most recent network scan from this app
tags: text("tags"), // JSON string: string[] — free-form labels for grouping and filtering, normalized by services/serverTags
// Optional link to a Proxmox VM/LXC — set by an admin, not the agent.
// Optional link to a Proxmox VM/LXC — set by an admin, not the agent. The "set null" below is what this file asks for,
// but migration 0001 created the column without it, so the database itself has no ON DELETE rule here: deleting an
// integration clears these columns in routes/integrations.ts instead. (See DATABASE.md.)
proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, {
onDelete: "set null",
}),
+2 -1
View File
@@ -29,7 +29,8 @@ export function warnIfAuthNotConfigured() {
if (!env.credentialsEncryptionEnabled) {
console.warn(
"CREDENTIALS_ENCRYPTION_KEY is not set to a 64-character hex string. " +
"Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured.",
"Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured, and the " +
"notification channels' credentials (Gotify/ntfy tokens, SMTP password, webhook secret) are stored unencrypted.",
);
}
}
+9
View File
@@ -8,6 +8,7 @@ import { mkdirSync, existsSync } from "node:fs";
import { env, warnIfAuthNotConfigured } from "./env.js";
import { resolveDataPath } from "./paths.js";
import { runMigrations } from "./db/migrate.js";
import { encryptStoredSettingsSecrets } from "./services/settingsStore.js";
import { authRouter } from "./auth/router.js";
import { meRouter } from "./routes/me.js";
import { usersRouter } from "./routes/users.js";
@@ -29,6 +30,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";
@@ -40,6 +43,10 @@ import { initHealthScheduler } from "./services/healthScheduler.js";
warnIfAuthNotConfigured();
await runMigrations();
{
const converted = await encryptStoredSettingsSecrets();
if (converted > 0) console.log(`Encrypted the stored credentials of ${converted} notification channel${converted === 1 ? "" : "s"}.`);
}
await initSecretExpiryScheduler();
await initTailscaleKeyExpiryScheduler();
await initLogRetentionScheduler();
@@ -104,6 +111,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));
+14
View File
@@ -0,0 +1,14 @@
import { Router } from "express";
import { requireAuth } from "../auth/middleware.js";
import { getAlerts } from "../services/alerts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const alertsRouter = Router();
alertsRouter.use(requireAuth);
// Everyone signed in can see it — it's the same server, disk and backup status the other pages already show.
// `?refresh=1` asks for a fresh check instead of a recent one.
alertsRouter.get("/", asyncHandler(async (req, res) => {
res.json(await getAlerts(req.query.refresh === "1"));
}));
+128
View File
@@ -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) });
}
}));
+9 -1
View File
@@ -272,6 +272,14 @@ integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req,
return res.status(404).json({ error: "not_found" });
}
// servers.proxmox_integration_id was meant to clear itself, but the database only has a plain REFERENCES on it (see
// DATABASE.md), so a linked server would block the delete. Clear the whole link here — the four columns go together.
const unlinked = await db
.update(servers)
.set({ proxmoxIntegrationId: null, proxmoxNode: null, proxmoxGuestType: null, proxmoxVmid: null })
.where(eq(servers.proxmoxIntegrationId, id))
.returning({ id: servers.id });
await db.delete(integrations).where(eq(integrations.id, id));
if (existing.credentialId) {
await db.delete(integrationCredentials).where(eq(integrationCredentials.id, existing.credentialId));
@@ -283,7 +291,7 @@ integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req,
action: "delete",
targetType: "integration",
targetId: id,
detail: { name: existing.name },
detail: { name: existing.name, ...(unlinked.length > 0 ? { unlinkedServers: unlinked.length } : {}) },
});
res.status(204).end();
+28
View File
@@ -0,0 +1,28 @@
// Shared by the alerts page's collector (services/alerts.ts) and the scheduled checks it reuses, which can't import
// that file themselves without going round in a circle.
export type AlertSeverity = "critical" | "warning" | "info";
/** What kind of problem — drives the filter on the Alerts page. */
export type AlertCategory = "offline" | "disk" | "backup" | "updates" | "expiry" | "automation" | "integration" | "monitoring" | "tickets";
export interface Alert {
/** Stable, so the page can key rows on it. */
id: string;
severity: AlertSeverity;
category: AlertCategory;
/** Where it comes from, as a short name: "Server", "Proxmox", "Secrets", ... */
source: string;
message: string;
/** In-app page that shows more, if there is one. */
link: string | null;
/** Under an active maintenance window: still a real problem, but notifications for it are held back. */
silenced: boolean;
}
/** One integration that couldn't be read while collecting — so the page never mistakes "couldn't check" for "all clear". */
export interface SourceFailure {
integrationId: number;
integrationName: string;
message: string;
}
+387
View File
@@ -0,0 +1,387 @@
/**
* Everything that's wrong right now, in one list — the Alerts page. Nothing here decides what counts as a problem: it asks
* the same checks that send the notifications (health, backups, updates, expiry, automation, ...) and turns what they find
* into a flat list, so the page and the notifications can't disagree. Unlike the notifications it ignores the per-event
* on/off toggles (the page is for looking at, not for being interrupted by) and keeps problems that are under a
* maintenance window, flagged as silenced rather than dropped.
*
* It reads live (a handful of API calls per integration), so a result is kept for a short while rather than re-run for
* every viewer, and every source has a time limit so one hung integration can't hang the page. A source that can't be
* read is reported as such — silence from it must never look like "all clear".
*/
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { integrations, secrets } from "../db/schema.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js";
import { createOsTicketAdapter } from "../integrations/osticket/adapter.js";
import { activeSubjects, isSourceInMaintenance, subjectOfConditionKey } from "./maintenance.js";
import { collectSnapshot, evaluateHealth, startupGraceRemainingMs } from "./healthMonitor.js";
import { collectAutomation, evaluateAutomation } from "./automationMonitor.js";
import { collectDockerUpdates } from "./dockerUpdateScheduler.js";
import { collectProxmoxBackupProblems } from "./proxmoxBackupScheduler.js";
import { collectPbsProblems } from "./pbsVerificationScheduler.js";
import { collectTailscaleKeyExpiries } from "./tailscaleKeyExpiryScheduler.js";
import { collectDomainAlerts } from "./domainMonitor.js";
import { computeSecretStatus } from "./secretStatus.js";
import { getFailingSources } from "./integrationHealthMonitor.js";
import { getSettings } from "./settingsStore.js";
import { sourceLabel } from "./notify.js";
import type { Alert, AlertCategory, AlertSeverity, SourceFailure } from "./alertTypes.js";
export interface AlertsReport {
alerts: Alert[];
/** Active problems by severity — those under a maintenance window are counted apart, not in these. */
counts: { critical: number; warning: number; info: number; silenced: number };
/** Things that couldn't be checked, and which checks that leaves blind. */
couldntCheck: { name: string; error: string; affects: string[] }[];
/** Context worth knowing about how complete the picture is. */
notes: string[];
generatedAt: string;
}
/** Where each kind of source lives in the app, and what to call it. */
const SOURCES: Record<string, { label: string; link: string }> = {
server: { label: "Server", link: "/servers" },
proxmox: { label: "Proxmox", link: "/proxmox" },
synology: { label: "Synology", link: "/synology" },
semaphore: { label: "Semaphore", link: "/semaphore" },
gitea: { label: "Gitea", link: "/gitea" },
dockhand: { label: "Docker", link: "/docker" },
tailscale: { label: "Tailscale", link: "/tailscale" },
pbs: { label: "Proxmox Backup", link: "/pbs" },
uptimekuma: { label: "Uptime Kuma", link: "/uptime-kuma" },
osticket: { label: "osTicket", link: "/osticket" },
secrets: { label: "Secrets", link: "/secrets" },
domains: { label: "Domains", link: "/domains" },
};
const SOURCE_TIMEOUT_MS = 20_000;
/** How long a result is reused. */
const CACHE_MS = 60_000;
/** Pressing Refresh over and over shouldn't hammer every integration — a result younger than this is reused even then. */
const MIN_REFRESH_MS = 10_000;
const SEVERITY_ORDER: Record<AlertSeverity, number> = { critical: 0, warning: 1, info: 2 };
/** Which kind of source, and which one, a health/automation condition key belongs to. */
function originOfConditionKey(key: string): { type: string; id: number | null; category: AlertCategory } {
let m = /^(?:offline|disk):server:(\d+)/.exec(key);
if (m) return { type: "server", id: Number(m[1]), category: key.startsWith("offline") ? "offline" : "disk" };
m = /^disk:proxmox:(\d+):/.exec(key);
if (m) return { type: "proxmox", id: Number(m[1]), category: "disk" };
m = /^(?:synology-volume|synology-disk|disk:synology):(\d+):/.exec(key);
if (m) return { type: "synology", id: Number(m[1]), category: "disk" };
m = /^automation:(semaphore|gitea):(\d+):/.exec(key);
if (m) return { type: m[1], id: Number(m[2]), category: "automation" };
return { type: "server", id: null, category: "disk" };
}
function withTimeout<T>(work: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const limit = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
});
return Promise.race([work, limit]).finally(() => clearTimeout(timer));
}
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export async function collectAlerts(): Promise<AlertsReport> {
const alerts: Alert[] = [];
const notes: string[] = [];
const blind = new Map<string, { error: string; affects: Set<string> }>();
const subjects = await activeSubjects();
const intRows = await db.select({ id: integrations.id, name: integrations.name, type: integrations.type, enabled: integrations.enabled }).from(integrations);
const intById = new Map(intRows.map((r) => [r.id, r]));
function push(a: { category: AlertCategory; severity: AlertSeverity; type: string; message: string; key: string; link?: string | null; silenced?: boolean }) {
const meta = SOURCES[a.type];
alerts.push({
id: `${a.category}:${a.key}`,
severity: a.severity,
category: a.category,
source: meta?.label ?? sourceLabel(a.type),
message: a.message,
link: a.link === undefined ? (meta?.link ?? null) : a.link,
silenced: !!a.silenced,
});
}
function cannotCheck(name: string, error: string, check: string) {
const entry = blind.get(name) ?? { error, affects: new Set<string>() };
entry.affects.add(check);
blind.set(name, entry);
}
const cannotCheckIntegration = (f: SourceFailure, check: string) => {
const row = intById.get(f.integrationId);
cannotCheck(`${row ? (SOURCES[row.type]?.label ?? row.type) : "Integration"} “${f.integrationName}”`, f.message, check);
};
const silencedIntegration = (id: number) => subjects.has(`integration:${id}`);
// One entry per check. A check that blows up, or hangs, only costs its own section of the page.
async function check(name: string, work: () => Promise<void>) {
try {
await withTimeout(work());
} catch (err) {
cannotCheck(name, errorText(err), name);
}
}
await Promise.all([
check("Server and storage health", async () => {
const { healthChecks } = await getSettings();
const { snapshot, held } = await collectSnapshot();
const now = Date.now();
const grace = startupGraceRemainingMs(now);
if (grace > 0) {
notes.push(
`Server-offline and server-disk checks are paused for another ${Math.ceil(grace / 60_000)} min after the app restarted, so agents get a chance to report before any server is judged.`,
);
}
for (const c of evaluateHealth(snapshot, healthChecks, now, { skipServers: grace > 0 })) {
const origin = originOfConditionKey(c.key);
const subject = subjectOfConditionKey(c.key);
push({
category: origin.category,
severity: c.severity ?? "warning",
type: origin.type,
message: c.message,
key: c.key,
link: origin.type === "server" && origin.id !== null ? `/servers/${origin.id}` : undefined,
silenced: subject !== null && subjects.has(subject),
});
}
// Integrations the check couldn't read this time. (A whole-integration entry covers its nodes, so skip those.)
for (const h of held) {
const m = /^(proxmox|synology):(\d+)(?::(.+))?$/.exec(h);
if (!m || (m[3] && held.has(`${m[1]}:${m[2]}`))) continue;
const row = intById.get(Number(m[2]));
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”${m[3] ? ` (node ${m[3]})` : ""}`, "couldn't be read — see the Diagnostic Log", "Server and storage health");
}
}),
check("Automation runs", async () => {
const { items, held } = await collectAutomation();
for (const c of evaluateAutomation(items).conditions) {
const origin = originOfConditionKey(c.key);
const subject = subjectOfConditionKey(c.key);
push({ category: "automation", severity: "warning", type: origin.type, message: c.message, key: c.key, silenced: subject !== null && subjects.has(subject) });
}
for (const h of held) {
const m = /^(semaphore|gitea):(\d+)$/.exec(h);
if (!m) continue;
const row = intById.get(Number(m[2]));
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”`, "couldn't be read — see the Diagnostic Log", "Automation runs");
}
}),
check("Proxmox backups", async () => {
const { failures, uncovered, sourceFailures } = await collectProxmoxBackupProblems({ skipSilenced: false });
for (const f of failures) {
push({
category: "backup",
severity: "critical",
type: "proxmox",
message: `Latest backup on ${f.node}${f.guestId ? ` (guest ${f.guestId})` : ""} didn't succeed [${f.integrationName}]: ${f.status}`,
key: `proxmox-backup:${f.integrationId}:${f.node}`,
silenced: f.silenced,
});
}
for (const u of uncovered) {
push({
category: "backup",
severity: "warning",
type: "proxmox",
message: `${u.guestName} (#${u.vmid}) on ${u.node} isn't covered by any backup job [${u.integrationName}]`,
key: `proxmox-uncovered:${u.integrationId}:${u.vmid}`,
silenced: u.silenced,
});
}
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Proxmox backups"));
}),
check("Backup verification", async () => {
const { problems, sourceFailures } = await collectPbsProblems({ skipSilenced: false });
for (const p of problems) {
push({
category: "backup",
severity: p.error ? "warning" : "critical",
type: "pbs",
message: p.error
? `Datastore "${p.datastore}" couldn't be read [${p.integrationName}]: ${p.error}`
: `Datastore "${p.datastore}" has ${plural(p.failedCount, "snapshot")} that failed verification [${p.integrationName}]`,
key: `pbs:${p.integrationId}:${p.datastore}`,
silenced: p.silenced,
});
}
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Backup verification"));
}),
check("Image updates", async () => {
const { items, failures } = await collectDockerUpdates();
for (const u of items) {
push({
category: "updates",
severity: "info",
type: "dockhand",
message: `${u.containerName} [${u.environmentName}, ${u.integrationName}] has an image update available${u.newerVersion ? ` → ${u.newerVersion}` : ""}`,
key: `docker:${u.integrationId}:${u.environmentName}:${u.containerName}`,
silenced: silencedIntegration(u.integrationId),
});
}
failures.forEach((f) => cannotCheckIntegration(f, "Image updates"));
}),
check("Tailscale keys", async () => {
const { items, failures } = await collectTailscaleKeyExpiries();
for (const k of items) {
push({
category: "expiry",
severity: k.daysLeft < 0 ? "critical" : "warning",
type: "tailscale",
message: k.daysLeft < 0 ? `Key for ${k.deviceLabel} [${k.integrationName}] has expired` : `Key for ${k.deviceLabel} [${k.integrationName}] expires in ${plural(k.daysLeft, "day")}`,
key: `tailscale-key:${k.integrationId}:${k.deviceLabel}`,
silenced: silencedIntegration(k.integrationId),
});
}
failures.forEach((f) => cannotCheckIntegration(f, "Tailscale keys"));
}),
check("Uptime Kuma", async () => {
for (const row of intRows.filter((r) => r.type === "uptimekuma" && r.enabled)) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
for (const m of await createUptimeKumaAdapter(loaded.config as any).listMonitors()) {
if (m.status !== "down") continue;
push({
category: "monitoring",
severity: "critical",
type: "uptimekuma",
message: `Monitor "${m.name}" is down${m.target ? ` (${m.target}${m.port ? `:${m.port}` : ""})` : ""} [${row.name}]`,
key: `kuma:${row.id}:${m.id}`,
silenced: silencedIntegration(row.id),
});
}
} catch (err) {
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "Uptime Kuma");
}
}
}),
check("osTicket", async () => {
for (const row of intRows.filter((r) => r.type === "osticket" && r.enabled)) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
const overdue = (await createOsTicketAdapter(loaded.config as any).listOpenTickets()).filter((t) => t.isOverdue).length;
if (overdue > 0) {
push({
category: "tickets",
severity: "warning",
type: "osticket",
message: `${plural(overdue, "open ticket")} ${overdue === 1 ? "is" : "are"} overdue [${row.name}]`,
key: `osticket:${row.id}`,
silenced: silencedIntegration(row.id),
});
}
} catch (err) {
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "osTicket");
}
}
}),
check("Secrets", async () => {
for (const s of await db.select().from(secrets)) {
const status = computeSecretStatus(s.expiryDate, s.warnDays);
if (status.status === "expired") {
push({ category: "expiry", severity: "critical", type: "secrets", message: `Secret "${s.name}" expired ${plural(Math.abs(status.daysLeft), "day")} ago (${s.expiryDate})`, key: `secret:${s.id}` });
} else if (status.status === "expiring") {
push({ category: "expiry", severity: "warning", type: "secrets", message: `Secret "${s.name}" expires in ${plural(status.daysLeft, "day")} (${s.expiryDate})`, key: `secret:${s.id}` });
}
if (s.checkHost && s.lastCheckError) {
push({
category: "expiry",
severity: "warning",
type: "secrets",
message: `Couldn't read the live certificate for "${s.name}" (${s.checkHost}:${s.checkPort ?? 443}) — the expiry shown may be stale: ${s.lastCheckError}`,
key: `secret-check:${s.id}`,
});
}
}
}),
check("Domains", async () => {
const { expiring, staleChecks } = await collectDomainAlerts();
for (const d of expiring) {
push({
category: "expiry",
severity: d.status === "expired" ? "critical" : "warning",
type: "domains",
message: d.status === "expired" ? `Domain ${d.name} expired on ${d.expiresAt}` : `Domain ${d.name} expires in ${plural(d.daysLeft, "day")} (${d.expiresAt})`,
key: `domain:${d.name}`,
});
}
for (const s of staleChecks) {
push({ category: "expiry", severity: "info", type: "domains", message: `Couldn't refresh the registration for ${s.name} — the expiry shown may be stale: ${s.error}`, key: `domain-stale:${s.name}` });
}
}),
check("Integration failures", async () => {
const { notifications } = await getSettings();
for (const f of getFailingSources()) {
push({
category: "integration",
severity: f.alerted ? "critical" : "warning",
type: f.source,
message: `${sourceLabel(f.source)} has failed its last ${plural(f.consecutiveFailures, "call")} in a row${f.alerted ? "" : ` (a notification goes out after ${notifications.integrationFailureThreshold})`} — see the Diagnostic Log`,
key: `failing:${f.source}`,
link: null,
silenced: await isSourceInMaintenance(f.source),
});
}
}),
]);
alerts.sort(
(a, b) =>
Number(a.silenced) - Number(b.silenced) ||
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
a.source.localeCompare(b.source) ||
a.message.localeCompare(b.message),
);
const active = alerts.filter((a) => !a.silenced);
return {
alerts,
counts: {
critical: active.filter((a) => a.severity === "critical").length,
warning: active.filter((a) => a.severity === "warning").length,
info: active.filter((a) => a.severity === "info").length,
silenced: alerts.length - active.length,
},
couldntCheck: [...blind.entries()].map(([name, v]) => ({ name, error: v.error, affects: [...v.affects] })),
notes,
generatedAt: new Date().toISOString(),
};
}
let cache: { at: number; report: AlertsReport } | null = null;
let inFlight: Promise<AlertsReport> | null = null;
/** The current alerts, reusing a recent result unless `force` asks for a fresh one (and even then not more than once every few seconds). */
export async function getAlerts(force: boolean): Promise<AlertsReport & { cached: boolean }> {
const age = cache ? Date.now() - cache.at : Infinity;
if (cache && age < (force ? MIN_REFRESH_MS : CACHE_MS)) return { ...cache.report, cached: true };
inFlight ??= collectAlerts()
.then((report) => {
cache = { at: Date.now(), report };
return report;
})
.finally(() => {
inFlight = null;
});
return { ...(await inFlight), cached: false };
}
+2 -2
View File
@@ -62,7 +62,7 @@ export function evaluateAutomation(items: AutomationItem[]): { conditions: Healt
// ─── Collection (I/O) ───────────────────────────────────────────────────────
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.
+20 -3
View File
@@ -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> {
+19 -1
View File
@@ -21,6 +21,8 @@ export interface HealthCondition {
/** Where the data came from, hierarchically ("server", "proxmox:3", "proxmox:3:pve1"). Used to hold a condition when its source can't be reached. */
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 };
+294
View File
@@ -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")) };
}
+5 -1
View File
@@ -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> {
+42 -8
View File
@@ -6,22 +6,50 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, guestsWithoutBackupCoverage, type ProxmoxBackupTask } from "../integrations/proxmox/adapter.js";
import { 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);
}
+113 -3
View File
@@ -1,6 +1,9 @@
import { sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { settings } from "../db/schema.js";
import { defaultThemes, type NameTheme } from "./nameThemes.js";
import { env } from "../env.js";
import { decryptSecret, encryptSecret } from "../crypto.js";
export interface GotifySettings {
enabled: boolean;
@@ -99,6 +102,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 +120,7 @@ export interface AppSettings {
quietHours: QuietHoursSettings;
healthChecks: HealthCheckSettings;
consistency: ConsistencySettings;
nameGenerator: NameGeneratorSettings;
}
const DEFAULTS: AppSettings = {
@@ -145,10 +154,107 @@ 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)[];
// ─── Encrypting the notification channels' credentials ─────────────────────────────────────
//
// A Gotify/ntfy token, the SMTP password and the webhook secret are stored encrypted with the same key as integration
// credentials (CREDENTIALS_ENCRYPTION_KEY). Everything above this file sees them as plain text — they're opened when
// settings are read and sealed when they're written — so nothing else needs to know. An encrypted value is marked
// with a prefix, which is how a value saved before this existed (plain text) is told apart and picked up later.
const SECRET_FIELDS: Partial<Record<keyof AppSettings, string[]>> = {
gotify: ["token"],
ntfy: ["token"],
smtp: ["password"],
webhook: ["secret"],
};
const ENCRYPTED_PREFIX = "enc:v1:";
const warnedUnreadable = new Set<string>();
/** Plain text in, the stored form out. Without a key configured the value is stored as it always was. */
function sealValue(plain: unknown): unknown {
if (typeof plain !== "string" || plain === "" || !env.credentialsEncryptionEnabled) return plain;
return ENCRYPTED_PREFIX + encryptSecret(plain);
}
/** The stored form in, plain text out. Anything not marked as encrypted is a legacy plain value and passes through. */
function openValue(section: string, field: string, stored: unknown): unknown {
if (typeof stored !== "string" || !stored.startsWith(ENCRYPTED_PREFIX)) return stored;
try {
return decryptSecret(stored.slice(ENCRYPTED_PREFIX.length));
} catch {
// The key was changed or removed. An empty credential is the honest result; shout once so it isn't a silent mystery.
const name = `${section}.${field}`;
if (!warnedUnreadable.has(name)) {
warnedUnreadable.add(name);
console.warn(`Can't decrypt the stored ${name} — CREDENTIALS_ENCRYPTION_KEY has changed or is missing. Enter it again under Settings → Notifications.`);
}
return "";
}
}
function transformSecrets(section: string, value: Record<string, unknown>, fn: (field: string, v: unknown) => unknown): Record<string, unknown> {
const fields = SECRET_FIELDS[section as keyof AppSettings];
if (!fields) return value;
const out = { ...value };
for (const field of fields) if (field in out) out[field] = fn(field, out[field]);
return out;
}
/**
* The stored form of a section about to be written. A credential this edit sets is sealed. One it doesn't touch keeps
* its stored value as it is when that's already encrypted — even if it can't be read right now (wrong or missing key), so
* an unrelated edit, like a new Gotify address, can't overwrite it with the empty value it reads back as.
*/
async function sealSection(section: string, merged: Record<string, unknown>, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
const fields = SECRET_FIELDS[section as keyof AppSettings];
if (!fields) return merged;
const [row] = await db.select().from(settings).where(sql`${settings.key} = ${section}`).limit(1);
let stored: Record<string, unknown> = {};
try {
stored = row ? JSON.parse(row.value) : {};
} catch {
stored = {};
}
const out = { ...merged };
for (const field of fields) {
const previous = stored[field];
if (field in patch) out[field] = sealValue(patch[field]);
else if (typeof previous === "string" && previous.startsWith(ENCRYPTED_PREFIX)) out[field] = previous;
else out[field] = sealValue(merged[field]);
}
return out;
}
/**
* Encrypts any credential still stored as plain text. Run once at startup, so an existing install is converted by
* upgrading and restarting — no one has to re-save anything. Safe to run every time; it only touches what isn't encrypted.
*/
export async function encryptStoredSettingsSecrets(): Promise<number> {
if (!env.credentialsEncryptionEnabled) return 0;
let converted = 0;
const rows = await db.select().from(settings);
for (const row of rows) {
const fields = SECRET_FIELDS[row.key as keyof AppSettings];
if (!fields) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.value);
} catch {
continue;
}
const needs = fields.some((f) => typeof parsed[f] === "string" && parsed[f] !== "" && !(parsed[f] as string).startsWith(ENCRYPTED_PREFIX));
if (!needs) continue;
const sealed = transformSecrets(row.key, parsed, (_f, v) => (typeof v === "string" && v !== "" && !v.startsWith(ENCRYPTED_PREFIX) ? sealValue(v) : v));
await db.update(settings).set({ value: JSON.stringify(sealed) }).where(sql`${settings.key} = ${row.key}`);
converted++;
}
return converted;
}
export async function getSettings(): Promise<AppSettings> {
const rows = await db.select().from(settings);
const byKey = new Map(rows.map((r) => [r.key, r.value]));
@@ -164,7 +270,10 @@ export async function getSettings(): Promise<AppSettings> {
parsed = {};
}
}
(result[key] as Record<string, unknown>) = { ...(DEFAULTS[key] as object), ...parsed };
(result[key] as Record<string, unknown>) = {
...(DEFAULTS[key] as object),
...transformSecrets(key, parsed, (field, v) => openValue(key, field, v)),
};
}
return result;
}
@@ -176,8 +285,9 @@ export async function updateSettings(partial: AppSettingsPatch): Promise<AppSett
for (const key of Object.keys(partial) as (keyof AppSettings)[]) {
if (!KEYS.includes(key)) continue;
const merged = { ...(current[key] as object), ...(partial[key] as object) };
const value = JSON.stringify(merged);
const patch = partial[key] as Record<string, unknown>;
const merged = { ...(current[key] as object), ...patch } as Record<string, unknown>;
const value = JSON.stringify(await sealSection(key, merged, patch));
await db
.insert(settings)
.values({ key, value })
@@ -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
View File
@@ -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>
);
}
+56
View File
@@ -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: {
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { settle, subscribe, type DialogRequest } from "../utils/dialogs";
const FOCUSABLE = "button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [href]";
/** Draws the confirm/prompt dialogs asked for through utils/dialogs. Mount once, near the root. */
export default function DialogHost() {
const [current, setCurrent] = useState<DialogRequest | null>(null);
useEffect(() => subscribe(setCurrent), []);
if (!current) return null;
// Keyed, so back-to-back dialogs each start fresh (an empty text box, the right button focused).
return createPortal(<DialogView key={current.id} request={current} />, document.body);
}
function DialogView({ request }: { request: DialogRequest }) {
const titleId = useId();
const inputId = useId();
const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const isPrompt = request.kind === "prompt";
const danger = request.kind === "confirm" && !!request.danger;
const [value, setValue] = useState(request.kind === "prompt" ? (request.defaultValue ?? "") : "");
const cancel = () => settle(request, false);
const blocked = request.kind === "prompt" && !!request.required && !value.trim();
// On open: stop the page behind from scrolling (without the scrollbar vanishing and shifting the layout), put the
// keyboard focus where it's useful, and on close hand it back to whatever had it — the button that was clicked.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const { overflow, paddingRight } = document.body.style;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden";
if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`;
if (isPrompt) {
inputRef.current?.focus();
inputRef.current?.select();
} else if (danger) {
cancelRef.current?.focus(); // the safe answer, so a stray Enter doesn't delete anything
} else {
confirmRef.current?.focus();
}
return () => {
document.body.style.overflow = overflow;
document.body.style.paddingRight = paddingRight;
previouslyFocused?.focus?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
cancel();
return;
}
if (e.key !== "Tab") return;
// Keep Tab inside the dialog.
const items = Array.from(modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
function onSubmit(e: FormEvent) {
e.preventDefault();
if (blocked) return;
settle(request, true, value);
}
const title = request.title ?? (isPrompt ? "Enter a value" : "Please confirm");
return (
<>
<div
ref={modalRef}
className="modal modal-blur fade show"
style={{ display: "block" }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={onKeyDown}
// mousedown rather than click, so selecting text in the box and letting go outside it doesn't close the dialog
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel();
}}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<form className="modal-content" onSubmit={onSubmit}>
{danger && <div className="modal-status bg-danger" />}
<div className="modal-header">
<h5 className="modal-title" id={titleId}>
{title}
</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={cancel} />
</div>
<div className="modal-body">
<div style={{ whiteSpace: "pre-line" }}>{request.message}</div>
{request.kind === "prompt" && (
<div className="mt-3">
{request.label && (
<label className="form-label" htmlFor={inputId}>
{request.label}
</label>
)}
<input
id={inputId}
ref={inputRef}
className="form-control"
value={value}
placeholder={request.placeholder}
onChange={(e) => setValue(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn me-auto" ref={cancelRef} onClick={cancel}>
{request.cancelLabel ?? "Cancel"}
</button>
<button type="submit" className={`btn ${danger ? "btn-danger" : "btn-primary"}`} ref={confirmRef} disabled={blocked}>
{request.confirmLabel ?? (isPrompt ? "OK" : "Confirm")}
</button>
</div>
</form>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
);
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { downloadCsv } from "../utils/csv";
import { readableError } from "../utils/errors";
import 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));
+2
View File
@@ -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} /> },
+2 -1
View File
@@ -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);
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api, type AlertCategory, type AlertItem, type AlertSeverity, type AlertsReport, type CurrentUser } from "../api/client";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv";
import { formatDateTime } from "../utils/date";
import { formatAgo } from "../utils/duration";
import { readableError } from "../utils/errors";
const SEVERITY: Record<AlertSeverity, { label: string; badge: string; rank: number }> = {
critical: { label: "Critical", badge: "bg-red-lt text-red", rank: 0 },
warning: { label: "Warning", badge: "bg-yellow-lt text-yellow", rank: 1 },
info: { label: "Info", badge: "bg-blue-lt text-blue", rank: 2 },
};
const CATEGORY_LABELS: Record<AlertCategory, string> = {
offline: "Server down",
disk: "Disk & storage",
backup: "Backups",
updates: "Updates",
expiry: "Expiry",
automation: "Automation",
integration: "Integration failing",
monitoring: "Monitoring",
tickets: "Tickets",
};
type Row = AlertItem & { rank: number };
export default function Alerts(_props: { user: CurrentUser }) {
const [report, setReport] = useState<AlertsReport | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [severity, setSeverity] = useState<"all" | AlertSeverity>("all");
const [category, setCategory] = useState<"all" | AlertCategory>("all");
const [search, setSearch] = useState("");
const [showSilenced, setShowSilenced] = useState(true);
function load(refresh = false) {
setLoading(true);
return api.alerts
.list(refresh)
.then((res) => {
setReport(res);
setError(null);
})
.catch((err) => setError(readableError(err)))
.finally(() => setLoading(false));
}
useEffect(() => {
void load();
}, []);
const rows: Row[] = useMemo(() => {
if (!report) return [];
const q = search.trim().toLowerCase();
return report.alerts
.filter((a) => (severity === "all" || a.severity === severity) && (category === "all" || a.category === category) && (showSilenced || !a.silenced))
.filter((a) => !q || [a.message, a.source, CATEGORY_LABELS[a.category]].some((v) => v.toLowerCase().includes(q)))
.map((a) => ({ ...a, rank: SEVERITY[a.severity].rank }));
}, [report, severity, category, search, showSilenced]);
// No initial sort: the server already puts active problems first, worst first.
const { sorted, sortKey, sortDir, requestSort } = useSortable(rows);
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
const presentCategories = useMemo(() => [...new Set((report?.alerts ?? []).map((a) => a.category))], [report]);
function exportCsv() {
downloadCsv(
"alerts.csv",
["Severity", "Category", "Source", "Alert", "Silenced"],
(sorted ?? []).map((a) => [SEVERITY[a.severity].label, CATEGORY_LABELS[a.category], a.source, a.message, a.silenced ? "yes" : "no"]),
);
}
const counts = report?.counts;
const nothingFound = report !== null && report.alerts.length === 0;
const cards: { label: string; value: number | undefined; color: string }[] = [
{ label: "Critical", value: counts?.critical, color: "text-red" },
{ label: "Warnings", value: counts?.warning, color: "text-yellow" },
{ label: "Info", value: counts?.info, color: "text-blue" },
{ label: "Silenced (maintenance)", value: counts?.silenced, color: "text-secondary" },
];
return (
<>
<div className="d-flex flex-wrap align-items-center gap-2 mb-1">
<h2 className="page-title mb-0">Alerts</h2>
<div className="ms-auto btn-list">
<button className="btn btn-outline-secondary" onClick={exportCsv} disabled={!sorted?.length}>
Export CSV
</button>
<button className="btn btn-outline-secondary" onClick={() => void load(true)} disabled={loading}>
{loading ? "Checking…" : "Check now"}
</button>
</div>
</div>
<div className="text-secondary mb-3">
What's wrong right now across your servers and integrations — full disks, servers that stopped reporting, failed backups, updates waiting,
things about to expire. It runs the same checks that send notifications, whether or not those notifications are switched on.
{report && (
<>
{" "}
<span title={formatDateTime(new Date(report.generatedAt))}>
Checked {formatAgo(report.generatedAt)}
{report.cached ? " (a recent result, reused)" : ""}.
</span>
</>
)}
</div>
{error && <div className="alert alert-danger">{error}</div>}
{!report && !error && (
<div className="card">
<div className="card-body text-secondary">Checking everything — this can take a few seconds…</div>
</div>
)}
{report && (
<>
<div className="row g-3 mb-3">
{cards.map((c) => (
<div className="col-6 col-md-3" key={c.label}>
<div className="card card-sm">
<div className="card-body">
<div className="text-secondary">{c.label}</div>
<div className={`h2 mb-0 ${c.value ? c.color : ""}`}>{c.value ?? "—"}</div>
</div>
</div>
</div>
))}
</div>
{report.couldntCheck.length > 0 && (
<div className="alert alert-warning">
<div>
<div className="fw-bold mb-1">Couldn't check everything — what's missing below isn't necessarily fine</div>
<ul className="mb-0">
{report.couldntCheck.map((c) => (
<li key={c.name}>
<strong>{c.name}</strong> — {c.error}
{c.affects.length > 0 && <span className="text-secondary"> (affects: {c.affects.join(", ")})</span>}
</li>
))}
</ul>
</div>
</div>
)}
{report.notes.map((n) => (
<div className="alert alert-info" key={n}>
{n}
</div>
))}
{nothingFound ? (
<div className="card">
<div className="card-body text-center py-5">
{report.couldntCheck.length === 0 ? (
<>
<div className="h3 text-green mb-1">All clear</div>
<div className="text-secondary">Nothing needs attention right now.</div>
</>
) : (
<div className="text-secondary">No problems found in what could be checked — see the warning above for what couldn't.</div>
)}
</div>
</div>
) : (
<div className="card">
<div className="card-header">
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
<input className="form-control" style={{ maxWidth: 260 }} placeholder="Search alerts…" value={search} onChange={(e) => setSearch(e.target.value)} />
<select className="form-select w-auto" value={severity} onChange={(e) => setSeverity(e.target.value as "all" | AlertSeverity)}>
<option value="all">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select className="form-select w-auto" value={category} onChange={(e) => setCategory(e.target.value as "all" | AlertCategory)}>
<option value="all">All kinds</option>
{presentCategories.map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c]}
</option>
))}
</select>
{(counts?.silenced ?? 0) > 0 && (
<label className="form-check mb-0">
<input type="checkbox" className="form-check-input" checked={showSilenced} onChange={(e) => setShowSilenced(e.target.checked)} />
<span className="form-check-label">Show silenced</span>
</label>
)}
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<Row> label="Severity" sortKeyName="rank" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<Row> label="Kind" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<Row> label="Source" sortKeyName="source" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<Row> label="Alert" sortKeyName="message" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
</tr>
</thead>
<tbody>
{(pageItems ?? []).map((a) => (
<tr key={a.id} className={a.silenced ? "text-secondary" : undefined} style={a.silenced ? { opacity: 0.65 } : undefined}>
<td>
<span className={`badge ${SEVERITY[a.severity].badge}`}>{SEVERITY[a.severity].label}</span>
</td>
<td>{CATEGORY_LABELS[a.category]}</td>
<td>{a.link ? <Link to={a.link}>{a.source}</Link> : a.source}</td>
<td>
{a.message}
{a.silenced && (
<span className="badge bg-secondary-lt text-secondary ms-2" title="Under a maintenance window — notifications for this are held back">
silenced
</span>
)}
</td>
</tr>
))}
{(sorted ?? []).length === 0 && (
<tr>
<td colSpan={4} className="text-secondary text-center">
Nothing matches these filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
)}
</>
)}
</>
);
}
+15 -7
View File
@@ -5,6 +5,7 @@ import { downloadCsv } from "../utils/csv";
import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { 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()]);
}
+2 -1
View File
@@ -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();
+3 -2
View File
@@ -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);
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
View File
@@ -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>
+2 -1
View File
@@ -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
View File
@@ -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 {
+2 -1
View File
@@ -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);
+10 -2
View File
@@ -195,8 +195,11 @@ export default function Privacy() {
<td>Until deleted.</td>
</tr>
<tr>
<td>Integration and DNS credentials</td>
<td>API tokens and passwords, encrypted (AES-256-GCM) with a key held in the server's environment, not in the database.</td>
<td>Integration, DNS and notification credentials</td>
<td>
API tokens and passwords — including the Gotify/ntfy tokens, SMTP password and webhook secret — encrypted (AES-256-GCM) with a key held in
the server's environment, not in the database. (If that key isn't set, notification credentials are kept unencrypted and the server says so at startup.)
</td>
<td>Until deleted. Settings → Backup exports include them, encrypted with a passphrase you choose.</td>
</tr>
<tr>
@@ -251,6 +254,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
+11 -1
View File
@@ -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);
+3 -2
View File
@@ -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 {
+2 -1
View File
@@ -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 {
+12 -3
View File
@@ -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);
+2 -1
View File
@@ -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();
+2 -1
View File
@@ -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 {
+1
View File
@@ -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" },
+3 -2
View File
@@ -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 {
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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);
+408
View File
@@ -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>
</>
);
}
+12 -3
View File
@@ -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"}` : ""}.`,
+69
View File
@@ -0,0 +1,69 @@
// In-app replacements for window.confirm() and window.prompt(). Call sites just await them, the same way they used to
// call the browser's — a single <DialogHost /> (mounted once, in App) draws whichever is asked for. Several asked for
// at once are shown one after another.
export interface ConfirmOptions {
title?: string;
message: string;
/** What the main button says — name the action ("Delete", "Stop") rather than leaving it as "OK". */
confirmLabel?: string;
cancelLabel?: string;
/** Destructive or hard to undo: red button, and the safe choice gets the keyboard focus. */
danger?: boolean;
}
export interface PromptOptions {
title?: string;
message: string;
/** Label for the text box itself, when the message above it isn't enough. */
label?: string;
defaultValue?: string;
placeholder?: string;
confirmLabel?: string;
cancelLabel?: string;
/** Disable the main button until something has been typed. */
required?: boolean;
}
export type DialogRequest =
| ({ kind: "confirm"; id: number; resolve: (accepted: boolean) => void } & ConfirmOptions)
| ({ kind: "prompt"; id: number; resolve: (value: string | null) => void } & PromptOptions);
let nextId = 1;
let queue: DialogRequest[] = [];
let listener: ((current: DialogRequest | null) => void) | null = null;
function emit() {
listener?.(queue[0] ?? null);
}
/** For the host: called with whatever should be on screen now (and straight away with what already is). */
export function subscribe(next: (current: DialogRequest | null) => void): () => void {
listener = next;
emit();
return () => {
if (listener === next) listener = null;
};
}
/** Resolves the dialog on screen. A cancelled confirm is false and a cancelled prompt is null — as with the browser's own. */
export function settle(request: DialogRequest, accepted: boolean, value = ""): void {
queue = queue.filter((r) => r !== request);
if (request.kind === "confirm") request.resolve(accepted);
else request.resolve(accepted ? value : null);
emit();
}
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
queue.push({ ...options, kind: "confirm", id: nextId++, resolve });
emit();
});
}
export function promptDialog(options: PromptOptions): Promise<string | null> {
return new Promise((resolve) => {
queue.push({ ...options, kind: "prompt", id: nextId++, resolve });
emit();
});
}
+8 -30
View File
@@ -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)}`;
}
+33
View File
@@ -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 };
}