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, "-"); +}