From e35da87886ea2dcb30771d88f97ef2e085c18296 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Thu, 17 Sep 2026 21:41:06 +0200 Subject: [PATCH] Add a Diagnostic Log, ported and generalized from Sloth Manager Sloth Manager tracked every API call made to DNS providers for connectivity troubleshooting. Port that here, generalized to cover every outbound integration this app makes, not just DNS -- Tailscale, Proxmox, Synology, Semaphore, Gitea, and Dockhand calls now show up too, since a broken API token or unreachable host on any of them is just as worth diagnosing. New services/diagLog.ts: a generic withDiagLogging(source, adapter) wraps every async method of any adapter object with timing + success/failure recording, without touching a single adapter's request/error-handling internals -- every DNS and integration adapter interface here is already just a flat set of async methods, so this one wrapper works for all twelve of them. Applied it at each adapter factory's own return statement (one line each) rather than at the route layer, so background jobs that construct adapters directly (the Tailscale key-expiry scheduler, IPAM sync, agent-driven Proxmox lookups) get logged too, not just requests through routes/integrations.ts. New diag_log table (ring-buffered to the last 500 rows, mirroring Sloth Manager's approach -- this is for live troubleshooting, not a durable record) and admin-only GET/DELETE /api/diag-log routes, source/ result filters, pagination. New admin-only Diagnostic Log page: filterable, paginated table with a Clear button. Also introduces the shared useSortable hook + SortableTh component used here for the first time -- a follow-up commit applies the same sorting (and CSV export) to the rest of the app's tables, per the same request. Verified end-to-end against a temp SQLite DB with real migrations: a fake wrapped adapter's successful and failing calls both land correctly in the log with the right source/operation/latency/error, and the source/ok filters and clear-log operation all behave correctly. Co-Authored-By: Claude Sonnet 5 --- README.md | 7 + server/drizzle/0003_fantastic_randall.sql | 9 + server/drizzle/meta/0003_snapshot.json | 1078 ++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 14 + server/src/dns/adapters/azure.ts | 3 +- server/src/dns/adapters/cloudflare.ts | 3 +- server/src/dns/adapters/cpanel.ts | 3 +- server/src/dns/adapters/loopia.ts | 3 +- server/src/dns/adapters/pihole.ts | 3 +- server/src/dns/adapters/technitium.ts | 3 +- server/src/index.ts | 2 + server/src/integrations/dockhand/adapter.ts | 3 +- server/src/integrations/gitea/adapter.ts | 3 +- server/src/integrations/proxmox/adapter.ts | 12 +- server/src/integrations/semaphore/adapter.ts | 3 +- server/src/integrations/synology/adapter.ts | 3 +- server/src/integrations/tailscale/adapter.ts | 3 +- server/src/routes/diagLog.ts | 26 + server/src/services/diagLog.ts | 87 ++ web/src/App.tsx | 9 + web/src/api/client.ts | 27 + web/src/components/SortableTh.tsx | 33 + web/src/hooks/useSortable.ts | 45 + web/src/layout/AppShell.tsx | 2 + web/src/pages/DiagLog.tsx | 213 ++++ 26 files changed, 1592 insertions(+), 12 deletions(-) create mode 100644 server/drizzle/0003_fantastic_randall.sql create mode 100644 server/drizzle/meta/0003_snapshot.json create mode 100644 server/src/routes/diagLog.ts create mode 100644 server/src/services/diagLog.ts create mode 100644 web/src/components/SortableTh.tsx create mode 100644 web/src/hooks/useSortable.ts create mode 100644 web/src/pages/DiagLog.tsx diff --git a/README.md b/README.md index 8d5880a..3227889 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ All modules from the original plan are built: - Monorepo scaffold, Tabler-themed app shell/navigation - Authentik OIDC login, roles (first user to sign in becomes admin), audit log +- **Diagnostic Log** (admin-only) — every call this app makes to a DNS + provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea, + Dockhand), success or failure, with latency and the error message if it + failed — the last 500 calls, filterable by source/result, for + troubleshooting connectivity issues (ported from Sloth Manager's + provider-diagnostics log, generalized to cover every integration this app + has, not just DNS) - **Secrets** — expiry tracking for API tokens/certs/passwords - **IP Addresses (IPAM)** — inventory of IPs across vendors/locations, with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in diff --git a/server/drizzle/0003_fantastic_randall.sql b/server/drizzle/0003_fantastic_randall.sql new file mode 100644 index 0000000..21c8346 --- /dev/null +++ b/server/drizzle/0003_fantastic_randall.sql @@ -0,0 +1,9 @@ +CREATE TABLE `diag_log` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `source` text NOT NULL, + `operation` text NOT NULL, + `ok` integer NOT NULL, + `latency_ms` integer NOT NULL, + `error` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); diff --git a/server/drizzle/meta/0003_snapshot.json b/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..8074dfe --- /dev/null +++ b/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1078 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "66364958-c33c-4fd8-9a2c-860616faaf5d", + "prevId": "9a36d1de-4337-427c-8cce-24d46bc29acb", + "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": {} + }, + "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 f38dc5b..f53f8d1 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1789506601790, "tag": "0002_motionless_lord_hawal", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1789673523898, + "tag": "0003_fantastic_randall", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index f0bf314..d6fe9fa 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -34,6 +34,20 @@ export const auditLog = sqliteTable("audit_log", { .default(sql`(current_timestamp)`), }); +// ─── Diagnostic log — every outbound call to a DNS provider or integration ── + +export const diagLog = sqliteTable("diag_log", { + id: integer("id").primaryKey({ autoIncrement: true }), + source: text("source").notNull(), // e.g. 'cloudflare' | 'tailscale' | 'proxmox' | ... + operation: text("operation").notNull(), // adapter method name, e.g. 'listZones' | 'listDevices' + ok: integer("ok", { mode: "boolean" }).notNull(), + latencyMs: integer("latency_ms").notNull(), + error: text("error"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + // ─── Settings (key/value) ─────────────────────────────────────────────────── export const settings = sqliteTable("settings", { diff --git a/server/src/dns/adapters/azure.ts b/server/src/dns/adapters/azure.ts index 78d0d57..d02554e 100644 --- a/server/src/dns/adapters/azure.ts +++ b/server/src/dns/adapters/azure.ts @@ -9,6 +9,7 @@ * resource group to target for record operations. */ import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; +import { withDiagLogging } from "../../services/diagLog.js"; const ARM_BASE = "https://management.azure.com"; const API_VERSION = "2018-05-01"; @@ -326,5 +327,5 @@ export function createAzureAdapter(config: AzureConfig): DnsAdapter { } } - return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; + return withDiagLogging("azure", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); } diff --git a/server/src/dns/adapters/cloudflare.ts b/server/src/dns/adapters/cloudflare.ts index b09b30f..f6d9e98 100644 --- a/server/src/dns/adapters/cloudflare.ts +++ b/server/src/dns/adapters/cloudflare.ts @@ -3,6 +3,7 @@ * Requires config: apiToken */ import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; +import { withDiagLogging } from "../../services/diagLog.js"; const BASE = "https://api.cloudflare.com/client/v4"; @@ -94,5 +95,5 @@ export function createCloudflareAdapter(config: CloudflareConfig): DnsAdapter { await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" }); } - return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; + return withDiagLogging("cloudflare", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); } diff --git a/server/src/dns/adapters/cpanel.ts b/server/src/dns/adapters/cpanel.ts index c192162..a2a8864 100644 --- a/server/src/dns/adapters/cpanel.ts +++ b/server/src/dns/adapters/cpanel.ts @@ -8,6 +8,7 @@ import * as https from "node:https"; import * as http from "node:http"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; +import { withDiagLogging } from "../../services/diagLog.js"; export interface CpanelConfig { url: string; @@ -273,5 +274,5 @@ export function createCpanelAdapter(config: CpanelConfig): DnsAdapter { await api2("remove_zone_record", { domain, line: String(lineIndex) }); } - return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; + return withDiagLogging("cpanel", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); } diff --git a/server/src/dns/adapters/loopia.ts b/server/src/dns/adapters/loopia.ts index 28f5e99..34d428b 100644 --- a/server/src/dns/adapters/loopia.ts +++ b/server/src/dns/adapters/loopia.ts @@ -6,6 +6,7 @@ */ import { parseStringPromise } from "xml2js"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; +import { withDiagLogging } from "../../services/diagLog.js"; const ENDPOINT = "https://api.loopia.se/RPCSERV"; @@ -179,5 +180,5 @@ export function createLoopiaAdapter(config: LoopiaConfig): DnsAdapter { } } - return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; + return withDiagLogging("loopia", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); } diff --git a/server/src/dns/adapters/pihole.ts b/server/src/dns/adapters/pihole.ts index be8c8f7..6af2459 100644 --- a/server/src/dns/adapters/pihole.ts +++ b/server/src/dns/adapters/pihole.ts @@ -7,6 +7,7 @@ * treated as a single zone. Record types are limited to A, AAAA, CNAME. */ import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; +import { withDiagLogging } from "../../services/diagLog.js"; export interface PiholeConfig { url: string; @@ -138,5 +139,5 @@ export function createPiholeAdapter(config: PiholeConfig): DnsAdapter { } } - return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; + return withDiagLogging("pihole", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); } diff --git a/server/src/dns/adapters/technitium.ts b/server/src/dns/adapters/technitium.ts index 3d94349..2f1c0ca 100644 --- a/server/src/dns/adapters/technitium.ts +++ b/server/src/dns/adapters/technitium.ts @@ -6,6 +6,7 @@ * Record ID format: "type||name||content||priority" */ import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; +import { withDiagLogging } from "../../services/diagLog.js"; export interface TechnitiumConfig { url: string; @@ -148,5 +149,5 @@ export function createTechnitiumAdapter(config: TechnitiumConfig): DnsAdapter { await apiPost("/api/zones/records/delete", { domain: name, zone, type, ...typeParams }); } - return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; + return withDiagLogging("technitium", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); } diff --git a/server/src/index.ts b/server/src/index.ts index bbac88c..0eaa64e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,6 +12,7 @@ import { authRouter } from "./auth/router.js"; import { meRouter } from "./routes/me.js"; import { usersRouter } from "./routes/users.js"; import { auditLogRouter } from "./routes/auditLog.js"; +import { diagLogRouter } from "./routes/diagLog.js"; import { secretsRouter } from "./routes/secrets.js"; import { ipamRouter } from "./routes/ipam.js"; import { dnsRouter } from "./routes/dns.js"; @@ -66,6 +67,7 @@ app.use("/auth", authRouter); app.use("/api/me", meRouter); app.use("/api/users", usersRouter); app.use("/api/audit-log", auditLogRouter); +app.use("/api/diag-log", diagLogRouter); app.use("/api/secrets", secretsRouter); app.use("/api/ipam", ipamRouter); app.use("/api/dns", dnsRouter); diff --git a/server/src/integrations/dockhand/adapter.ts b/server/src/integrations/dockhand/adapter.ts index 0ea3ebe..a2816bc 100644 --- a/server/src/integrations/dockhand/adapter.ts +++ b/server/src/integrations/dockhand/adapter.ts @@ -9,6 +9,7 @@ * API reference verified against Dockhand's published OpenAPI spec * (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json). */ +import { withDiagLogging } from "../../services/diagLog.js"; export interface DockhandConfig { url: string; @@ -122,5 +123,5 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter { } } - return { ping, listContainers, startContainer, stopContainer, restartContainer }; + return withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer }); } diff --git a/server/src/integrations/gitea/adapter.ts b/server/src/integrations/gitea/adapter.ts index 4a0d3cd..489775d 100644 --- a/server/src/integrations/gitea/adapter.ts +++ b/server/src/integrations/gitea/adapter.ts @@ -5,6 +5,7 @@ * API docs: https://gitea.labsconnect.se/api/swagger (or any instance's /api/swagger) * Verified against Gitea 1.27. */ +import { withDiagLogging } from "../../services/diagLog.js"; export interface GiteaConfig { url: string; @@ -150,5 +151,5 @@ export function createGiteaAdapter(config: GiteaConfig): GiteaAdapter { } } - return { ping, listReposWithStatus, rerunFailedJobs }; + return withDiagLogging("gitea", { ping, listReposWithStatus, rerunFailedJobs }); } diff --git a/server/src/integrations/proxmox/adapter.ts b/server/src/integrations/proxmox/adapter.ts index 0a473db..975a426 100644 --- a/server/src/integrations/proxmox/adapter.ts +++ b/server/src/integrations/proxmox/adapter.ts @@ -16,6 +16,7 @@ * fetch, to support an "insecure" opt-out of certificate verification. */ import * as https from "node:https"; +import { withDiagLogging } from "../../services/diagLog.js"; export interface ProxmoxConfig { url: string; @@ -398,5 +399,14 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { } } - return { ping, listGuests, getGuestDetail, listNodeStats, startGuest, stopGuest, restartGuest, shutdownGuest }; + return withDiagLogging("proxmox", { + ping, + listGuests, + getGuestDetail, + listNodeStats, + startGuest, + stopGuest, + restartGuest, + shutdownGuest, + }); } diff --git a/server/src/integrations/semaphore/adapter.ts b/server/src/integrations/semaphore/adapter.ts index ebe7b2e..28e1956 100644 --- a/server/src/integrations/semaphore/adapter.ts +++ b/server/src/integrations/semaphore/adapter.ts @@ -7,6 +7,7 @@ * source (db/Task.go, pkg/task_logger/task_logger.go) for the exact task * status enum, since the swagger doc itself doesn't enumerate it. */ +import { withDiagLogging } from "../../services/diagLog.js"; export interface SemaphoreConfig { url: string; @@ -140,5 +141,5 @@ export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapte } } - return { ping, listTemplatesWithStatus, runTemplate }; + return withDiagLogging("semaphore", { ping, listTemplatesWithStatus, runTemplate }); } diff --git a/server/src/integrations/synology/adapter.ts b/server/src/integrations/synology/adapter.ts index 7d4ca59..95496cd 100644 --- a/server/src/integrations/synology/adapter.ts +++ b/server/src/integrations/synology/adapter.ts @@ -23,6 +23,7 @@ */ import * as https from "node:https"; import * as http from "node:http"; +import { withDiagLogging } from "../../services/diagLog.js"; export interface SynologyConfig { url: string; @@ -280,5 +281,5 @@ export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter { }; } - return { ping, getStorageInfo, getSystemInfo }; + return withDiagLogging("synology", { ping, getStorageInfo, getSystemInfo }); } diff --git a/server/src/integrations/tailscale/adapter.ts b/server/src/integrations/tailscale/adapter.ts index db14c3e..e29380f 100644 --- a/server/src/integrations/tailscale/adapter.ts +++ b/server/src/integrations/tailscale/adapter.ts @@ -16,6 +16,7 @@ * in the same call as the basic fields, so no per-device follow-up is * needed. */ +import { withDiagLogging } from "../../services/diagLog.js"; const BASE = "https://api.tailscale.com"; @@ -129,5 +130,5 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte } } - return { ping, listDevices, setAuthorized, deleteDevice }; + return withDiagLogging("tailscale", { ping, listDevices, setAuthorized, deleteDevice }); } diff --git a/server/src/routes/diagLog.ts b/server/src/routes/diagLog.ts new file mode 100644 index 0000000..9d16546 --- /dev/null +++ b/server/src/routes/diagLog.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { getDiagEntries, clearDiagLog } from "../services/diagLog.js"; +import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const diagLogRouter = Router(); + +diagLogRouter.use(requireAuth, requireRole("admin")); + +diagLogRouter.get("/", asyncHandler(async (req, res) => { + const { source, ok, limit, offset } = req.query; + const result = await getDiagEntries({ + source: typeof source === "string" && source ? source : undefined, + ok: ok === "true" ? true : ok === "false" ? false : undefined, + limit: Math.min(Number(limit) || 100, 200), + offset: Number(offset) || 0, + }); + res.json(result); +})); + +diagLogRouter.delete("/", asyncHandler(async (req, res) => { + await clearDiagLog(); + await recordAudit({ actor: req.currentUser!, category: "diag_log", action: "clear" }); + res.status(204).end(); +})); diff --git a/server/src/services/diagLog.ts b/server/src/services/diagLog.ts new file mode 100644 index 0000000..2bc349a --- /dev/null +++ b/server/src/services/diagLog.ts @@ -0,0 +1,87 @@ +import { and, desc, eq, lt, sql } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { diagLog } from "../db/schema.js"; + +const MAX_ENTRIES = 500; + +/** Records one outbound-call result. Never throws — a logging failure must not break the call it's logging. */ +async function recordDiagEntry(entry: { source: string; operation: string; ok: boolean; latencyMs: number; error: string | null }) { + try { + await db.insert(diagLog).values(entry); + // Trim to the most recent MAX_ENTRIES rows (a simple ring buffer, mirroring + // Sloth Manager's diagnostic log — this table is for live troubleshooting, + // not a durable audit trail, so unbounded growth isn't worth guarding here). + const [cutoff] = await db.select({ id: diagLog.id }).from(diagLog).orderBy(desc(diagLog.id)).limit(1).offset(MAX_ENTRIES); + if (cutoff) { + await db.delete(diagLog).where(lt(diagLog.id, cutoff.id)); + } + } catch (err) { + console.error("[diagLog] failed to record entry:", err); + } +} + +/** + * Wraps every method of an adapter (DNS provider or integration) so each call + * is timed and recorded to the diagnostic log, success or failure, without + * touching the adapter's own request/error-handling logic. Safe for any + * adapter whose interface is entirely async methods (true for every DNS and + * integration adapter in this codebase). + */ +export function withDiagLogging(source: string, adapter: T): T { + const wrapped = {} as T; + for (const key of Object.keys(adapter) as (keyof T)[]) { + const value = adapter[key]; + if (typeof value !== "function") { + wrapped[key] = value; + continue; + } + const original = value as (...args: unknown[]) => Promise; + wrapped[key] = (async (...args: unknown[]) => { + const start = Date.now(); + try { + const result = await original.apply(adapter, args); + recordDiagEntry({ source, operation: String(key), ok: true, latencyMs: Date.now() - start, error: null }); + return result; + } catch (err) { + recordDiagEntry({ + source, + operation: String(key), + ok: false, + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } + }) as T[keyof T]; + } + return wrapped; +} + +export interface DiagLogQuery { + source?: string; + ok?: boolean; + limit?: number; + offset?: number; +} + +export async function getDiagEntries({ source, ok, limit = 100, offset = 0 }: DiagLogQuery) { + const conditions = []; + if (source) conditions.push(eq(diagLog.source, source)); + if (ok !== undefined) conditions.push(eq(diagLog.ok, ok)); + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [{ total }] = await db.select({ total: sql`count(*)` }).from(diagLog).where(where); + const entries = await db + .select() + .from(diagLog) + .where(where) + .orderBy(desc(diagLog.id)) + .limit(Math.min(limit, 200)) + .offset(offset); + + return { total, entries }; +} + +export async function clearDiagLog(): Promise { + await db.delete(diagLog); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index bdfb80e..50d47b2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Users from "./pages/Users"; import AuditLog from "./pages/AuditLog"; +import DiagLog from "./pages/DiagLog"; import Secrets from "./pages/Secrets"; import Ipam from "./pages/Ipam"; import Dns from "./pages/Dns"; @@ -100,6 +101,14 @@ export default function App() { } /> + + + + } + /> request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`), }, + diagLog: { + list: (params: { source?: string; ok?: boolean; limit?: number; offset?: number } = {}) => { + const qs = new URLSearchParams(); + if (params.source) qs.set("source", params.source); + if (params.ok !== undefined) qs.set("ok", String(params.ok)); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.offset !== undefined) qs.set("offset", String(params.offset)); + const query = qs.toString(); + return request(`/api/diag-log${query ? `?${query}` : ""}`); + }, + clear: () => request("/api/diag-log", { method: "DELETE" }), + }, secrets: { list: () => request<{ secrets: SecretRecord[] }>("/api/secrets"), create: (data: SecretInput) => diff --git a/web/src/components/SortableTh.tsx b/web/src/components/SortableTh.tsx new file mode 100644 index 0000000..57fedd4 --- /dev/null +++ b/web/src/components/SortableTh.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import type { SortDirection } from "../hooks/useSortable"; + +export default function SortableTh({ + label, + sortKeyName, + activeKey, + direction, + onSort, + className, +}: { + label: ReactNode; + sortKeyName: keyof T; + activeKey: keyof T | undefined; + direction: SortDirection; + onSort: (key: keyof T) => void; + className?: string; +}) { + const active = activeKey === sortKeyName; + return ( + onSort(sortKeyName)} + > + {label} + + {active ? (direction === "asc" ? "▲" : "▼") : "↕"} + + + ); +} diff --git a/web/src/hooks/useSortable.ts b/web/src/hooks/useSortable.ts new file mode 100644 index 0000000..f2bf9bc --- /dev/null +++ b/web/src/hooks/useSortable.ts @@ -0,0 +1,45 @@ +import { useMemo, useState } from "react"; + +export type SortDirection = "asc" | "desc"; + +/** + * Generic client-side table sort. Sorts on the raw underlying field (numbers + * numerically, booleans false-before-true, everything else via a + * locale/numeric-aware string compare), not on however a column happens to + * render it — so a formatted "1.2 GB" column still sorts by its raw byte + * count, and a "5d 15h 2m" column sorts by its raw timestamp/seconds field. + * Nulls/undefined always sort to the end regardless of direction. + */ +export function useSortable(items: T[] | null | undefined, initialKey?: keyof T, initialDir: SortDirection = "asc") { + const [sortKey, setSortKey] = useState(initialKey); + const [sortDir, setSortDir] = useState(initialDir); + + function requestSort(key: keyof T) { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("asc"); + } + } + + const sorted = useMemo(() => { + if (!items || !sortKey) return items ?? null; + const copy = [...items]; + copy.sort((a, b) => { + const av = a[sortKey] as unknown; + const bv = b[sortKey] as unknown; + if (av === bv) return 0; + if (av === null || av === undefined) return 1; + if (bv === null || bv === undefined) return -1; + let cmp: number; + if (typeof av === "number" && typeof bv === "number") cmp = av - bv; + else if (typeof av === "boolean" && typeof bv === "boolean") cmp = av === bv ? 0 : av ? 1 : -1; + else cmp = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: "base" }); + return sortDir === "asc" ? cmp : -cmp; + }); + return copy; + }, [items, sortKey, sortDir]); + + return { sorted, sortKey, sortDir, requestSort }; +} diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index d279767..4d36ae2 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -15,6 +15,7 @@ import { IconDatabase, IconUsers, IconHistory, + IconStethoscope, IconSettings, IconMenu2, } from "@tabler/icons-react"; @@ -42,6 +43,7 @@ const NAV_ITEMS: NavItem[] = [ { to: "/integrations", label: "Integrations", icon: }, { to: "/users", label: "Users", icon: , minRole: "admin" }, { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, + { to: "/diag-log", label: "Diagnostic Log", icon: , minRole: "admin" }, { to: "/settings", label: "Settings", icon: , minRole: "admin" }, ]; diff --git a/web/src/pages/DiagLog.tsx b/web/src/pages/DiagLog.tsx new file mode 100644 index 0000000..a511a33 --- /dev/null +++ b/web/src/pages/DiagLog.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { api, type DiagLogEntry } from "../api/client"; +import { formatDateTime } from "../utils/date"; +import { useSortable } from "../hooks/useSortable"; +import SortableTh from "../components/SortableTh"; +import { downloadCsv } from "../utils/csv"; + +const SOURCE_LABELS: Record = { + cloudflare: "Cloudflare", + loopia: "Loopia", + pihole: "Pi-hole", + azure: "Azure DNS", + cpanel: "cPanel", + technitium: "Technitium", + tailscale: "Tailscale", + proxmox: "Proxmox", + synology: "Synology", + semaphore: "Semaphore", + gitea: "Gitea", + dockhand: "Dockhand", +}; + +const PAGE_SIZE = 100; + +export default function DiagLog() { + const [entries, setEntries] = useState(null); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filterSource, setFilterSource] = useState(""); + const [filterOk, setFilterOk] = useState(""); + const [clearing, setClearing] = useState(false); + + function load(pg: number) { + setLoading(true); + setError(null); + api.diagLog + .list({ + source: filterSource || undefined, + ok: filterOk === "" ? undefined : filterOk === "true", + limit: PAGE_SIZE, + offset: pg * PAGE_SIZE, + }) + .then((res) => { + setEntries(res.entries); + setTotal(res.total); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + setPage(0); + load(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterSource, filterOk]); + + function goToPage(pg: number) { + setPage(pg); + load(pg); + } + + async function handleClear() { + if (!confirm("Clear the entire diagnostic log? This cannot be undone.")) return; + setClearing(true); + try { + await api.diagLog.clear(); + setEntries([]); + setTotal(0); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setClearing(false); + } + } + + const { sorted, sortKey, sortDir, requestSort } = useSortable(entries, "createdAt", "desc"); + const totalPages = Math.ceil(total / PAGE_SIZE); + const errorCount = entries?.filter((e) => !e.ok).length ?? 0; + + function exportCsv() { + if (!sorted) return; + downloadCsv( + "diagnostic-log.csv", + ["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"], + sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]), + ); + } + + return ( + <> +
+
+

Diagnostic Log

+
+ Every call this app makes to a DNS provider or integration — the last 500 calls are kept. Use this to + troubleshoot connectivity issues. +
+
+ +
+ {error &&
{error}
} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+

+ {total} entries + {errorCount > 0 && {errorCount} errors on this page} +

+
+
+ + + + label="Time" sortKeyName="createdAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Source" sortKeyName="source" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Operation" sortKeyName="operation" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Result" sortKeyName="ok" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + label="Latency" + sortKeyName="latencyMs" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + + + + {sorted?.map((e) => ( + + + + + + + + + ))} + {sorted?.length === 0 && ( + + + + )} + +
Error
+ {formatDateTime(new Date(e.createdAt))} + {SOURCE_LABELS[e.source] ?? e.source}{e.operation} + {e.ok ? ( + OK + ) : ( + Error + )} + 2000 ? "text-yellow" : ""}`}>{e.latencyMs}ms + {e.error ?? ""} +
+ No diagnostic entries{filterSource || filterOk ? " match these filters" : " yet"}. +
+
+ {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} + + +
+ )} +
+ + ); +}