From 655862c94ed64f658b4a82ecc3ce071f6807490d Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Thu, 17 Sep 2026 22:51:10 +0200 Subject: [PATCH] Add per-server admin-page links (Dockge, Webmin, Cockpit, etc.) Each server's detail page gets an "Admin Links" card for bookmarking that host's own web UIs -- container managers, Webmin, Cockpit, or anything else reachable by URL -- so there's a quick way to jump there without hunting down the address each time. New server_links table (serverId FK, label, url, ON DELETE CASCADE so removing a server cleans up its links automatically) and three new routes: POST/PATCH/DELETE /api/servers/:id/links, gated to operator+ like the rest of this page's editing actions; the existing GET /:id/detail now includes the server's links alongside hardware/DNS info. URLs are validated to start with http:// or https:// server-side (rejecting e.g. a javascript: URL that would otherwise render as a clickable link). Rendered as a row of pill buttons (label opens the URL in a new tab), with inline edit/remove controls next to each when the viewer can edit, and an "Add link" form matching the existing task-form style on the same page. Verified against a temp SQLite DB with real migrations: create/list/ update/delete all work, and deleting the parent server cascades to remove its links rather than leaving them orphaned. Co-Authored-By: Claude Sonnet 5 --- README.md | 5 +- server/drizzle/0004_brown_gambit.sql | 8 + server/drizzle/meta/0004_snapshot.json | 1138 ++++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 14 + server/src/routes/servers.ts | 96 +- web/src/api/client.ts | 13 + web/src/pages/ServerDetail.tsx | 130 ++- 8 files changed, 1407 insertions(+), 4 deletions(-) create mode 100644 server/drizzle/0004_brown_gambit.sql create mode 100644 server/drizzle/meta/0004_snapshot.json diff --git a/README.md b/README.md index ba3784a..711a527 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ All modules from the original plan are built: its scheduled tasks — live hardware from Proxmox for VM/LXC-backed servers, or from the agent's own hardware report for everything else. Proxmox-linked servers also get start/stop/restart buttons right on the - detail page. + detail page. The detail page also has an **Admin Links** section + (operator/admin to add/edit/remove) for bookmarking that server's own + admin UIs — Dockge, Webmin, Cockpit, Portainer, or anything else reachable + by URL. - **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**, and **Docker** each get their own top-level page (backed by the matching integration) instead of living inside a shared Integrations diff --git a/server/drizzle/0004_brown_gambit.sql b/server/drizzle/0004_brown_gambit.sql new file mode 100644 index 0000000..1aa2125 --- /dev/null +++ b/server/drizzle/0004_brown_gambit.sql @@ -0,0 +1,8 @@ +CREATE TABLE `server_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `server_id` integer NOT NULL, + `label` text NOT NULL, + `url` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/server/drizzle/meta/0004_snapshot.json b/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..238a750 --- /dev/null +++ b/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1138 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3d36b5f2-7b22-45b1-840c-dc9b15b6aa43", + "prevId": "66364958-c33c-4fd8-9a2c-860616faaf5d", + "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": {} + }, + "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 + }, + "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": {} + }, + "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 + }, + "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 + } + }, + "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 f53f8d1..2fcf8d1 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1789673523898, "tag": "0003_fantastic_randall", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1789677988329, + "tag": "0004_brown_gambit", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index d6fe9fa..0f821b6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -222,6 +222,20 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", { .default(sql`(current_timestamp)`), }); +// ─── Server links — admin-page bookmarks per server (Dockge, Webmin, Cockpit, etc.) ─ + +export const serverLinks = sqliteTable("server_links", { + id: integer("id").primaryKey({ autoIncrement: true }), + serverId: integer("server_id") + .notNull() + .references(() => servers.id, { onDelete: "cascade" }), + label: text("label").notNull(), + url: text("url").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + // ─── Live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand) ─ export const integrationTypes = [ diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 057b701..a6f3c02 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { eq, and, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "../db/client.js"; -import { servers, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js"; +import { servers, serverLinks, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { generateApiToken } from "../services/tokens.js"; import { recordAudit } from "../services/audit.js"; @@ -221,6 +221,12 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { .where(inArray(dnsRecordsCache.content, ipAddresses)); } + const links = await db + .select({ id: serverLinks.id, label: serverLinks.label, url: serverLinks.url }) + .from(serverLinks) + .where(eq(serverLinks.serverId, id)) + .orderBy(serverLinks.label); + const { apiTokenHash, ipAddresses: _rawIpAddresses, @@ -233,7 +239,93 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { ...serverOut } = server; - res.json({ server: serverOut, ipAddresses, hardware, dnsMatches }); + res.json({ server: serverOut, ipAddresses, hardware, dnsMatches, links }); +})); + +const linkSchema = z.object({ + label: z.string().min(1).max(60), + url: z + .string() + .url() + .refine((u) => u.startsWith("http://") || u.startsWith("https://"), { message: "URL must start with http:// or https://" }), +}); + +serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (req, res) => { + const serverId = Number(req.params.id); + if (!Number.isInteger(serverId)) return res.status(400).json({ error: "invalid_id" }); + + const parsed = linkSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, serverId)).limit(1); + if (!server) return res.status(404).json({ error: "not_found" }); + + const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "add_link", + targetType: "server", + targetId: serverId, + detail: { label: created.label, url: created.url }, + }); + + res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } }); +})); + +serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => { + const serverId = Number(req.params.id); + const linkId = Number(req.params.linkId); + if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" }); + + const parsed = linkSchema.partial().safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const [updated] = await db + .update(serverLinks) + .set(parsed.data) + .where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId))) + .returning(); + if (!updated) return res.status(404).json({ error: "not_found" }); + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "update_link", + targetType: "server", + targetId: serverId, + detail: { label: updated.label, url: updated.url }, + }); + + res.json({ link: { id: updated.id, label: updated.label, url: updated.url } }); +})); + +serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => { + const serverId = Number(req.params.id); + const linkId = Number(req.params.linkId); + if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" }); + + const deleted = await db + .delete(serverLinks) + .where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId))) + .returning(); + if (deleted.length === 0) return res.status(404).json({ error: "not_found" }); + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "remove_link", + targetType: "server", + targetId: serverId, + detail: { label: deleted[0].label }, + }); + + res.status(204).end(); })); serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e2d8ca2..dd9c3f7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -249,11 +249,18 @@ export interface DnsMatch { providerType: string; } +export interface ServerLink { + id: number; + label: string; + url: string; +} + export interface ServerDetail { server: ServerRecord; ipAddresses: string[]; hardware: ServerHardware; dnsMatches: DnsMatch[]; + links: ServerLink[]; } export interface ServerUpdateInput { @@ -631,6 +638,12 @@ 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" }), + 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 }) => + request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }), + removeLink: (id: number, linkId: number) => + request(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }), }, tasks: { list: ( diff --git a/web/src/pages/ServerDetail.tsx b/web/src/pages/ServerDetail.tsx index 09c9e14..5ffd23b 100644 --- a/web/src/pages/ServerDetail.tsx +++ b/web/src/pages/ServerDetail.tsx @@ -8,6 +8,7 @@ import { type ProxmoxGuest, type ScheduleType, type ServerDetail as ServerDetailResponse, + type ServerLink, type TaskRecord, } from "../api/client"; import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable"; @@ -82,6 +83,11 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { const [actingOnGuest, setActingOnGuest] = useState(false); const [guestActionError, setGuestActionError] = useState(null); + const [adminLinkForm, setAdminLinkForm] = useState<{ label: string; url: string } | null>(null); + const [editingAdminLinkId, setEditingAdminLinkId] = useState(null); + const [savingAdminLink, setSavingAdminLink] = useState(false); + const [adminLinkError, setAdminLinkError] = useState(null); + const loadDetail = useCallback(() => { setRefreshing(true); return api.servers @@ -224,11 +230,59 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { } } + function openAddAdminLink() { + setEditingAdminLinkId(null); + setAdminLinkError(null); + setAdminLinkForm({ label: "", url: "" }); + } + + function startEditAdminLink(link: ServerLink) { + setEditingAdminLinkId(link.id); + setAdminLinkError(null); + setAdminLinkForm({ label: link.label, url: link.url }); + } + + function cancelAdminLinkForm() { + setAdminLinkForm(null); + setEditingAdminLinkId(null); + setAdminLinkError(null); + } + + async function submitAdminLink(e: React.FormEvent) { + e.preventDefault(); + if (!adminLinkForm) return; + setSavingAdminLink(true); + setAdminLinkError(null); + try { + if (editingAdminLinkId) { + await api.servers.updateLink(serverId, editingAdminLinkId, adminLinkForm); + } else { + await api.servers.addLink(serverId, adminLinkForm); + } + cancelAdminLinkForm(); + await loadDetail(); + } catch (err) { + setAdminLinkError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingAdminLink(false); + } + } + + async function removeAdminLink(link: ServerLink) { + if (!confirm(`Remove the "${link.label}" link?`)) return; + try { + await api.servers.removeLink(serverId, link.id); + await loadDetail(); + } catch (err) { + setAdminLinkError(err instanceof Error ? err.message : String(err)); + } + } + if (loading) return
Loading…
; if (error && !detail) return
{error}
; if (!detail) return null; - const { server, hardware, ipAddresses, dnsMatches } = detail; + const { server, hardware, ipAddresses, dnsMatches, links } = detail; async function guestAction(action: "start" | "stop" | "restart" | "shutdown") { if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return; @@ -269,6 +323,80 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { {error &&
{error}
} +
+
+

Admin Links

+ {canEditTasks && !adminLinkForm && ( +
+ +
+ )} +
+
+ {adminLinkError &&
{adminLinkError}
} + + {adminLinkForm && ( +
+
+ + setAdminLinkForm({ ...adminLinkForm, label: e.target.value })} + /> +
+
+ + setAdminLinkForm({ ...adminLinkForm, url: e.target.value })} + /> +
+
+ + +
+
+ )} + + {links.length === 0 ? ( +
No admin links added yet — e.g. Dockge, Webmin, or Cockpit for this server.
+ ) : ( +
+ {links.map((l) => ( +
+ + {l.label} + + {canEditTasks && ( + <> + + + + )} +
+ ))} +
+ )} +
+
+ {isAdmin && (