Compare commits
8
Commits
21054aaa8c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae7089448 | ||
|
|
f246410f24 | ||
|
|
86dfa9ae2e | ||
|
|
3035d7fc08 | ||
|
|
447f33fff6 | ||
|
|
ad1fb5338f | ||
|
|
88d9c8e097 | ||
|
|
22cfdbede0 |
No files matched your search
+4
-3
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,8 @@ All modules from the original plan are built:
|
||||
|
||||
- Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered)
|
||||
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log
|
||||
(changes made in the app, sign-ins and sign-outs with the IP they came from, new accounts, and the log's own
|
||||
automatic trimming — attributed to "system"; settings changes show what changed, but never credentials)
|
||||
- **Dashboard** — an overview of every system this app tracks, all sharing
|
||||
one widget-card design (label + status badge, a small stat row, then its
|
||||
own breakdown): DNS (domain/record counts per provider, cached records by
|
||||
@@ -204,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
|
||||
@@ -253,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
|
||||
@@ -298,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+
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import * as client from "openid-client";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getOidcConfig } from "./oidc.js";
|
||||
import { upsertUserFromLogin } from "./users.js";
|
||||
import { env } from "../env.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
@@ -63,7 +67,28 @@ authRouter.get("/callback", async (req, res, next) => {
|
||||
// fall back to ID token claims already captured above
|
||||
}
|
||||
|
||||
await upsertUserFromLogin({ sub: claims.sub, email, name });
|
||||
const { user, created } = await upsertUserFromLogin({ sub: claims.sub, email, name });
|
||||
|
||||
// Access to the app is itself something worth being able to look back on: who got an account (and the very first
|
||||
// one becomes admin), and every sign-in with where it came from.
|
||||
if (created) {
|
||||
await recordAudit({
|
||||
actor: user,
|
||||
category: "user",
|
||||
action: "create",
|
||||
targetType: "user",
|
||||
targetId: user.id,
|
||||
detail: { name: user.name ?? user.email ?? user.oidcSub, role: user.role, firstUser: user.role === "admin" },
|
||||
});
|
||||
}
|
||||
await recordAudit({
|
||||
actor: user,
|
||||
category: "session",
|
||||
action: "login",
|
||||
targetType: "user",
|
||||
targetId: user.id,
|
||||
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
|
||||
});
|
||||
|
||||
delete req.session.pendingAuth;
|
||||
req.session.user = {
|
||||
@@ -85,6 +110,26 @@ authRouter.get("/callback", async (req, res, next) => {
|
||||
|
||||
authRouter.get("/logout", async (req, res, next) => {
|
||||
const idToken = req.session.user?.idToken;
|
||||
|
||||
// Recorded first, and never allowed to get in the way of signing out.
|
||||
if (req.session.user) {
|
||||
try {
|
||||
const [user] = await db.select().from(users).where(eq(users.oidcSub, req.session.user.sub)).limit(1);
|
||||
if (user) {
|
||||
await recordAudit({
|
||||
actor: user,
|
||||
category: "session",
|
||||
action: "logout",
|
||||
targetType: "user",
|
||||
targetId: user.id,
|
||||
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[auth] couldn't record sign-out:", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
let endSessionUrl: URL | undefined;
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function upsertUserFromLogin(params: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
}): Promise<{ user: typeof users.$inferSelect; created: boolean }> {
|
||||
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function upsertUserFromLogin(params: {
|
||||
})
|
||||
.where(eq(users.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
return { user: updated, created: false };
|
||||
}
|
||||
|
||||
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
|
||||
@@ -41,5 +41,5 @@ export async function upsertUserFromLogin(params: {
|
||||
lastLoginAt: now,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
return { user: created, created: true };
|
||||
}
|
||||
@@ -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
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
}));
|
||||
@@ -11,6 +11,7 @@ auditLogRouter.use(requireAuth, requireRole("operator"));
|
||||
|
||||
auditLogRouter.get("/", asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit ?? 200), 500);
|
||||
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit);
|
||||
// createdAt only has one-second resolution, so entries made within the same second are ordered by id.
|
||||
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt), desc(auditLog.id)).limit(limit);
|
||||
res.json({ entries: rows });
|
||||
}));
|
||||
@@ -60,6 +60,14 @@ domainsRouter.post("/:id/check", requireRole("operator"), asyncHandler(async (re
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
const row = await checkDomain(id);
|
||||
if (!row) return res.status(404).json({ error: "not_found" });
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "domain",
|
||||
action: "check",
|
||||
targetType: "domain",
|
||||
targetId: id,
|
||||
detail: { name: row.name, error: row.lastCheckError },
|
||||
});
|
||||
const { healthChecks } = await getSettings();
|
||||
res.json({ domain: present(row, healthChecks.domainWarnDays) });
|
||||
}));
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}));
|
||||
@@ -139,10 +139,18 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
let credentialId = existing.credentialId;
|
||||
let configJson = existing.config;
|
||||
let baseUrl = existing.baseUrl;
|
||||
// For the audit entry: what this edit actually changed. Names only for settings (operators can read the audit
|
||||
// log but not an integration's config), and never anything about the credentials beyond "they were replaced".
|
||||
let credentialsReplaced = false;
|
||||
let fieldsChanged: string[] = [];
|
||||
|
||||
if (parsed.data.config) {
|
||||
const loaded = await loadIntegrationConfig(id);
|
||||
const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config);
|
||||
credentialsReplaced = Object.keys(secretFields).length > 0;
|
||||
fieldsChanged = Object.keys(nonSecretFields).filter(
|
||||
(key) => String(loaded?.config[key] ?? "") !== String(nonSecretFields[key] ?? ""),
|
||||
);
|
||||
const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields };
|
||||
for (const field of INTEGRATION_FIELDS[existing.type] ?? []) {
|
||||
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
|
||||
@@ -188,7 +196,13 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
action: "update",
|
||||
targetType: "integration",
|
||||
targetId: id,
|
||||
detail: { name: updated.name },
|
||||
detail: {
|
||||
name: updated.name,
|
||||
...(existing.name !== updated.name ? { renamedFrom: existing.name } : {}),
|
||||
...(existing.enabled !== updated.enabled ? { enabled: updated.enabled } : {}),
|
||||
credentialsReplaced,
|
||||
fieldsChanged,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -258,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));
|
||||
@@ -269,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();
|
||||
|
||||
@@ -341,7 +341,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||
const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||
if (!server) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning();
|
||||
@@ -352,7 +352,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re
|
||||
action: "add_link",
|
||||
targetType: "server",
|
||||
targetId: serverId,
|
||||
detail: { label: created.label, url: created.url },
|
||||
detail: { name: server.name, label: created.label, url: created.url },
|
||||
});
|
||||
|
||||
res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } });
|
||||
@@ -375,13 +375,14 @@ serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler(
|
||||
.returning();
|
||||
if (!updated) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "server",
|
||||
action: "update_link",
|
||||
targetType: "server",
|
||||
targetId: serverId,
|
||||
detail: { label: updated.label, url: updated.url },
|
||||
detail: { name: owner?.name, label: updated.label, url: updated.url },
|
||||
});
|
||||
|
||||
res.json({ link: { id: updated.id, label: updated.label, url: updated.url } });
|
||||
@@ -398,13 +399,14 @@ serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler
|
||||
.returning();
|
||||
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "server",
|
||||
action: "remove_link",
|
||||
targetType: "server",
|
||||
targetId: serverId,
|
||||
detail: { label: deleted[0].label },
|
||||
detail: { name: owner?.name, label: deleted[0].label, url: deleted[0].url },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { describeSettingsChanges } from "../services/settingsDiff.js";
|
||||
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
||||
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
|
||||
import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js";
|
||||
@@ -106,6 +107,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const before = await getSettings();
|
||||
const updated = await updateSettings(parsed.data);
|
||||
|
||||
if (parsed.data.notifications) {
|
||||
@@ -127,7 +129,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
category: "settings",
|
||||
action: "update",
|
||||
targetType: "settings",
|
||||
detail: { sections: Object.keys(parsed.data) },
|
||||
detail: { sections: Object.keys(parsed.data), changes: describeSettingsChanges(before, parsed.data) },
|
||||
});
|
||||
|
||||
res.json({ settings: updated });
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Shared by the alerts page's collector (services/alerts.ts) and the scheduled checks it reuses, which can't import
|
||||
// that file themselves without going round in a circle.
|
||||
|
||||
export type AlertSeverity = "critical" | "warning" | "info";
|
||||
|
||||
/** What kind of problem — drives the filter on the Alerts page. */
|
||||
export type AlertCategory = "offline" | "disk" | "backup" | "updates" | "expiry" | "automation" | "integration" | "monitoring" | "tickets";
|
||||
|
||||
export interface Alert {
|
||||
/** Stable, so the page can key rows on it. */
|
||||
id: string;
|
||||
severity: AlertSeverity;
|
||||
category: AlertCategory;
|
||||
/** Where it comes from, as a short name: "Server", "Proxmox", "Secrets", ... */
|
||||
source: string;
|
||||
message: string;
|
||||
/** In-app page that shows more, if there is one. */
|
||||
link: string | null;
|
||||
/** Under an active maintenance window: still a real problem, but notifications for it are held back. */
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
/** One integration that couldn't be read while collecting — so the page never mistakes "couldn't check" for "all clear". */
|
||||
export interface SourceFailure {
|
||||
integrationId: number;
|
||||
integrationName: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Everything that's wrong right now, in one list — the Alerts page. Nothing here decides what counts as a problem: it asks
|
||||
* the same checks that send the notifications (health, backups, updates, expiry, automation, ...) and turns what they find
|
||||
* into a flat list, so the page and the notifications can't disagree. Unlike the notifications it ignores the per-event
|
||||
* on/off toggles (the page is for looking at, not for being interrupted by) and keeps problems that are under a
|
||||
* maintenance window, flagged as silenced rather than dropped.
|
||||
*
|
||||
* It reads live (a handful of API calls per integration), so a result is kept for a short while rather than re-run for
|
||||
* every viewer, and every source has a time limit so one hung integration can't hang the page. A source that can't be
|
||||
* read is reported as such — silence from it must never look like "all clear".
|
||||
*/
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { integrations, secrets } from "../db/schema.js";
|
||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js";
|
||||
import { createOsTicketAdapter } from "../integrations/osticket/adapter.js";
|
||||
import { activeSubjects, isSourceInMaintenance, subjectOfConditionKey } from "./maintenance.js";
|
||||
import { collectSnapshot, evaluateHealth, startupGraceRemainingMs } from "./healthMonitor.js";
|
||||
import { collectAutomation, evaluateAutomation } from "./automationMonitor.js";
|
||||
import { collectDockerUpdates } from "./dockerUpdateScheduler.js";
|
||||
import { collectProxmoxBackupProblems } from "./proxmoxBackupScheduler.js";
|
||||
import { collectPbsProblems } from "./pbsVerificationScheduler.js";
|
||||
import { collectTailscaleKeyExpiries } from "./tailscaleKeyExpiryScheduler.js";
|
||||
import { collectDomainAlerts } from "./domainMonitor.js";
|
||||
import { computeSecretStatus } from "./secretStatus.js";
|
||||
import { getFailingSources } from "./integrationHealthMonitor.js";
|
||||
import { getSettings } from "./settingsStore.js";
|
||||
import { sourceLabel } from "./notify.js";
|
||||
import type { Alert, AlertCategory, AlertSeverity, SourceFailure } from "./alertTypes.js";
|
||||
|
||||
export interface AlertsReport {
|
||||
alerts: Alert[];
|
||||
/** Active problems by severity — those under a maintenance window are counted apart, not in these. */
|
||||
counts: { critical: number; warning: number; info: number; silenced: number };
|
||||
/** Things that couldn't be checked, and which checks that leaves blind. */
|
||||
couldntCheck: { name: string; error: string; affects: string[] }[];
|
||||
/** Context worth knowing about how complete the picture is. */
|
||||
notes: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/** Where each kind of source lives in the app, and what to call it. */
|
||||
const SOURCES: Record<string, { label: string; link: string }> = {
|
||||
server: { label: "Server", link: "/servers" },
|
||||
proxmox: { label: "Proxmox", link: "/proxmox" },
|
||||
synology: { label: "Synology", link: "/synology" },
|
||||
semaphore: { label: "Semaphore", link: "/semaphore" },
|
||||
gitea: { label: "Gitea", link: "/gitea" },
|
||||
dockhand: { label: "Docker", link: "/docker" },
|
||||
tailscale: { label: "Tailscale", link: "/tailscale" },
|
||||
pbs: { label: "Proxmox Backup", link: "/pbs" },
|
||||
uptimekuma: { label: "Uptime Kuma", link: "/uptime-kuma" },
|
||||
osticket: { label: "osTicket", link: "/osticket" },
|
||||
secrets: { label: "Secrets", link: "/secrets" },
|
||||
domains: { label: "Domains", link: "/domains" },
|
||||
};
|
||||
|
||||
const SOURCE_TIMEOUT_MS = 20_000;
|
||||
/** How long a result is reused. */
|
||||
const CACHE_MS = 60_000;
|
||||
/** Pressing Refresh over and over shouldn't hammer every integration — a result younger than this is reused even then. */
|
||||
const MIN_REFRESH_MS = 10_000;
|
||||
|
||||
const SEVERITY_ORDER: Record<AlertSeverity, number> = { critical: 0, warning: 1, info: 2 };
|
||||
|
||||
/** Which kind of source, and which one, a health/automation condition key belongs to. */
|
||||
function originOfConditionKey(key: string): { type: string; id: number | null; category: AlertCategory } {
|
||||
let m = /^(?:offline|disk):server:(\d+)/.exec(key);
|
||||
if (m) return { type: "server", id: Number(m[1]), category: key.startsWith("offline") ? "offline" : "disk" };
|
||||
m = /^disk:proxmox:(\d+):/.exec(key);
|
||||
if (m) return { type: "proxmox", id: Number(m[1]), category: "disk" };
|
||||
m = /^(?:synology-volume|synology-disk|disk:synology):(\d+):/.exec(key);
|
||||
if (m) return { type: "synology", id: Number(m[1]), category: "disk" };
|
||||
m = /^automation:(semaphore|gitea):(\d+):/.exec(key);
|
||||
if (m) return { type: m[1], id: Number(m[2]), category: "automation" };
|
||||
return { type: "server", id: null, category: "disk" };
|
||||
}
|
||||
|
||||
function withTimeout<T>(work: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const limit = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
|
||||
});
|
||||
return Promise.race([work, limit]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
export async function collectAlerts(): Promise<AlertsReport> {
|
||||
const alerts: Alert[] = [];
|
||||
const notes: string[] = [];
|
||||
const blind = new Map<string, { error: string; affects: Set<string> }>();
|
||||
const subjects = await activeSubjects();
|
||||
const intRows = await db.select({ id: integrations.id, name: integrations.name, type: integrations.type, enabled: integrations.enabled }).from(integrations);
|
||||
const intById = new Map(intRows.map((r) => [r.id, r]));
|
||||
|
||||
function push(a: { category: AlertCategory; severity: AlertSeverity; type: string; message: string; key: string; link?: string | null; silenced?: boolean }) {
|
||||
const meta = SOURCES[a.type];
|
||||
alerts.push({
|
||||
id: `${a.category}:${a.key}`,
|
||||
severity: a.severity,
|
||||
category: a.category,
|
||||
source: meta?.label ?? sourceLabel(a.type),
|
||||
message: a.message,
|
||||
link: a.link === undefined ? (meta?.link ?? null) : a.link,
|
||||
silenced: !!a.silenced,
|
||||
});
|
||||
}
|
||||
|
||||
function cannotCheck(name: string, error: string, check: string) {
|
||||
const entry = blind.get(name) ?? { error, affects: new Set<string>() };
|
||||
entry.affects.add(check);
|
||||
blind.set(name, entry);
|
||||
}
|
||||
const cannotCheckIntegration = (f: SourceFailure, check: string) => {
|
||||
const row = intById.get(f.integrationId);
|
||||
cannotCheck(`${row ? (SOURCES[row.type]?.label ?? row.type) : "Integration"} “${f.integrationName}”`, f.message, check);
|
||||
};
|
||||
const silencedIntegration = (id: number) => subjects.has(`integration:${id}`);
|
||||
|
||||
// One entry per check. A check that blows up, or hangs, only costs its own section of the page.
|
||||
async function check(name: string, work: () => Promise<void>) {
|
||||
try {
|
||||
await withTimeout(work());
|
||||
} catch (err) {
|
||||
cannotCheck(name, errorText(err), name);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
check("Server and storage health", async () => {
|
||||
const { healthChecks } = await getSettings();
|
||||
const { snapshot, held } = await collectSnapshot();
|
||||
const now = Date.now();
|
||||
const grace = startupGraceRemainingMs(now);
|
||||
if (grace > 0) {
|
||||
notes.push(
|
||||
`Server-offline and server-disk checks are paused for another ${Math.ceil(grace / 60_000)} min after the app restarted, so agents get a chance to report before any server is judged.`,
|
||||
);
|
||||
}
|
||||
for (const c of evaluateHealth(snapshot, healthChecks, now, { skipServers: grace > 0 })) {
|
||||
const origin = originOfConditionKey(c.key);
|
||||
const subject = subjectOfConditionKey(c.key);
|
||||
push({
|
||||
category: origin.category,
|
||||
severity: c.severity ?? "warning",
|
||||
type: origin.type,
|
||||
message: c.message,
|
||||
key: c.key,
|
||||
link: origin.type === "server" && origin.id !== null ? `/servers/${origin.id}` : undefined,
|
||||
silenced: subject !== null && subjects.has(subject),
|
||||
});
|
||||
}
|
||||
// Integrations the check couldn't read this time. (A whole-integration entry covers its nodes, so skip those.)
|
||||
for (const h of held) {
|
||||
const m = /^(proxmox|synology):(\d+)(?::(.+))?$/.exec(h);
|
||||
if (!m || (m[3] && held.has(`${m[1]}:${m[2]}`))) continue;
|
||||
const row = intById.get(Number(m[2]));
|
||||
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”${m[3] ? ` (node ${m[3]})` : ""}`, "couldn't be read — see the Diagnostic Log", "Server and storage health");
|
||||
}
|
||||
}),
|
||||
|
||||
check("Automation runs", async () => {
|
||||
const { items, held } = await collectAutomation();
|
||||
for (const c of evaluateAutomation(items).conditions) {
|
||||
const origin = originOfConditionKey(c.key);
|
||||
const subject = subjectOfConditionKey(c.key);
|
||||
push({ category: "automation", severity: "warning", type: origin.type, message: c.message, key: c.key, silenced: subject !== null && subjects.has(subject) });
|
||||
}
|
||||
for (const h of held) {
|
||||
const m = /^(semaphore|gitea):(\d+)$/.exec(h);
|
||||
if (!m) continue;
|
||||
const row = intById.get(Number(m[2]));
|
||||
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”`, "couldn't be read — see the Diagnostic Log", "Automation runs");
|
||||
}
|
||||
}),
|
||||
|
||||
check("Proxmox backups", async () => {
|
||||
const { failures, uncovered, sourceFailures } = await collectProxmoxBackupProblems({ skipSilenced: false });
|
||||
for (const f of failures) {
|
||||
push({
|
||||
category: "backup",
|
||||
severity: "critical",
|
||||
type: "proxmox",
|
||||
message: `Latest backup on ${f.node}${f.guestId ? ` (guest ${f.guestId})` : ""} didn't succeed [${f.integrationName}]: ${f.status}`,
|
||||
key: `proxmox-backup:${f.integrationId}:${f.node}`,
|
||||
silenced: f.silenced,
|
||||
});
|
||||
}
|
||||
for (const u of uncovered) {
|
||||
push({
|
||||
category: "backup",
|
||||
severity: "warning",
|
||||
type: "proxmox",
|
||||
message: `${u.guestName} (#${u.vmid}) on ${u.node} isn't covered by any backup job [${u.integrationName}]`,
|
||||
key: `proxmox-uncovered:${u.integrationId}:${u.vmid}`,
|
||||
silenced: u.silenced,
|
||||
});
|
||||
}
|
||||
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Proxmox backups"));
|
||||
}),
|
||||
|
||||
check("Backup verification", async () => {
|
||||
const { problems, sourceFailures } = await collectPbsProblems({ skipSilenced: false });
|
||||
for (const p of problems) {
|
||||
push({
|
||||
category: "backup",
|
||||
severity: p.error ? "warning" : "critical",
|
||||
type: "pbs",
|
||||
message: p.error
|
||||
? `Datastore "${p.datastore}" couldn't be read [${p.integrationName}]: ${p.error}`
|
||||
: `Datastore "${p.datastore}" has ${plural(p.failedCount, "snapshot")} that failed verification [${p.integrationName}]`,
|
||||
key: `pbs:${p.integrationId}:${p.datastore}`,
|
||||
silenced: p.silenced,
|
||||
});
|
||||
}
|
||||
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Backup verification"));
|
||||
}),
|
||||
|
||||
check("Image updates", async () => {
|
||||
const { items, failures } = await collectDockerUpdates();
|
||||
for (const u of items) {
|
||||
push({
|
||||
category: "updates",
|
||||
severity: "info",
|
||||
type: "dockhand",
|
||||
message: `${u.containerName} [${u.environmentName}, ${u.integrationName}] has an image update available${u.newerVersion ? ` → ${u.newerVersion}` : ""}`,
|
||||
key: `docker:${u.integrationId}:${u.environmentName}:${u.containerName}`,
|
||||
silenced: silencedIntegration(u.integrationId),
|
||||
});
|
||||
}
|
||||
failures.forEach((f) => cannotCheckIntegration(f, "Image updates"));
|
||||
}),
|
||||
|
||||
check("Tailscale keys", async () => {
|
||||
const { items, failures } = await collectTailscaleKeyExpiries();
|
||||
for (const k of items) {
|
||||
push({
|
||||
category: "expiry",
|
||||
severity: k.daysLeft < 0 ? "critical" : "warning",
|
||||
type: "tailscale",
|
||||
message: k.daysLeft < 0 ? `Key for ${k.deviceLabel} [${k.integrationName}] has expired` : `Key for ${k.deviceLabel} [${k.integrationName}] expires in ${plural(k.daysLeft, "day")}`,
|
||||
key: `tailscale-key:${k.integrationId}:${k.deviceLabel}`,
|
||||
silenced: silencedIntegration(k.integrationId),
|
||||
});
|
||||
}
|
||||
failures.forEach((f) => cannotCheckIntegration(f, "Tailscale keys"));
|
||||
}),
|
||||
|
||||
check("Uptime Kuma", async () => {
|
||||
for (const row of intRows.filter((r) => r.type === "uptimekuma" && r.enabled)) {
|
||||
try {
|
||||
const loaded = await loadIntegrationConfig(row.id);
|
||||
if (!loaded) continue;
|
||||
for (const m of await createUptimeKumaAdapter(loaded.config as any).listMonitors()) {
|
||||
if (m.status !== "down") continue;
|
||||
push({
|
||||
category: "monitoring",
|
||||
severity: "critical",
|
||||
type: "uptimekuma",
|
||||
message: `Monitor "${m.name}" is down${m.target ? ` (${m.target}${m.port ? `:${m.port}` : ""})` : ""} [${row.name}]`,
|
||||
key: `kuma:${row.id}:${m.id}`,
|
||||
silenced: silencedIntegration(row.id),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "Uptime Kuma");
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
check("osTicket", async () => {
|
||||
for (const row of intRows.filter((r) => r.type === "osticket" && r.enabled)) {
|
||||
try {
|
||||
const loaded = await loadIntegrationConfig(row.id);
|
||||
if (!loaded) continue;
|
||||
const overdue = (await createOsTicketAdapter(loaded.config as any).listOpenTickets()).filter((t) => t.isOverdue).length;
|
||||
if (overdue > 0) {
|
||||
push({
|
||||
category: "tickets",
|
||||
severity: "warning",
|
||||
type: "osticket",
|
||||
message: `${plural(overdue, "open ticket")} ${overdue === 1 ? "is" : "are"} overdue [${row.name}]`,
|
||||
key: `osticket:${row.id}`,
|
||||
silenced: silencedIntegration(row.id),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "osTicket");
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
check("Secrets", async () => {
|
||||
for (const s of await db.select().from(secrets)) {
|
||||
const status = computeSecretStatus(s.expiryDate, s.warnDays);
|
||||
if (status.status === "expired") {
|
||||
push({ category: "expiry", severity: "critical", type: "secrets", message: `Secret "${s.name}" expired ${plural(Math.abs(status.daysLeft), "day")} ago (${s.expiryDate})`, key: `secret:${s.id}` });
|
||||
} else if (status.status === "expiring") {
|
||||
push({ category: "expiry", severity: "warning", type: "secrets", message: `Secret "${s.name}" expires in ${plural(status.daysLeft, "day")} (${s.expiryDate})`, key: `secret:${s.id}` });
|
||||
}
|
||||
if (s.checkHost && s.lastCheckError) {
|
||||
push({
|
||||
category: "expiry",
|
||||
severity: "warning",
|
||||
type: "secrets",
|
||||
message: `Couldn't read the live certificate for "${s.name}" (${s.checkHost}:${s.checkPort ?? 443}) — the expiry shown may be stale: ${s.lastCheckError}`,
|
||||
key: `secret-check:${s.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
check("Domains", async () => {
|
||||
const { expiring, staleChecks } = await collectDomainAlerts();
|
||||
for (const d of expiring) {
|
||||
push({
|
||||
category: "expiry",
|
||||
severity: d.status === "expired" ? "critical" : "warning",
|
||||
type: "domains",
|
||||
message: d.status === "expired" ? `Domain ${d.name} expired on ${d.expiresAt}` : `Domain ${d.name} expires in ${plural(d.daysLeft, "day")} (${d.expiresAt})`,
|
||||
key: `domain:${d.name}`,
|
||||
});
|
||||
}
|
||||
for (const s of staleChecks) {
|
||||
push({ category: "expiry", severity: "info", type: "domains", message: `Couldn't refresh the registration for ${s.name} — the expiry shown may be stale: ${s.error}`, key: `domain-stale:${s.name}` });
|
||||
}
|
||||
}),
|
||||
|
||||
check("Integration failures", async () => {
|
||||
const { notifications } = await getSettings();
|
||||
for (const f of getFailingSources()) {
|
||||
push({
|
||||
category: "integration",
|
||||
severity: f.alerted ? "critical" : "warning",
|
||||
type: f.source,
|
||||
message: `${sourceLabel(f.source)} has failed its last ${plural(f.consecutiveFailures, "call")} in a row${f.alerted ? "" : ` (a notification goes out after ${notifications.integrationFailureThreshold})`} — see the Diagnostic Log`,
|
||||
key: `failing:${f.source}`,
|
||||
link: null,
|
||||
silenced: await isSourceInMaintenance(f.source),
|
||||
});
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
alerts.sort(
|
||||
(a, b) =>
|
||||
Number(a.silenced) - Number(b.silenced) ||
|
||||
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
|
||||
a.source.localeCompare(b.source) ||
|
||||
a.message.localeCompare(b.message),
|
||||
);
|
||||
|
||||
const active = alerts.filter((a) => !a.silenced);
|
||||
return {
|
||||
alerts,
|
||||
counts: {
|
||||
critical: active.filter((a) => a.severity === "critical").length,
|
||||
warning: active.filter((a) => a.severity === "warning").length,
|
||||
info: active.filter((a) => a.severity === "info").length,
|
||||
silenced: alerts.length - active.length,
|
||||
},
|
||||
couldntCheck: [...blind.entries()].map(([name, v]) => ({ name, error: v.error, affects: [...v.affects] })),
|
||||
notes,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
let cache: { at: number; report: AlertsReport } | null = null;
|
||||
let inFlight: Promise<AlertsReport> | null = null;
|
||||
|
||||
/** The current alerts, reusing a recent result unless `force` asks for a fresh one (and even then not more than once every few seconds). */
|
||||
export async function getAlerts(force: boolean): Promise<AlertsReport & { cached: boolean }> {
|
||||
const age = cache ? Date.now() - cache.at : Infinity;
|
||||
if (cache && age < (force ? MIN_REFRESH_MS : CACHE_MS)) return { ...cache.report, cached: true };
|
||||
inFlight ??= collectAlerts()
|
||||
.then((report) => {
|
||||
cache = { at: Date.now(), report };
|
||||
return report;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return { ...(await inFlight), cached: false };
|
||||
}
|
||||
@@ -3,9 +3,12 @@ import { auditLog, users } from "../db/schema.js";
|
||||
|
||||
type CurrentUser = typeof users.$inferSelect;
|
||||
|
||||
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. */
|
||||
/** Who an automatic, no-one-clicked-anything entry is attributed to. */
|
||||
export const SYSTEM_ACTOR_LABEL = "system";
|
||||
|
||||
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. Leave `actor` out for something the app did by itself. */
|
||||
export async function recordAudit(params: {
|
||||
actor: CurrentUser;
|
||||
actor?: CurrentUser;
|
||||
category: string;
|
||||
action: string;
|
||||
targetType?: string;
|
||||
@@ -13,8 +16,8 @@ export async function recordAudit(params: {
|
||||
detail?: unknown;
|
||||
}) {
|
||||
await db.insert(auditLog).values({
|
||||
actorUserId: params.actor.id,
|
||||
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub,
|
||||
actorUserId: params.actor?.id,
|
||||
actorLabel: params.actor ? (params.actor.name ?? params.actor.email ?? params.actor.oidcSub) : SYSTEM_ACTOR_LABEL,
|
||||
category: params.category,
|
||||
action: params.action,
|
||||
targetType: params.targetType,
|
||||
|
||||
@@ -62,7 +62,7 @@ export function evaluateAutomation(items: AutomationItem[]): { conditions: Healt
|
||||
|
||||
// ─── Collection (I/O) ───────────────────────────────────────────────────────
|
||||
|
||||
async function collect(): Promise<{ items: AutomationItem[]; held: Set<string>; readable: number }> {
|
||||
export async function collectAutomation(): Promise<{ items: AutomationItem[]; held: Set<string>; readable: number }> {
|
||||
const items: AutomationItem[] = [];
|
||||
const held = new Set<string>();
|
||||
let readable = 0;
|
||||
@@ -138,7 +138,7 @@ async function loadState(): Promise<ActiveState | null> {
|
||||
* the first time this feature runs) that would otherwise be a wall of alerts about failures that are months old.
|
||||
*/
|
||||
export async function runAutomationCheck(now: number = Date.now()): Promise<{ added: number; resolved: number; baseline: boolean }> {
|
||||
const { items, held: readHeld, readable } = await collect();
|
||||
const { items, held: readHeld, readable } = await collectAutomation();
|
||||
const stored = await loadState();
|
||||
|
||||
// Nothing could be read at all (or nothing is configured): don't spend the "first pass" on an empty picture.
|
||||
|
||||
@@ -5,17 +5,28 @@ import { integrations } from "../db/schema.js";
|
||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||
import { notifyDockerUpdates } from "./notify.js";
|
||||
import type { SourceFailure } from "./alertTypes.js";
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
|
||||
const LAST_RUN_FLAG = "dockerUpdateCheckLastRunDate";
|
||||
|
||||
async function checkDockerUpdates(): Promise<void> {
|
||||
export interface DockerUpdate {
|
||||
integrationId: number;
|
||||
integrationName: string;
|
||||
containerName: string;
|
||||
environmentName: string;
|
||||
newerVersion: string | null;
|
||||
}
|
||||
|
||||
/** Every container with an image update waiting, across the enabled Dockhand integrations. Shared with the Alerts page. */
|
||||
export async function collectDockerUpdates(): Promise<{ items: DockerUpdate[]; failures: SourceFailure[] }> {
|
||||
const rows = await db
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, "dockhand"), eq(integrations.enabled, true)));
|
||||
|
||||
const updatesAvailable: { integrationName: string; containerName: string; environmentName: string; newerVersion: string | null }[] = [];
|
||||
const updatesAvailable: DockerUpdate[] = [];
|
||||
const failures: SourceFailure[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
@@ -28,6 +39,7 @@ async function checkDockerUpdates(): Promise<void> {
|
||||
for (const c of containers) {
|
||||
if (!c.updateAvailable) continue;
|
||||
updatesAvailable.push({
|
||||
integrationId: row.id,
|
||||
integrationName: row.name,
|
||||
containerName: c.name,
|
||||
environmentName: c.environmentName,
|
||||
@@ -36,10 +48,15 @@ async function checkDockerUpdates(): Promise<void> {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[dockerUpdate] check failed for integration ${row.id}:`, err);
|
||||
failures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
await notifyDockerUpdates(updatesAvailable);
|
||||
return { items: updatesAvailable, failures };
|
||||
}
|
||||
|
||||
async function checkDockerUpdates(): Promise<void> {
|
||||
await notifyDockerUpdates((await collectDockerUpdates()).items);
|
||||
}
|
||||
|
||||
async function checkDockerUpdatesOnce(): Promise<void> {
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface HealthCondition {
|
||||
/** Where the data came from, hierarchically ("server", "proxmox:3", "proxmox:3:pve1"). Used to hold a condition when its source can't be reached. */
|
||||
source: string;
|
||||
message: string;
|
||||
/** How bad, for the Alerts page. Notifications don't use it. Left out means "warning". */
|
||||
severity?: "critical" | "warning";
|
||||
}
|
||||
|
||||
export interface ServerSnapshot {
|
||||
@@ -91,6 +93,8 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
const out: HealthCondition[] = [];
|
||||
const limit = thresholds.diskUsagePercent;
|
||||
const pct = (n: number) => `${Math.round(n)}%`;
|
||||
/** Over the configured threshold is a warning; practically out of room is critical. */
|
||||
const fullness = (p: number): "critical" | "warning" => (p >= 95 ? "critical" : "warning");
|
||||
|
||||
if (!opts.skipServers) {
|
||||
for (const s of snapshot.servers) {
|
||||
@@ -103,6 +107,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `offline:server:${s.id}`,
|
||||
source: "server",
|
||||
message: `${s.name} hasn't reported for ${formatDuration(age)}`,
|
||||
severity: "critical",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -115,6 +120,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `disk:server:${s.id}:${d.mount}`,
|
||||
source: "server",
|
||||
message: `${s.name}: ${d.mount} is ${pct(p)} full (${formatBytes(d.usedBytes)} of ${formatBytes(d.sizeBytes)})`,
|
||||
severity: fullness(p),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -132,6 +138,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `disk:proxmox:${px.integrationId}:${node.node}:rootfs`,
|
||||
source: `proxmox:${px.integrationId}:${node.node}`,
|
||||
message: `${px.integrationName} / ${node.node}: root filesystem is ${pct(rootP)} full`,
|
||||
severity: fullness(rootP),
|
||||
});
|
||||
}
|
||||
for (const st of node.storages) {
|
||||
@@ -146,12 +153,14 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `disk:proxmox:${px.integrationId}:storage:${st.id}`,
|
||||
source: `proxmox:${px.integrationId}:shared`,
|
||||
message: `${px.integrationName}: shared storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`,
|
||||
severity: fullness(p),
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
key: `disk:proxmox:${px.integrationId}:${node.node}:storage:${st.id}`,
|
||||
source: `proxmox:${px.integrationId}:${node.node}`,
|
||||
message: `${px.integrationName} / ${node.node}: storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`,
|
||||
severity: fullness(p),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -166,6 +175,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `synology-volume:${syn.integrationId}:${v.id}`,
|
||||
source: src,
|
||||
message: `${syn.integrationName}: volume ${v.id} status is "${v.status}"`,
|
||||
severity: "critical",
|
||||
});
|
||||
}
|
||||
const p = usage(v.sizeUsed, v.sizeTotal);
|
||||
@@ -174,6 +184,7 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `disk:synology:${syn.integrationId}:${v.id}`,
|
||||
source: src,
|
||||
message: `${syn.integrationName}: volume ${v.id} is ${pct(p)} full (${formatBytes(v.sizeUsed ?? 0)} of ${formatBytes(v.sizeTotal ?? 0)})`,
|
||||
severity: fullness(p),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -188,6 +199,8 @@ export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds,
|
||||
key: `synology-disk:${syn.integrationId}:${d.id}`,
|
||||
source: src,
|
||||
message: `${syn.integrationName}: disk ${d.name || d.id} — ${problems.join(", ")}`,
|
||||
// A disk that's no longer "normal" is failing; a SMART warning or a threshold crossing is the early notice.
|
||||
severity: d.status && d.status.toLowerCase() !== "normal" ? "critical" : "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -247,7 +260,12 @@ export function diffConditions(
|
||||
|
||||
// ─── Collection (I/O) ───────────────────────────────────────────────────────
|
||||
|
||||
async function collectSnapshot(): Promise<{ snapshot: HealthSnapshot; held: Set<string> }> {
|
||||
/** How much longer, in ms, servers are exempt from being judged after a restart (their agents get a chance to report first). 0 once it's over. */
|
||||
export function startupGraceRemainingMs(now: number = Date.now()): number {
|
||||
return Math.max(0, STARTUP_GRACE_MS - (now - PROCESS_START));
|
||||
}
|
||||
|
||||
export async function collectSnapshot(): Promise<{ snapshot: HealthSnapshot; held: Set<string> }> {
|
||||
const held = new Set<string>();
|
||||
const snapshot: HealthSnapshot = { servers: [], proxmox: [], synology: [] };
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@ interface SourceHealth {
|
||||
*/
|
||||
const health = new Map<string, SourceHealth>();
|
||||
|
||||
/** Services whose most recent calls have been failing right now, for the Alerts page. `alerted` means a "down" notification has gone out for the streak. */
|
||||
export function getFailingSources(): { source: string; consecutiveFailures: number; alerted: boolean }[] {
|
||||
return [...health.entries()]
|
||||
.filter(([, state]) => state.consecutiveFailures > 0)
|
||||
.map(([source, state]) => ({ source, consecutiveFailures: state.consecutiveFailures, alerted: state.alerted }));
|
||||
}
|
||||
|
||||
/** Called after every diagnostic-log entry is recorded, to track consecutive failures per source and alert on threshold-cross / recovery. */
|
||||
export async function trackIntegrationHealth(source: string, ok: boolean): Promise<void> {
|
||||
const state = health.get(source) ?? { consecutiveFailures: 0, alerted: false };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
import { purgeOldLogs } from "./logRetention.js";
|
||||
import { recordAudit } from "./audit.js";
|
||||
|
||||
const LAST_RUN_FLAG = "logRetentionLastRunAt";
|
||||
|
||||
@@ -9,6 +10,12 @@ async function runPurge(): Promise<void> {
|
||||
const result = await purgeOldLogs(logRetention.retentionDays);
|
||||
await setInternalFlag(LAST_RUN_FLAG, new Date().toISOString());
|
||||
if (result.diagDeleted || result.auditDeleted) {
|
||||
// Trimming the audit log is itself something to be able to look back on — attributed to the system, since no one asked for it.
|
||||
await recordAudit({
|
||||
category: "settings",
|
||||
action: "purge_logs",
|
||||
detail: { automatic: true, retentionDays: logRetention.retentionDays, ...result },
|
||||
});
|
||||
console.log(
|
||||
`[logRetention] purged ${result.diagDeleted} diagnostic log and ${result.auditDeleted} audit log entries older than ${logRetention.retentionDays} days`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* The name lists behind Operations → Generator's server-name picker.
|
||||
*
|
||||
* The built-in lists below are hand-picked, not taken from any official source — they're a starting point. Admins can
|
||||
* edit every list under Settings → Names, and can replace a Swedish list with real statistics from Skatteverket.
|
||||
*/
|
||||
|
||||
export interface NameThemeSource {
|
||||
kind: "skatteverket";
|
||||
sex: "girls" | "boys";
|
||||
/** Birth years that were combined. */
|
||||
years: number[];
|
||||
/** How many names the import asked for. */
|
||||
count: number;
|
||||
importedAt: string;
|
||||
}
|
||||
|
||||
export interface NameTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
names: string[];
|
||||
/** The last import into this list, if there's been one. Editing the list by hand doesn't clear it. */
|
||||
lastImport?: NameThemeSource;
|
||||
}
|
||||
|
||||
export const MAX_THEMES = 20;
|
||||
export const MAX_NAMES_PER_THEME = 2000;
|
||||
export const MAX_LABEL_LENGTH = 40;
|
||||
export const MAX_NAME_LENGTH = 30;
|
||||
|
||||
/**
|
||||
* Names end up as server names and hostnames, so they're kept to lowercase a–z, digits and inner hyphens. Accents are
|
||||
* folded away first (Åsa → asa, José → jose) so a pasted Swedish name isn't rejected over its å, ä or ö.
|
||||
*/
|
||||
export function normalizeName(raw: string): string | null {
|
||||
const folded = raw
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(folded) ? folded : null;
|
||||
}
|
||||
|
||||
export function slugifyId(label: string): string {
|
||||
return (
|
||||
label
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40) || "list"
|
||||
);
|
||||
}
|
||||
|
||||
function words(text: string): string[] {
|
||||
return text.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
const SWEDISH_GIRLS = words(`
|
||||
freja elsa alice maja wilma alma ebba lilly ella saga agnes stella selma vera ingrid astrid linnea nova sara emma
|
||||
julia olivia isabelle klara nellie elin signe tuva moa tyra hedda nora amanda anna elvira iris matilda molly sofia
|
||||
thea vilma cornelia filippa livia meja ronja sigrid tilde greta hilda leia lovisa siri jasmine felicia ida lina
|
||||
mira mimmi lykke ellen ellie emelie hanna jenny josefin kajsa karin lisa lotta maria märta nathalie pia
|
||||
rebecka sanna sally stina svea tindra ylva yvonne
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const SWEDISH_BOYS = words(`
|
||||
noah liam hugo lucas oliver elias oscar william adam alfred nils axel arvid vincent theo leo ludvig filip viktor
|
||||
albin gustav melvin sixten love ivar edvin otto wilmer malte valter folke sigge algot ebbe noel benjamin felix isak
|
||||
jonathan anton erik emil johan karl lars anders mattias henrik jakob sebastian daniel samuel alex max rasmus
|
||||
tage olle tobias simon kasper julius ture vidar viggo vilgot ville lennart gunnar bertil sten stig bo björn
|
||||
rolf ulf kurt mats magnus mikael peter per pontus
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const DISNEY = words(`
|
||||
moana elsa anna belle ariel jasmine aurora cinderella rapunzel mulan tiana merida pocahontas mickey minnie simba
|
||||
nala stitch lilo olaf baymax dory nemo woody buzz genie aladdin eric flynn kristoff sven pumbaa timon mufasa scar
|
||||
ursula maleficent gaston hercules meg tinkerbell wendy pinocchio bambi dumbo thumper flounder sebastian iago abu
|
||||
pascal maximus heihei goofy donald daisy pluto chip dale bagheera baloo mowgli rafiki zazu piglet tigger eeyore
|
||||
pooh hades phil jafar rajah tarzan jane kida milo pacha kuzco yzma bolt judy nick gazelle mirabel bruno luisa
|
||||
isabela cruella dodger tramp lady jiminy figaro cleo shenzi banzai kronk vanellope ralph esmeralda quasimodo
|
||||
megara belle gus jaq
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const PIXAR = words(`
|
||||
jessie rex hamm slinky bo lotso flik heimlich mike sulley boo randall marlin crush bruce gill remy linguini colette
|
||||
walle eve carl russell dug kevin lightning mater sally doc luigi guido fillmore joy sadness anger fear disgust bing
|
||||
arlo spot miguel hector dante ian barley luca alberto giulia mei ming elastigirl dash violet jack edna syndrome
|
||||
forky gabby ducky bunny duke ember wade bing-bong riley
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const NORSE = words(`
|
||||
odin thor loki freya frigg baldur tyr heimdall idun bragi njord freyr sif hel ymir fenrir sleipnir ran aegir skadi
|
||||
vidar vali ullr forseti hermod sigyn nanna jord eir fulla gefjon mimir yggdrasil asgard midgard valhalla bifrost
|
||||
ragnarok jormungandr hugin munin audhumla surtr
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const LINDGREN = words(`
|
||||
pippi emil ida lotta madicken mio ronja birk mattis borka karlsson lillebror rasmus tengil katla skorpan jonatan
|
||||
nangijala bullerbyn vimmerby lonneberga junibacken villekulla kalle
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
function dedupe(list: string[]): string[] {
|
||||
return [...new Set(list)];
|
||||
}
|
||||
|
||||
/** What a fresh install starts with, and what "restore" puts back. */
|
||||
export function defaultThemes(): NameTheme[] {
|
||||
return [
|
||||
{ id: "swedish-girls", label: "Swedish girl names", names: dedupe(SWEDISH_GIRLS) },
|
||||
{ id: "swedish-boys", label: "Swedish boy names", names: dedupe(SWEDISH_BOYS) },
|
||||
{ id: "disney", label: "Disney characters", names: dedupe(DISNEY) },
|
||||
{ id: "pixar", label: "Pixar characters", names: dedupe(PIXAR) },
|
||||
{ id: "norse", label: "Norse mythology", names: dedupe(NORSE) },
|
||||
{ id: "lindgren", label: "Astrid Lindgren", names: dedupe(LINDGREN) },
|
||||
];
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_IDS = defaultThemes().map((t) => t.id);
|
||||
|
||||
// ─── Validating an edit ──────────────────────────────────────────────────────
|
||||
|
||||
export interface InvalidName {
|
||||
theme: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class InvalidThemesError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly invalid: InvalidName[] = [],
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever the editor sent into clean themes, or throws with a message that says what to fix. Names are folded
|
||||
* and de-duplicated; a name that can't be made into a hostname-safe one is reported, never silently dropped.
|
||||
*/
|
||||
export function cleanThemes(input: { id?: string; label: string; names: string[]; lastImport?: NameThemeSource }[]): NameTheme[] {
|
||||
if (input.length > MAX_THEMES) throw new InvalidThemesError(`At most ${MAX_THEMES} lists.`);
|
||||
const usedIds = new Set<string>();
|
||||
const invalid: InvalidName[] = [];
|
||||
const out: NameTheme[] = [];
|
||||
|
||||
for (const raw of input) {
|
||||
const label = raw.label.trim();
|
||||
if (!label) throw new InvalidThemesError("Every list needs a name.");
|
||||
if (label.length > MAX_LABEL_LENGTH) throw new InvalidThemesError(`“${label}” — list names can be at most ${MAX_LABEL_LENGTH} characters.`);
|
||||
|
||||
let id = raw.id && /^[a-z0-9-]{1,40}$/.test(raw.id) ? raw.id : slugifyId(label);
|
||||
for (let n = 2; usedIds.has(id); n++) id = `${slugifyId(label)}-${n}`;
|
||||
usedIds.add(id);
|
||||
|
||||
const names: string[] = [];
|
||||
for (const entry of raw.names) {
|
||||
if (!entry.trim()) continue;
|
||||
const clean = normalizeName(entry);
|
||||
if (clean === null) invalid.push({ theme: label, name: entry.trim() });
|
||||
else names.push(clean);
|
||||
}
|
||||
const unique = dedupe(names);
|
||||
if (unique.length > MAX_NAMES_PER_THEME) throw new InvalidThemesError(`“${label}” has ${unique.length} names — at most ${MAX_NAMES_PER_THEME} per list.`);
|
||||
out.push({ id, label, names: unique, ...(raw.lastImport ? { lastImport: raw.lastImport } : {}) });
|
||||
}
|
||||
|
||||
if (invalid.length > 0) {
|
||||
const shown = invalid.slice(0, 5).map((i) => `“${i.name}”`).join(", ");
|
||||
throw new InvalidThemesError(
|
||||
`${invalid.length} name${invalid.length === 1 ? " isn't" : "s aren't"} usable as a server name (${shown}${invalid.length > 5 ? ", …" : ""}). Use letters, digits and hyphens, no spaces.`,
|
||||
invalid,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads themes back out of settings defensively — a backup restore or hand-edited row must not be able to break the Generator. */
|
||||
export function readStoredThemes(stored: unknown): NameTheme[] {
|
||||
if (!Array.isArray(stored)) return defaultThemes();
|
||||
const themes: NameTheme[] = [];
|
||||
for (const t of stored) {
|
||||
if (!t || typeof t !== "object") continue;
|
||||
const { id, label, names, lastImport } = t as Partial<NameTheme>;
|
||||
if (typeof id !== "string" || typeof label !== "string" || !Array.isArray(names)) continue;
|
||||
themes.push({
|
||||
id,
|
||||
label,
|
||||
names: names.filter((n): n is string => typeof n === "string"),
|
||||
...(lastImport && typeof lastImport === "object" ? { lastImport } : {}),
|
||||
});
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
// ─── Importing from Skatteverket ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Skatteverket's open-data API for "Namn på nyfödda": the most common given names of babies, by sex and birth year,
|
||||
* for the whole country ("Total"). It needs no key. Statistics Sweden (SCB), which used to publish this, stopped
|
||||
* after 2023 and points to Skatteverket.
|
||||
*/
|
||||
const SKATTEVERKET_DATASET = "https://skatteverket.entryscape.net/rowstore/dataset/da2556d0-c717-45e8-a1d8-3320161d3a7d";
|
||||
const PAGE_SIZE = 500;
|
||||
const MAX_PAGES = 4;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
export interface NameImport {
|
||||
names: string[];
|
||||
years: number[];
|
||||
/** Years that had no data (yet) and were left out. */
|
||||
missingYears: number[];
|
||||
/** Names Skatteverket lists that can't be used as a server name (a space, an unusual letter) and were left out. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
interface Row {
|
||||
namn?: string;
|
||||
antal?: string;
|
||||
rangordning?: string;
|
||||
}
|
||||
|
||||
async function fetchYear(sex: "girls" | "boys", year: number): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const url = `${SKATTEVERKET_DATASET}?gruppering=Total&fodelsear=${year}&k%C3%B6n=${sex === "girls" ? "Kvinna" : "Man"}&_limit=${PAGE_SIZE}&_offset=${page * PAGE_SIZE}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Skatteverket's name service answered ${res.status}.`);
|
||||
const body = (await res.json()) as { results?: Row[] };
|
||||
const results = Array.isArray(body.results) ? body.results : [];
|
||||
rows.push(...results);
|
||||
if (results.length < PAGE_SIZE) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most common names over the last `yearCount` full calendar years (the running year is only partly counted, so it's
|
||||
* skipped). Counts are added up across years, so one unusually popular year doesn't decide the list.
|
||||
*/
|
||||
export async function importSkatteverketNames(sex: "girls" | "boys", yearCount: number, count: number, now = new Date()): Promise<NameImport> {
|
||||
const wanted = Array.from({ length: yearCount }, (_, i) => now.getUTCFullYear() - 1 - i);
|
||||
const totals = new Map<string, number>();
|
||||
const skipped = new Set<string>();
|
||||
const years: number[] = [];
|
||||
const missingYears: number[] = [];
|
||||
|
||||
for (const year of wanted) {
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await fetchYear(sex, year);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "TimeoutError") throw new Error("Skatteverket's name service didn't answer in time.");
|
||||
throw err;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
missingYears.push(year);
|
||||
continue;
|
||||
}
|
||||
years.push(year);
|
||||
for (const row of rows) {
|
||||
const raw = (row.namn ?? "").trim();
|
||||
const amount = Number(row.antal);
|
||||
if (!raw || !Number.isFinite(amount)) continue;
|
||||
const clean = normalizeName(raw);
|
||||
if (clean === null) {
|
||||
skipped.add(raw);
|
||||
continue;
|
||||
}
|
||||
totals.set(clean, (totals.get(clean) ?? 0) + amount);
|
||||
}
|
||||
}
|
||||
|
||||
if (years.length === 0) throw new Error("Skatteverket has no name statistics for those years.");
|
||||
|
||||
const names = [...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "sv"))
|
||||
.slice(0, count)
|
||||
.map(([name]) => name);
|
||||
return { names, years: years.sort((a, b) => a - b), missingYears, skipped: [...skipped].sort((a, b) => a.localeCompare(b, "sv")) };
|
||||
}
|
||||
@@ -382,9 +382,13 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
phpipam: "phpIPAM",
|
||||
pbs: "Proxmox Backup Server",
|
||||
osticket: "osTicket",
|
||||
};
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
export function sourceLabel(source: string): string {
|
||||
return SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,39 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createPbsAdapter } from "../integrations/pbs/adapter.js";
|
||||
import { notifyPbsVerificationFailed } from "./notify.js";
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
import { isInMaintenance } from "./maintenance.js";
|
||||
import { activeSubjects } from "./maintenance.js";
|
||||
import type { SourceFailure } from "./alertTypes.js";
|
||||
|
||||
const LAST_RUN_FLAG = "pbsVerificationCheckLastRunDate";
|
||||
|
||||
async function checkPbsVerification(): Promise<void> {
|
||||
export interface PbsProblem {
|
||||
integrationId: number;
|
||||
integrationName: string;
|
||||
datastore: string;
|
||||
failedCount: number;
|
||||
error: string | null;
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datastores with snapshots that failed verification, or that couldn't be read at all, across the enabled PBS
|
||||
* integrations. The daily check skips integrations under a maintenance window altogether; the Alerts page passes
|
||||
* skipSilenced: false to see them, flagged.
|
||||
*/
|
||||
export async function collectPbsProblems(opts: { skipSilenced: boolean }): Promise<{ problems: PbsProblem[]; sourceFailures: SourceFailure[] }> {
|
||||
const rows = await db
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, "pbs"), eq(integrations.enabled, true)));
|
||||
|
||||
const failures: { integrationName: string; datastore: string; failedCount: number; error: string | null }[] = [];
|
||||
const failures: PbsProblem[] = [];
|
||||
const sourceFailures: SourceFailure[] = [];
|
||||
const silencedSubjects = await activeSubjects();
|
||||
|
||||
for (const row of rows) {
|
||||
// A PBS host being worked on can't be reached reliably; this check is daily, so tomorrow's pass covers it.
|
||||
if (await isInMaintenance("integration", row.id)) continue;
|
||||
const silenced = silencedSubjects.has(`integration:${row.id}`);
|
||||
if (silenced && opts.skipSilenced) continue;
|
||||
try {
|
||||
const loaded = await loadIntegrationConfig(row.id);
|
||||
if (!loaded) continue;
|
||||
@@ -29,17 +47,22 @@ async function checkPbsVerification(): Promise<void> {
|
||||
|
||||
for (const d of datastores) {
|
||||
if (d.error) {
|
||||
failures.push({ integrationName: row.name, datastore: d.name, failedCount: 0, error: d.error });
|
||||
failures.push({ integrationId: row.id, integrationName: row.name, datastore: d.name, failedCount: 0, error: d.error, silenced });
|
||||
} else if (d.failedCount > 0) {
|
||||
failures.push({ integrationName: row.name, datastore: d.name, failedCount: d.failedCount, error: null });
|
||||
failures.push({ integrationId: row.id, integrationName: row.name, datastore: d.name, failedCount: d.failedCount, error: null, silenced });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[pbsVerification] check failed for integration ${row.id}:`, err);
|
||||
sourceFailures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
await notifyPbsVerificationFailed(failures);
|
||||
return { problems: failures, sourceFailures };
|
||||
}
|
||||
|
||||
async function checkPbsVerification(): Promise<void> {
|
||||
await notifyPbsVerificationFailed((await collectPbsProblems({ skipSilenced: true })).problems);
|
||||
}
|
||||
|
||||
async function checkPbsVerificationOnce(): Promise<void> {
|
||||
|
||||
@@ -6,22 +6,50 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createProxmoxAdapter, guestsWithoutBackupCoverage, type ProxmoxBackupTask } from "../integrations/proxmox/adapter.js";
|
||||
import { notifyProxmoxBackupFailure, notifyProxmoxUncoveredGuests } from "./notify.js";
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
import { isInMaintenance } from "./maintenance.js";
|
||||
import { activeSubjects } from "./maintenance.js";
|
||||
import type { SourceFailure } from "./alertTypes.js";
|
||||
|
||||
const LAST_RUN_FLAG = "proxmoxBackupCheckLastRunDate";
|
||||
|
||||
async function checkProxmoxBackups(): Promise<void> {
|
||||
export interface BackupFailure {
|
||||
integrationId: number;
|
||||
integrationName: string;
|
||||
node: string;
|
||||
guestId: string | null;
|
||||
status: string;
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
export interface UncoveredGuest {
|
||||
integrationId: number;
|
||||
integrationName: string;
|
||||
guestName: string;
|
||||
vmid: number;
|
||||
node: string;
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed latest backups and guests no backup job covers, across the enabled Proxmox integrations. The daily check skips
|
||||
* integrations under a maintenance window altogether (a host being worked on can't run or report backups, and tomorrow's
|
||||
* pass covers it); the Alerts page passes skipSilenced: false to see them, flagged.
|
||||
*/
|
||||
export async function collectProxmoxBackupProblems(opts: {
|
||||
skipSilenced: boolean;
|
||||
}): Promise<{ failures: BackupFailure[]; uncovered: UncoveredGuest[]; sourceFailures: SourceFailure[] }> {
|
||||
const rows = await db
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, "proxmox"), eq(integrations.enabled, true)));
|
||||
|
||||
const failures: { integrationName: string; node: string; guestId: string | null; status: string }[] = [];
|
||||
const uncovered: { integrationName: string; guestName: string; vmid: number; node: string }[] = [];
|
||||
const failures: BackupFailure[] = [];
|
||||
const uncovered: UncoveredGuest[] = [];
|
||||
const sourceFailures: SourceFailure[] = [];
|
||||
const silencedSubjects = await activeSubjects();
|
||||
|
||||
for (const row of rows) {
|
||||
// A host being worked on can't run or report backups; this check is daily, so tomorrow's pass covers it.
|
||||
if (await isInMaintenance("integration", row.id)) continue;
|
||||
const silenced = silencedSubjects.has(`integration:${row.id}`);
|
||||
if (silenced && opts.skipSilenced) continue;
|
||||
try {
|
||||
const loaded = await loadIntegrationConfig(row.id);
|
||||
if (!loaded) continue;
|
||||
@@ -44,18 +72,24 @@ async function checkProxmoxBackups(): Promise<void> {
|
||||
|
||||
for (const [node, task] of latestByNode) {
|
||||
if (!task.ok && task.status !== "running") {
|
||||
failures.push({ integrationName: row.name, node, guestId: task.guestId, status: task.status });
|
||||
failures.push({ integrationId: row.id, integrationName: row.name, node, guestId: task.guestId, status: task.status, silenced });
|
||||
}
|
||||
}
|
||||
|
||||
for (const g of guestsWithoutBackupCoverage(guests, jobs)) {
|
||||
uncovered.push({ integrationName: row.name, guestName: g.name, vmid: g.vmid, node: g.node });
|
||||
uncovered.push({ integrationId: row.id, integrationName: row.name, guestName: g.name, vmid: g.vmid, node: g.node, silenced });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[proxmoxBackup] check failed for integration ${row.id}:`, err);
|
||||
sourceFailures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { failures, uncovered, sourceFailures };
|
||||
}
|
||||
|
||||
async function checkProxmoxBackups(): Promise<void> {
|
||||
const { failures, uncovered } = await collectProxmoxBackupProblems({ skipSilenced: true });
|
||||
await notifyProxmoxBackupFailure(failures);
|
||||
await notifyProxmoxUncoveredGuests(uncovered);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* What a settings update actually changed, in a form fit for the audit log.
|
||||
*
|
||||
* The audit log can be read by operators, but Settings can't — so values only go in for sections an operator could
|
||||
* already see in the app. The notification channels (Gotify, ntfy, SMTP, webhook) hold credentials, and even their
|
||||
* addresses can act as one (a webhook URL carries its own token; a public ntfy topic is the only thing protecting
|
||||
* it), so for those only the names of the fields that changed are recorded, never what they changed to or from.
|
||||
*/
|
||||
const NAMES_ONLY_SECTIONS = new Set(["gotify", "ntfy", "smtp", "webhook"]);
|
||||
|
||||
/** Anything longer than this isn't a useful thing to read in a table cell. */
|
||||
const MAX_VALUE_JSON = 300;
|
||||
|
||||
export type SettingsChange = { from: unknown; to: unknown } | "(changed)";
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function capture(value: unknown): unknown {
|
||||
return value !== undefined && JSON.stringify(value)?.length > MAX_VALUE_JSON ? "(too long to show)" : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares each section in `patch` with what was stored before. Only fields that really differ are listed, and a
|
||||
* section where nothing differed is left out entirely.
|
||||
*/
|
||||
export function describeSettingsChanges(before: object, patch: object): Record<string, Record<string, SettingsChange>> {
|
||||
const out: Record<string, Record<string, SettingsChange>> = {};
|
||||
for (const [section, incoming] of Object.entries(patch)) {
|
||||
if (incoming === null || typeof incoming !== "object") continue;
|
||||
const previous = ((before as Record<string, unknown>)[section] ?? {}) as Record<string, unknown>;
|
||||
const changes: Record<string, SettingsChange> = {};
|
||||
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
|
||||
if (same(previous[key], value)) continue;
|
||||
changes[key] = NAMES_ONLY_SECTIONS.has(section) ? "(changed)" : { from: capture(previous[key]), to: capture(value) };
|
||||
}
|
||||
if (Object.keys(changes).length > 0) out[section] = changes;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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
@@ -24,6 +24,7 @@ import UptimeKuma from "./pages/UptimeKuma";
|
||||
import PbsBackup from "./pages/PbsBackup";
|
||||
import OsTicket from "./pages/OsTicket";
|
||||
import AdminLinks from "./pages/AdminLinks";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import Generator from "./pages/Generator";
|
||||
import Maintenance from "./pages/Maintenance";
|
||||
import Domains from "./pages/Domains";
|
||||
@@ -37,7 +38,9 @@ import TagSettings from "./pages/settings/TagSettings";
|
||||
import CacheSettings from "./pages/settings/CacheSettings";
|
||||
import LogSettings from "./pages/settings/LogSettings";
|
||||
import BackupSettings from "./pages/settings/BackupSettings";
|
||||
import NameSettings from "./pages/settings/NameSettings";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import DialogHost from "./components/DialogHost";
|
||||
import { setDateTimeSettings } from "./utils/date";
|
||||
import { setPageSize } from "./utils/pageSize";
|
||||
|
||||
@@ -98,7 +101,7 @@ export default function App() {
|
||||
<Route path="/ports" element={<Ports user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
<Route path="/integrations" element={<Integrations user={user} />} />
|
||||
<Route path="/generator" element={<Generator />} />
|
||||
<Route path="/generator" element={<Generator user={user} />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/consistency" element={<Consistency user={user} />} />
|
||||
<Route path="/domains" element={<Domains user={user} />} />
|
||||
@@ -113,6 +116,7 @@ export default function App() {
|
||||
<Route path="/pbs" element={<PbsBackup user={user} />} />
|
||||
<Route path="/osticket" element={<OsTicket user={user} />} />
|
||||
<Route path="/admin-links" element={<AdminLinks user={user} />} />
|
||||
<Route path="/alerts" element={<Alerts user={user} />} />
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
@@ -158,11 +162,13 @@ export default function App() {
|
||||
<Route path="badges" element={<BadgeSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="tags" element={<TagSettings />} />
|
||||
<Route path="names" element={<NameSettings />} />
|
||||
<Route path="cache" element={<CacheSettings />} />
|
||||
<Route path="logs" element={<LogSettings />} />
|
||||
<Route path="backup" element={<BackupSettings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<DialogHost />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -397,6 +397,30 @@ export interface ServerDetail {
|
||||
links: ServerLink[];
|
||||
}
|
||||
|
||||
export type AlertSeverity = "critical" | "warning" | "info";
|
||||
export type AlertCategory = "offline" | "disk" | "backup" | "updates" | "expiry" | "automation" | "integration" | "monitoring" | "tickets";
|
||||
|
||||
export interface AlertItem {
|
||||
id: string;
|
||||
severity: AlertSeverity;
|
||||
category: AlertCategory;
|
||||
source: string;
|
||||
message: string;
|
||||
link: string | null;
|
||||
/** Under a maintenance window: still a real problem, but its notifications are held back. */
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
export interface AlertsReport {
|
||||
alerts: AlertItem[];
|
||||
counts: { critical: number; warning: number; info: number; silenced: number };
|
||||
couldntCheck: { name: string; error: string; affects: string[] }[];
|
||||
notes: string[];
|
||||
generatedAt: string;
|
||||
/** This is a recent result being reused, not a fresh check. */
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export interface AdminLink {
|
||||
id: number;
|
||||
serverId: number;
|
||||
@@ -627,6 +651,28 @@ export interface PrivacyOverview {
|
||||
};
|
||||
}
|
||||
|
||||
export interface NameThemeSource {
|
||||
kind: "skatteverket";
|
||||
sex: "girls" | "boys";
|
||||
years: number[];
|
||||
count: number;
|
||||
importedAt: string;
|
||||
}
|
||||
|
||||
export interface NameTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
names: string[];
|
||||
lastImport?: NameThemeSource;
|
||||
}
|
||||
|
||||
export interface NameImportPreview {
|
||||
names: string[];
|
||||
source: NameThemeSource;
|
||||
missingYears: number[];
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export interface TagEntry {
|
||||
name: string;
|
||||
/** "#rrggbb", or null for the automatic colour. */
|
||||
@@ -1006,6 +1052,16 @@ export const api = {
|
||||
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
||||
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
|
||||
},
|
||||
generator: {
|
||||
themes: () => request<{ themes: NameTheme[]; builtinIds: string[] }>("/api/generator/themes"),
|
||||
defaults: () => request<{ themes: NameTheme[] }>("/api/generator/themes/defaults"),
|
||||
save: (themes: (Omit<NameTheme, "id"> & { id?: string })[]) => request<{ themes: NameTheme[] }>("/api/generator/themes", { method: "PUT", body: JSON.stringify({ themes }) }),
|
||||
importNames: (sex: "girls" | "boys", years: number, count: number) =>
|
||||
request<NameImportPreview>("/api/generator/themes/import", { method: "POST", body: JSON.stringify({ sex, years, count }) }),
|
||||
},
|
||||
alerts: {
|
||||
list: (refresh = false) => request<AlertsReport>(`/api/alerts${refresh ? "?refresh=1" : ""}`),
|
||||
},
|
||||
ports: {
|
||||
agent: () => request<AgentPortsResponse>("/api/ports/agent"),
|
||||
forwards: {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { settle, subscribe, type DialogRequest } from "../utils/dialogs";
|
||||
|
||||
const FOCUSABLE = "button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [href]";
|
||||
|
||||
/** Draws the confirm/prompt dialogs asked for through utils/dialogs. Mount once, near the root. */
|
||||
export default function DialogHost() {
|
||||
const [current, setCurrent] = useState<DialogRequest | null>(null);
|
||||
useEffect(() => subscribe(setCurrent), []);
|
||||
if (!current) return null;
|
||||
// Keyed, so back-to-back dialogs each start fresh (an empty text box, the right button focused).
|
||||
return createPortal(<DialogView key={current.id} request={current} />, document.body);
|
||||
}
|
||||
|
||||
function DialogView({ request }: { request: DialogRequest }) {
|
||||
const titleId = useId();
|
||||
const inputId = useId();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const isPrompt = request.kind === "prompt";
|
||||
const danger = request.kind === "confirm" && !!request.danger;
|
||||
const [value, setValue] = useState(request.kind === "prompt" ? (request.defaultValue ?? "") : "");
|
||||
|
||||
const cancel = () => settle(request, false);
|
||||
const blocked = request.kind === "prompt" && !!request.required && !value.trim();
|
||||
|
||||
// On open: stop the page behind from scrolling (without the scrollbar vanishing and shifting the layout), put the
|
||||
// keyboard focus where it's useful, and on close hand it back to whatever had it — the button that was clicked.
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const { overflow, paddingRight } = document.body.style;
|
||||
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.overflow = "hidden";
|
||||
if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`;
|
||||
|
||||
if (isPrompt) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
} else if (danger) {
|
||||
cancelRef.current?.focus(); // the safe answer, so a stray Enter doesn't delete anything
|
||||
} else {
|
||||
confirmRef.current?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = overflow;
|
||||
document.body.style.paddingRight = paddingRight;
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
// Keep Tab inside the dialog.
|
||||
const items = Array.from(modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []);
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (blocked) return;
|
||||
settle(request, true, value);
|
||||
}
|
||||
|
||||
const title = request.title ?? (isPrompt ? "Enter a value" : "Please confirm");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="modal modal-blur fade show"
|
||||
style={{ display: "block" }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onKeyDown}
|
||||
// mousedown rather than click, so selecting text in the box and letting go outside it doesn't close the dialog
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) cancel();
|
||||
}}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered" role="document">
|
||||
<form className="modal-content" onSubmit={onSubmit}>
|
||||
{danger && <div className="modal-status bg-danger" />}
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title" id={titleId}>
|
||||
{title}
|
||||
</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={cancel} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div style={{ whiteSpace: "pre-line" }}>{request.message}</div>
|
||||
{request.kind === "prompt" && (
|
||||
<div className="mt-3">
|
||||
{request.label && (
|
||||
<label className="form-label" htmlFor={inputId}>
|
||||
{request.label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
className="form-control"
|
||||
value={value}
|
||||
placeholder={request.placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn me-auto" ref={cancelRef} onClick={cancel}>
|
||||
{request.cancelLabel ?? "Cancel"}
|
||||
</button>
|
||||
<button type="submit" className={`btn ${danger ? "btn-danger" : "btn-primary"}`} ref={confirmRef} disabled={blocked}>
|
||||
{request.confirmLabel ?? (isPrompt ? "OK" : "Confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-backdrop fade show" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { downloadCsv } from "../utils/csv";
|
||||
import { readableError } from "../utils/errors";
|
||||
import Pagination from "./Pagination";
|
||||
import SortableTh from "./SortableTh";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const PRESETS: { key: string; label: string; from: number; to: number }[] = [
|
||||
{ key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 },
|
||||
@@ -188,7 +189,8 @@ export default function ServerPorts({
|
||||
async function removeNote(entry: PortEntry) {
|
||||
if (entry.id === null) return;
|
||||
const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`;
|
||||
if (!confirm(what)) return;
|
||||
const reserved = entry.state === "reserved";
|
||||
if (!(await confirmDialog({ title: reserved ? "Remove reservation" : "Clear note", message: what, confirmLabel: reserved ? "Remove" : "Clear", danger: true }))) return;
|
||||
try {
|
||||
await api.servers.ports.remove(serverId, entry.id);
|
||||
setData(await api.servers.ports.list(serverId));
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
IconTicket,
|
||||
IconPlug,
|
||||
IconExternalLink,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
@@ -101,6 +102,7 @@ const NAV: NavEntry[] = [
|
||||
label: "Operations",
|
||||
icon: <IconTool size={20} />,
|
||||
items: [
|
||||
{ to: "/alerts", label: "Alerts", icon: <IconAlertTriangle size={20} /> },
|
||||
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
|
||||
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
|
||||
{ to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
interface LinkForm {
|
||||
serverId: number | "";
|
||||
@@ -133,7 +134,7 @@ export default function AdminLinks({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function remove(link: AdminLink) {
|
||||
if (!confirm(`Remove the "${link.label}" link from ${link.serverName}?`)) return;
|
||||
if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link from ${link.serverName}?`, confirmLabel: "Remove", danger: true }))) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.servers.removeLink(link.serverId, link.id);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type AlertCategory, type AlertItem, type AlertSeverity, type AlertsReport, type CurrentUser } from "../api/client";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import { formatAgo } from "../utils/duration";
|
||||
import { readableError } from "../utils/errors";
|
||||
|
||||
const SEVERITY: Record<AlertSeverity, { label: string; badge: string; rank: number }> = {
|
||||
critical: { label: "Critical", badge: "bg-red-lt text-red", rank: 0 },
|
||||
warning: { label: "Warning", badge: "bg-yellow-lt text-yellow", rank: 1 },
|
||||
info: { label: "Info", badge: "bg-blue-lt text-blue", rank: 2 },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<AlertCategory, string> = {
|
||||
offline: "Server down",
|
||||
disk: "Disk & storage",
|
||||
backup: "Backups",
|
||||
updates: "Updates",
|
||||
expiry: "Expiry",
|
||||
automation: "Automation",
|
||||
integration: "Integration failing",
|
||||
monitoring: "Monitoring",
|
||||
tickets: "Tickets",
|
||||
};
|
||||
|
||||
type Row = AlertItem & { rank: number };
|
||||
|
||||
export default function Alerts(_props: { user: CurrentUser }) {
|
||||
const [report, setReport] = useState<AlertsReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [severity, setSeverity] = useState<"all" | AlertSeverity>("all");
|
||||
const [category, setCategory] = useState<"all" | AlertCategory>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showSilenced, setShowSilenced] = useState(true);
|
||||
|
||||
function load(refresh = false) {
|
||||
setLoading(true);
|
||||
return api.alerts
|
||||
.list(refresh)
|
||||
.then((res) => {
|
||||
setReport(res);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(readableError(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!report) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return report.alerts
|
||||
.filter((a) => (severity === "all" || a.severity === severity) && (category === "all" || a.category === category) && (showSilenced || !a.silenced))
|
||||
.filter((a) => !q || [a.message, a.source, CATEGORY_LABELS[a.category]].some((v) => v.toLowerCase().includes(q)))
|
||||
.map((a) => ({ ...a, rank: SEVERITY[a.severity].rank }));
|
||||
}, [report, severity, category, search, showSilenced]);
|
||||
|
||||
// No initial sort: the server already puts active problems first, worst first.
|
||||
const { sorted, sortKey, sortDir, requestSort } = useSortable(rows);
|
||||
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
||||
|
||||
const presentCategories = useMemo(() => [...new Set((report?.alerts ?? []).map((a) => a.category))], [report]);
|
||||
|
||||
function exportCsv() {
|
||||
downloadCsv(
|
||||
"alerts.csv",
|
||||
["Severity", "Category", "Source", "Alert", "Silenced"],
|
||||
(sorted ?? []).map((a) => [SEVERITY[a.severity].label, CATEGORY_LABELS[a.category], a.source, a.message, a.silenced ? "yes" : "no"]),
|
||||
);
|
||||
}
|
||||
|
||||
const counts = report?.counts;
|
||||
const nothingFound = report !== null && report.alerts.length === 0;
|
||||
const cards: { label: string; value: number | undefined; color: string }[] = [
|
||||
{ label: "Critical", value: counts?.critical, color: "text-red" },
|
||||
{ label: "Warnings", value: counts?.warning, color: "text-yellow" },
|
||||
{ label: "Info", value: counts?.info, color: "text-blue" },
|
||||
{ label: "Silenced (maintenance)", value: counts?.silenced, color: "text-secondary" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex flex-wrap align-items-center gap-2 mb-1">
|
||||
<h2 className="page-title mb-0">Alerts</h2>
|
||||
<div className="ms-auto btn-list">
|
||||
<button className="btn btn-outline-secondary" onClick={exportCsv} disabled={!sorted?.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={() => void load(true)} disabled={loading}>
|
||||
{loading ? "Checking…" : "Check now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-secondary mb-3">
|
||||
What's wrong right now across your servers and integrations — full disks, servers that stopped reporting, failed backups, updates waiting,
|
||||
things about to expire. It runs the same checks that send notifications, whether or not those notifications are switched on.
|
||||
{report && (
|
||||
<>
|
||||
{" "}
|
||||
<span title={formatDateTime(new Date(report.generatedAt))}>
|
||||
Checked {formatAgo(report.generatedAt)}
|
||||
{report.cached ? " (a recent result, reused)" : ""}.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{!report && !error && (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">Checking everything — this can take a few seconds…</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<div className="row g-3 mb-3">
|
||||
{cards.map((c) => (
|
||||
<div className="col-6 col-md-3" key={c.label}>
|
||||
<div className="card card-sm">
|
||||
<div className="card-body">
|
||||
<div className="text-secondary">{c.label}</div>
|
||||
<div className={`h2 mb-0 ${c.value ? c.color : ""}`}>{c.value ?? "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{report.couldntCheck.length > 0 && (
|
||||
<div className="alert alert-warning">
|
||||
<div>
|
||||
<div className="fw-bold mb-1">Couldn't check everything — what's missing below isn't necessarily fine</div>
|
||||
<ul className="mb-0">
|
||||
{report.couldntCheck.map((c) => (
|
||||
<li key={c.name}>
|
||||
<strong>{c.name}</strong> — {c.error}
|
||||
{c.affects.length > 0 && <span className="text-secondary"> (affects: {c.affects.join(", ")})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{report.notes.map((n) => (
|
||||
<div className="alert alert-info" key={n}>
|
||||
{n}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{nothingFound ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-5">
|
||||
{report.couldntCheck.length === 0 ? (
|
||||
<>
|
||||
<div className="h3 text-green mb-1">All clear</div>
|
||||
<div className="text-secondary">Nothing needs attention right now.</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-secondary">No problems found in what could be checked — see the warning above for what couldn't.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
|
||||
<input className="form-control" style={{ maxWidth: 260 }} placeholder="Search alerts…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<select className="form-select w-auto" value={severity} onChange={(e) => setSeverity(e.target.value as "all" | AlertSeverity)}>
|
||||
<option value="all">All severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select className="form-select w-auto" value={category} onChange={(e) => setCategory(e.target.value as "all" | AlertCategory)}>
|
||||
<option value="all">All kinds</option>
|
||||
{presentCategories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CATEGORY_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(counts?.silenced ?? 0) > 0 && (
|
||||
<label className="form-check mb-0">
|
||||
<input type="checkbox" className="form-check-input" checked={showSilenced} onChange={(e) => setShowSilenced(e.target.checked)} />
|
||||
<span className="form-check-label">Show silenced</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<Row> label="Severity" sortKeyName="rank" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<Row> label="Kind" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<Row> label="Source" sortKeyName="source" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<Row> label="Alert" sortKeyName="message" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(pageItems ?? []).map((a) => (
|
||||
<tr key={a.id} className={a.silenced ? "text-secondary" : undefined} style={a.silenced ? { opacity: 0.65 } : undefined}>
|
||||
<td>
|
||||
<span className={`badge ${SEVERITY[a.severity].badge}`}>{SEVERITY[a.severity].label}</span>
|
||||
</td>
|
||||
<td>{CATEGORY_LABELS[a.category]}</td>
|
||||
<td>{a.link ? <Link to={a.link}>{a.source}</Link> : a.source}</td>
|
||||
<td>
|
||||
{a.message}
|
||||
{a.silenced && (
|
||||
<span className="badge bg-secondary-lt text-secondary ms-2" title="Under a maintenance window — notifications for this are held back">
|
||||
silenced
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(sorted ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
Nothing matches these filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AuditLogEntry } from "../api/client";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
@@ -23,6 +23,22 @@ function targetLabel(e: AuditLogEntry): string {
|
||||
return `${typeLabel}${e.targetId ? ` #${e.targetId}` : ""}`;
|
||||
}
|
||||
|
||||
/** What was done, beyond the target — the link's label and URL, a scan's address and range, and so on. The name is already in the Target column. */
|
||||
function detailSummary(e: AuditLogEntry): string {
|
||||
if (!e.detail) return "";
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(e.detail);
|
||||
} catch {
|
||||
return e.detail;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return String(parsed);
|
||||
return Object.entries(parsed as Record<string, unknown>)
|
||||
.filter(([key, value]) => key !== "name" && value !== null && value !== undefined && value !== "" && !(Array.isArray(value) && value.length === 0))
|
||||
.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
export default function AuditLog() {
|
||||
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -41,8 +57,8 @@ export default function AuditLog() {
|
||||
if (!sorted) return;
|
||||
downloadCsv(
|
||||
"audit-log.csv",
|
||||
["When", "Actor", "Category", "Action", "Target"],
|
||||
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e)]),
|
||||
["When", "Actor", "Category", "Action", "Target", "Details"],
|
||||
sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e), detailSummary(e)]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,23 +81,27 @@ export default function AuditLog() {
|
||||
<SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems?.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>{formatDateTime(new Date(e.createdAt))}</td>
|
||||
<td>{formatDateTime(parseDbTimestamp(e.createdAt))}</td>
|
||||
<td>{e.actorLabel ?? "—"}</td>
|
||||
<td>
|
||||
<span className="badge bg-blue-lt">{e.category}</span>
|
||||
</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{targetLabel(e)}</td>
|
||||
<td className="text-secondary small text-break" style={{ maxWidth: 420 }}>
|
||||
{detailSummary(e) || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sorted?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-secondary text-center">
|
||||
<td colSpan={6} className="text-secondary text-center">
|
||||
No activity recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
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()]);
|
||||
}
|
||||
@@ -356,7 +364,7 @@ Range (a network like 192.168.16.0/20, or a single address):`,
|
||||
<div className="text-secondary small">
|
||||
{i.reason ? `${i.reason} · ` : ""}
|
||||
{i.createdBy ? `${i.createdBy}, ` : ""}
|
||||
{formatDateTime(new Date(i.createdAt.includes("T") ? i.createdAt : `${i.createdAt.replace(" ", "T")}Z`))}
|
||||
{formatDateTime(parseDbTimestamp(i.createdAt))}
|
||||
{!i.stillPresent && " · no longer occurring"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type DiagLogEntry } from "../api/client";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
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();
|
||||
@@ -84,7 +85,7 @@ export default function DiagLog() {
|
||||
downloadCsv(
|
||||
"diagnostic-log.csv",
|
||||
["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"],
|
||||
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]),
|
||||
sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,7 +168,7 @@ export default function DiagLog() {
|
||||
{sorted?.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="text-secondary" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDateTime(new Date(e.createdAt))}
|
||||
{formatDateTime(parseDbTimestamp(e.createdAt))}
|
||||
</td>
|
||||
<td>{SOURCE_LABELS[e.source] ?? e.source}</td>
|
||||
<td className="text-secondary">{e.operation}</td>
|
||||
|
||||
@@ -16,6 +16,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
@@ -182,7 +183,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function removeRecord(r: DnsRecord) {
|
||||
if (!selectedProviderId || !selectedZone) return;
|
||||
if (!confirm(`Delete ${r.type} record "${r.name}"?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete DNS record", message: `Delete ${r.type} record "${r.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id);
|
||||
@@ -202,7 +203,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function removeProvider(p: DnsProviderSummary) {
|
||||
if (!confirm(`Delete provider "${p.name}"? This removes its cached zones and records too.`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete DNS provider", message: `Delete provider "${p.name}"? This removes its cached zones and records too.`, confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await api.dns.providers.remove(p.id);
|
||||
if (selectedProviderId === p.id) setSelectedProviderId(null);
|
||||
|
||||
@@ -12,6 +12,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function updateBadge(c: DockhandContainer) {
|
||||
if (c.updateAvailable === null) return <span className="text-secondary">—</span>;
|
||||
@@ -124,7 +125,7 @@ export default function Docker({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
|
||||
if (!selectedId) return;
|
||||
if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return;
|
||||
if (action === "stop" && !(await confirmDialog({ title: "Stop container", message: `Stop container "${c.name}"?`, confirmLabel: "Stop", danger: true }))) return;
|
||||
setActingOnContainer(c.id);
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSortable } from "../hooks/useSortable";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function statusBadge(d: DomainRecord) {
|
||||
switch (d.status) {
|
||||
@@ -87,7 +88,7 @@ export default function Domains({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function remove(d: DomainRecord) {
|
||||
if (!confirm(`Stop tracking ${d.name}?`)) return;
|
||||
if (!(await confirmDialog({ title: "Stop tracking domain", message: `Stop tracking ${d.name}?`, confirmLabel: "Stop tracking", danger: true }))) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.domains.remove(d.id);
|
||||
|
||||
+46
-13
@@ -1,21 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type CurrentUser, type NameTheme } from "../api/client";
|
||||
import CopyButton from "../components/CopyButton";
|
||||
import { readableError } from "../utils/errors";
|
||||
import {
|
||||
generateServerName,
|
||||
generateUsername,
|
||||
generatePassword,
|
||||
passwordEntropyBits,
|
||||
passwordStrengthLabel,
|
||||
type ServerNameTheme,
|
||||
type UsernameOptions,
|
||||
type PasswordOptions,
|
||||
} from "../utils/generators";
|
||||
|
||||
export default function Generator() {
|
||||
const MIXED = "mixed";
|
||||
|
||||
export default function Generator({ user }: { user: CurrentUser }) {
|
||||
// ─── Server name ───────────────────────────────────────────────────────
|
||||
const [existingServerNames, setExistingServerNames] = useState<string[]>([]);
|
||||
const [theme, setTheme] = useState<ServerNameTheme>("mixed");
|
||||
const [themes, setThemes] = useState<NameTheme[] | null>(null);
|
||||
const [themesError, setThemesError] = useState<string | null>(null);
|
||||
const [theme, setTheme] = useState<string>(MIXED);
|
||||
const [serverName, setServerName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,8 +32,22 @@ export default function Generator() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.generator
|
||||
.themes()
|
||||
.then((res) => setThemes(res.themes))
|
||||
.catch((err) => setThemesError(readableError(err)));
|
||||
}, []);
|
||||
|
||||
// "Mixed" is every list together, each name once even if it appears in several.
|
||||
const pool = useMemo(() => {
|
||||
if (!themes) return [];
|
||||
if (theme === MIXED) return [...new Set(themes.flatMap((t) => t.names))];
|
||||
return themes.find((t) => t.id === theme)?.names ?? [];
|
||||
}, [themes, theme]);
|
||||
|
||||
function rollServerName() {
|
||||
setServerName(generateServerName(theme, existingServerNames));
|
||||
setServerName(generateServerName(pool, existingServerNames));
|
||||
}
|
||||
|
||||
// ─── Username ────────────────────────────────────────────────────────
|
||||
@@ -64,7 +83,8 @@ export default function Generator() {
|
||||
<>
|
||||
<h2 className="page-title mb-3">Generator</h2>
|
||||
<div className="text-secondary mb-3">
|
||||
Everything here is generated locally in your browser — nothing is sent to or stored on the server.
|
||||
Usernames and passwords are generated locally in your browser — nothing is sent to or stored on the server. Server names are
|
||||
picked in your browser too, from name lists the server hands out.
|
||||
</div>
|
||||
|
||||
<div className="row row-cards">
|
||||
@@ -74,20 +94,33 @@ export default function Generator() {
|
||||
<h3 className="card-title">Server name</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">Picks from Swedish girl names and Disney characters, avoiding names already in use.</p>
|
||||
<p className="text-secondary">
|
||||
Picks from a list of names, avoiding names already in use.
|
||||
{user.role === "admin" && (
|
||||
<>
|
||||
{" "}
|
||||
<Link to="/settings/names">Edit the lists</Link>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{themesError && <div className="alert alert-danger py-2">{themesError}</div>}
|
||||
<label className="form-label">Theme</label>
|
||||
<select className="form-select mb-3" value={theme} onChange={(e) => setTheme(e.target.value as ServerNameTheme)}>
|
||||
<option value="mixed">Mixed</option>
|
||||
<option value="swedish">Swedish girl names</option>
|
||||
<option value="disney">Disney characters</option>
|
||||
<select className="form-select mb-1" value={theme} onChange={(e) => setTheme(e.target.value)} disabled={!themes}>
|
||||
<option value={MIXED}>Mixed — all lists</option>
|
||||
{(themes ?? []).map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label} ({t.names.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-hint mb-3">{themes ? `${pool.length} name${pool.length === 1 ? "" : "s"} to pick from.` : "Loading the lists…"}</div>
|
||||
<div className="input-group">
|
||||
<input type="text" className="form-control" readOnly value={serverName} placeholder="Click Generate…" />
|
||||
{serverName && <CopyButton text={serverName} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button className="btn btn-primary" onClick={rollServerName}>
|
||||
<button className="btn btn-primary" onClick={rollServerName} disabled={pool.length === 0}>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import IntegrationEditForm from "../components/IntegrationEditForm";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
tailscale: "Tailscale",
|
||||
@@ -79,7 +80,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function removeIntegration(i: IntegrationSummary) {
|
||||
if (!confirm(`Delete integration "${i.name}"?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete integration", message: `Delete integration "${i.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await api.integrations.remove(i.id);
|
||||
loadIntegrations();
|
||||
|
||||
+11
-6
@@ -7,6 +7,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { useSelection } from "../hooks/useSelection";
|
||||
import { confirmDialog, promptDialog } from "../utils/dialogs";
|
||||
|
||||
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
|
||||
|
||||
@@ -74,10 +75,14 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function excludeAround(entry: IpamEntry) {
|
||||
const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`;
|
||||
const range = window.prompt(
|
||||
`Hide this range from IP Addresses and the Consistency report — every address in it, not just this one:`,
|
||||
suggestion,
|
||||
);
|
||||
const range = await promptDialog({
|
||||
title: "Exclude a range",
|
||||
message: "Hide this range from IP Addresses and the Consistency report — every address in it, not just this one.",
|
||||
label: "Range (a network like 172.17.0.0/16, or a single address)",
|
||||
defaultValue: suggestion,
|
||||
confirmLabel: "Exclude",
|
||||
required: true,
|
||||
});
|
||||
if (range === null || !range.trim()) return;
|
||||
await saveRanges([...excludedRanges, range.trim()]);
|
||||
}
|
||||
@@ -139,7 +144,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function remove(entry: IpamEntry) {
|
||||
if (!confirm(`Delete IP address "${entry.ipAddress}"?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete IP address", message: `Delete IP address "${entry.ipAddress}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.ipam.remove(entry.id);
|
||||
@@ -152,7 +157,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
async function bulkDelete() {
|
||||
const ids = Array.from(selection.selected);
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm(`Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete IP addresses", message: `Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
setError(null);
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { formatAgo } from "../utils/duration";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const emptyForm: PortForwardInput = {
|
||||
label: "",
|
||||
@@ -163,7 +164,7 @@ export default function Ports({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function remove(fwd: PortForward) {
|
||||
if (!confirm(`Delete port opening "${fwd.label}"?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete port opening", message: `Delete port opening "${fwd.label}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
setForwardError(null);
|
||||
try {
|
||||
await api.ports.forwards.remove(fwd.id);
|
||||
|
||||
@@ -163,7 +163,10 @@ export default function Privacy() {
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Audit log</td>
|
||||
<td>Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.</td>
|
||||
<td>
|
||||
Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.
|
||||
Also each time you sign in or out — with the IP address you came from — and when your account was first created.
|
||||
</td>
|
||||
<td>
|
||||
{retention?.enabled
|
||||
? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).`
|
||||
@@ -192,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>
|
||||
@@ -248,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
|
||||
@@ -288,7 +299,7 @@ export default function Privacy() {
|
||||
<div className="card-body">
|
||||
<ul className="mb-0">
|
||||
<li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li>
|
||||
<li className="mb-2">Operators and admins: also the audit log — who did what.</li>
|
||||
<li className="mb-2">Operators and admins: also the audit log — who did what, and who signed in from where.</li>
|
||||
<li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function proxmoxStatusBadge(status: string) {
|
||||
return status === "running" ? (
|
||||
@@ -164,7 +165,16 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
||||
if (!selectedId) return;
|
||||
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`))
|
||||
const kind = g.type === "qemu" ? "VM" : "container";
|
||||
if (
|
||||
action === "stop" &&
|
||||
!(await confirmDialog({
|
||||
title: `Stop ${kind}`,
|
||||
message: `Stop ${kind} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`,
|
||||
confirmLabel: "Stop now",
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setActingOnGuest(g.vmid);
|
||||
setError(null);
|
||||
|
||||
@@ -9,6 +9,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { useSelection } from "../hooks/useSelection";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
|
||||
api_token: "API Token",
|
||||
@@ -134,7 +135,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function remove(s: SecretRecord) {
|
||||
if (!confirm(`Delete secret "${s.name}"?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete secret", message: `Delete secret "${s.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.secrets.remove(s.id);
|
||||
@@ -147,7 +148,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
|
||||
async function bulkDelete() {
|
||||
const ids = Array.from(selection.selected);
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm(`Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete secrets", message: `Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
setError(null);
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function semaphoreStatusBadge(template: SemaphoreTemplate) {
|
||||
const status = template.lastTask?.status;
|
||||
@@ -85,7 +86,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function runTemplate(t: SemaphoreTemplate) {
|
||||
if (!selectedId) return;
|
||||
if (!confirm(`Run "${t.name}" now?`)) return;
|
||||
if (!(await confirmDialog({ title: "Run template", message: `Run "${t.name}" now?`, confirmLabel: "Run now" }))) return;
|
||||
setRunningTemplate(t.id);
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@ import ServerTags from "../components/ServerTags";
|
||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
|
||||
([value, label]) => ({ value: value as ScheduleType, label }),
|
||||
@@ -258,7 +259,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function deleteTask(task: TaskRecord) {
|
||||
if (!confirm(`Delete "${task.name}"?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete task", message: `Delete "${task.name}"?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await api.tasks.remove(task.id);
|
||||
loadTasks();
|
||||
@@ -330,7 +331,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function removeAdminLink(link: ServerLink) {
|
||||
if (!confirm(`Remove the "${link.label}" link?`)) return;
|
||||
if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link?`, confirmLabel: "Remove", danger: true }))) return;
|
||||
try {
|
||||
await api.servers.removeLink(serverId, link.id);
|
||||
await loadDetail();
|
||||
@@ -347,7 +348,15 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function guestAction(action: "start" | "stop" | "restart" | "shutdown") {
|
||||
if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return;
|
||||
if (action === "stop" && !confirm(`Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`))
|
||||
if (
|
||||
action === "stop" &&
|
||||
!(await confirmDialog({
|
||||
title: "Stop server",
|
||||
message: `Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`,
|
||||
confirmLabel: "Stop now",
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setActingOnGuest(true);
|
||||
setGuestActionError(null);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { downloadCsv } from "../utils/csv";
|
||||
import { TagBadges } from "../components/ServerTags";
|
||||
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
|
||||
import { tagBadge, useTagColors } from "../utils/tags";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
||||
const color = colors[type];
|
||||
@@ -85,7 +86,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
|
||||
async function deleteServer(id: number) {
|
||||
if (!confirm("Remove this server and all its tracked tasks?")) return;
|
||||
if (!(await confirmDialog({ title: "Remove server", message: "Remove this server and all its tracked tasks?", confirmLabel: "Remove", danger: true }))) return;
|
||||
try {
|
||||
await api.servers.remove(id);
|
||||
await loadServers();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatDateTime } from "../utils/date";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
function friendlyUserAgent(ua: string | null): string {
|
||||
if (!ua) return "—";
|
||||
@@ -54,7 +55,7 @@ export default function Sessions() {
|
||||
const confirmMsg = isSelf
|
||||
? `This is your current session — revoking it will sign you out immediately. Continue?`
|
||||
: `Revoke ${label}'s session? They'll be signed out immediately.`;
|
||||
if (!confirm(confirmMsg)) return;
|
||||
if (!(await confirmDialog({ title: isSelf ? "Sign out of this session" : "End session", message: confirmMsg, confirmLabel: isSelf ? "Sign me out" : "Revoke session", danger: true }))) return;
|
||||
setError(null);
|
||||
setRevokingId(s.id);
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ const SUB_NAV = [
|
||||
{ to: "/settings/badges", label: "Badges" },
|
||||
{ to: "/settings/display", label: "Display" },
|
||||
{ to: "/settings/tags", label: "Tags" },
|
||||
{ to: "/settings/names", label: "Names" },
|
||||
{ to: "/settings/cache", label: "Cache" },
|
||||
{ to: "/settings/logs", label: "Logs" },
|
||||
{ to: "/settings/backup", label: "Backup" },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { useSelection } from "../hooks/useSelection";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const KEY_EXPIRY_WARN_DAYS = 30;
|
||||
|
||||
@@ -89,7 +90,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
|
||||
async function removeDevice(device: TailscaleDevice) {
|
||||
if (!selectedId) return;
|
||||
if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return;
|
||||
if (!(await confirmDialog({ title: "Remove device", message: `Remove device "${device.label || device.hostname}" from the tailnet?`, confirmLabel: "Remove", danger: true }))) return;
|
||||
try {
|
||||
await api.integrations.tailscale.remove(selectedId, device.id);
|
||||
loadDevices(selectedId);
|
||||
@@ -119,7 +120,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||
if (!selectedId) return;
|
||||
const ids = Array.from(selection.selected);
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm(`Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`)) return;
|
||||
if (!(await confirmDialog({ title: "Remove devices", message: `Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`, confirmLabel: "Remove", danger: true }))) return;
|
||||
setError(null);
|
||||
setBulkActing(true);
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { api, type EncryptedExportFile, type ImportResult } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
|
||||
function downloadJson(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||
@@ -65,7 +66,7 @@ export default function BackupSettings() {
|
||||
|
||||
async function handleImport() {
|
||||
if (!importFile) return;
|
||||
if (!confirm("Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.")) return;
|
||||
if (!(await confirmDialog({ title: "Import backup", message: "Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.", confirmLabel: "Import" }))) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
setImportResult(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
|
||||
export default function CacheSettings() {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
@@ -7,7 +8,7 @@ export default function CacheSettings() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleClear() {
|
||||
if (!confirm("Clear the DNS record cache? All zones will need to be re-synced afterwards.")) return;
|
||||
if (!(await confirmDialog({ title: "Clear DNS cache", message: "Clear the DNS record cache? All zones will need to be re-synced afterwards.", confirmLabel: "Clear cache" }))) return;
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
setCleared(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AppSettings } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
|
||||
const INTERVAL_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: "Every hour" },
|
||||
@@ -51,7 +52,7 @@ export default function LogSettings() {
|
||||
}
|
||||
|
||||
async function handlePurgeNow() {
|
||||
if (!confirm(`Delete diagnostic and audit log entries older than ${retentionDays} days now?`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete old log entries", message: `Delete diagnostic and audit log entries older than ${retentionDays} days now?`, confirmLabel: "Delete", danger: true }))) return;
|
||||
setPurging(true);
|
||||
setPurgeError(null);
|
||||
setPurgeResult(null);
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type NameImportPreview, type NameTheme, type NameThemeSource } from "../../api/client";
|
||||
import { confirmDialog } from "../../utils/dialogs";
|
||||
import { formatDateTime } from "../../utils/date";
|
||||
import { readableError } from "../../utils/errors";
|
||||
import { parseNames } from "../../utils/nameLists";
|
||||
|
||||
/** One list as it's being edited. The names are kept as the text in the box, so typing stays natural. */
|
||||
interface Draft {
|
||||
/** Stable for the life of the page; a list that hasn't been saved yet has no `id`. */
|
||||
key: string;
|
||||
id?: string;
|
||||
label: string;
|
||||
text: string;
|
||||
lastImport?: NameThemeSource;
|
||||
}
|
||||
|
||||
const MAX_LABEL = 40;
|
||||
|
||||
function toDraft(t: NameTheme): Draft {
|
||||
return { key: t.id, id: t.id, label: t.label, text: t.names.join("\n"), lastImport: t.lastImport };
|
||||
}
|
||||
|
||||
/** What identifies the content of a list, for telling whether anything changed. */
|
||||
function snapshot(id: string | undefined, label: string, names: string[], lastImport: NameThemeSource | undefined): string {
|
||||
return JSON.stringify({ id, label, names, lastImport });
|
||||
}
|
||||
|
||||
function describeImport(s: NameThemeSource): string {
|
||||
const years = s.years.length === 0 ? "" : s.years.length === 1 ? String(s.years[0]) : `${s.years[0]}–${s.years[s.years.length - 1]}`;
|
||||
return `${s.sex === "girls" ? "girls" : "boys"}, ${years}, top ${s.count} — ${formatDateTime(new Date(s.importedAt))}`;
|
||||
}
|
||||
|
||||
export default function NameSettings() {
|
||||
const [saved, setSaved] = useState<NameTheme[] | null>(null);
|
||||
const [builtinIds, setBuiltinIds] = useState<string[]>([]);
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
|
||||
// Skatteverket import panel
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [sex, setSex] = useState<"girls" | "boys">("girls");
|
||||
const [years, setYears] = useState(3);
|
||||
const [count, setCount] = useState(100);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<NameImportPreview | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.generator
|
||||
.themes()
|
||||
.then((res) => {
|
||||
setSaved(res.themes);
|
||||
setBuiltinIds(res.builtinIds);
|
||||
const next = res.themes.map(toDraft);
|
||||
setDrafts(next);
|
||||
setSelectedKey(next[0]?.key ?? null);
|
||||
})
|
||||
.catch((err) => setError(readableError(err)));
|
||||
}, []);
|
||||
|
||||
const parsed = useMemo(() => drafts.map((d) => parseNames(d.text)), [drafts]);
|
||||
const selectedIndex = Math.max(0, drafts.findIndex((d) => d.key === selectedKey));
|
||||
const selected = drafts[selectedIndex];
|
||||
const selectedParsed = parsed[selectedIndex];
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!saved) return false;
|
||||
const now = drafts.map((d, i) => snapshot(d.id, d.label.trim(), parsed[i].names, d.lastImport));
|
||||
const before = saved.map((t) => snapshot(t.id, t.label, t.names, t.lastImport));
|
||||
return JSON.stringify(now) !== JSON.stringify(before);
|
||||
}, [saved, drafts, parsed]);
|
||||
|
||||
const problem = useMemo(() => {
|
||||
for (let i = 0; i < drafts.length; i++) {
|
||||
if (!drafts[i].label.trim()) return "Every list needs a name.";
|
||||
if (parsed[i].invalid.length > 0) return `“${drafts[i].label.trim() || "A list"}” has names that can't be used — see the list.`;
|
||||
}
|
||||
return null;
|
||||
}, [drafts, parsed]);
|
||||
|
||||
function update(key: string, patch: Partial<Draft>) {
|
||||
setDrafts((all) => all.map((d) => (d.key === key ? { ...d, ...patch } : d)));
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
function select(key: string) {
|
||||
setSelectedKey(key);
|
||||
setImportOpen(false);
|
||||
setPreview(null);
|
||||
setImportError(null);
|
||||
}
|
||||
|
||||
function addList() {
|
||||
const key = `new-${newCount}`;
|
||||
setNewCount((n) => n + 1);
|
||||
setDrafts((all) => [...all, { key, label: "New list", text: "" }]);
|
||||
select(key);
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
async function removeList(d: Draft) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Delete list",
|
||||
message: `Delete “${d.label.trim() || "this list"}”? It's removed when you save${d.id && builtinIds.includes(d.id) ? ", and can be brought back with “Restore built-in lists”" : ""}.`,
|
||||
confirmLabel: "Delete",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const index = drafts.findIndex((x) => x.key === d.key);
|
||||
const rest = drafts.filter((x) => x.key !== d.key);
|
||||
setDrafts(rest);
|
||||
select(rest[Math.min(index, rest.length - 1)]?.key ?? "");
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
async function resetToBuiltIn(d: Draft) {
|
||||
if (!d.id) return;
|
||||
const ok = await confirmDialog({
|
||||
title: "Reset list",
|
||||
message: `Put “${d.label.trim() || d.id}” back to its built-in names? Your edits to this list are lost (once you save).`,
|
||||
confirmLabel: "Reset",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const { themes } = await api.generator.defaults();
|
||||
const original = themes.find((t) => t.id === d.id);
|
||||
if (!original) throw new Error("There's no built-in version of this list.");
|
||||
update(d.key, { label: original.label, text: original.names.join("\n"), lastImport: undefined });
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
}
|
||||
}
|
||||
|
||||
const missingBuiltIns = builtinIds.filter((id) => !drafts.some((d) => d.id === id));
|
||||
|
||||
async function restoreBuiltIns() {
|
||||
try {
|
||||
const { themes } = await api.generator.defaults();
|
||||
const missing = themes.filter((t) => missingBuiltIns.includes(t.id));
|
||||
setDrafts((all) => [...all, ...missing.map(toDraft)]);
|
||||
setNotice(`Brought back ${missing.length} built-in list${missing.length === 1 ? "" : "s"} — save to keep ${missing.length === 1 ? "it" : "them"}.`);
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await api.generator.save(
|
||||
drafts.map((d, i) => ({ id: d.id, label: d.label.trim(), names: parsed[i].names, lastImport: d.lastImport })),
|
||||
);
|
||||
const keepAt = selectedIndex;
|
||||
setSaved(res.themes);
|
||||
const next = res.themes.map(toDraft);
|
||||
setDrafts(next);
|
||||
setSelectedKey(next[Math.min(keepAt, next.length - 1)]?.key ?? null);
|
||||
setNotice("Saved. The Generator uses these lists from now on.");
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (!saved) return;
|
||||
const next = saved.map(toDraft);
|
||||
setDrafts(next);
|
||||
setSelectedKey(next[0]?.key ?? null);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
setImportOpen(false);
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
async function fetchPreview() {
|
||||
setFetching(true);
|
||||
setImportError(null);
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await api.generator.importNames(sex, years, count));
|
||||
} catch (err) {
|
||||
setImportError(readableError(err));
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreview(mode: "add" | "replace") {
|
||||
if (!preview || !selected) return;
|
||||
const existing = mode === "add" ? selectedParsed.names : [];
|
||||
const have = new Set(existing);
|
||||
const fresh = preview.names.filter((n) => !have.has(n));
|
||||
update(selected.key, { text: [...existing, ...fresh].join("\n"), lastImport: preview.source });
|
||||
setNotice(
|
||||
mode === "add"
|
||||
? `Added ${fresh.length} new name${fresh.length === 1 ? "" : "s"} (${preview.names.length - fresh.length} were already there) — save to keep ${fresh.length === 1 ? "it" : "them"}.`
|
||||
: `Replaced the list with ${preview.names.length} names — save to keep them.`,
|
||||
);
|
||||
setPreview(null);
|
||||
setImportOpen(false);
|
||||
}
|
||||
|
||||
function sortList() {
|
||||
if (!selected) return;
|
||||
update(selected.key, { text: [...selectedParsed.names].sort((a, b) => a.localeCompare(b, "sv")).join("\n") });
|
||||
}
|
||||
|
||||
if (!saved) {
|
||||
return error ? <div className="alert alert-danger">{error}</div> : <div className="text-secondary">Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{notice && <div className="alert alert-success">{notice}</div>}
|
||||
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Name lists</h3>
|
||||
<div className="card-actions btn-list">
|
||||
{missingBuiltIns.length > 0 && (
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => void restoreBuiltIns()}>
|
||||
Restore built-in lists ({missingBuiltIns.length})
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={addList} disabled={drafts.length >= 20}>
|
||||
New list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="text-secondary small mb-3">
|
||||
Operations → Generator picks server names from these lists. A name is letters, digits and hyphens (å, ä and ö become a, a and o), so
|
||||
it works as a hostname. Changes apply when you save.
|
||||
</div>
|
||||
{drafts.length === 0 ? (
|
||||
<div className="text-secondary">No lists. Add one, or restore the built-in ones.</div>
|
||||
) : (
|
||||
<ul className="nav nav-pills gap-1">
|
||||
{drafts.map((d, i) => (
|
||||
<li className="nav-item" key={d.key}>
|
||||
<button className={`nav-link ${d.key === selected?.key ? "active" : ""}`} onClick={() => select(d.key)}>
|
||||
{d.label.trim() || "(unnamed)"} <span className="ms-1 opacity-75">{parsed[i].names.length}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && selectedParsed && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">List name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
maxLength={MAX_LABEL}
|
||||
value={selected.label}
|
||||
onChange={(e) => update(selected.key, { label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6 d-flex align-items-end justify-content-md-end">
|
||||
<div className="btn-list">
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => setImportOpen((v) => !v)}>
|
||||
Import from Skatteverket…
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={sortList} disabled={selectedParsed.names.length < 2}>
|
||||
Sort A–Z
|
||||
</button>
|
||||
{selected.id && builtinIds.includes(selected.id) && (
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => void resetToBuiltIn(selected)}>
|
||||
Reset to built-in
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-outline-danger btn-sm" onClick={() => void removeList(selected)}>
|
||||
Delete list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importOpen && (
|
||||
<div className="border rounded p-3 mt-3">
|
||||
<div className="fw-bold mb-1">Import the most common names from Skatteverket</div>
|
||||
<div className="text-secondary small mb-3">
|
||||
Skatteverket publishes the given names of newborns in Sweden, by sex and birth year, as open data. This fetches the most common ones
|
||||
across the years you pick (the running year isn't counted — it isn't complete). You see them first; nothing changes until you add
|
||||
them to this list and save.
|
||||
</div>
|
||||
<div className="d-flex flex-wrap align-items-end gap-2">
|
||||
<div>
|
||||
<label className="form-label mb-1">Names for</label>
|
||||
<select className="form-select" value={sex} onChange={(e) => setSex(e.target.value as "girls" | "boys")}>
|
||||
<option value="girls">Girls</option>
|
||||
<option value="boys">Boys</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label mb-1">Latest years</label>
|
||||
<select className="form-select" value={years} onChange={(e) => setYears(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} year{n === 1 ? "" : "s"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label mb-1">How many names</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
style={{ width: 120 }}
|
||||
min={10}
|
||||
max={500}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Math.min(500, Math.max(10, Number(e.target.value) || 10)))}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => void fetchPreview()} disabled={fetching}>
|
||||
{fetching ? "Fetching…" : "Fetch names"}
|
||||
</button>
|
||||
</div>
|
||||
{importError && <div className="alert alert-danger mt-3 mb-0">{importError}</div>}
|
||||
{preview && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2">
|
||||
Found <strong>{preview.names.length}</strong> names for {preview.source.sex === "girls" ? "girls" : "boys"}, born{" "}
|
||||
{preview.source.years.join(", ")}.
|
||||
{preview.missingYears.length > 0 && <span className="text-secondary"> No data yet for {preview.missingYears.join(", ")}.</span>}
|
||||
{preview.skipped.length > 0 && (
|
||||
<span className="text-secondary">
|
||||
{" "}
|
||||
{preview.skipped.length} left out because they can't be used as a server name ({preview.skipped.slice(0, 5).join(", ")}
|
||||
{preview.skipped.length > 5 ? ", …" : ""}).
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-secondary small mb-3" style={{ maxHeight: 96, overflow: "auto" }}>
|
||||
{preview.names.join(", ")}
|
||||
</div>
|
||||
<div className="btn-list">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => applyPreview("add")}>
|
||||
Add to this list
|
||||
</button>
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={() => applyPreview("replace")}>
|
||||
Replace this list
|
||||
</button>
|
||||
<button className="btn btn-link btn-sm" onClick={() => setPreview(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="form-label mt-3">
|
||||
Names <span className="text-secondary fw-normal">— one per line, or separated by commas or spaces</span>
|
||||
</label>
|
||||
<textarea
|
||||
className={`form-control font-monospace ${selectedParsed.invalid.length > 0 ? "is-invalid" : ""}`}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
value={selected.text}
|
||||
onChange={(e) => update(selected.key, { text: e.target.value })}
|
||||
/>
|
||||
{selectedParsed.invalid.length > 0 && (
|
||||
<div className="invalid-feedback d-block">
|
||||
Can't be used as a server name: {selectedParsed.invalid.slice(0, 8).map((n) => `“${n}”`).join(", ")}
|
||||
{selectedParsed.invalid.length > 8 ? `, and ${selectedParsed.invalid.length - 8} more` : ""}. Use letters, digits and hyphens.
|
||||
</div>
|
||||
)}
|
||||
<div className="text-secondary small mt-2">
|
||||
{selectedParsed.names.length} name{selectedParsed.names.length === 1 ? "" : "s"}
|
||||
{selected.id && builtinIds.includes(selected.id) ? " · built-in list" : ""}
|
||||
{selected.lastImport ? ` · last imported from Skatteverket (${describeImport(selected.lastImport)})` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||
<button className="btn btn-primary" onClick={() => void save()} disabled={busy || !dirty || problem !== null}>
|
||||
{busy ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={discard} disabled={busy || !dirty}>
|
||||
Discard changes
|
||||
</button>
|
||||
{dirty && !problem && <span className="text-secondary small">You have unsaved changes.</span>}
|
||||
{problem && <span className="text-danger small">{problem}</span>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { api, type TagEntry } from "../../api/client";
|
||||
import { readableError } from "../../utils/errors";
|
||||
import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags";
|
||||
import { confirmDialog, promptDialog } from "../../utils/dialogs";
|
||||
|
||||
const DEFAULT_PICK = "#3b82f6";
|
||||
|
||||
@@ -64,12 +65,20 @@ export default function TagSettings() {
|
||||
}
|
||||
|
||||
async function rename(t: TagEntry) {
|
||||
const input = window.prompt(`Rename “${t.name}” to:`, t.name);
|
||||
const input = await promptDialog({ title: "Rename tag", message: `Rename “${t.name}” to:`, defaultValue: t.name, confirmLabel: "Rename", required: true });
|
||||
if (input === null) return;
|
||||
const to = normalizeTagInput(input);
|
||||
if (!to || to === t.name) return;
|
||||
const target = tags?.find((x) => x.name === to);
|
||||
if (target && !window.confirm(`“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`)) return;
|
||||
if (
|
||||
target &&
|
||||
!(await confirmDialog({
|
||||
title: "Merge tags",
|
||||
message: `“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`,
|
||||
confirmLabel: "Merge",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.tags.rename(t.name, to),
|
||||
(res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`,
|
||||
@@ -78,7 +87,7 @@ export default function TagSettings() {
|
||||
|
||||
async function remove(t: TagEntry) {
|
||||
const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : "";
|
||||
if (!window.confirm(`Delete the tag “${t.name}”?${used}`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete tag", message: `Delete the tag “${t.name}”?${used}`, confirmLabel: "Delete", danger: true }))) return;
|
||||
await run(
|
||||
() => api.tags.remove(t.name),
|
||||
(res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`,
|
||||
|
||||
@@ -17,6 +17,15 @@ export function is24HourFormat(): boolean {
|
||||
return current.timeFormat === "24h";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a timestamp the server stored. SQLite's own `current_timestamp` ("2026-10-02 20:27:55") is UTC but carries no
|
||||
* zone marker, and `new Date()` would read that as local time — showing every entry shifted by the viewer's UTC offset.
|
||||
* Timestamps the app wrote itself are ISO strings with a zone and parse as they are.
|
||||
*/
|
||||
export function parseDbTimestamp(value: string): Date {
|
||||
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value.replace(" ", "T")}Z`);
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// In-app replacements for window.confirm() and window.prompt(). Call sites just await them, the same way they used to
|
||||
// call the browser's — a single <DialogHost /> (mounted once, in App) draws whichever is asked for. Several asked for
|
||||
// at once are shown one after another.
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
/** What the main button says — name the action ("Delete", "Stop") rather than leaving it as "OK". */
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Destructive or hard to undo: red button, and the safe choice gets the keyboard focus. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface PromptOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
/** Label for the text box itself, when the message above it isn't enough. */
|
||||
label?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Disable the main button until something has been typed. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export type DialogRequest =
|
||||
| ({ kind: "confirm"; id: number; resolve: (accepted: boolean) => void } & ConfirmOptions)
|
||||
| ({ kind: "prompt"; id: number; resolve: (value: string | null) => void } & PromptOptions);
|
||||
|
||||
let nextId = 1;
|
||||
let queue: DialogRequest[] = [];
|
||||
let listener: ((current: DialogRequest | null) => void) | null = null;
|
||||
|
||||
function emit() {
|
||||
listener?.(queue[0] ?? null);
|
||||
}
|
||||
|
||||
/** For the host: called with whatever should be on screen now (and straight away with what already is). */
|
||||
export function subscribe(next: (current: DialogRequest | null) => void): () => void {
|
||||
listener = next;
|
||||
emit();
|
||||
return () => {
|
||||
if (listener === next) listener = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves the dialog on screen. A cancelled confirm is false and a cancelled prompt is null — as with the browser's own. */
|
||||
export function settle(request: DialogRequest, accepted: boolean, value = ""): void {
|
||||
queue = queue.filter((r) => r !== request);
|
||||
if (request.kind === "confirm") request.resolve(accepted);
|
||||
else request.resolve(accepted ? value : null);
|
||||
emit();
|
||||
}
|
||||
|
||||
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
queue.push({ ...options, kind: "confirm", id: nextId++, resolve });
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function promptDialog(options: PromptOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
queue.push({ ...options, kind: "prompt", id: nextId++, resolve });
|
||||
emit();
|
||||
});
|
||||
}
|
||||
@@ -16,41 +16,19 @@ function pick<T>(list: T[]): T {
|
||||
|
||||
// ─── Server names ───────────────────────────────────────────────────────────
|
||||
|
||||
export type ServerNameTheme = "swedish" | "disney" | "mixed";
|
||||
|
||||
// Common Swedish girl names, per SCB/Skatteverket's own published
|
||||
// name-popularity statistics for recent birth cohorts.
|
||||
const SWEDISH_GIRL_NAMES = [
|
||||
"freja", "elsa", "alice", "maja", "wilma", "alma", "ebba", "lilly", "ella", "saga",
|
||||
"agnes", "stella", "selma", "vera", "ingrid", "astrid", "linnea", "nova", "sara", "emma",
|
||||
"julia", "olivia", "isabelle", "klara", "nellie", "elin", "signe", "tuva", "moa", "tyra",
|
||||
"hedda", "nora", "amanda", "anna", "elvira", "iris", "matilda", "molly", "sofia", "thea",
|
||||
"vilma", "cornelia", "filippa", "livia", "meja", "ronja", "sigrid", "tilde", "greta", "hilda",
|
||||
];
|
||||
|
||||
// Single-word Disney character names (kept single-word so they slug into a
|
||||
// hostname cleanly without a judgment call on how to join "Snow White").
|
||||
const DISNEY_CHARACTERS = [
|
||||
"moana", "elsa", "anna", "belle", "ariel", "jasmine", "aurora", "cinderella", "rapunzel", "mulan",
|
||||
"tiana", "merida", "pocahontas", "mickey", "minnie", "simba", "nala", "stitch", "lilo", "olaf",
|
||||
"baymax", "dory", "nemo", "woody", "buzz", "genie", "aladdin", "eric", "flynn", "kristoff",
|
||||
"sven", "pumbaa", "timon", "mufasa", "scar", "ursula", "maleficent", "gaston", "hercules", "meg",
|
||||
"tinkerbell", "wendy", "pinocchio", "bambi", "dumbo", "thumper", "flounder", "sebastian", "iago", "abu",
|
||||
"pascal", "maximus", "heihei", "goofy", "donald", "daisy", "pluto", "chip", "dale", "bagheera",
|
||||
"baloo", "mowgli", "rafiki", "zazu", "piglet", "tigger", "eeyore", "pooh",
|
||||
];
|
||||
|
||||
/** Picks a name from the given theme not already present (case-insensitively) in `existing`, appending -2/-3/... only if the whole list is exhausted. */
|
||||
export function generateServerName(theme: ServerNameTheme, existing: string[] = []): string {
|
||||
const pool = theme === "swedish" ? SWEDISH_GIRL_NAMES : theme === "disney" ? DISNEY_CHARACTERS : [...SWEDISH_GIRL_NAMES, ...DISNEY_CHARACTERS];
|
||||
/**
|
||||
* Picks a name from `pool` that isn't already in `existing` (case-insensitively). Only if the whole pool is taken does it
|
||||
* fall back to numbering one — maja2, maja3, …. The pools themselves are the name lists under Settings → Names.
|
||||
*/
|
||||
export function generateServerName(pool: string[], existing: string[] = []): string {
|
||||
if (pool.length === 0) return "";
|
||||
const used = new Set(existing.map((n) => n.toLowerCase()));
|
||||
const available = pool.filter((n) => !used.has(n));
|
||||
const available = pool.filter((n) => !used.has(n.toLowerCase()));
|
||||
if (available.length > 0) return pick(available);
|
||||
// Every name in the theme is already taken — fall back to numbering a random one.
|
||||
const base = pick(pool);
|
||||
for (let suffix = 2; suffix < 1000; suffix++) {
|
||||
const candidate = `${base}${suffix}`;
|
||||
if (!used.has(candidate)) return candidate;
|
||||
if (!used.has(candidate.toLowerCase())) return candidate;
|
||||
}
|
||||
return `${base}${randomInt(100000)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The same rule the server applies to every name in the Generator's lists (server/src/services/nameThemes.ts), so the
|
||||
* editor can point out a bad name while it's being typed. The server checks again on save and is the one that decides.
|
||||
*/
|
||||
export function normalizeNameInput(raw: string): string | null {
|
||||
const folded = raw
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(folded) ? folded : null;
|
||||
}
|
||||
|
||||
/** Names are separated by line breaks, commas, semicolons or spaces. */
|
||||
export function splitNames(text: string): string[] {
|
||||
return text.split(/[\s,;]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The usable names in `text` (folded and de-duplicated, in the order written) and whatever couldn't be used. */
|
||||
export function parseNames(text: string): { names: string[]; invalid: string[] } {
|
||||
const names: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of splitNames(text)) {
|
||||
const clean = normalizeNameInput(token);
|
||||
if (clean === null) invalid.push(token);
|
||||
else if (!seen.has(clean)) {
|
||||
seen.add(clean);
|
||||
names.push(clean);
|
||||
}
|
||||
}
|
||||
return { names, invalid };
|
||||
}
|
||||
Reference in new issue
Block a user