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; +}