From ae64cb345c728af4a8f66b10170f2582bcf3dcee Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 26 Sep 2026 21:17:09 +0200 Subject: [PATCH] Manage server tags in Settings: pre-add tags, recolour, rename, delete New Settings > Tags tab (admin) listing every tag -- ones servers use and ones added ahead of time -- with how many servers carry each: - Add a tag before anything uses it, optionally with a colour. Such tags are offered as one-click "Add:" chips (and datalist suggestions) when tagging a server, so the same word gets spelled the same way everywhere. - Give any tag a colour of your choosing, in use or not, or reset it to the automatic one. Changes are drafted with Save/Cancel rather than saved as the picker drags. Colours show on the Servers page, its tag filter bar, the detail page and the editor, and update everywhere without a reload through one shared cached colour map. - Rename a tag; every server that has it is rewritten. Renaming to a name that already exists merges the two after a confirmation naming what will happen; the target keeps its own colour unless it had none, and a server carrying both ends up with one. - Delete a tag, which removes it from every server that has it, with a confirmation stating how many. Tags still live on the servers (servers.tags); a new tag_definitions table holds only what a server can't: existence before use, and a colour. A defined tag stays listed until an admin deletes it, even with no servers. Rename and delete change the servers and the catalogue in one transaction so they can't disagree. Only servers that actually carry the tag are rewritten and counted -- an earlier draft also counted servers whose tags merely weren't in sorted order, which the tests caught. Reading the list and colours is open to everyone signed in (needed to draw tags anywhere); changing the catalogue is admin-only, while tagging a server stays an operator action. Names go through the same normalisation as before, colours must be #rrggbb, and every change is audit-logged. New table tag_definitions (migration 0013). Verified with 44 backend checks (list/counts, roles, create/adopt/ duplicate/rejects, colour set/reset, rename incl. defined, undefined and unused tags, merge colour rules and both-sides servers, delete, audit, and that normal tagging still works afterwards) and in a browser against the real routers: add with colour, set/reset a colour and see it change on the Servers page live, merge with confirmation, delete, and the error path. Not clicked through: the quick-add chips inside the tag editor on a server's detail page (typechecked; same colour code as the rest). Co-Authored-By: Claude Sonnet 5 --- README.md | 5 + server/drizzle/0013_sturdy_bloodstorm.sql | 8 + server/drizzle/meta/0013_snapshot.json | 1591 +++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 14 + server/src/index.ts | 2 + server/src/routes/tags.ts | 148 ++ server/src/services/serverTags.ts | 14 + web/src/App.tsx | 2 + web/src/api/client.ts | 19 + web/src/components/ServerTags.tsx | 66 +- web/src/pages/Servers.tsx | 7 +- web/src/pages/Settings.tsx | 1 + web/src/pages/settings/TagSettings.tsx | 227 +++ web/src/utils/tags.ts | 50 +- 15 files changed, 2136 insertions(+), 25 deletions(-) create mode 100644 server/drizzle/0013_sturdy_bloodstorm.sql create mode 100644 server/drizzle/meta/0013_snapshot.json create mode 100644 server/src/routes/tags.ts create mode 100644 web/src/pages/settings/TagSettings.tsx diff --git a/README.md b/README.md index a1fbca7..b81767a 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ page by operators and admins. Tags show as coloured chips on the Servers page, which can be filtered by one or several tags (the filter is in the URL, so a tag on a server's page links to everything sharing it), and they're searchable from the global search box. +Admins manage tags under Settings → Tags: add tags before any server uses them (they're +offered as one-click suggestions when tagging), give any tag a colour of your choosing +(or leave it on the automatic one), rename tags, and delete them. Renaming to a name +that already exists merges the two, and both rename and delete rewrite every server +that carries the tag. **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 diff --git a/server/drizzle/0013_sturdy_bloodstorm.sql b/server/drizzle/0013_sturdy_bloodstorm.sql new file mode 100644 index 0000000..d57b7d2 --- /dev/null +++ b/server/drizzle/0013_sturdy_bloodstorm.sql @@ -0,0 +1,8 @@ +CREATE TABLE `tag_definitions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `color` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tag_definitions_name_unique` ON `tag_definitions` (`name`); \ No newline at end of file diff --git a/server/drizzle/meta/0013_snapshot.json b/server/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..66d2bfc --- /dev/null +++ b/server/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1591 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cfc111bc-750b-462b-a59d-7a5bce844a8f", + "prevId": "b16a94c8-1cfa-489a-a64f-0cb0c578bafb", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "consistency_ignores": { + "name": "consistency_ignores", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "consistency_ignores_key_unique": { + "name": "consistency_ignores_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "diag_log": { + "name": "diag_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_providers": { + "name": "dns_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "dns_providers_credential_id_integration_credentials_id_fk": { + "name": "dns_providers_credential_id_integration_credentials_id_fk", + "tableFrom": "dns_providers", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_records_cache": { + "name": "dns_records_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl": { + "name": "ttl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxied": { + "name": "proxied", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dns_records_cache_provider_id_dns_providers_id_fk": { + "name": "dns_records_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_records_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_zones_cache": { + "name": "dns_zones_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dns_zones_cache_provider_zone_idx": { + "name": "dns_zones_cache_provider_zone_idx", + "columns": [ + "provider_id", + "zone_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dns_zones_cache_provider_id_dns_providers_id_fk": { + "name": "dns_zones_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_zones_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domains": { + "name": "domains", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registrar": { + "name": "registrar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lookup_source": { + "name": "lookup_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_ok_at": { + "name": "last_checked_ok_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "domains_name_unique": { + "name": "domains_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_credentials": { + "name": "integration_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrations": { + "name": "integrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_credential_id_integration_credentials_id_fk": { + "name": "integrations_credential_id_integration_credentials_id_fk", + "tableFrom": "integrations", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ipam_entries": { + "name": "ipam_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "ipam_entries_ip_address_unique": { + "name": "ipam_entries_ip_address_unique", + "columns": [ + "ip_address" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_windows": { + "name": "maintenance_windows", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_queue": { + "name": "notification_queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_expression": { + "name": "schedule_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stale": { + "name": "is_stale", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "scheduled_tasks_server_id_servers_id_fk": { + "name": "scheduled_tasks_server_id_servers_id_fk", + "tableFrom": "scheduled_tasks", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secrets": { + "name": "secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warn_days": { + "name": "warn_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_host": { + "name": "check_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_port": { + "name": "check_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_links": { + "name": "server_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "server_links_server_id_servers_id_fk": { + "name": "server_links_server_id_servers_id_fk", + "tableFrom": "server_links", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_ports": { + "name": "server_ports", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tcp'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open": { + "name": "open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_seen_open_at": { + "name": "last_seen_open_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "server_ports_unique": { + "name": "server_ports_unique", + "columns": [ + "server_id", + "port", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "server_ports_server_id_servers_id_fk": { + "name": "server_ports_server_id_servers_id_fk", + "tableFrom": "server_ports", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os_type": { + "name": "os_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'linux'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_token_hash": { + "name": "api_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token_prefix": { + "name": "api_token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_model": { + "name": "cpu_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_cores": { + "name": "cpu_cores", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_load_percent": { + "name": "cpu_load_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_total_bytes": { + "name": "mem_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_used_bytes": { + "name": "mem_used_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disks": { + "name": "disks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "listening_ports": { + "name": "listening_ports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_port_scan": { + "name": "last_port_scan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_integration_id": { + "name": "proxmox_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_node": { + "name": "proxmox_node", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_guest_type": { + "name": "proxmox_guest_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_vmid": { + "name": "proxmox_vmid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_proxmox_link": { + "name": "hide_proxmox_link", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "servers_proxmox_integration_id_integrations_id_fk": { + "name": "servers_proxmox_integration_id_integrations_id_fk", + "tableFrom": "servers", + "tableTo": "integrations", + "columnsFrom": [ + "proxmox_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag_definitions": { + "name": "tag_definitions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "tag_definitions_name_unique": { + "name": "tag_definitions_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_login_at": { + "name": "last_login_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_oidc_sub_unique": { + "name": "users_oidc_sub_unique", + "columns": [ + "oidc_sub" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 8270ead..1b17d43 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1790388232273, "tag": "0012_cool_harry_osborn", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1790449897833, + "tag": "0013_sturdy_bloodstorm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 222c881..a0263f6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -363,3 +363,17 @@ export const consistencyIgnores = sqliteTable("consistency_ignores", { .notNull() .default(sql`(current_timestamp)`), }); + +// ─── Tag catalogue ────────────────────────────────────────────────────────── + +// Tags themselves live on the servers that carry them (servers.tags). A row here adds two things a server can't: a tag +// that exists before anything uses it (so it's offered when tagging), and a chosen colour. A defined tag is kept until an +// admin deletes it, even with no server using it; a tag with no row is simply one that's in use and has an automatic colour. +export const tagDefinitions = sqliteTable("tag_definitions", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull().unique(), // normalised, as in services/serverTags + color: text("color"), // "#rrggbb"; null = automatic + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); diff --git a/server/src/index.ts b/server/src/index.ts index 29bc161..4207158 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -27,6 +27,7 @@ import { maintenanceRouter } from "./routes/maintenance.js"; 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 { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; @@ -98,6 +99,7 @@ app.use("/api/maintenance", maintenanceRouter); app.use("/api/domains", domainsRouter); app.use("/api/consistency", consistencyRouter); app.use("/api/privacy", privacyRouter); +app.use("/api/tags", tagsRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/tags.ts b/server/src/routes/tags.ts new file mode 100644 index 0000000..f406e5f --- /dev/null +++ b/server/src/routes/tags.ts @@ -0,0 +1,148 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { servers, tagDefinitions } from "../db/schema.js"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { InvalidTagError, normalizeColor, normalizeTag, parseTags } from "../services/serverTags.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const tagsRouter = Router(); +tagsRouter.use(requireAuth); + +interface TagEntry { + name: string; + /** "#rrggbb", or null for the automatic colour. */ + color: string | null; + /** Servers currently carrying it. */ + count: number; +} + +async function listTags(): Promise { + const [defs, rows] = await Promise.all([db.select().from(tagDefinitions), db.select({ tags: servers.tags }).from(servers)]); + const counts = new Map(); + for (const r of rows) for (const t of new Set(parseTags(r.tags))) counts.set(t, (counts.get(t) ?? 0) + 1); + const defined = new Map(defs.map((d) => [d.name, d.color])); + const names = new Set([...defined.keys(), ...counts.keys()]); + return [...names] + .map((name) => ({ name, color: defined.get(name) ?? null, count: counts.get(name) ?? 0 })) + .sort((a, b) => a.name.localeCompare(b.name, "sv")); +} + +// Read by everyone signed in: colours are needed to draw tags anywhere, and operators need the list to offer suggestions. +tagsRouter.get("/", asyncHandler(async (_req, res) => { + res.json({ tags: await listTags() }); +})); + +// Changing the catalogue is a Settings matter, so admin-only — tagging a server (operator) is separate. +const nameField = z.string().min(1).max(100); +const colorField = z.string().max(20); + +/** Turns the shared validation failures (bad name, bad colour) into a 400 with a message worth showing. */ +function handleInvalid(err: unknown, res: import("express").Response) { + if (err instanceof InvalidTagError) return res.status(400).json({ error: "invalid_tag", message: err.message }); + throw err; +} + +tagsRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = z.object({ name: nameField, color: colorField.nullish() }).safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Give the tag a name.", details: parsed.error.flatten() }); + try { + const name = normalizeTag(parsed.data.name); + const color = parsed.data.color ? normalizeColor(parsed.data.color) : null; + const [existing] = await db.select().from(tagDefinitions).where(eq(tagDefinitions.name, name)).limit(1); + if (existing) return res.status(409).json({ error: "exists", message: `"${name}" already exists.` }); + await db.insert(tagDefinitions).values({ name, color }); + await recordAudit({ actor: req.currentUser!, category: "tag", action: "create", targetType: "tag", detail: { name, color } }); + res.status(201).json({ tags: await listTags() }); + } catch (err) { + return handleInvalid(err, res); + } +})); + +tagsRouter.put("/color", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = z.object({ name: nameField, color: colorField.nullable() }).safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Choose a tag and a colour.", details: parsed.error.flatten() }); + try { + const name = normalizeTag(parsed.data.name); + const color = parsed.data.color === null ? null : normalizeColor(parsed.data.color); + const known = (await listTags()).some((t) => t.name === name); + if (!known) return res.status(404).json({ error: "not_found", message: `There's no tag "${name}".` }); + await db + .insert(tagDefinitions) + .values({ name, color }) + .onConflictDoUpdate({ target: tagDefinitions.name, set: { color } }); + await recordAudit({ actor: req.currentUser!, category: "tag", action: "set_color", targetType: "tag", detail: { name, color } }); + res.json({ tags: await listTags() }); + } catch (err) { + return handleInvalid(err, res); + } +})); + +/** Rewrites every server's tag list through `change`, saving only the ones that differ. Returns how many servers changed. */ +async function rewriteServerTags(tx: Pick, change: (tags: string[]) => string[]): Promise { + let changed = 0; + for (const row of await tx.select({ id: servers.id, tags: servers.tags }).from(servers)) { + const before = parseTags(row.tags); + if (before.length === 0) continue; + const sorted = (tags: string[]) => [...new Set(tags)].sort((a, b) => a.localeCompare(b, "sv")); + const after = sorted(change(before)); + // Compare against the same tags in the same order, so a server is only rewritten (and counted) when the change really touched it. + if (JSON.stringify(after) === JSON.stringify(sorted(before))) continue; + await tx.update(servers).set({ tags: after.length > 0 ? JSON.stringify(after) : null }).where(eq(servers.id, row.id)); + changed++; + } + return changed; +} + +tagsRouter.post("/rename", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = z.object({ from: nameField, to: nameField }).safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Give the current and the new name.", details: parsed.error.flatten() }); + try { + const from = normalizeTag(parsed.data.from); + const to = normalizeTag(parsed.data.to); + const all = await listTags(); + if (!all.some((t) => t.name === from)) return res.status(404).json({ error: "not_found", message: `There's no tag "${from}".` }); + if (from === to) return res.status(400).json({ error: "same_name", message: "That's already its name." }); + const merged = all.some((t) => t.name === to); + + // One transaction: the servers and the catalogue must never disagree about what a tag is called. + const updatedServers = await db.transaction(async (tx) => { + const n = await rewriteServerTags(tx, (tags) => tags.map((t) => (t === from ? to : t))); + const [fromDef] = await tx.select().from(tagDefinitions).where(eq(tagDefinitions.name, from)).limit(1); + const [toDef] = await tx.select().from(tagDefinitions).where(eq(tagDefinitions.name, to)).limit(1); + if (fromDef && toDef) { + // Merging into a tag that already exists: it keeps its own colour, unless it never had one. + if (!toDef.color && fromDef.color) await tx.update(tagDefinitions).set({ color: fromDef.color }).where(eq(tagDefinitions.id, toDef.id)); + await tx.delete(tagDefinitions).where(eq(tagDefinitions.id, fromDef.id)); + } else if (fromDef) { + await tx.update(tagDefinitions).set({ name: to }).where(eq(tagDefinitions.id, fromDef.id)); + } + return n; + }); + + await recordAudit({ actor: req.currentUser!, category: "tag", action: merged ? "merge" : "rename", targetType: "tag", detail: { from, to, servers: updatedServers } }); + res.json({ tags: await listTags(), updatedServers, merged }); + } catch (err) { + return handleInvalid(err, res); + } +})); + +tagsRouter.post("/delete", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = z.object({ name: nameField }).safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Choose a tag.", details: parsed.error.flatten() }); + try { + const name = normalizeTag(parsed.data.name); + if (!(await listTags()).some((t) => t.name === name)) return res.status(404).json({ error: "not_found", message: `There's no tag "${name}".` }); + const updatedServers = await db.transaction(async (tx) => { + const n = await rewriteServerTags(tx, (tags) => tags.filter((t) => t !== name)); + await tx.delete(tagDefinitions).where(eq(tagDefinitions.name, name)); + return n; + }); + await recordAudit({ actor: req.currentUser!, category: "tag", action: "delete", targetType: "tag", detail: { name, servers: updatedServers } }); + res.json({ tags: await listTags(), updatedServers }); + } catch (err) { + return handleInvalid(err, res); + } +})); diff --git a/server/src/services/serverTags.ts b/server/src/services/serverTags.ts index 129eba7..be6e2c9 100644 --- a/server/src/services/serverTags.ts +++ b/server/src/services/serverTags.ts @@ -36,3 +36,17 @@ export function parseTags(stored: string | null | undefined): string[] { return []; } } + +/** One tag through the same rules as normalizeTags; throws InvalidTagError (also for an empty one). */ +export function normalizeTag(input: string): string { + const [tag] = normalizeTags([input]); + if (!tag) throw new InvalidTagError("A tag needs a name."); + return tag; +} + +/** "#rrggbb" in lowercase, or throws InvalidTagError. (Reusing the tag error keeps the route's one catch.) */ +export function normalizeColor(input: string): string { + const c = input.trim().toLowerCase(); + if (!/^#[0-9a-f]{6}$/.test(c)) throw new InvalidTagError(`"${input.trim()}" isn't a colour — use the form #3b82f6.`); + return c; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 8f93ae2..de639dd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -28,6 +28,7 @@ import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; import DisplaySettings from "./pages/settings/DisplaySettings"; +import TagSettings from "./pages/settings/TagSettings"; import CacheSettings from "./pages/settings/CacheSettings"; import LogSettings from "./pages/settings/LogSettings"; import BackupSettings from "./pages/settings/BackupSettings"; @@ -146,6 +147,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 38bc6a0..80386cc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -570,6 +570,14 @@ export interface PrivacyOverview { }; } +export interface TagEntry { + name: string; + /** "#rrggbb", or null for the automatic colour. */ + color: string | null; + /** Servers currently carrying it. */ + count: number; +} + export interface DockhandContainer { id: string; name: string; @@ -934,6 +942,17 @@ export const api = { remove: (id: number, portId: number) => request(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }), }, }, + tags: { + list: () => request<{ tags: TagEntry[] }>("/api/tags"), + create: (name: string, color?: string) => + request<{ tags: TagEntry[] }>("/api/tags", { method: "POST", body: JSON.stringify({ name, color }) }), + setColor: (name: string, color: string | null) => + request<{ tags: TagEntry[] }>("/api/tags/color", { method: "PUT", body: JSON.stringify({ name, color }) }), + rename: (from: string, to: string) => + request<{ tags: TagEntry[]; updatedServers: number; merged: boolean }>("/api/tags/rename", { method: "POST", body: JSON.stringify({ from, to }) }), + remove: (name: string) => + request<{ tags: TagEntry[]; updatedServers: number }>("/api/tags/delete", { method: "POST", body: JSON.stringify({ name }) }), + }, privacy: { overview: () => request("/api/privacy"), exportOwnData: async (): Promise => { diff --git a/web/src/components/ServerTags.tsx b/web/src/components/ServerTags.tsx index a0c82b6..8a72ce5 100644 --- a/web/src/components/ServerTags.tsx +++ b/web/src/components/ServerTags.tsx @@ -1,23 +1,25 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api/client"; -import { normalizeTagInput, tagColorClass } from "../utils/tags"; +import { normalizeTagInput, tagBadge, useTagColors } from "../utils/tags"; import { readableError } from "../utils/errors"; export function TagBadges({ tags, linked = false }: { tags: string[]; linked?: boolean }) { + const colors = useTagColors(); return ( <> - {tags.map((tag) => - linked ? ( - + {tags.map((tag) => { + const { className, style } = tagBadge(tag, colors); + return linked ? ( + {tag} ) : ( - + {tag} - ), - )} + ); + })} ); } @@ -40,16 +42,17 @@ export default function ServerTags({ const [suggestions, setSuggestions] = useState([]); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const colors = useTagColors(); function startEditing() { setDraftTags(tags); setInput(""); setError(null); setEditing(true); - // Offer the tags already used elsewhere, so the same word ends up spelled the same way everywhere. - api.servers + // Offer every tag that exists — pre-added ones and those already used — so the same word is spelled the same way everywhere. + api.tags .list() - .then((res) => setSuggestions([...new Set(res.servers.flatMap((s) => s.tags))].sort())) + .then((res) => setSuggestions(res.tags.map((t) => t.name))) .catch(() => {}); } @@ -99,18 +102,21 @@ export default function ServerTags({ {error &&
{error}
}
- {draftTags.map((tag) => ( - - {tag} -
@@ -145,6 +151,22 @@ export default function ServerTags({ Cancel
+ {suggestions.some((s) => !draftTags.includes(s)) && ( +
+ Add: + {suggestions + .filter((s) => !draftTags.includes(s)) + .slice(0, 30) + .map((s) => { + const { className, style } = tagBadge(s, colors); + return ( + + ); + })} +
+ )}
Lowercase letters, numbers and - _ . : / — up to 12 tags per server.
diff --git a/web/src/pages/Servers.tsx b/web/src/pages/Servers.tsx index 9ba706e..963ecaf 100644 --- a/web/src/pages/Servers.tsx +++ b/web/src/pages/Servers.tsx @@ -9,7 +9,7 @@ import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; import { TagBadges } from "../components/ServerTags"; -import { tagColorClass } from "../utils/tags"; +import { tagBadge, useTagColors } from "../utils/tags"; function typeBadgeStyle(colors: Record, type: string): CSSProperties { const color = colors[type]; @@ -40,6 +40,7 @@ export default function Servers({ user }: { user: CurrentUser }) { // The active tag filter lives in the URL, so a tag chip on a server's page can link straight to "everything with this tag". const [searchParams, setSearchParams] = useSearchParams(); const selectedTags = searchParams.getAll("tag"); + const tagColors = useTagColors(); useEffect(() => { api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {}); @@ -342,11 +343,13 @@ export default function Servers({ user }: { user: CurrentUser }) { Tags {allTags.map((tag) => { const active = selectedTags.includes(tag); + const look = tagBadge(tag, tagColors); return ( + + + + + +
+
+

All tags

+
+
+ + + + + + + + + + + {tags?.map((t) => { + const draft = drafts[t.name]; + const shown = draft ?? t.color; + const badge = tagBadge(t.name, shown ? { [t.name]: shown } : {}); + return ( + + + + + + + ); + })} + {tags && tags.length === 0 && ( + + + + )} + +
TagServersColourActions
+ + {t.name} + + {t.count === 0 ? "not used yet" : t.count} +
+ {shown ? ( + setDraft(t.name, e.target.value)} + /> + ) : ( + <> + Automatic + + + )} + {draft && draft !== t.color && ( + <> + + + + )} + {t.color && !(draft && draft !== t.color) && ( + + )} +
+
+
+ + +
+
+ No tags yet. Add one above, or tag a server from its page. +
+
+
+ Renaming a tag to a name that already exists merges them. Deleting a tag removes it from every server that has it. “Automatic” colours + are picked from the tag's name, so they stay the same on every page. +
+
+ + ); +} diff --git a/web/src/utils/tags.ts b/web/src/utils/tags.ts index 7544c9c..70c726f 100644 --- a/web/src/utils/tags.ts +++ b/web/src/utils/tags.ts @@ -1,6 +1,9 @@ +import { useEffect, useState, type CSSProperties } from "react"; +import { api } from "../api/client"; + const PALETTE = ["blue", "azure", "indigo", "purple", "pink", "orange", "yellow", "lime", "green", "teal", "cyan", "red"]; -/** The same tag always gets the same colour, so "prod" is recognisable at a glance across every server. */ +/** The automatic colour: the same tag always gets the same one, so "prod" is recognisable at a glance across every server. */ export function tagColorClass(tag: string): string { let hash = 0; for (const ch of tag) hash = (hash * 31 + ch.codePointAt(0)!) >>> 0; @@ -12,3 +15,48 @@ export function tagColorClass(tag: string): string { export function normalizeTagInput(raw: string): string { return raw.trim().toLowerCase().replace(/\s+/g, "-"); } + +/** How to draw a tag: its chosen colour if it has one (same tinted style as the other colour-customised badges), else the automatic one. */ +export function tagBadge(tag: string, colors: Record): { className: string; style?: CSSProperties } { + const color = colors[tag]; + if (!color) return { className: `badge ${tagColorClass(tag)}` }; + return { className: "badge", style: { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` } }; +} + +// ─── Shared colour map ────────────────────────────────────────────────────── +// Tags are drawn on several pages; they all read one cached map so a colour change shows up everywhere without a reload. + +let cache: Record | null = null; +let inflight: Promise | null = null; +const listeners = new Set<() => void>(); + +/** Re-reads the tag colours from the server and tells every mounted tag. Failures leave the automatic colours in place. */ +export function refreshTagColors(): Promise { + inflight = api.tags + .list() + .then((res) => { + const next: Record = {}; + for (const t of res.tags) if (t.color) next[t.name] = t.color; + cache = next; + listeners.forEach((l) => l()); + }) + .catch(() => {}) + .finally(() => { + inflight = null; + }); + return inflight; +} + +export function useTagColors(): Record { + const [colors, setColors] = useState>(cache ?? {}); + useEffect(() => { + const update = () => setColors(cache ?? {}); + listeners.add(update); + if (cache) update(); + else if (!inflight) void refreshTagColors(); + return () => { + listeners.delete(update); + }; + }, []); + return colors; +}