From 9f1609c4eda1ac74c779d853aa6fff955c3e15de Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 26 Sep 2026 03:06:29 +0200 Subject: [PATCH] Add tags to servers, with a tag filter on the Servers page Servers can be tagged (prod, media, rack-1, ...) for grouping. Operators and admins edit tags inline on a server's detail page; the input suggests tags already used on other servers so the same word ends up spelled the same way everywhere. Tags show as chips that keep one colour per tag, on the server cards, in the Manage table (and its CSV export), and on the detail page, where each chip links to the Servers list filtered by that tag. The Servers page has a tag bar with counts; picking several tags narrows to servers that have all of them. The filter lives in the URL, so it survives a refresh and can be linked to. Tags are also matched by the global search. Tags are normalized on the server (trimmed, lowercased, spaces become "-", duplicates merged, sorted); letters in any language are allowed, plus digits and - _ . : /, at most 30 characters and 12 per server. Invalid input is rejected with a message naming the offending tag, and nothing is saved. Changes are audit-logged with the before and after lists. Stored as a JSON column on servers (migration 0010). Every server response now returns tags as an array, and the shared response shaping strips the token hash in one place instead of five. Verified with 27 backend checks (normalization edge cases including Swedish letters, roles, validation, search, audit, PATCH/detail/list shapes) and by driving the real Servers and detail pages against the real router in a browser (filtering, editing, invalid tag, viewer view). Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 --- README.md | 6 + server/drizzle/0010_magenta_alice.sql | 1 + server/drizzle/meta/0010_snapshot.json | 1393 ++++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 1 + server/src/routes/search.ts | 2 +- server/src/routes/servers.ts | 57 +- server/src/services/serverTags.ts | 38 + web/src/api/client.ts | 3 + web/src/components/ServerTags.tsx | 152 +++ web/src/pages/ServerDetail.tsx | 8 + web/src/pages/Servers.tsx | 75 +- web/src/utils/tags.ts | 14 + 13 files changed, 1745 insertions(+), 12 deletions(-) create mode 100644 server/drizzle/0010_magenta_alice.sql create mode 100644 server/drizzle/meta/0010_snapshot.json create mode 100644 server/src/services/serverTags.ts create mode 100644 web/src/components/ServerTags.tsx create mode 100644 web/src/utils/tags.ts diff --git a/README.md b/README.md index c62f0d4..8496b54 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,12 @@ raise one notification when the problem starts and one when it clears (both thresholds are set under Settings → Notifications). Active problems are remembered across restarts, so a rebuild doesn't re-alert them. +**Tags** — servers can be tagged (prod, media, rack-1, …) from their detail +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. + **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, and "integration down" for that service type). diff --git a/server/drizzle/0010_magenta_alice.sql b/server/drizzle/0010_magenta_alice.sql new file mode 100644 index 0000000..44f6674 --- /dev/null +++ b/server/drizzle/0010_magenta_alice.sql @@ -0,0 +1 @@ +ALTER TABLE `servers` ADD `tags` text; \ No newline at end of file diff --git a/server/drizzle/meta/0010_snapshot.json b/server/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..81f5b8d --- /dev/null +++ b/server/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1393 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d502ae04-16b8-472f-b5e0-23da93390b96", + "prevId": "6a9e331e-4576-4063-9306-6e8daf2468f4", + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 06c4f71..252206f 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1790382571470, "tag": "0009_sharp_magus", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1790384497980, + "tag": "0010_magenta_alice", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 70a3010..d76c710 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -222,6 +222,7 @@ export const servers = sqliteTable("servers", { disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[] listeningPorts: text("listening_ports"), // JSON string: {protocol, port, address, process}[] — what the agent sees bound on the host lastPortScan: text("last_port_scan"), // JSON string: summary of the most recent network scan from this app + tags: text("tags"), // JSON string: string[] — free-form labels for grouping and filtering, normalized by services/serverTags // Optional link to a Proxmox VM/LXC — set by an admin, not the agent. proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, { diff --git a/server/src/routes/search.ts b/server/src/routes/search.ts index e8bcd08..a3d9d08 100644 --- a/server/src/routes/search.ts +++ b/server/src/routes/search.ts @@ -36,7 +36,7 @@ searchRouter.get("/", asyncHandler(async (req, res) => { db .select({ id: servers.id, name: servers.name, hostname: servers.hostname }) .from(servers) - .where(or(like(servers.name, term), like(servers.hostname, term), like(servers.description, term))) + .where(or(like(servers.name, term), like(servers.hostname, term), like(servers.description, term), like(servers.tags, term))) .limit(RESULT_LIMIT), db .select({ id: secrets.id, name: secrets.name, type: secrets.type }) diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index ff67e77..e4f87c5 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -10,8 +10,16 @@ import { asyncHandler } from "../utils/asyncHandler.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js"; import { serverPortsRouter } from "./serverPorts.js"; +import { InvalidTagError, normalizeTags, parseTags } from "../services/serverTags.js"; export const serversRouter = Router(); + +/** A server row as the API returns it: no token hash, and tags as an array rather than the stored JSON. */ +function publicServer(row: T) { + const { apiTokenHash: _hash, tags, ...rest } = row; + return { ...rest, tags: parseTags(tags) }; +} + serversRouter.use(requireAuth); serversRouter.use("/:id/ports", serverPortsRouter); @@ -25,7 +33,7 @@ const createServerSchema = z.object({ serversRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] }); res.json({ - servers: rows.map(({ apiTokenHash, ...rest }) => rest), + servers: rows.map(publicServer), }); })); @@ -58,7 +66,7 @@ serversRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => { detail: { name: created.name }, }); - const { apiTokenHash, ...serverOut } = created; + const serverOut = publicServer(created); // The full token is only ever shown once, at creation time. res.status(201).json({ server: serverOut, token }); })); @@ -85,7 +93,7 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async detail: { name: updated.name }, }); - const { apiTokenHash, ...serverOut } = updated; + const serverOut = publicServer(updated); res.json({ server: serverOut, token }); })); @@ -145,7 +153,7 @@ serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) detail: { name: updated.name }, }); - const { apiTokenHash, ...serverOut } = updated; + const serverOut = publicServer(updated); res.json({ server: serverOut }); })); @@ -232,7 +240,8 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { .orderBy(serverLinks.label); const { - apiTokenHash, + apiTokenHash: _apiTokenHash, + tags: rawTags, ipAddresses: _rawIpAddresses, disks: _rawDisks, cpuModel: _cpuModel, @@ -245,7 +254,43 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { ...serverOut } = server; - res.json({ server: serverOut, ipAddresses, hardware, dnsMatches, links }); + res.json({ server: { ...serverOut, tags: parseTags(rawTags) }, ipAddresses, hardware, dnsMatches, links }); +})); + +const tagsSchema = z.object({ tags: z.array(z.string().max(100)).max(50) }); + +// Tags are lightweight labels, edited by operators like port notes and admin links — not admin-only like renaming a server. +serversRouter.put("/:id/tags", requireRole("operator"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); + + const parsed = tagsSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", message: "Tags must be a list of text.", details: parsed.error.flatten() }); + } + let tags: string[]; + try { + tags = normalizeTags(parsed.data.tags); + } catch (err) { + if (err instanceof InvalidTagError) return res.status(400).json({ error: "invalid_tag", message: err.message }); + throw err; + } + + const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1); + if (!existing) return res.status(404).json({ error: "not_found" }); + + await db.update(servers).set({ tags: tags.length > 0 ? JSON.stringify(tags) : null }).where(eq(servers.id, id)); + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "set_tags", + targetType: "server", + targetId: id, + detail: { name: existing.name, before: parseTags(existing.tags), after: tags }, + }); + + res.json({ tags }); })); const linkSchema = z.object({ diff --git a/server/src/services/serverTags.ts b/server/src/services/serverTags.ts new file mode 100644 index 0000000..129eba7 --- /dev/null +++ b/server/src/services/serverTags.ts @@ -0,0 +1,38 @@ +export const MAX_TAGS_PER_SERVER = 12; +export const MAX_TAG_LENGTH = 30; + +// Letters and digits (any language — Swedish åäö included) first, then a few separators people actually use in tags. +const TAG_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}._:/-]*$/u; + +export class InvalidTagError extends Error {} + +/** + * Turns what someone typed into the stored form: trimmed, lowercased, inner whitespace turned into "-", duplicates + * merged (so "Prod" and "prod " are one tag), sorted so the display is stable. Throws InvalidTagError with a + * message fit to show the user. + */ +export function normalizeTags(input: string[]): string[] { + const seen = new Set(); + for (const raw of input) { + const tag = raw.trim().toLowerCase().replace(/\s+/g, "-"); + if (!tag) continue; // an empty box isn't an error, it's just nothing + if (tag.length > MAX_TAG_LENGTH) throw new InvalidTagError(`"${raw.trim()}" is too long — tags are at most ${MAX_TAG_LENGTH} characters.`); + if (!TAG_PATTERN.test(tag)) { + throw new InvalidTagError(`"${raw.trim()}" isn't a valid tag — use letters, numbers, and - _ . : /`); + } + seen.add(tag); + } + if (seen.size > MAX_TAGS_PER_SERVER) throw new InvalidTagError(`A server can have at most ${MAX_TAGS_PER_SERVER} tags.`); + return [...seen].sort((a, b) => a.localeCompare(b, "sv")); +} + +/** Reads the stored JSON column, tolerating null and anything unexpected. */ +export function parseTags(stored: string | null | undefined): string[] { + if (!stored) return []; + try { + const value = JSON.parse(stored); + return Array.isArray(value) ? value.filter((t): t is string => typeof t === "string") : []; + } catch { + return []; + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b4d4acd..f5f2341 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -303,6 +303,7 @@ export interface ServerRecord { proxmoxGuestType: "qemu" | "lxc" | null; proxmoxVmid: number | null; hideProxmoxLink: boolean; + tags: string[]; } export interface ServerHardware { @@ -835,6 +836,8 @@ export const api = { request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }), detail: (id: number) => request(`/api/servers/${id}/detail`), remove: (id: number) => request(`/api/servers/${id}`, { method: "DELETE" }), + setTags: (id: number, tags: string[]) => + request<{ tags: string[] }>(`/api/servers/${id}/tags`, { method: "PUT", body: JSON.stringify({ tags }) }), addLink: (id: number, data: { label: string; url: string }) => request<{ link: ServerLink }>(`/api/servers/${id}/links`, { method: "POST", body: JSON.stringify(data) }), updateLink: (id: number, linkId: number, data: { label?: string; url?: string }) => diff --git a/web/src/components/ServerTags.tsx b/web/src/components/ServerTags.tsx new file mode 100644 index 0000000..a0c82b6 --- /dev/null +++ b/web/src/components/ServerTags.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api/client"; +import { normalizeTagInput, tagColorClass } from "../utils/tags"; +import { readableError } from "../utils/errors"; + +export function TagBadges({ tags, linked = false }: { tags: string[]; linked?: boolean }) { + return ( + <> + {tags.map((tag) => + linked ? ( + + {tag} + + ) : ( + + {tag} + + ), + )} + + ); +} + +/** Tags on a server's detail page: the chips (each links to the filtered Servers list) and, for operators, an inline editor. */ +export default function ServerTags({ + serverId, + tags, + canEdit, + onSaved, +}: { + serverId: number; + tags: string[]; + canEdit: boolean; + onSaved: (tags: string[]) => void; +}) { + const [editing, setEditing] = useState(false); + const [draftTags, setDraftTags] = useState([]); + const [input, setInput] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + 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 + .list() + .then((res) => setSuggestions([...new Set(res.servers.flatMap((s) => s.tags))].sort())) + .catch(() => {}); + } + + function addFromInput(): string[] { + const additions = input + .split(",") + .map(normalizeTagInput) + .filter(Boolean); + const next = [...new Set([...draftTags, ...additions])]; + setDraftTags(next); + setInput(""); + return next; + } + + async function save() { + const finalTags = input.trim() ? addFromInput() : draftTags; + setSaving(true); + setError(null); + try { + const res = await api.servers.setTags(serverId, finalTags); + onSaved(res.tags); + setEditing(false); + } catch (err) { + setError(readableError(err)); + } finally { + setSaving(false); + } + } + + if (!editing) { + if (tags.length === 0 && !canEdit) return null; + return ( +
+ + {canEdit && ( + + )} +
+ ); + } + + return ( +
+
+ {error &&
{error}
} + +
+ {draftTags.map((tag) => ( + + {tag} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + if (input.trim()) addFromInput(); + } else if (e.key === "Backspace" && !input && draftTags.length > 0) { + setDraftTags(draftTags.slice(0, -1)); + } + }} + /> + + {suggestions + .filter((s) => !draftTags.includes(s)) + .map((s) => ( + + + +
+
Lowercase letters, numbers and - _ . : / — up to 12 tags per server.
+
+
+ ); +} diff --git a/web/src/pages/ServerDetail.tsx b/web/src/pages/ServerDetail.tsx index 1ea2fee..f6bde4a 100644 --- a/web/src/pages/ServerDetail.tsx +++ b/web/src/pages/ServerDetail.tsx @@ -12,6 +12,7 @@ import { type TaskRecord, } from "../api/client"; import ServerPorts from "../components/ServerPorts"; +import ServerTags from "../components/ServerTags"; import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable"; import { formatDateTime } from "../utils/date"; @@ -355,6 +356,13 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { {error &&
{error}
} + setDetail((d) => (d ? { ...d, server: { ...d.server, tags } } : d))} + /> +

Admin Links

diff --git a/web/src/pages/Servers.tsx b/web/src/pages/Servers.tsx index 4958e54..9ba706e 100644 --- a/web/src/pages/Servers.tsx +++ b/web/src/pages/Servers.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, type CSSProperties } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { api, type CurrentUser, type ServerRecord } from "../api/client"; import CopyButton from "../components/CopyButton"; import { formatDateTime } from "../utils/date"; @@ -8,6 +8,8 @@ import SortableTh from "../components/SortableTh"; 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"; function typeBadgeStyle(colors: Record, type: string): CSSProperties { const color = colors[type]; @@ -35,6 +37,9 @@ export default function Servers({ user }: { user: CurrentUser }) { const [error, setError] = useState(null); const [managingServers, setManagingServers] = useState(false); const [integrationColors, setIntegrationColors] = useState>({}); + // 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"); useEffect(() => { api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {}); @@ -101,12 +106,30 @@ export default function Servers({ user }: { user: CurrentUser }) { const { pageItems: serverPageItems, page: serverPage, setPage: setServerPage, pageCount: serverPageCount, totalCount: serverTotalCount } = usePagination(sortedServers); + const tagCounts = new Map(); + for (const s of servers) for (const t of s.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1); + // A tag from the URL that no server has (any more) stays listed, so it can still be switched off. + for (const t of selectedTags) if (!tagCounts.has(t)) tagCounts.set(t, 0); + const allTags = [...tagCounts.keys()].sort((a, b) => a.localeCompare(b, "sv")); + const visibleServers = servers.filter((s) => selectedTags.every((t) => s.tags.includes(t))); + + function toggleTag(tag: string) { + const next = selectedTags.includes(tag) ? selectedTags.filter((t) => t !== tag) : [...selectedTags, tag]; + setSearchParams(next.length > 0 ? { tag: next } : {}, { replace: true }); + } + function exportCsv() { if (!sortedServers) return; downloadCsv( "servers.csv", - ["Name", "Hostname", "Token prefix", "Last seen"], - sortedServers.map((s) => [s.name, s.hostname ?? "", s.apiTokenPrefix, s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"]), + ["Name", "Hostname", "Tags", "Token prefix", "Last seen"], + sortedServers.map((s) => [ + s.name, + s.hostname ?? "", + s.tags.join(" "), + s.apiTokenPrefix, + s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never", + ]), ); } @@ -243,6 +266,7 @@ export default function Servers({ user }: { user: CurrentUser }) { direction={sortDir} onSort={requestSort} /> + Tags Token label="Last seen" @@ -261,6 +285,11 @@ export default function Servers({ user }: { user: CurrentUser }) { {s.name} {s.hostname ?? "—"} + +
+ +
+ {s.apiTokenPrefix}… {s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"} @@ -280,7 +309,7 @@ export default function Servers({ user }: { user: CurrentUser }) { ))} {sortedServers?.length === 0 && ( - + No servers registered yet. @@ -307,8 +336,38 @@ export default function Servers({ user }: { user: CurrentUser }) {
) : ( + <> + {allTags.length > 0 && ( +
+ Tags + {allTags.map((tag) => { + const active = selectedTags.includes(tag); + return ( + + ); + })} + {selectedTags.length > 0 && ( + + )} +
+ )} + {visibleServers.length === 0 && ( +
+
No server has {selectedTags.length > 1 ? "all of those tags" : "that tag"}.
+
+ )}
- {servers.map((s) => ( + {visibleServers.map((s) => (
@@ -327,6 +386,11 @@ export default function Servers({ user }: { user: CurrentUser }) { No data )}
+ {s.tags.length > 0 && ( +
+ +
+ )}
{s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"}
@@ -335,6 +399,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
))}
+ )} ); diff --git a/web/src/utils/tags.ts b/web/src/utils/tags.ts new file mode 100644 index 0000000..7544c9c --- /dev/null +++ b/web/src/utils/tags.ts @@ -0,0 +1,14 @@ +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. */ +export function tagColorClass(tag: string): string { + let hash = 0; + for (const ch of tag) hash = (hash * 31 + ch.codePointAt(0)!) >>> 0; + const color = PALETTE[hash % PALETTE.length]; + return `bg-${color}-lt text-${color}`; +} + +/** Mirrors the server's normalisation for what's shown while typing; the server stays the authority on what's valid. */ +export function normalizeTagInput(raw: string): string { + return raw.trim().toLowerCase().replace(/\s+/g, "-"); +}