Compare commits
9
Commits
de5c39dddf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3035d7fc08 | ||
|
|
447f33fff6 | ||
|
|
ad1fb5338f | ||
|
|
88d9c8e097 | ||
|
|
22cfdbede0 | ||
|
|
21054aaa8c | ||
|
|
1ff1c6550c | ||
|
|
236b1da0dc | ||
|
|
4e48377348 |
No files matched your search
@@ -0,0 +1,126 @@
|
||||
# Notifications
|
||||
|
||||
Every notification in Homelab Manager is sent through the same pipe
|
||||
(`server/src/services/notify.ts`) to whichever channels are enabled under
|
||||
**Settings → Notifications**: Gotify, ntfy, SMTP (email), and a generic
|
||||
JSON webhook. All four fire for every notification below — there's no
|
||||
per-event channel routing, only a per-event on/off toggle (the "Notify
|
||||
on" list on that same page) and, for the daily ones, one shared
|
||||
time/timezone.
|
||||
|
||||
Two settings apply to *every* notification regardless of what triggered
|
||||
it:
|
||||
|
||||
- **Quiet hours** (Settings → Notifications → Quiet hours): while
|
||||
enabled and inside the configured window, a notification is held
|
||||
instead of sent immediately, then delivered as a single digest at the
|
||||
window's end time. This matters most for the real-time alerts below —
|
||||
the daily reminders already fire once at a time you pick, usually
|
||||
outside the window anyway.
|
||||
- **Maintenance windows** (the Maintenance page): opening one for a
|
||||
server, integration, or DNS provider silences the alerts that target
|
||||
it specifically (offline/disk-full for a server; storage, backup, and
|
||||
automation-failure alerts for an integration; "integration down" for
|
||||
that whole service type) — see the Maintenance page's own "What gets
|
||||
silenced" panel for the exact list.
|
||||
|
||||
Where a trigger has a configurable threshold, that's noted in its row
|
||||
below; several are fixed and can't be changed from the UI.
|
||||
|
||||
## Daily reminders
|
||||
|
||||
These six checks share one schedule: **Settings → Notifications → Daily
|
||||
reminder time** (default 08:00) and **Timezone** (default UTC). Unlike
|
||||
the state-based alerts further down, these **re-send every day the
|
||||
condition is still true** — there's no "only once" de-duplication, so an
|
||||
expired secret you haven't renewed yet will be mentioned again at the
|
||||
next day's check, and the day after that.
|
||||
|
||||
Each one also runs once at server startup if it hasn't already run
|
||||
today (e.g. after an upgrade or a period offline), so you're not waiting
|
||||
until the next scheduled time to catch up.
|
||||
|
||||
| Check | "Notify on" toggle | Fires when | Configured at |
|
||||
|---|---|---|---|
|
||||
| Secret expiry | *Secret expiry reminder* | Any tracked secret (API token, SSL cert, password, generic) is expired or within its own configured warning window. SSL certificates with a host:port are re-checked live first, so this reflects the real current expiry, not a stale saved date. A certificate that couldn't be re-checked live is mentioned separately, so the expiry shown may be stale. | Per-secret warning threshold, set when adding/editing that secret |
|
||||
| Domain expiry | *Domain registration expiring or expired (daily reminder)* | Any tracked domain registration is expired or within the warning window; every domain is re-looked-up against its registry first. A domain whose registry lookup has been failing for 3+ days is also mentioned, separately, as "may be stale." Registries that don't publish an expiry (e.g. `.de`, `.eu`) are tracked but never trigger this. | Settings → Notifications → Health checks → **Domain expiry warning** (default 30 days) |
|
||||
| Tailscale key expiry | *Tailscale key expiry reminder* | Any device's node key (across every enabled Tailscale integration) is within 30 days of expiring, or already expired. Devices with key expiry disabled are skipped. | Fixed at 30 days |
|
||||
| Docker image updates | *Docker image update available* | Any container (across every enabled Dockhand integration) has an image update available, per Dockhand's own cached update-check results — this doesn't trigger a fresh registry lookup, just reads what the Docker page itself would show. | Not configurable (reflects Dockhand's own check interval) |
|
||||
| Proxmox backups | *Proxmox backup failed or a guest has no coverage* | Two independent conditions, both under this one toggle: (1) a node's most recent `vzdump` backup task (across every enabled Proxmox integration) didn't succeed; (2) a VM/LXC isn't covered by any enabled backup job at all. | Not configurable |
|
||||
| Proxmox Backup Server verification | *Proxmox Backup Server snapshot failed verification* | Across every enabled PBS integration: any datastore has at least one stored snapshot whose verification state is "failed", or a datastore couldn't be read at all (e.g. a permissions problem). This is distinct from the Proxmox check above — PVE only knows a backup *ran*, PBS is the only place that knows whether the stored data still verifies. | Not configurable |
|
||||
|
||||
## State-based alerts
|
||||
|
||||
These two run on a fixed **15-minute interval** (matching the agent's
|
||||
own default report interval) — not the daily reminder time above, and
|
||||
not user-configurable. Unlike the daily reminders, these are
|
||||
**state-based**: a problem is announced once when it first appears, and
|
||||
once more when it clears — not repeated on every 15-minute pass while it
|
||||
continues. A condition that can't currently be read (an integration
|
||||
that's down, a server whose agent hasn't reported) is held exactly as it
|
||||
was rather than cleared or re-announced, so a temporary read failure
|
||||
can't fake a recovery. On first-ever run (or right after upgrading to
|
||||
this feature), whatever's already failing is recorded silently as the
|
||||
starting baseline rather than announced all at once.
|
||||
|
||||
| Check | "Notify on" toggle | Fires when | Configured at |
|
||||
|---|---|---|---|
|
||||
| Health — server offline | *Server offline, disk nearly full, or Synology volume/disk problem* | A server's agent hasn't reported for longer than the offline threshold. Held for the first 20 minutes after this app restarts, since agents haven't had a chance to report yet. | Settings → Notifications → Health checks → **Server offline after** (default 60 min) |
|
||||
| Health — disk usage | *(same toggle)* | A server disk, a Proxmox node's root filesystem or any of its storages, or a Synology volume, is at or above the usage threshold. A server that's currently offline isn't also judged on disk usage (its figures are stale). | Settings → Notifications → Health checks → **Disk usage alert at** (default 90%) |
|
||||
| Health — Synology status | *(same toggle)* | A Synology volume's status isn't "normal", or a disk's status/SMART result isn't normal, or it's over the bad-sector or under the remaining-life threshold. | Not configurable |
|
||||
| Automation — failed run | *Semaphore template or Gitea workflow run failed* | A Semaphore template's, or a Gitea repo's, most recent run status is a clear failure. A run that's still going, was cancelled, or was stopped by hand doesn't count as failed or as a recovery — it's left exactly as it was. For Gitea this follows the repo's most recent run on any workflow or branch. | Not configurable |
|
||||
|
||||
## Real-time alerts
|
||||
|
||||
These fire immediately, as the triggering action happens — not on any
|
||||
schedule.
|
||||
|
||||
| Event | "Notify on" toggle | Fires when |
|
||||
|---|---|---|
|
||||
| DNS record added | *DNS record added* | A DNS record is created against any DNS provider through this app (manually, or via any automated action that writes one). |
|
||||
| DNS record updated | *DNS record updated* | An existing DNS record is edited. |
|
||||
| DNS record deleted | *DNS record deleted* | A DNS record is deleted. |
|
||||
| Integration/DNS provider down | *Integration/DNS provider failing repeatedly* | Any integration or DNS provider adapter's calls fail a configurable number of times **in a row**. Tracked per adapter *type* (e.g. "proxmox"), not per individual integration row — with two Proxmox integrations, a streak of failures on either one counts toward the same total. Only fires once per failure streak (not on every failure past the threshold), and is skipped entirely if that type is currently in a maintenance window. | Settings → Notifications → **Alert after** (default 3 consecutive failures) |
|
||||
| Integration/DNS provider recovered | *(same toggle)* | The next call for that source succeeds, after a "down" alert was already sent for the current streak. |
|
||||
|
||||
## Digest
|
||||
|
||||
| Notification | Fires when |
|
||||
|---|---|
|
||||
| "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
|
||||
the app alerts on something:
|
||||
|
||||
- **osTicket** — the integration lists open tickets, but there is no
|
||||
scheduled check or alert wired up for it (e.g. nothing pings you about
|
||||
a newly-overdue ticket). It's a read-only dashboard/page today.
|
||||
- **phpIPAM sync**, **Uptime Kuma monitor/maintenance import**, and any
|
||||
other manual "Sync from X" action — these run when you click the
|
||||
button and report their result on the page itself, not via a
|
||||
notification.
|
||||
- **Test buttons** (Settings → Notifications → each channel's "Send
|
||||
test") send a one-off message through that one channel only, to
|
||||
confirm it's wired up correctly — not a real event and not affected by
|
||||
quiet hours.
|
||||
- **Consistency reports**, **Diagnostic Log**, and **Audit Log** are all
|
||||
read-only views you check yourself; none of them push a notification
|
||||
on their own (the Diagnostic Log's failures are what feed the
|
||||
integration-down alert above, but reading the log itself never
|
||||
triggers anything).
|
||||
@@ -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
|
||||
@@ -60,7 +62,11 @@ All modules from the original plan are built:
|
||||
actions to pull in tailnet device IPs, VM/LXC IPs, and phpIPAM's own
|
||||
addresses (never overwrites a manually-entered IP, or one a different sync
|
||||
owns), and each entry now shows its matching DNS record(s) from the DNS
|
||||
module's cache
|
||||
module's cache. Shares the Consistency page's excluded-ranges setting (see
|
||||
below) so addresses that aren't interesting to track — a Docker bridge
|
||||
network repeating on every host, say — can be hidden here too, with a
|
||||
"Hide excluded addresses" toggle and a per-entry "Exclude…" shortcut to add
|
||||
a range on the spot; managing ranges from either page updates the other
|
||||
- **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
|
||||
DNS, cPanel, and Technitium; providers are configured in-app (not via env
|
||||
vars) and their credentials are encrypted at rest
|
||||
@@ -80,7 +86,11 @@ All modules from the original plan are built:
|
||||
detail page. The detail page also has an **Admin Links** section
|
||||
(operator/admin to add/edit/remove) for bookmarking that server's own
|
||||
admin UIs — Dockge, Webmin, Cockpit, Portainer, or anything else reachable
|
||||
by URL. Since not every server is a Proxmox VM, an admin can hide the
|
||||
by URL. **Operations → Admin Links** summarizes every server's admin links
|
||||
in one sortable, searchable table (server, label, URL, an "Open" link, and
|
||||
the same add/edit/delete as the per-server section — adding one here just
|
||||
asks which server it belongs to), so finding or managing one doesn't mean
|
||||
visiting each server's own page. Since not every server is a Proxmox VM, an admin can hide the
|
||||
"Proxmox link" card per server ("Not a VM? Hide this" / "+ Show Proxmox
|
||||
link options") — it stays visible regardless once a server actually is
|
||||
linked, so unlinking is always reachable.
|
||||
@@ -196,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
|
||||
@@ -216,7 +238,9 @@ many servers reported addresses and how fresh the synced DNS zones are. Only
|
||||
private addresses are compared; ranges you exclude (Docker's, which repeat the same
|
||||
subnet on many hosts — 172.16.0.0/12 is excluded by default, remove it if that's a
|
||||
real LAN for you) are left out of every source; anything that's fine on purpose
|
||||
can be ignored with a reason, and stays ignored.
|
||||
can be ignored with a reason, and stays ignored. The excluded-ranges list is the
|
||||
same one the IP Addresses page manages — edit it from either page and both
|
||||
reflect the change.
|
||||
|
||||
**Domains** — when each domain registration expires, read from the registry
|
||||
itself. The domains behind your DNS zones are picked up automatically (a zone
|
||||
@@ -243,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
|
||||
@@ -268,6 +307,20 @@ host (`ss`), which catches services listening on localhost only — a scan from
|
||||
elsewhere can't see those, so they'd otherwise look free. Re-run the agent
|
||||
install one-liner on a host to pick that up.
|
||||
|
||||
**Network → Ports** is the cross-server counterpart: one page listing every
|
||||
port every agent currently reports as listening, across all servers at once
|
||||
(protocol, address, process, last report time), each linking back to its
|
||||
server. Below that, a separate, manually-maintained table is for the ports
|
||||
this app can't see on its own — a router's port forward, an edge firewall
|
||||
rule, a cloud security group — the same reason people keep a spreadsheet of
|
||||
"what did I open and why." Each entry has a label, the external port/protocol,
|
||||
an optional link to a tracked server (plus its own internal port, when NAT
|
||||
changes it) or a freeform destination, a free-text "source" (which
|
||||
router/firewall/service it's actually configured on — this app doesn't talk
|
||||
to any firewall, so it can't manage or verify the rule, only record it), and
|
||||
a comment. Viewers can see both tables; adding, editing, or deleting a manual
|
||||
entry needs operator or admin.
|
||||
|
||||
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
|
||||
browser gives it its own icon and a standalone window on phone or desktop. This
|
||||
needs the site to be served over HTTPS (browsers only offer install on secure
|
||||
|
||||
@@ -30,15 +30,18 @@ page's own top-level link.
|
||||
| DNS | `/dns` | ✅ | ✅ | ✅ |
|
||||
| Domains | `/domains` | ✅ | ✅ | ✅ |
|
||||
| IP Addresses | `/ipam` | ✅ | ✅ | ✅ |
|
||||
| Ports | `/ports` | ✅ | ✅ | ✅ |
|
||||
| Consistency | `/consistency` | ✅ | ✅ | ✅ |
|
||||
| **Automation** | | | | |
|
||||
| Semaphore | `/semaphore` | ✅ | ✅ | ✅ |
|
||||
| Gitea | `/gitea` | ✅ | ✅ | ✅ |
|
||||
| Secrets | `/secrets` | ✅ | ✅ | ✅ |
|
||||
| **Operations** | | | | |
|
||||
| Alerts | `/alerts` | ✅ | ✅ | ✅ |
|
||||
| Maintenance | `/maintenance` | ✅ | ✅ | ✅ |
|
||||
| Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ |
|
||||
| osTicket | `/osticket` | ✅ | ✅ | ✅ |
|
||||
| Admin Links | `/admin-links` | ✅ | ✅ | ✅ |
|
||||
| Generator | `/generator` | ✅ | ✅ | ✅ |
|
||||
| **Administration** | | | | |
|
||||
| Integrations | `/integrations` | ✅ | ✅ | ✅ |
|
||||
@@ -71,6 +74,14 @@ even further, to admin only:
|
||||
linking/unlinking it to a Proxmox guest just need operator.
|
||||
- **Tags**: creating, renaming, recoloring, or deleting a tag is
|
||||
admin-only (applying an existing tag to a server needs operator).
|
||||
- **Ports**: everyone can see both the agent-reported and manual tables;
|
||||
adding, editing, or deleting a manual port opening needs operator.
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `port_forwards` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`label` text NOT NULL,
|
||||
`external_port` integer NOT NULL,
|
||||
`protocol` text DEFAULT 'tcp' NOT NULL,
|
||||
`server_id` integer,
|
||||
`destination` text,
|
||||
`internal_port` integer,
|
||||
`source` text,
|
||||
`comment` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,13 @@
|
||||
"when": 1790449897833,
|
||||
"tag": "0013_sturdy_bloodstorm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1790718056783,
|
||||
"tag": "0014_empty_gabe_jones",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -331,6 +331,33 @@ export const serverPorts = sqliteTable(
|
||||
(t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)],
|
||||
);
|
||||
|
||||
// A manually-recorded port opening on something this app doesn't monitor directly — a router's port forward, an
|
||||
// edge firewall rule, a cloud provider's security group, etc. Distinct from serverPorts (which is what a server
|
||||
// itself, or a scan of it, reports): this is what someone tells the app is open further out on the network path,
|
||||
// for the same reason people keep a spreadsheet of "what did I open on the router and why."
|
||||
export const portForwards = sqliteTable("port_forwards", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
label: text("label").notNull(),
|
||||
externalPort: integer("external_port").notNull(),
|
||||
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
|
||||
// Optional link to a tracked server this forward points at; "destination" covers anything else (a bare IP,
|
||||
// an untracked device) or extra detail alongside a linked server.
|
||||
serverId: integer("server_id").references(() => servers.id, { onDelete: "set null" }),
|
||||
destination: text("destination"),
|
||||
// The port it's actually forwarded to, when NAT changes it (a router forwarding external 8443 to internal 443).
|
||||
internalPort: integer("internal_port"),
|
||||
// Free text: where this rule actually lives ("Home router", "OPNsense WAN rule", "Cloudflare Tunnel") — this
|
||||
// app has no integration with any firewall/router, so it can't verify or manage the rule, only record it.
|
||||
source: text("source"),
|
||||
comment: text("comment"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Domain registrations ───────────────────────────────────────────────────
|
||||
|
||||
export const domainOrigins = ["manual", "zone"] as const;
|
||||
|
||||
@@ -28,6 +28,9 @@ import { domainsRouter } from "./routes/domains.js";
|
||||
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";
|
||||
@@ -102,6 +105,9 @@ app.use("/api/domains", domainsRouter);
|
||||
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({
|
||||
|
||||
@@ -11,6 +11,8 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
|
||||
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
|
||||
import { createPhpIpamAdapter } from "../integrations/phpipam/adapter.js";
|
||||
import { makeExclusion } from "../services/consistency.js";
|
||||
import { getSettings } from "../services/settingsStore.js";
|
||||
|
||||
export const ipamRouter = Router();
|
||||
|
||||
@@ -36,7 +38,14 @@ async function matchingDnsRecordsByIp(ips: string[]): Promise<Map<string, string
|
||||
ipamRouter.get("/", asyncHandler(async (_req, res) => {
|
||||
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
|
||||
const byIp = await matchingDnsRecordsByIp(rows.map((r) => r.ipAddress));
|
||||
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: byIp.get(r.ipAddress) ?? [] })) });
|
||||
// Same excluded-ranges setting the Consistency page manages — "not interesting" addresses (Docker's bridge
|
||||
// network repeating on every host, say) can be hidden here too, without duplicating that configuration.
|
||||
const { consistency } = await getSettings();
|
||||
const excluded = makeExclusion(consistency.excludedRanges);
|
||||
res.json({
|
||||
entries: rows.map((r) => ({ ...r, matchingDnsRecords: byIp.get(r.ipAddress) ?? [], excluded: excluded(r.ipAddress) })),
|
||||
excludedRanges: consistency.excludedRanges,
|
||||
});
|
||||
}));
|
||||
|
||||
const createInput = z.object({
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { servers, portForwards } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { type AgentPort, groupAgentPorts, parseJson, splitPortKey } from "../services/agentPorts.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const portsRouter = Router();
|
||||
|
||||
portsRouter.use(requireAuth);
|
||||
|
||||
// ─── Ports reported by agents, across every server ──────────────────────────
|
||||
|
||||
export interface AgentPortRow {
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
serverHostname: string | null;
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
addresses: string[];
|
||||
process: string | null;
|
||||
localOnly: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
portsRouter.get("/agent", asyncHandler(async (_req, res) => {
|
||||
const rows = await db
|
||||
.select({ id: servers.id, name: servers.name, hostname: servers.hostname, listeningPorts: servers.listeningPorts, lastSeenAt: servers.lastSeenAt })
|
||||
.from(servers);
|
||||
|
||||
const out: AgentPortRow[] = [];
|
||||
for (const s of rows) {
|
||||
const raw = parseJson<AgentPort[] | null>(s.listeningPorts, null);
|
||||
if (!raw) continue;
|
||||
for (const [key, grouped] of groupAgentPorts(raw)) {
|
||||
const { protocol, port } = splitPortKey(key);
|
||||
out.push({
|
||||
serverId: s.id,
|
||||
serverName: s.name,
|
||||
serverHostname: s.hostname,
|
||||
protocol,
|
||||
port,
|
||||
addresses: grouped.addresses,
|
||||
process: grouped.process,
|
||||
localOnly: grouped.localOnly,
|
||||
lastSeenAt: s.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.serverName.localeCompare(b.serverName) || a.port - b.port || a.protocol.localeCompare(b.protocol));
|
||||
|
||||
res.json({ ports: out, reportingServers: rows.filter((s) => s.listeningPorts !== null).length, totalServers: rows.length });
|
||||
}));
|
||||
|
||||
// ─── Manually-recorded port openings (router/firewall/cloud security group, etc.) ──
|
||||
|
||||
// Blank/omitted optional fields normalize to null right here, so every downstream handler can
|
||||
// just use parsed.data as-is (matching the DB columns, which store NULL, not empty strings).
|
||||
const optionalText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => v || null);
|
||||
|
||||
const forwardInput = z.object({
|
||||
label: z.string().trim().min(1).max(200),
|
||||
externalPort: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"]).default("tcp"),
|
||||
serverId: z
|
||||
.number()
|
||||
.int()
|
||||
.nullish()
|
||||
.transform((v) => v ?? null),
|
||||
destination: optionalText(255),
|
||||
internalPort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.nullish()
|
||||
.transform((v) => v ?? null),
|
||||
source: optionalText(200),
|
||||
comment: optionalText(2000),
|
||||
});
|
||||
|
||||
const updateForwardInput = forwardInput.partial();
|
||||
|
||||
portsRouter.get("/forwards", asyncHandler(async (_req, res) => {
|
||||
const rows = await db.query.portForwards.findMany({ orderBy: (p, { asc }) => [asc(p.externalPort)] });
|
||||
const serverRows = await db.select({ id: servers.id, name: servers.name }).from(servers);
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name]));
|
||||
|
||||
res.json({
|
||||
forwards: rows.map((r) => ({ ...r, serverName: r.serverId !== null ? nameById.get(r.serverId) ?? null : null })),
|
||||
});
|
||||
}));
|
||||
|
||||
portsRouter.post("/forwards", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const parsed = forwardInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.serverId) {
|
||||
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
|
||||
if (!server) return res.status(400).json({ error: "invalid_server" });
|
||||
}
|
||||
|
||||
const [created] = await db.insert(portForwards).values(data).returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "network",
|
||||
action: "create_port_forward",
|
||||
targetType: "port_forward",
|
||||
targetId: created.id,
|
||||
detail: { label: created.label, externalPort: created.externalPort, protocol: created.protocol },
|
||||
});
|
||||
|
||||
res.status(201).json({ forward: created });
|
||||
}));
|
||||
|
||||
portsRouter.patch("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const parsed = updateForwardInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
if (data.serverId) {
|
||||
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
|
||||
if (!server) return res.status(400).json({ error: "invalid_server" });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(portForwards)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(portForwards.id, id))
|
||||
.returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "network",
|
||||
action: "update_port_forward",
|
||||
targetType: "port_forward",
|
||||
targetId: id,
|
||||
detail: { label: updated.label, externalPort: updated.externalPort, protocol: updated.protocol },
|
||||
});
|
||||
|
||||
res.json({ forward: updated });
|
||||
}));
|
||||
|
||||
portsRouter.delete("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
await db.delete(portForwards).where(eq(portForwards.id, id));
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "network",
|
||||
action: "delete_port_forward",
|
||||
targetType: "port_forward",
|
||||
targetId: id,
|
||||
detail: { label: existing.label, externalPort: existing.externalPort, protocol: existing.protocol },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
}));
|
||||
@@ -6,18 +6,12 @@ import { servers, serverPorts } from "../db/schema.js";
|
||||
import { requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { beginScan, endScan, MAX_SCAN_SPAN, resolveScanTarget, scanPorts, toRanges } from "../services/portScan.js";
|
||||
import { type AgentPort, groupAgentPorts, parseJson } from "../services/agentPorts.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
// Mounted under /api/servers/:id/ports by the servers router, which has already required a signed-in user.
|
||||
export const serverPortsRouter = Router({ mergeParams: true });
|
||||
|
||||
interface AgentPort {
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
address: string;
|
||||
process?: string;
|
||||
}
|
||||
|
||||
interface StoredScan {
|
||||
at: string;
|
||||
address: string;
|
||||
@@ -45,38 +39,6 @@ export interface PortEntry {
|
||||
state: "open" | "reserved";
|
||||
}
|
||||
|
||||
function parseJson<T>(text: string | null | undefined, fallback: T): T {
|
||||
if (!text) return fallback;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopback(address: string): boolean {
|
||||
const bare = address.replace(/%.*$/, "").replace(/^\[|\]$/g, "");
|
||||
return bare.startsWith("127.") || bare === "::1";
|
||||
}
|
||||
|
||||
/** Groups the agent's raw one-row-per-socket report into one entry per protocol+port. */
|
||||
function groupAgentPorts(raw: AgentPort[]): Map<string, { addresses: string[]; process: string | null; localOnly: boolean }> {
|
||||
const grouped = new Map<string, { addresses: Set<string>; process: string | null }>();
|
||||
for (const p of raw) {
|
||||
const key = `${p.protocol}:${p.port}`;
|
||||
const entry = grouped.get(key) ?? { addresses: new Set<string>(), process: null };
|
||||
entry.addresses.add(p.address);
|
||||
if (!entry.process && p.process) entry.process = p.process;
|
||||
grouped.set(key, entry);
|
||||
}
|
||||
const out = new Map<string, { addresses: string[]; process: string | null; localOnly: boolean }>();
|
||||
for (const [key, entry] of grouped) {
|
||||
const addresses = [...entry.addresses];
|
||||
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildPortList(serverId: number) {
|
||||
const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||
if (!server) return null;
|
||||
|
||||
@@ -170,6 +170,24 @@ serversRouter.get("/summary", asyncHandler(async (_req, res) => {
|
||||
res.json(buildServerSummary(rows, healthChecks, Date.now(), await activeSubjects()));
|
||||
}));
|
||||
|
||||
// For the Operations > Admin Links page — every server's admin bookmarks in one place, instead of visiting
|
||||
// each server's own detail page to find them.
|
||||
serversRouter.get("/links", asyncHandler(async (_req, res) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: serverLinks.id,
|
||||
serverId: serverLinks.serverId,
|
||||
serverName: servers.name,
|
||||
serverHostname: servers.hostname,
|
||||
label: serverLinks.label,
|
||||
url: serverLinks.url,
|
||||
})
|
||||
.from(serverLinks)
|
||||
.innerJoin(servers, eq(serverLinks.serverId, servers.id))
|
||||
.orderBy(servers.name, serverLinks.label);
|
||||
res.json({ links: rows });
|
||||
}));
|
||||
|
||||
serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
@@ -323,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();
|
||||
@@ -334,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 } });
|
||||
@@ -357,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 } });
|
||||
@@ -380,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,53 @@
|
||||
// Shared between the per-server Ports card (routes/serverPorts.ts) and the cross-server
|
||||
// Network > Ports page (routes/ports.ts) — both read the same agent-reported "listeningPorts"
|
||||
// JSON column and need the same one-row-per-socket -> one-row-per-protocol+port grouping.
|
||||
|
||||
export interface AgentPort {
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
address: string;
|
||||
process?: string;
|
||||
}
|
||||
|
||||
export interface GroupedAgentPort {
|
||||
addresses: string[];
|
||||
process: string | null;
|
||||
localOnly: boolean;
|
||||
}
|
||||
|
||||
export function parseJson<T>(text: string | null | undefined, fallback: T): T {
|
||||
if (!text) return fallback;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopback(address: string): boolean {
|
||||
const bare = address.replace(/%.*$/, "").replace(/^\[|\]$/g, "");
|
||||
return bare.startsWith("127.") || bare === "::1";
|
||||
}
|
||||
|
||||
/** Groups the agent's raw one-row-per-socket report into one entry per protocol+port, keyed "tcp:443". */
|
||||
export function groupAgentPorts(raw: AgentPort[]): Map<string, GroupedAgentPort> {
|
||||
const grouped = new Map<string, { addresses: Set<string>; process: string | null }>();
|
||||
for (const p of raw) {
|
||||
const key = `${p.protocol}:${p.port}`;
|
||||
const entry = grouped.get(key) ?? { addresses: new Set<string>(), process: null };
|
||||
entry.addresses.add(p.address);
|
||||
if (!entry.process && p.process) entry.process = p.process;
|
||||
grouped.set(key, entry);
|
||||
}
|
||||
const out = new Map<string, GroupedAgentPort>();
|
||||
for (const [key, entry] of grouped) {
|
||||
const addresses = [...entry.addresses];
|
||||
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function splitPortKey(key: string): { protocol: "tcp" | "udp"; port: number } {
|
||||
const [protocol, port] = key.split(":");
|
||||
return { protocol: protocol as "tcp" | "udp", port: Number(port) };
|
||||
}
|
||||
@@ -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,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { settings } from "../db/schema.js";
|
||||
import { defaultThemes, type NameTheme } from "./nameThemes.js";
|
||||
|
||||
export interface GotifySettings {
|
||||
enabled: boolean;
|
||||
@@ -99,6 +100,11 @@ export interface ConsistencySettings {
|
||||
excludedRanges: string[];
|
||||
}
|
||||
|
||||
export interface NameGeneratorSettings {
|
||||
/** The lists the Generator picks server names from — edited under Settings → Names. */
|
||||
themes: NameTheme[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
gotify: GotifySettings;
|
||||
ntfy: NtfySettings;
|
||||
@@ -112,6 +118,7 @@ export interface AppSettings {
|
||||
quietHours: QuietHoursSettings;
|
||||
healthChecks: HealthCheckSettings;
|
||||
consistency: ConsistencySettings;
|
||||
nameGenerator: NameGeneratorSettings;
|
||||
}
|
||||
|
||||
const DEFAULTS: AppSettings = {
|
||||
@@ -145,6 +152,7 @@ const DEFAULTS: AppSettings = {
|
||||
// Docker's default bridge (172.17.0.0/16) and the pool it hands custom networks from (172.18–31) live here, and every
|
||||
// Docker host repeats them. Shown on the Consistency page, where it can be removed if 172.16/12 is used as a real LAN.
|
||||
consistency: { excludedRanges: ["172.16.0.0/12"] },
|
||||
nameGenerator: { themes: defaultThemes() },
|
||||
};
|
||||
|
||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||
|
||||
@@ -5,17 +5,27 @@ import { integrations } from "../db/schema.js";
|
||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createTailscaleAdapter, isKeyExpiringSoon } from "../integrations/tailscale/adapter.js";
|
||||
import { notifyTailscaleKeyExpiry } from "./notify.js";
|
||||
import type { SourceFailure } from "./alertTypes.js";
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
|
||||
const LAST_RUN_FLAG = "tailscaleKeyCheckLastRunDate";
|
||||
|
||||
async function checkTailscaleKeyExpiry(): Promise<void> {
|
||||
export interface TailscaleKeyExpiry {
|
||||
integrationId: number;
|
||||
integrationName: string;
|
||||
deviceLabel: string;
|
||||
daysLeft: number;
|
||||
}
|
||||
|
||||
/** Every device key that's expired or about to, across the enabled Tailscale integrations. Shared with the Alerts page. */
|
||||
export async function collectTailscaleKeyExpiries(): Promise<{ items: TailscaleKeyExpiry[]; failures: SourceFailure[] }> {
|
||||
const rows = await db
|
||||
.select({ id: integrations.id, name: integrations.name })
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, "tailscale"), eq(integrations.enabled, true)));
|
||||
|
||||
const expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[] = [];
|
||||
const expiring: TailscaleKeyExpiry[] = [];
|
||||
const failures: SourceFailure[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -27,14 +37,19 @@ async function checkTailscaleKeyExpiry(): Promise<void> {
|
||||
for (const d of devices) {
|
||||
if (!isKeyExpiringSoon(d, now)) continue;
|
||||
const daysLeft = Math.floor((new Date(d.keyExpiry!).getTime() - now) / 86_400_000);
|
||||
expiring.push({ integrationName: row.name, deviceLabel: d.label || d.hostname, daysLeft });
|
||||
expiring.push({ integrationId: row.id, integrationName: row.name, deviceLabel: d.label || d.hostname, daysLeft });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[tailscaleKeyExpiry] check failed for integration ${row.id}:`, err);
|
||||
failures.push({ integrationId: row.id, integrationName: row.name, message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
await notifyTailscaleKeyExpiry(expiring);
|
||||
return { items: expiring, failures };
|
||||
}
|
||||
|
||||
async function checkTailscaleKeyExpiry(): Promise<void> {
|
||||
await notifyTailscaleKeyExpiry((await collectTailscaleKeyExpiries()).items);
|
||||
}
|
||||
|
||||
async function checkTailscaleKeyExpiryOnce(): Promise<void> {
|
||||
|
||||
+11
-1
@@ -9,6 +9,7 @@ import AuditLog from "./pages/AuditLog";
|
||||
import DiagLog from "./pages/DiagLog";
|
||||
import Secrets from "./pages/Secrets";
|
||||
import Ipam from "./pages/Ipam";
|
||||
import Ports from "./pages/Ports";
|
||||
import Dns from "./pages/Dns";
|
||||
import Servers from "./pages/Servers";
|
||||
import ServerDetail from "./pages/ServerDetail";
|
||||
@@ -22,6 +23,8 @@ import Synology from "./pages/Synology";
|
||||
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";
|
||||
@@ -35,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";
|
||||
|
||||
@@ -93,9 +98,10 @@ export default function App() {
|
||||
<Route path="/servers/:id" element={<ServerDetail user={user} />} />
|
||||
<Route path="/dns" element={<Dns user={user} />} />
|
||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||
<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} />} />
|
||||
@@ -109,6 +115,8 @@ export default function App() {
|
||||
<Route path="/uptime-kuma" element={<UptimeKuma user={user} />} />
|
||||
<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={
|
||||
@@ -154,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>
|
||||
);
|
||||
}
|
||||
+124
-1
@@ -122,6 +122,8 @@ export interface IpamEntry {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
matchingDnsRecords: string[];
|
||||
/** Falls within one of the Consistency page's excluded ranges — e.g. Docker's bridge network, repeated on every host. */
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface IpamSyncResult {
|
||||
@@ -140,6 +142,50 @@ export interface IpamInput {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AgentPortRow {
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
serverHostname: string | null;
|
||||
protocol: "tcp" | "udp";
|
||||
port: number;
|
||||
addresses: string[];
|
||||
process: string | null;
|
||||
localOnly: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export interface AgentPortsResponse {
|
||||
ports: AgentPortRow[];
|
||||
reportingServers: number;
|
||||
totalServers: number;
|
||||
}
|
||||
|
||||
export interface PortForward {
|
||||
id: number;
|
||||
label: string;
|
||||
externalPort: number;
|
||||
protocol: "tcp" | "udp";
|
||||
serverId: number | null;
|
||||
serverName: string | null;
|
||||
destination: string | null;
|
||||
internalPort: number | null;
|
||||
source: string | null;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PortForwardInput {
|
||||
label: string;
|
||||
externalPort: number;
|
||||
protocol: "tcp" | "udp";
|
||||
serverId?: number | null;
|
||||
destination?: string | null;
|
||||
internalPort?: number | null;
|
||||
source?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
|
||||
|
||||
export interface GotifySettings {
|
||||
@@ -351,6 +397,39 @@ 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;
|
||||
serverName: string;
|
||||
serverHostname: string | null;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PortEntry {
|
||||
/** Null for a port only known from the agent that has no note yet. */
|
||||
id: number | null;
|
||||
@@ -572,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. */
|
||||
@@ -941,7 +1042,7 @@ export const api = {
|
||||
),
|
||||
},
|
||||
ipam: {
|
||||
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"),
|
||||
list: () => request<{ entries: IpamEntry[]; excludedRanges: string[] }>("/api/ipam"),
|
||||
create: (data: IpamInput) =>
|
||||
request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
|
||||
@@ -951,6 +1052,27 @@ 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: {
|
||||
list: () => request<{ forwards: PortForward[] }>("/api/ports/forwards"),
|
||||
create: (data: PortForwardInput) =>
|
||||
request<{ forward: PortForward }>("/api/ports/forwards", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<PortForwardInput>) =>
|
||||
request<{ forward: PortForward }>(`/api/ports/forwards/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
remove: (id: number) => request<void>(`/api/ports/forwards/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
},
|
||||
dns: {
|
||||
providerFields: () =>
|
||||
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"),
|
||||
@@ -1037,6 +1159,7 @@ export const api = {
|
||||
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
removeLink: (id: number, linkId: number) =>
|
||||
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
|
||||
allLinks: () => request<{ links: AdminLink[] }>("/api/servers/links"),
|
||||
ports: {
|
||||
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
|
||||
scan: (id: number, data: { address: string; from: number; to: number }) =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
IconActivityHeartbeat,
|
||||
IconShieldCheck,
|
||||
IconTicket,
|
||||
IconPlug,
|
||||
IconExternalLink,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
@@ -80,6 +83,7 @@ const NAV: NavEntry[] = [
|
||||
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
||||
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
|
||||
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
||||
{ to: "/ports", label: "Ports", icon: <IconPlug size={20} /> },
|
||||
{ to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> },
|
||||
],
|
||||
},
|
||||
@@ -98,9 +102,11 @@ 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} /> },
|
||||
{ to: "/admin-links", label: "Admin Links", icon: <IconExternalLink size={20} /> },
|
||||
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type AdminLink, type CurrentUser, type PortEntry, type ServerRecord } from "../api/client";
|
||||
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 | "";
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const emptyForm: LinkForm = { serverId: "", label: "", url: "" };
|
||||
|
||||
export default function AdminLinks({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [links, setLinks] = useState<AdminLink[] | null>(null);
|
||||
const [servers, setServers] = useState<ServerRecord[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const [editing, setEditing] = useState<AdminLink | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState<LinkForm>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [portOptions, setPortOptions] = useState<PortEntry[] | null>(null);
|
||||
const [portAddress, setPortAddress] = useState<string | null>(null);
|
||||
const [loadingPorts, setLoadingPorts] = useState(false);
|
||||
const [portPick, setPortPick] = useState("");
|
||||
|
||||
function load() {
|
||||
api.servers.allLinks()
|
||||
.then((res) => setLinks(res.links))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
api.servers
|
||||
.list()
|
||||
.then((res) => setServers(res.servers))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Once a server is picked in the add/edit form, offer to fill the URL from one of its own known
|
||||
// ports (agent-reported or already noted on its Ports card) instead of typing it out by hand.
|
||||
useEffect(() => {
|
||||
setPortPick("");
|
||||
if (form.serverId === "") {
|
||||
setPortOptions(null);
|
||||
setPortAddress(null);
|
||||
return;
|
||||
}
|
||||
const serverId = form.serverId;
|
||||
setLoadingPorts(true);
|
||||
Promise.all([api.servers.detail(serverId), api.servers.ports.list(serverId)])
|
||||
.then(([detail, portList]) => {
|
||||
setPortAddress(detail.server.hostname || detail.ipAddresses[0] || null);
|
||||
setPortOptions(portList.ports);
|
||||
})
|
||||
.catch(() => {
|
||||
setPortOptions(null);
|
||||
setPortAddress(null);
|
||||
})
|
||||
.finally(() => setLoadingPorts(false));
|
||||
}, [form.serverId]);
|
||||
|
||||
function pickPort(key: string) {
|
||||
setPortPick(key);
|
||||
const entry = portOptions?.find((p) => `${p.protocol}:${p.port}` === key);
|
||||
if (!entry) return;
|
||||
const url = guessAdminUrl(portAddress, entry);
|
||||
setForm((f) => ({ ...f, url: url ?? f.url, label: f.label || entry.label || entry.agent?.process || "" }));
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!links) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return links;
|
||||
return links.filter((l) => [l.serverName, l.serverHostname, l.label, l.url].some((v) => (v ?? "").toLowerCase().includes(q)));
|
||||
}, [links, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, requestSort } = useSortable(filtered, "serverName");
|
||||
|
||||
function exportCsv() {
|
||||
downloadCsv(
|
||||
"admin-links.csv",
|
||||
["Server", "Label", "URL"],
|
||||
(sorted ?? []).map((l) => [l.serverName, l.label, l.url]),
|
||||
);
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
setAdding(true);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
function startEdit(link: AdminLink) {
|
||||
setEditing(link);
|
||||
setAdding(false);
|
||||
setForm({ serverId: link.serverId, label: link.label, url: link.url });
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setAdding(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (form.serverId === "") return;
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.servers.updateLink(editing.serverId, editing.id, { label: form.label, url: form.url });
|
||||
} else {
|
||||
await api.servers.addLink(form.serverId, { label: form.label, url: form.url });
|
||||
}
|
||||
cancelForm();
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(link: AdminLink) {
|
||||
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);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const showForm = adding || editing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Admin Links</h2>
|
||||
<p className="text-secondary">
|
||||
Every admin bookmark added to a server's own page (Infrastructure → Servers → a server → Admin Links) — Dockge,
|
||||
Webmin, Cockpit, and the like — in one place, instead of visiting each server to find them.
|
||||
</p>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{canEdit && showForm && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{editing ? `Edit "${editing.label}"` : "Add an admin link"}</h3>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Server</label>
|
||||
<select
|
||||
className="form-select"
|
||||
required
|
||||
disabled={!!editing}
|
||||
value={form.serverId}
|
||||
onChange={(e) => setForm({ ...form, serverId: e.target.value ? Number(e.target.value) : "" })}
|
||||
>
|
||||
<option value="" disabled>
|
||||
— choose a server —
|
||||
</option>
|
||||
{(servers ?? []).map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editing && <div className="form-hint">Remove and re-add to move it to a different server.</div>}
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Label</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
maxLength={60}
|
||||
placeholder="e.g. Dockge, Webmin, Cockpit"
|
||||
value={form.label}
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="http://10.0.0.5:5001"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{form.serverId !== "" && (
|
||||
<div className="col-12">
|
||||
<label className="form-label">Fill from a known port (optional)</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ maxWidth: 460 }}
|
||||
value={portPick}
|
||||
disabled={loadingPorts || !portOptions?.length}
|
||||
onChange={(e) => pickPort(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{loadingPorts
|
||||
? "Loading this server's ports…"
|
||||
: portOptions?.length
|
||||
? "— pick a port to fill in the URL above —"
|
||||
: "No known ports for this server yet"}
|
||||
</option>
|
||||
{portOptions?.map((p) => (
|
||||
<option key={`${p.protocol}:${p.port}`} value={`${p.protocol}:${p.port}`}>
|
||||
{portOptionLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{portOptions && portOptions.length > 0 && !portAddress && (
|
||||
<div className="form-hint">
|
||||
This server has no known hostname or IP yet, so picking a port won't fill in an address —
|
||||
see its Ports card under Infrastructure → Servers.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{editing ? "Save changes" : "Add link"}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={cancelForm}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ maxWidth: 280 }}
|
||||
placeholder="Search server, label, URL…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="card-actions">
|
||||
{canEdit && !showForm && (
|
||||
<button className="btn btn-primary btn-sm" onClick={startAdd}>
|
||||
Add link
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted?.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<AdminLink> label="Server" sortKeyName="serverName" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<AdminLink> label="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<th>URL</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sorted ?? []).map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>
|
||||
<Link to={`/servers/${l.serverId}`}>{l.serverName}</Link>
|
||||
{l.serverHostname && <div className="text-secondary small">{l.serverHostname}</div>}
|
||||
</td>
|
||||
<td>{l.label}</td>
|
||||
<td>
|
||||
<a href={l.url} target="_blank" rel="noopener noreferrer">
|
||||
{l.url}
|
||||
</a>
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => startEdit(l)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(l)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{links !== null && links.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 4 : 3} className="text-secondary text-center">
|
||||
No admin links added yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{links !== null && links.length > 0 && (sorted ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 4 : 3} className="text-secondary text-center">
|
||||
Nothing matches "{search}".
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+121
-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: "" };
|
||||
|
||||
@@ -17,6 +18,11 @@ function isIpv6(ip: string) {
|
||||
export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
const [entries, setEntries] = useState<IpamEntry[] | null>(null);
|
||||
const [excludedRanges, setExcludedRanges] = useState<string[]>([]);
|
||||
const [hideExcluded, setHideExcluded] = useState(true);
|
||||
const [showRanges, setShowRanges] = useState(false);
|
||||
const [newRange, setNewRange] = useState("");
|
||||
const [savingRanges, setSavingRanges] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
|
||||
@@ -34,6 +40,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
.list()
|
||||
.then((res) => {
|
||||
setEntries(res.entries);
|
||||
setExcludedRanges(res.excludedRanges);
|
||||
// Deep-link from global search (?editId=) — jump straight to that entry's edit form.
|
||||
const editId = Number(searchParams.get("editId"));
|
||||
const target = res.entries.find((e) => e.id === editId);
|
||||
@@ -44,14 +51,53 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
/** Saves the whole list (the server validates and normalises it) and reloads — shared with the Consistency page. */
|
||||
async function saveRanges(ranges: string[]): Promise<boolean> {
|
||||
setSavingRanges(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.consistency.setExcludedRanges(ranges);
|
||||
load();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setSavingRanges(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addRange(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newRange.trim()) return;
|
||||
if (await saveRanges([...excludedRanges, newRange.trim()])) setNewRange("");
|
||||
}
|
||||
|
||||
async function excludeAround(entry: IpamEntry) {
|
||||
const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`;
|
||||
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()]);
|
||||
}
|
||||
|
||||
const excludedCount = useMemo(() => entries?.filter((e) => e.excluded).length ?? 0, [entries]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!entries) return [];
|
||||
const base = hideExcluded ? entries.filter((e) => !e.excluded) : entries;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return entries;
|
||||
return entries.filter((e) =>
|
||||
if (!q) return base;
|
||||
return base.filter((e) =>
|
||||
[e.ipAddress, e.label, e.vendor, e.location].some((v) => (v ?? "").toLowerCase().includes(q)),
|
||||
);
|
||||
}, [entries, search]);
|
||||
}, [entries, hideExcluded, search]);
|
||||
|
||||
function startAdd() {
|
||||
setAdding(true);
|
||||
@@ -98,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);
|
||||
@@ -111,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 {
|
||||
@@ -176,6 +222,67 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{syncResult && <div className="alert alert-success">Synced from {syncResult}</div>}
|
||||
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||
<label className="form-check mb-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={hideExcluded}
|
||||
onChange={(e) => setHideExcluded(e.target.checked)}
|
||||
/>
|
||||
<span className="form-check-label">
|
||||
Hide excluded addresses{excludedCount > 0 && ` (${excludedCount})`}
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="btn btn-link btn-sm p-0 ms-2" onClick={() => setShowRanges((s) => !s)}>
|
||||
{showRanges ? "Hide" : "Manage"} excluded ranges
|
||||
</button>
|
||||
</div>
|
||||
{showRanges && (
|
||||
<div className="mt-2">
|
||||
<div className="text-secondary small mb-2">
|
||||
Addresses in these ranges are left out here and on the Consistency report — the same setting either
|
||||
page manages. Meant for networks that aren't part of your LAN, like Docker's, which repeat the same
|
||||
subnet on many hosts.
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-1 mb-2">
|
||||
{excludedRanges.length === 0 && <span className="text-secondary small">Nothing excluded.</span>}
|
||||
{excludedRanges.map((r) => (
|
||||
<span key={r} className="badge bg-secondary-lt text-secondary d-inline-flex align-items-center">
|
||||
<code className="bg-transparent p-0">{r}</code>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close ms-1"
|
||||
style={{ fontSize: "0.5rem" }}
|
||||
aria-label={`Stop excluding ${r}`}
|
||||
disabled={savingRanges}
|
||||
onClick={() => void saveRanges(excludedRanges.filter((x) => x !== r))}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<form onSubmit={addRange} className="d-flex gap-2" style={{ maxWidth: 460 }}>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="e.g. 172.17.0.0/16 or 10.1.2.3"
|
||||
value={newRange}
|
||||
onChange={(e) => setNewRange(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-sm btn-primary" disabled={savingRanges || !newRange.trim()}>
|
||||
Exclude
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && showForm && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
@@ -327,6 +434,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
|
||||
</span>
|
||||
{entry.ipAddress}
|
||||
{entry.excluded && <span className="badge bg-secondary-lt text-secondary ms-2">excluded</span>}
|
||||
</td>
|
||||
<td>{entry.label ?? "—"}</td>
|
||||
<td>
|
||||
@@ -343,6 +451,11 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
<button className="btn btn-sm" onClick={() => startEdit(entry)}>
|
||||
Edit
|
||||
</button>
|
||||
{!entry.excluded && (
|
||||
<button className="btn btn-sm" onClick={() => excludeAround(entry)} title="Hide this and every address in the same range">
|
||||
Exclude…
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(entry)}>
|
||||
Delete
|
||||
</button>
|
||||
@@ -354,7 +467,9 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
{(sorted ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 7 : 5} className="text-secondary text-center">
|
||||
No IP addresses tracked yet.
|
||||
{entries && entries.length > 0
|
||||
? "Every tracked address is currently excluded or filtered out — turn off “Hide excluded addresses”, or adjust your search, to see them."
|
||||
: "No IP addresses tracked yet."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type AgentPortRow,
|
||||
type CurrentUser,
|
||||
type PortForward,
|
||||
type PortForwardInput,
|
||||
type ServerRecord,
|
||||
} 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 { formatAgo } from "../utils/duration";
|
||||
import { confirmDialog } from "../utils/dialogs";
|
||||
|
||||
const emptyForm: PortForwardInput = {
|
||||
label: "",
|
||||
externalPort: 0,
|
||||
protocol: "tcp",
|
||||
serverId: null,
|
||||
destination: "",
|
||||
internalPort: null,
|
||||
source: "",
|
||||
comment: "",
|
||||
};
|
||||
|
||||
export default function Ports({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [agentPorts, setAgentPorts] = useState<AgentPortRow[] | null>(null);
|
||||
const [reportingServers, setReportingServers] = useState(0);
|
||||
const [totalServers, setTotalServers] = useState(0);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
||||
const [servers, setServers] = useState<ServerRecord[] | null>(null);
|
||||
const [forwards, setForwards] = useState<PortForward[] | null>(null);
|
||||
const [forwardError, setForwardError] = useState<string | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<PortForward | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState<PortForwardInput>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function loadAgentPorts() {
|
||||
api.ports
|
||||
.agent()
|
||||
.then((res) => {
|
||||
setAgentPorts(res.ports);
|
||||
setReportingServers(res.reportingServers);
|
||||
setTotalServers(res.totalServers);
|
||||
})
|
||||
.catch((err) => setAgentError(err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
|
||||
function loadForwards() {
|
||||
api.ports.forwards
|
||||
.list()
|
||||
.then((res) => setForwards(res.forwards))
|
||||
.catch((err) => setForwardError(err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentPorts();
|
||||
loadForwards();
|
||||
api.servers
|
||||
.list()
|
||||
.then((res) => setServers(res.servers))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { sorted: sortedAgentPorts, sortKey: agentSortKey, sortDir: agentSortDir, requestSort: requestAgentSort } = useSortable(
|
||||
agentPorts,
|
||||
"serverName",
|
||||
);
|
||||
const { pageItems: agentPageItems, page: agentPage, setPage: setAgentPage, pageCount: agentPageCount, totalCount: agentTotalCount } = usePagination(
|
||||
sortedAgentPorts,
|
||||
);
|
||||
|
||||
const { sorted: sortedForwards, sortKey: forwardSortKey, sortDir: forwardSortDir, requestSort: requestForwardSort } = useSortable(
|
||||
forwards,
|
||||
"externalPort",
|
||||
);
|
||||
|
||||
function exportAgentCsv() {
|
||||
downloadCsv(
|
||||
"agent-reported-ports.csv",
|
||||
["Server", "Hostname", "Protocol", "Port", "Addresses", "Process", "Local only"],
|
||||
(sortedAgentPorts ?? []).map((p) => [
|
||||
p.serverName,
|
||||
p.serverHostname ?? "",
|
||||
p.protocol,
|
||||
p.port,
|
||||
p.addresses.join(", "),
|
||||
p.process ?? "",
|
||||
p.localOnly ? "yes" : "no",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function exportForwardsCsv() {
|
||||
downloadCsv(
|
||||
"port-forwards.csv",
|
||||
["Label", "External port", "Protocol", "Server", "Destination", "Internal port", "Source", "Comment"],
|
||||
(sortedForwards ?? []).map((f) => [
|
||||
f.label,
|
||||
f.externalPort,
|
||||
f.protocol,
|
||||
f.serverName ?? "",
|
||||
f.destination ?? "",
|
||||
f.internalPort ?? "",
|
||||
f.source ?? "",
|
||||
f.comment ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
setAdding(true);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
function startEdit(fwd: PortForward) {
|
||||
setEditing(fwd);
|
||||
setAdding(false);
|
||||
setForm({
|
||||
label: fwd.label,
|
||||
externalPort: fwd.externalPort,
|
||||
protocol: fwd.protocol,
|
||||
serverId: fwd.serverId,
|
||||
destination: fwd.destination ?? "",
|
||||
internalPort: fwd.internalPort,
|
||||
source: fwd.source ?? "",
|
||||
comment: fwd.comment ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setAdding(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setForwardError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.ports.forwards.update(editing.id, form);
|
||||
} else {
|
||||
await api.ports.forwards.create(form);
|
||||
}
|
||||
cancelForm();
|
||||
loadForwards();
|
||||
} catch (err) {
|
||||
setForwardError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(fwd: PortForward) {
|
||||
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);
|
||||
loadForwards();
|
||||
} catch (err) {
|
||||
setForwardError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const showForm = adding || editing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Ports</h2>
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Ports reported by agents</h3>
|
||||
<div className="card-actions">
|
||||
<span className="text-secondary me-3">
|
||||
{reportingServers} of {totalServers} server{totalServers !== 1 ? "s" : ""} reporting
|
||||
</span>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={exportAgentCsv} disabled={!sortedAgentPorts?.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={loadAgentPorts}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{agentError && <div className="alert alert-danger m-3">{agentError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<AgentPortRow> label="Server" sortKeyName="serverName" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
<SortableTh<AgentPortRow> label="Protocol" sortKeyName="protocol" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
<SortableTh<AgentPortRow> label="Port" sortKeyName="port" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
<th>Address</th>
|
||||
<th>Process</th>
|
||||
<SortableTh<AgentPortRow> label="Last report" sortKeyName="lastSeenAt" activeKey={agentSortKey} direction={agentSortDir} onSort={requestAgentSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(agentPageItems ?? []).map((p) => (
|
||||
<tr key={`${p.serverId}-${p.protocol}-${p.port}`}>
|
||||
<td>
|
||||
<Link to={`/servers/${p.serverId}`}>{p.serverName}</Link>
|
||||
{p.serverHostname && <div className="text-secondary small">{p.serverHostname}</div>}
|
||||
</td>
|
||||
<td className="text-uppercase text-secondary">{p.protocol}</td>
|
||||
<td>{p.port}</td>
|
||||
<td className="text-secondary">
|
||||
{p.addresses.join(", ")}
|
||||
{p.localOnly && <span className="badge bg-secondary-lt text-secondary ms-2">local only</span>}
|
||||
</td>
|
||||
<td className="text-secondary">{p.process ?? "—"}</td>
|
||||
<td className="text-secondary">{p.lastSeenAt ? formatAgo(p.lastSeenAt) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{agentPorts !== null && agentPorts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-secondary text-center">
|
||||
No servers have reported listening ports yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={agentPage} pageCount={agentPageCount} totalCount={agentTotalCount} onPageChange={setAgentPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && showForm && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{editing ? `Edit "${editing.label}"` : "Add a manual port opening"}</h3>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Label</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="Plex remote access"
|
||||
value={form.label}
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">External port</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
required
|
||||
min={1}
|
||||
max={65535}
|
||||
value={form.externalPort || ""}
|
||||
onChange={(e) => setForm({ ...form, externalPort: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Protocol</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.protocol}
|
||||
onChange={(e) => setForm({ ...form, protocol: e.target.value as "tcp" | "udp" })}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Internal port</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder="if different"
|
||||
value={form.internalPort ?? ""}
|
||||
onChange={(e) => setForm({ ...form, internalPort: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Server (optional)</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.serverId ?? ""}
|
||||
onChange={(e) => setForm({ ...form, serverId: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{(servers ?? []).map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Destination</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="IP/host, if not a tracked server"
|
||||
value={form.destination ?? ""}
|
||||
onChange={(e) => setForm({ ...form, destination: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Source</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Home router, OPNsense, Cloudflare Tunnel…"
|
||||
value={form.source ?? ""}
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Comment</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.comment ?? ""}
|
||||
onChange={(e) => setForm({ ...form, comment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{editing ? "Save changes" : "Add port opening"}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={cancelForm}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Manual port openings</h3>
|
||||
<p className="text-secondary mb-0 me-auto">
|
||||
Router port forwards, edge firewall rules, cloud security groups — anything opened further out on the
|
||||
network that this app can't see on its own.
|
||||
</p>
|
||||
<div className="card-actions">
|
||||
{canEdit && !showForm && (
|
||||
<button className="btn btn-primary btn-sm" onClick={startAdd}>
|
||||
Add port opening
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={exportForwardsCsv} disabled={!sortedForwards?.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{forwardError && <div className="alert alert-danger m-3">{forwardError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<PortForward> label="Label" sortKeyName="label" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} />
|
||||
<SortableTh<PortForward>
|
||||
label="External port"
|
||||
sortKeyName="externalPort"
|
||||
activeKey={forwardSortKey}
|
||||
direction={forwardSortDir}
|
||||
onSort={requestForwardSort}
|
||||
/>
|
||||
<th>Destination</th>
|
||||
<SortableTh<PortForward> label="Source" sortKeyName="source" activeKey={forwardSortKey} direction={forwardSortDir} onSort={requestForwardSort} />
|
||||
<th>Comment</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(sortedForwards ?? []).map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td>{f.label}</td>
|
||||
<td>
|
||||
<span className="text-uppercase text-secondary me-1">{f.protocol}</span>
|
||||
{f.externalPort}
|
||||
{f.internalPort && f.internalPort !== f.externalPort && (
|
||||
<span className="text-secondary"> → {f.internalPort}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-secondary">
|
||||
{f.serverId ? <Link to={`/servers/${f.serverId}`}>{f.serverName}</Link> : f.destination || "—"}
|
||||
{f.serverId && f.destination && <div className="small">{f.destination}</div>}
|
||||
</td>
|
||||
<td className="text-secondary">{f.source ?? "—"}</td>
|
||||
<td className="text-secondary">{f.comment ?? "—"}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => startEdit(f)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(f)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{forwards !== null && forwards.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
||||
No manual port openings recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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).`
|
||||
@@ -248,6 +251,11 @@ export default function Privacy() {
|
||||
<strong>Certificate checks</strong> — for {outbound?.tlsCertificateChecks ?? 0} secret{outbound?.tlsCertificateChecks === 1 ? "" : "s"} with
|
||||
a host to check, the app connects to that host to read its certificate.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Skatteverket</strong> — only when an admin clicks "Import from Skatteverket" under Settings → Names, the app asks
|
||||
Skatteverket's open name statistics for the most common given names. The request carries a sex and a birth year, nothing about you
|
||||
or your servers.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Servers and agents</strong> — {outbound?.serversWithAgent ?? 0} of {outbound?.servers ?? 0} servers have reported in. Agents push
|
||||
their reports to the app; the app doesn't connect out to them, apart from port scans, which run only when an operator starts one
|
||||
@@ -288,7 +296,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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type CurrentUser,
|
||||
type IntegrationSummary,
|
||||
type ManualTaskInput,
|
||||
type PortEntry,
|
||||
type ProxmoxGuest,
|
||||
type ScheduleType,
|
||||
type ServerDetail as ServerDetailResponse,
|
||||
@@ -15,6 +16,8 @@ import ServerPorts from "../components/ServerPorts";
|
||||
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 }),
|
||||
@@ -111,6 +114,8 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
const [editingAdminLinkId, setEditingAdminLinkId] = useState<number | null>(null);
|
||||
const [savingAdminLink, setSavingAdminLink] = useState(false);
|
||||
const [adminLinkError, setAdminLinkError] = useState<string | null>(null);
|
||||
const [adminLinkPorts, setAdminLinkPorts] = useState<PortEntry[] | null>(null);
|
||||
const [adminLinkPortPick, setAdminLinkPortPick] = useState("");
|
||||
|
||||
const loadDetail = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
@@ -254,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();
|
||||
@@ -263,22 +268,46 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadAdminLinkPorts() {
|
||||
setAdminLinkPortPick("");
|
||||
api.servers.ports
|
||||
.list(serverId)
|
||||
.then((res) => setAdminLinkPorts(res.ports))
|
||||
.catch(() => setAdminLinkPorts(null));
|
||||
}
|
||||
|
||||
function openAddAdminLink() {
|
||||
setEditingAdminLinkId(null);
|
||||
setAdminLinkError(null);
|
||||
setAdminLinkForm({ label: "", url: "" });
|
||||
loadAdminLinkPorts();
|
||||
}
|
||||
|
||||
function startEditAdminLink(link: ServerLink) {
|
||||
setEditingAdminLinkId(link.id);
|
||||
setAdminLinkError(null);
|
||||
setAdminLinkForm({ label: link.label, url: link.url });
|
||||
loadAdminLinkPorts();
|
||||
}
|
||||
|
||||
function pickAdminLinkPort(key: string) {
|
||||
setAdminLinkPortPick(key);
|
||||
const entry = adminLinkPorts?.find((p) => `${p.protocol}:${p.port}` === key);
|
||||
if (!entry || !adminLinkForm) return;
|
||||
const address = detail?.server.hostname || detail?.ipAddresses[0] || null;
|
||||
const url = guessAdminUrl(address, entry);
|
||||
setAdminLinkForm({
|
||||
label: adminLinkForm.label || entry.label || entry.agent?.process || "",
|
||||
url: url ?? adminLinkForm.url,
|
||||
});
|
||||
}
|
||||
|
||||
function cancelAdminLinkForm() {
|
||||
setAdminLinkForm(null);
|
||||
setEditingAdminLinkId(null);
|
||||
setAdminLinkError(null);
|
||||
setAdminLinkPorts(null);
|
||||
setAdminLinkPortPick("");
|
||||
}
|
||||
|
||||
async function submitAdminLink(e: React.FormEvent) {
|
||||
@@ -302,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();
|
||||
@@ -319,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);
|
||||
@@ -400,6 +437,24 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
onChange={(e) => setAdminLinkForm({ ...adminLinkForm, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{!!adminLinkPorts?.length && (
|
||||
<div className="col-12">
|
||||
<label className="form-label">Fill from a known port (optional)</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ maxWidth: 460 }}
|
||||
value={adminLinkPortPick}
|
||||
onChange={(e) => pickAdminLinkPort(e.target.value)}
|
||||
>
|
||||
<option value="">— pick a port to fill in the URL above —</option>
|
||||
{adminLinkPorts.map((p) => (
|
||||
<option key={`${p.protocol}:${p.port}`} value={`${p.protocol}:${p.port}`}>
|
||||
{portOptionLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-md-3 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingAdminLink}>
|
||||
{editingAdminLinkId ? "Save" : "Add"}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -633,7 +633,10 @@ export default function NotificationSettings() {
|
||||
disabled={!dailyChecksEnabled}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
|
||||
/>
|
||||
<div className="form-hint">Shared by the secret, Tailscale key, Docker update, and Proxmox backup reminders above.</div>
|
||||
<div className="form-hint">
|
||||
Shared by the secret, Tailscale key, Docker update, Proxmox backup, Proxmox Backup Server, and
|
||||
domain expiry reminders above.
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<label className="form-label">Timezone</label>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { api, type TagEntry } from "../../api/client";
|
||||
import { readableError } from "../../utils/errors";
|
||||
import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags";
|
||||
import { confirmDialog, promptDialog } from "../../utils/dialogs";
|
||||
|
||||
const DEFAULT_PICK = "#3b82f6";
|
||||
|
||||
@@ -64,12 +65,20 @@ export default function TagSettings() {
|
||||
}
|
||||
|
||||
async function rename(t: TagEntry) {
|
||||
const input = window.prompt(`Rename “${t.name}” to:`, t.name);
|
||||
const input = await promptDialog({ title: "Rename tag", message: `Rename “${t.name}” to:`, defaultValue: t.name, confirmLabel: "Rename", required: true });
|
||||
if (input === null) return;
|
||||
const to = normalizeTagInput(input);
|
||||
if (!to || to === t.name) return;
|
||||
const target = tags?.find((x) => x.name === to);
|
||||
if (target && !window.confirm(`“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`)) return;
|
||||
if (
|
||||
target &&
|
||||
!(await confirmDialog({
|
||||
title: "Merge tags",
|
||||
message: `“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`,
|
||||
confirmLabel: "Merge",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.tags.rename(t.name, to),
|
||||
(res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`,
|
||||
@@ -78,7 +87,7 @@ export default function TagSettings() {
|
||||
|
||||
async function remove(t: TagEntry) {
|
||||
const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : "";
|
||||
if (!window.confirm(`Delete the tag “${t.name}”?${used}`)) return;
|
||||
if (!(await confirmDialog({ title: "Delete tag", message: `Delete the tag “${t.name}”?${used}`, confirmLabel: "Delete", danger: true }))) return;
|
||||
await run(
|
||||
() => api.tags.remove(t.name),
|
||||
(res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PortEntry } from "../api/client";
|
||||
|
||||
// Common admin-panel ports that are HTTPS-only (often with a self-signed cert) even though nothing
|
||||
// on the wire says so — a small, honest guess, not a detection. Anything else defaults to http://,
|
||||
// and the URL field stays a normal editable text input either way.
|
||||
const HTTPS_PORTS = new Set([443, 5001, 8006, 8007, 8443, 9090, 9443, 10000]);
|
||||
|
||||
/** What to show for one port in a "fill from a known port" picker. */
|
||||
export function portOptionLabel(entry: PortEntry): string {
|
||||
const bits = [`${entry.port}/${entry.protocol}`];
|
||||
if (entry.agent?.process) bits.push(entry.agent.process);
|
||||
if (entry.label) bits.push(entry.label);
|
||||
return bits.join(" — ");
|
||||
}
|
||||
|
||||
/** A best-effort URL for a server's port, or null if no address is known for it yet. */
|
||||
export function guessAdminUrl(address: string | null, entry: PortEntry): string | null {
|
||||
if (!address) return null;
|
||||
return `${HTTPS_PORTS.has(entry.port) ? "https" : "http"}://${address}:${entry.port}`;
|
||||
}
|
||||
@@ -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