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