From b9409d309555993e80ac06954027227aee451fa9 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 13:02:50 +0200 Subject: [PATCH] Add server hardware/network detail view with Proxmox sync and agent reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers & Tasks only tracked scheduled tasks — there was no overview of the servers themselves and no way to see CPU/RAM/disk/IP info. Add a clickable server overview (visible to every role, not just admins) that opens a per-server detail page. - servers table gains an agent-reported hardware/network snapshot (IPs, CPU model/cores/load, memory, disks) and an optional link to a Proxmox VM/LXC (integration + node + guest type + vmid). - Proxmox adapter gains getGuestDetail(): live cores/memory/disk from /config, live cpu/mem/uptime from /status/current, and IPs (LXC net config directly, QEMU via a best-effort guest-agent call that degrades gracefully when the agent isn't installed). - New GET /api/servers/:id/detail combines whichever hardware source applies (live Proxmox vs. last agent report) with a DNS reverse-lookup against the DNS module's own record cache, so matching hostnames show up next to each IP. New PATCH /api/servers/:id manages the Proxmox link. - agent/linux/report-tasks.sh now also collects and reports IPs, CPU/memory/disk info on every check-in (load-average-based CPU number, not instantaneous, to keep the agent a cheap oneshot). - Extracted the per-server task table into a shared ServerTaskTable component so the all-servers view and the new detail page render tasks identically. Verified server-side end-to-end against the real dev server (agent report -> detail endpoint -> DNS match) and the Proxmox adapter against a mock HTTPS server covering LXC/QEMU config parsing and the guest-agent-unavailable fallback. Co-Authored-By: Claude Sonnet 5 --- README.md | 6 +- agent/linux/report-tasks.sh | 53 +- server/drizzle/0001_yummy_luke_cage.sql | 11 + server/drizzle/meta/0001_snapshot.json | 1011 ++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 22 +- server/src/integrations/proxmox/adapter.ts | 115 ++- server/src/routes/agentReport.ts | 9 + server/src/routes/servers.ts | 155 ++- server/src/services/taskSync.ts | 27 +- web/src/App.tsx | 2 + web/src/api/client.ts | 51 + web/src/components/ServerTaskTable.tsx | 142 +++ web/src/pages/ServerDetail.tsx | 559 +++++++++++ web/src/pages/ServersTasks.tsx | 176 +--- 15 files changed, 2206 insertions(+), 140 deletions(-) create mode 100644 server/drizzle/0001_yummy_luke_cage.sql create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 web/src/components/ServerTaskTable.tsx create mode 100644 web/src/pages/ServerDetail.tsx diff --git a/README.md b/README.md index 0636c2c..995ee42 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,11 @@ All modules from the original plan are built: vars) and their credentials are encrypted at rest - **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers via a lightweight push agent (`agent/linux/`), plus manual entries for - things an agent can't see (Docker jobs, backups) + things an agent can't see (Docker jobs, backups). A clickable server + overview opens a detail page with CPU/RAM/disk status, IP addresses, and + matching DNS names (looked up from the DNS module's cache) — live from + Proxmox for VM/LXC-backed servers, or from the agent's own hardware + report for everything else. - **Integrations → Tailscale** — device list with online/authorized status, and authorize/deauthorize/remove actions; a live device-count widget. - **Integrations → Gitea** — repo list with each repo's last CI run status, diff --git a/agent/linux/report-tasks.sh b/agent/linux/report-tasks.sh index a703c5d..38f3ca3 100644 --- a/agent/linux/report-tasks.sh +++ b/agent/linux/report-tasks.sh @@ -173,16 +173,67 @@ collect_systemd_timers() { done < <(jq -c '.[]' <<<"$timers_json") } +collect_system_info() { + local ip_json="[]" + if command -v ip >/dev/null 2>&1; then + ip_json=$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{print $4}' | cut -d/ -f1 \ + | jq -R -s -c 'split("\n") | map(select(length > 0))') + fi + + local cpu_model="" cpu_cores=0 cpu_load_percent="null" + cpu_model=$(grep -m1 "model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2- | sed 's/^ *//') + cpu_cores=$(nproc 2>/dev/null || echo 0) + if [[ -r /proc/loadavg && "$cpu_cores" -gt 0 ]]; then + local load1 + load1=$(awk '{print $1}' /proc/loadavg) + cpu_load_percent=$(awk -v l="$load1" -v c="$cpu_cores" 'BEGIN { printf "%.1f", (l/c)*100 }') + fi + + local mem_total=0 mem_used=0 + if command -v free >/dev/null 2>&1; then + read -r mem_total mem_used < <(free -b | awk '/^Mem:/ {print $2, $3}') + fi + + local disks_json="[]" + if command -v df >/dev/null 2>&1; then + disks_json=$(df -B1 --output=target,size,used -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null \ + | tail -n +2 \ + | awk '{print $1"\t"$2"\t"$3}' \ + | jq -R -s -c ' + split("\n") | map(select(length > 0) | split("\t")) | + map({mount: .[0], size_bytes: (.[1]|tonumber), used_bytes: (.[2]|tonumber)}) + ') + fi + + SYSTEM_JSON=$(jq -n \ + --argjson ip_addresses "$ip_json" \ + --arg cpu_model "$cpu_model" \ + --argjson cpu_cores "${cpu_cores:-0}" \ + --argjson cpu_load_percent "$cpu_load_percent" \ + --argjson mem_total_bytes "${mem_total:-0}" \ + --argjson mem_used_bytes "${mem_used:-0}" \ + --argjson disks "$disks_json" \ + '{ + ip_addresses: $ip_addresses, + cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent }, + memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes }, + disks: $disks + }') +} + collect_cron collect_systemd_timers +collect_system_info HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname) PAYLOAD=$(jq -n \ --arg hostname "$HOSTNAME_VALUE" \ --arg os_type "linux" \ --arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --argjson system "$SYSTEM_JSON" \ --argjson tasks "$TASKS_JSON" \ - '{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, tasks: $tasks}') + '{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, system: $system, tasks: $tasks}') if [[ "$DRY_RUN" == "1" ]]; then echo "$PAYLOAD" | jq . diff --git a/server/drizzle/0001_yummy_luke_cage.sql b/server/drizzle/0001_yummy_luke_cage.sql new file mode 100644 index 0000000..3ee4e86 --- /dev/null +++ b/server/drizzle/0001_yummy_luke_cage.sql @@ -0,0 +1,11 @@ +ALTER TABLE `servers` ADD `ip_addresses` text;--> statement-breakpoint +ALTER TABLE `servers` ADD `cpu_model` text;--> statement-breakpoint +ALTER TABLE `servers` ADD `cpu_cores` integer;--> statement-breakpoint +ALTER TABLE `servers` ADD `cpu_load_percent` real;--> statement-breakpoint +ALTER TABLE `servers` ADD `mem_total_bytes` integer;--> statement-breakpoint +ALTER TABLE `servers` ADD `mem_used_bytes` integer;--> statement-breakpoint +ALTER TABLE `servers` ADD `disks` text;--> statement-breakpoint +ALTER TABLE `servers` ADD `proxmox_integration_id` integer REFERENCES integrations(id);--> statement-breakpoint +ALTER TABLE `servers` ADD `proxmox_node` text;--> statement-breakpoint +ALTER TABLE `servers` ADD `proxmox_guest_type` text;--> statement-breakpoint +ALTER TABLE `servers` ADD `proxmox_vmid` integer; \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..b098607 --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1011 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b877d379-67c6-47db-970b-ba73f85d44b0", + "prevId": "19306643-f6f6-49dc-842a-ac2958e4d93a", + "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": {} + }, + "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 + }, + "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 d667c44..a935c9b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1789419241921, "tag": "0000_colossal_violations", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1789468464784, + "tag": "0001_yummy_luke_cage", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 4634bee..a30f847 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core"; // ─── Users & roles ────────────────────────────────────────────────────────── @@ -150,6 +150,9 @@ export const dnsRecordsCache = sqliteTable("dns_records_cache", { // ─── Servers & scheduled tasks (ported from Schedule Task Manager) ───────── +export const proxmoxGuestTypes = ["qemu", "lxc"] as const; +export type ProxmoxGuestTypeCol = (typeof proxmoxGuestTypes)[number]; + export const servers = sqliteTable("servers", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull(), @@ -162,6 +165,23 @@ export const servers = sqliteTable("servers", { .notNull() .default(sql`(current_timestamp)`), lastSeenAt: text("last_seen_at"), + + // Agent-reported hardware/network snapshot — updated on every agent report. + ipAddresses: text("ip_addresses"), // JSON string[] + cpuModel: text("cpu_model"), + cpuCores: integer("cpu_cores"), + cpuLoadPercent: real("cpu_load_percent"), + memTotalBytes: integer("mem_total_bytes"), + memUsedBytes: integer("mem_used_bytes"), + disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[] + + // Optional link to a Proxmox VM/LXC — set by an admin, not the agent. + proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, { + onDelete: "set null", + }), + proxmoxNode: text("proxmox_node"), + proxmoxGuestType: text("proxmox_guest_type").$type(), + proxmoxVmid: integer("proxmox_vmid"), }); export const scheduledTasks = sqliteTable("scheduled_tasks", { diff --git a/server/src/integrations/proxmox/adapter.ts b/server/src/integrations/proxmox/adapter.ts index 3030589..03f4fed 100644 --- a/server/src/integrations/proxmox/adapter.ts +++ b/server/src/integrations/proxmox/adapter.ts @@ -38,14 +38,54 @@ export interface ProxmoxGuest { uptime: number | null; } +export interface ProxmoxGuestDetail { + vmid: number; + node: string; + type: ProxmoxGuestType; + name: string; + status: string; + cpuCores: number | null; + cpuUsagePercent: number | null; + memoryBytes: number | null; + memUsedBytes: number | null; + diskBytes: number | null; + uptime: number | null; + ipAddresses: string[]; + /** QEMU only — false when the guest agent call failed (not installed/running). Always true for LXC (IPs read from config, no agent needed). */ + guestAgentAvailable: boolean; +} + export interface ProxmoxAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; listGuests(): Promise; + getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise; startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; } +function parseSizeToBytes(size: string): number | null { + const match = size.match(/^(\d+(?:\.\d+)?)\s*([KMGT])?$/i); + if (!match) return null; + const value = parseFloat(match[1]); + const unit = (match[2] ?? "").toUpperCase(); + const multiplier = ({ "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 } as Record)[unit] ?? 1; + return Math.round(value * multiplier); +} + +function extractSizeParam(configValue: string): number | null { + const match = configValue.match(/(?:^|,)size=([\d.]+[KMGT]?)/i); + return match ? parseSizeToBytes(match[1]) : null; +} + +function extractIpFromNetConfig(configValue: string): string | null { + const match = configValue.match(/(?:^|,)ip=([^,]+)/i); + if (!match) return null; + const ip = match[1]; + if (ip.toLowerCase() === "dhcp" || ip.toLowerCase() === "manual") return null; + return ip.split("/")[0]; +} + interface RawResponse { status: number; text: () => string; @@ -147,6 +187,79 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { return perNode.flat(); } + async function getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise { + const [config, status] = await Promise.all([ + api("GET", `/nodes/${node}/${type}/${vmid}/config`), + api("GET", `/nodes/${node}/${type}/${vmid}/status/current`), + ]); + + let cpuCores: number | null = null; + let diskBytes: number | null = null; + const ipAddresses: string[] = []; + let guestAgentAvailable = true; + + if (type === "lxc") { + cpuCores = typeof config.cores === "number" ? config.cores : null; + if (typeof config.rootfs === "string") { + diskBytes = extractSizeParam(config.rootfs); + } + for (const key of Object.keys(config)) { + if (/^net\d+$/.test(key) && typeof config[key] === "string") { + const ip = extractIpFromNetConfig(config[key]); + if (ip) ipAddresses.push(ip); + } + } + } else { + const sockets = typeof config.sockets === "number" ? config.sockets : 1; + cpuCores = typeof config.cores === "number" ? config.cores * sockets : null; + + let totalDisk = 0; + let foundDisk = false; + for (const key of Object.keys(config)) { + if (/^(scsi|virtio|sata|ide)\d+$/.test(key) && typeof config[key] === "string") { + const size = extractSizeParam(config[key]); + if (size !== null) { + totalDisk += size; + foundDisk = true; + } + } + } + diskBytes = foundDisk ? totalDisk : null; + + try { + const agentData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/network-get-interfaces`); + const interfaces = agentData?.result ?? []; + for (const iface of interfaces) { + for (const addr of iface["ip-addresses"] ?? []) { + if (addr["ip-address-type"] === "ipv4" && addr["ip-address"] !== "127.0.0.1") { + ipAddresses.push(addr["ip-address"]); + } + } + } + } catch { + guestAgentAvailable = false; + } + } + + const memoryBytes = typeof config.memory === "number" ? config.memory * 1024 * 1024 : null; + + return { + vmid, + node, + type, + name: config.name ?? `${type}/${vmid}`, + status: status.status, + cpuCores, + cpuUsagePercent: typeof status.cpu === "number" ? status.cpu * 100 : null, + memoryBytes, + memUsedBytes: typeof status.mem === "number" ? status.mem : null, + diskBytes, + uptime: typeof status.uptime === "number" ? status.uptime : null, + ipAddresses, + guestAgentAvailable: type === "qemu" ? guestAgentAvailable : true, + }; + } + async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise { await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`); } @@ -165,5 +278,5 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { } } - return { ping, listGuests, startGuest, stopGuest, restartGuest }; + return { ping, listGuests, getGuestDetail, startGuest, stopGuest, restartGuest }; } diff --git a/server/src/routes/agentReport.ts b/server/src/routes/agentReport.ts index 303791e..d197e52 100644 --- a/server/src/routes/agentReport.ts +++ b/server/src/routes/agentReport.ts @@ -7,10 +7,18 @@ import { asyncHandler } from "../utils/asyncHandler.js"; export const agentReportRouter = Router(); +const systemSchema = z.object({ + ip_addresses: z.array(z.string()).optional(), + cpu: z.object({ model: z.string().optional(), cores: z.number().optional(), load_percent: z.number().nullable().optional() }).optional(), + memory: z.object({ total_bytes: z.number().optional(), used_bytes: z.number().optional() }).optional(), + disks: z.array(z.object({ mount: z.string(), size_bytes: z.number(), used_bytes: z.number() })).optional(), +}); + const reportSchema = z.object({ hostname: z.string().max(255).optional(), os_type: z.string().optional(), reported_at: z.string().optional(), + system: systemSchema.optional(), tasks: z.array( z.object({ schedule_type: z.enum(["cron", "systemd_timer"]), @@ -47,6 +55,7 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => { await syncServerTasks(server.id, { hostname: parsed.data.hostname, + system: parsed.data.system, tasks: parsed.data.tasks.map((t) => ({ scheduleType: t.schedule_type, name: t.name, diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 7b865a2..057b701 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -1,12 +1,14 @@ import { Router } from "express"; -import { eq } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "../db/client.js"; -import { servers } from "../db/schema.js"; +import { servers, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { generateApiToken } from "../services/tokens.js"; import { recordAudit } from "../services/audit.js"; import { asyncHandler } from "../utils/asyncHandler.js"; +import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; +import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js"; export const serversRouter = Router(); serversRouter.use(requireAuth); @@ -85,6 +87,155 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async res.json({ server: serverOut, token }); })); +const updateServerSchema = z + .object({ + name: z.string().min(1).max(100).optional(), + hostname: z.string().max(255).optional(), + description: z.string().max(500).optional(), + proxmoxIntegrationId: z.number().int().nullable().optional(), + proxmoxNode: z.string().nullable().optional(), + proxmoxGuestType: z.enum(["qemu", "lxc"]).nullable().optional(), + proxmoxVmid: z.number().int().nullable().optional(), + }) + .refine( + (data) => { + const proxmoxFields = [data.proxmoxIntegrationId, data.proxmoxNode, data.proxmoxGuestType, data.proxmoxVmid]; + if (proxmoxFields.every((f) => f === undefined)) return true; + const allNull = proxmoxFields.every((f) => f === null); + const allSet = proxmoxFields.every((f) => f !== undefined && f !== null); + return allNull || allSet; + }, + { message: "proxmoxIntegrationId/proxmoxNode/proxmoxGuestType/proxmoxVmid must be set together or all cleared to null" }, + ); + +serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); + + const parsed = updateServerSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1); + if (!existing) return res.status(404).json({ error: "not_found" }); + + if (parsed.data.proxmoxIntegrationId) { + const [integration] = await db + .select() + .from(integrations) + .where(eq(integrations.id, parsed.data.proxmoxIntegrationId)) + .limit(1); + if (!integration || integration.type !== "proxmox") { + return res.status(400).json({ error: "invalid_proxmox_integration" }); + } + } + + const [updated] = await db.update(servers).set(parsed.data).where(eq(servers.id, id)).returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "server", + action: "update", + targetType: "server", + targetId: id, + detail: { name: updated.name }, + }); + + const { apiTokenHash, ...serverOut } = updated; + res.json({ server: serverOut }); +})); + +serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); + + const [server] = await db.select().from(servers).where(eq(servers.id, id)).limit(1); + if (!server) return res.status(404).json({ error: "not_found" }); + + let ipAddresses: string[] = []; + let hardware: Record; + + if (server.proxmoxIntegrationId && server.proxmoxNode && server.proxmoxGuestType && server.proxmoxVmid !== null) { + const loaded = await loadIntegrationConfig(server.proxmoxIntegrationId); + if (!loaded || loaded.integration.type !== "proxmox") { + hardware = { source: "proxmox", error: "The linked Proxmox integration no longer exists or has changed type." }; + } else { + try { + const adapter = createProxmoxAdapter(loaded.config as { url: string; tokenId: string; tokenSecret: string; insecure?: boolean }); + const detail = await adapter.getGuestDetail( + server.proxmoxNode, + server.proxmoxGuestType as ProxmoxGuestType, + server.proxmoxVmid, + ); + ipAddresses = detail.ipAddresses; + hardware = { + source: "proxmox", + integrationId: loaded.integration.id, + integrationName: loaded.integration.name, + status: detail.status, + cpuCores: detail.cpuCores, + cpuUsagePercent: detail.cpuUsagePercent, + memTotalBytes: detail.memoryBytes, + memUsedBytes: detail.memUsedBytes, + diskBytes: detail.diskBytes, + uptime: detail.uptime, + guestAgentAvailable: detail.guestAgentAvailable, + }; + } catch (err) { + hardware = { source: "proxmox", error: err instanceof Error ? err.message : String(err) }; + } + } + } else if (server.cpuModel || server.cpuCores || server.memTotalBytes || server.disks) { + ipAddresses = server.ipAddresses ? JSON.parse(server.ipAddresses) : []; + hardware = { + source: "agent", + cpuModel: server.cpuModel, + cpuCores: server.cpuCores, + cpuLoadPercent: server.cpuLoadPercent, + memTotalBytes: server.memTotalBytes, + memUsedBytes: server.memUsedBytes, + disks: server.disks ? JSON.parse(server.disks) : [], + }; + } else { + hardware = { source: "none" }; + } + + let dnsMatches: { recordName: string; recordType: string; ip: string; zoneName: string | null; providerName: string; providerType: string }[] = []; + if (ipAddresses.length > 0) { + dnsMatches = await db + .select({ + recordName: dnsRecordsCache.name, + recordType: dnsRecordsCache.type, + ip: dnsRecordsCache.content, + zoneName: dnsZonesCache.zoneName, + providerName: dnsProviders.name, + providerType: dnsProviders.providerType, + }) + .from(dnsRecordsCache) + .innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id)) + .leftJoin( + dnsZonesCache, + and(eq(dnsZonesCache.providerId, dnsRecordsCache.providerId), eq(dnsZonesCache.zoneId, dnsRecordsCache.zoneId)), + ) + .where(inArray(dnsRecordsCache.content, ipAddresses)); + } + + const { + apiTokenHash, + ipAddresses: _rawIpAddresses, + disks: _rawDisks, + cpuModel: _cpuModel, + cpuCores: _cpuCores, + cpuLoadPercent: _cpuLoadPercent, + memTotalBytes: _memTotalBytes, + memUsedBytes: _memUsedBytes, + ...serverOut + } = server; + + res.json({ server: serverOut, ipAddresses, hardware, dnsMatches }); +})); + serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); diff --git a/server/src/services/taskSync.ts b/server/src/services/taskSync.ts index bb5442b..af8b195 100644 --- a/server/src/services/taskSync.ts +++ b/server/src/services/taskSync.ts @@ -13,8 +13,16 @@ export interface IncomingTask { metadata?: unknown; } +export interface IncomingSystemInfo { + ip_addresses?: string[]; + cpu?: { model?: string; cores?: number; load_percent?: number | null }; + memory?: { total_bytes?: number; used_bytes?: number }; + disks?: { mount: string; size_bytes: number; used_bytes: number }[]; +} + export interface AgentReport { hostname?: string; + system?: IncomingSystemInfo; tasks: IncomingTask[]; } @@ -85,8 +93,25 @@ export async function syncServerTasks(serverId: number, report: AgentReport) { await db.update(scheduledTasks).set({ isStale: true }).where(agentScope); } + const system = report.system; await db .update(servers) - .set({ lastSeenAt: now, ...(report.hostname ? { hostname: report.hostname } : {}) }) + .set({ + lastSeenAt: now, + ...(report.hostname ? { hostname: report.hostname } : {}), + ...(system?.ip_addresses ? { ipAddresses: JSON.stringify(system.ip_addresses) } : {}), + ...(system?.cpu?.model !== undefined ? { cpuModel: system.cpu.model } : {}), + ...(system?.cpu?.cores !== undefined ? { cpuCores: system.cpu.cores } : {}), + ...(system?.cpu?.load_percent !== undefined ? { cpuLoadPercent: system.cpu.load_percent } : {}), + ...(system?.memory?.total_bytes !== undefined ? { memTotalBytes: system.memory.total_bytes } : {}), + ...(system?.memory?.used_bytes !== undefined ? { memUsedBytes: system.memory.used_bytes } : {}), + ...(system?.disks + ? { + disks: JSON.stringify( + system.disks.map((d) => ({ mount: d.mount, sizeBytes: d.size_bytes, usedBytes: d.used_bytes })), + ), + } + : {}), + }) .where(eq(servers.id, serverId)); } diff --git a/web/src/App.tsx b/web/src/App.tsx index ab483f8..79276c4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import Secrets from "./pages/Secrets"; import Ipam from "./pages/Ipam"; import Dns from "./pages/Dns"; import ServersTasks from "./pages/ServersTasks"; +import ServerDetail from "./pages/ServerDetail"; import Integrations from "./pages/Integrations"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; @@ -63,6 +64,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 68553ab..7340543 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -181,6 +181,54 @@ export interface ServerRecord { apiTokenPrefix: string; createdAt: string; lastSeenAt: string | null; + proxmoxIntegrationId: number | null; + proxmoxNode: string | null; + proxmoxGuestType: "qemu" | "lxc" | null; + proxmoxVmid: number | null; +} + +export interface ServerHardware { + source: "proxmox" | "agent" | "none"; + error?: string; + integrationId?: number; + integrationName?: string; + status?: string; + cpuModel?: string; + cpuCores?: number | null; + cpuUsagePercent?: number | null; + cpuLoadPercent?: number | null; + memTotalBytes?: number | null; + memUsedBytes?: number | null; + diskBytes?: number | null; + disks?: { mount: string; sizeBytes: number; usedBytes: number }[]; + uptime?: number | null; + guestAgentAvailable?: boolean; +} + +export interface DnsMatch { + recordName: string; + recordType: string; + ip: string; + zoneName: string | null; + providerName: string; + providerType: string; +} + +export interface ServerDetail { + server: ServerRecord; + ipAddresses: string[]; + hardware: ServerHardware; + dnsMatches: DnsMatch[]; +} + +export interface ServerUpdateInput { + name?: string; + hostname?: string; + description?: string; + proxmoxIntegrationId?: number | null; + proxmoxNode?: string | null; + proxmoxGuestType?: "qemu" | "lxc" | null; + proxmoxVmid?: number | null; } export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual"; @@ -486,6 +534,9 @@ export const api = { request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, { method: "POST", }), + update: (id: number, data: ServerUpdateInput) => + request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + detail: (id: number) => request(`/api/servers/${id}/detail`), remove: (id: number) => request(`/api/servers/${id}`, { method: "DELETE" }), }, tasks: { diff --git a/web/src/components/ServerTaskTable.tsx b/web/src/components/ServerTaskTable.tsx new file mode 100644 index 0000000..bdcc63c --- /dev/null +++ b/web/src/components/ServerTaskTable.tsx @@ -0,0 +1,142 @@ +import cronstrue from "cronstrue"; +import { CronExpressionParser } from "cron-parser"; +import type { TaskRecord } from "../api/client"; +import { formatDateTime } from "../utils/date"; + +export const SCHEDULE_TYPE_LABELS: Record = { + cron: "Cron jobs", + systemd_timer: "systemd timers", + docker: "Docker jobs", + backup: "Backups", + update: "Updates", + n8n_workflow: "n8n workflows", + manual: "Other", +}; + +function describeSchedule(task: TaskRecord): string { + if (!task.scheduleExpression) return "—"; + if (task.scheduleType === "cron") { + try { + return cronstrue.toString(task.scheduleExpression, { verbose: false }); + } catch { + return task.scheduleExpression; + } + } + return task.scheduleExpression; +} + +interface NextRun { + date: Date; + estimated: boolean; +} + +function computeNextRun(task: TaskRecord): NextRun | null { + if (task.nextRunAt) return { date: new Date(task.nextRunAt), estimated: false }; + if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) return null; + try { + const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate(); + return { date, estimated: true }; + } catch { + return null; + } +} + +export default function ServerTaskTable({ + tasks, + canEditTasks, + onEdit, + onDelete, +}: { + tasks: TaskRecord[]; + canEditTasks: boolean; + onEdit: (task: TaskRecord) => void; + onDelete: (task: TaskRecord) => void; +}) { + const byType = new Map(); + for (const task of tasks) { + const list = byType.get(task.scheduleType) ?? []; + list.push(task); + byType.set(task.scheduleType, list); + } + + return ( + <> + {[...byType.entries()].map(([scheduleType, groupTasks]) => ( +
+
+ {SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType} +
+
+ + + + + + + + + {canEditTasks && } + + + + {groupTasks.map((task) => { + const nextRun = computeNextRun(task); + return ( + + + + + + + {canEditTasks && ( + + )} + + ); + })} + +
NameCommandScheduleNext runStatusActions
{task.name}{task.command ?? "—"} +
{describeSchedule(task)}
+ {task.scheduleExpression && {task.scheduleExpression}} +
+ {nextRun ? ( + + {nextRun.estimated ? "~" : ""} + {formatDateTime(nextRun.date)} + + ) : ( + "—" + )} + + {task.isStale ? ( + Missing + ) : task.enabled ? ( + Enabled + ) : ( + Disabled + )} + {task.origin === "manual" && Manual} + + {task.origin === "manual" && ( +
+ + +
+ )} +
+
+
+ ))} + + ); +} diff --git a/web/src/pages/ServerDetail.tsx b/web/src/pages/ServerDetail.tsx new file mode 100644 index 0000000..20ae89e --- /dev/null +++ b/web/src/pages/ServerDetail.tsx @@ -0,0 +1,559 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type ManualTaskInput, + type ProxmoxGuest, + type ScheduleType, + type ServerDetail as ServerDetailResponse, + type TaskRecord, +} from "../api/client"; +import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable"; +import { formatDateTime } from "../utils/date"; + +const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map( + ([value, label]) => ({ value: value as ScheduleType, label }), +); + +function formatBytes(bytes: number | null | undefined): string { + if (bytes === null || bytes === undefined) return "—"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +function formatUptime(seconds: number | null | undefined): string { + if (!seconds) return "—"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + if (days > 0) return `${days}d ${hours}h`; + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; +} + +function usageBar(used: number | null | undefined, total: number | null | undefined) { + if (!total) return null; + const pct = Math.min(100, Math.round(((used ?? 0) / total) * 100)); + return ( +
+
90 ? "bg-red" : pct > 75 ? "bg-yellow" : "bg-green"}`} style={{ width: `${pct}%` }} /> +
+ ); +} + +const emptyTaskForm = { + scheduleType: "docker" as ScheduleType, + name: "", + command: "", + scheduleExpression: "", + enabled: true, +}; + +export default function ServerDetail({ user }: { user: CurrentUser }) { + const { id } = useParams<{ id: string }>(); + const serverId = Number(id); + const isAdmin = user.role === "admin"; + const canEditTasks = user.role === "admin" || user.role === "operator"; + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const [tasks, setTasks] = useState(null); + const [taskForm, setTaskForm] = useState(null); + const [editingTask, setEditingTask] = useState(null); + const [savingTask, setSavingTask] = useState(false); + + const [proxmoxIntegrations, setProxmoxIntegrations] = useState(null); + const [pickedIntegrationId, setPickedIntegrationId] = useState(null); + const [proxmoxGuests, setProxmoxGuests] = useState(null); + const [pickedGuestKey, setPickedGuestKey] = useState(""); + const [linking, setLinking] = useState(false); + const [linkError, setLinkError] = useState(null); + + const loadDetail = useCallback(() => { + setRefreshing(true); + return api.servers + .detail(serverId) + .then((res) => { + setDetail(res); + setError(null); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => { + setLoading(false); + setRefreshing(false); + }); + }, [serverId]); + + const loadTasks = useCallback(() => { + api.tasks + .list({ serverId, includeStale: true }) + .then((res) => setTasks(res.tasks)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, [serverId]); + + useEffect(() => { + loadDetail(); + loadTasks(); + }, [loadDetail, loadTasks]); + + useEffect(() => { + if (!isAdmin) return; + api.integrations + .list() + .then((res) => setProxmoxIntegrations(res.integrations.filter((i) => i.type === "proxmox" && i.enabled))) + .catch(() => setProxmoxIntegrations([])); + }, [isAdmin]); + + useEffect(() => { + if (!pickedIntegrationId) { + setProxmoxGuests(null); + return; + } + api.integrations.proxmox + .guests(pickedIntegrationId) + .then((res) => setProxmoxGuests(res.guests)) + .catch(() => setProxmoxGuests([])); + }, [pickedIntegrationId]); + + async function linkProxmox() { + if (!pickedIntegrationId || !pickedGuestKey) return; + const guest = proxmoxGuests?.find((g) => `${g.node}/${g.type}/${g.vmid}` === pickedGuestKey); + if (!guest) return; + setLinking(true); + setLinkError(null); + try { + await api.servers.update(serverId, { + proxmoxIntegrationId: pickedIntegrationId, + proxmoxNode: guest.node, + proxmoxGuestType: guest.type, + proxmoxVmid: guest.vmid, + }); + await loadDetail(); + } catch (err) { + setLinkError(err instanceof Error ? err.message : String(err)); + } finally { + setLinking(false); + } + } + + async function unlinkProxmox() { + setLinking(true); + setLinkError(null); + try { + await api.servers.update(serverId, { + proxmoxIntegrationId: null, + proxmoxNode: null, + proxmoxGuestType: null, + proxmoxVmid: null, + }); + setPickedIntegrationId(null); + setPickedGuestKey(""); + await loadDetail(); + } catch (err) { + setLinkError(err instanceof Error ? err.message : String(err)); + } finally { + setLinking(false); + } + } + + function openAddTask() { + setEditingTask(null); + setTaskForm({ ...emptyTaskForm }); + } + + function openEditTask(task: TaskRecord) { + setEditingTask(task); + setTaskForm({ + scheduleType: task.scheduleType, + name: task.name, + command: task.command ?? "", + scheduleExpression: task.scheduleExpression ?? "", + enabled: task.enabled, + }); + } + + async function submitTask(e: React.FormEvent) { + e.preventDefault(); + if (!taskForm) return; + setSavingTask(true); + setError(null); + try { + const data: ManualTaskInput = { + serverId, + scheduleType: taskForm.scheduleType, + name: taskForm.name, + command: taskForm.command || undefined, + scheduleExpression: taskForm.scheduleExpression || undefined, + enabled: taskForm.enabled, + }; + if (editingTask) { + await api.tasks.update(editingTask.id, data); + } else { + await api.tasks.create(data); + } + setTaskForm(null); + setEditingTask(null); + loadTasks(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingTask(false); + } + } + + async function deleteTask(task: TaskRecord) { + if (!confirm(`Delete "${task.name}"?`)) return; + try { + await api.tasks.remove(task.id); + loadTasks(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + if (loading) return
Loading…
; + if (error && !detail) return
{error}
; + if (!detail) return null; + + const { server, hardware, ipAddresses, dnsMatches } = detail; + + return ( + <> +
+ + ← Servers & Tasks + +
+
+
+

{server.name}

+ {server.description &&
{server.description}
} +
+ +
+ {error &&
{error}
} + + {isAdmin && ( +
+
+

Proxmox link

+
+
+ {server.proxmoxIntegrationId ? ( +
+ + Linked to {hardware.integrationName ?? "a Proxmox integration"} — {server.proxmoxGuestType}/{server.proxmoxVmid} on{" "} + {server.proxmoxNode} + + +
+ ) : (proxmoxIntegrations?.length ?? 0) === 0 ? ( +
No enabled Proxmox integrations configured yet.
+ ) : ( +
+
+ + +
+
+ + +
+
+ +
+ {linkError && ( +
+
{linkError}
+
+ )} +
+ )} +
+
+ )} + +
+
+
+
+

Hardware & Network

+
+
+ {hardware.source === "none" && ( +
+ No hardware data yet. Install the agent on this server, or link it to a Proxmox VM/LXC above. +
+ )} + {hardware.error &&
{hardware.error}
} + {hardware.source === "proxmox" && !hardware.error && ( + <> +
Synced live from Proxmox just now.
+
+
+
CPU
+
+ {hardware.cpuCores ?? "—"} cores + {hardware.cpuUsagePercent !== null && hardware.cpuUsagePercent !== undefined + ? ` · ${hardware.cpuUsagePercent.toFixed(1)}% used` + : ""} +
+
+
+
Memory
+
+ {formatBytes(hardware.memUsedBytes)} / {formatBytes(hardware.memTotalBytes)} +
+ {usageBar(hardware.memUsedBytes, hardware.memTotalBytes)} +
+
+
Disk (allocated)
+
{formatBytes(hardware.diskBytes)}
+
+
+
Uptime
+
{formatUptime(hardware.uptime)}
+
+
+ {hardware.guestAgentAvailable === false && ( +
+ IP addresses unavailable — the QEMU guest agent isn't installed or running on this VM. +
+ )} + + )} + {hardware.source === "agent" && ( + <> +
+ Reported by the agent as of {server.lastSeenAt ? formatDateTime(new Date(server.lastSeenAt)) : "unknown"}. +
+
+
+
CPU
+
+ {hardware.cpuModel || "Unknown model"} + {hardware.cpuCores ? ` · ${hardware.cpuCores} cores` : ""} +
+ {hardware.cpuLoadPercent !== null && hardware.cpuLoadPercent !== undefined && ( +
Load: {hardware.cpuLoadPercent.toFixed(1)}%
+ )} +
+
+
Memory
+
+ {formatBytes(hardware.memUsedBytes)} / {formatBytes(hardware.memTotalBytes)} +
+ {usageBar(hardware.memUsedBytes, hardware.memTotalBytes)} +
+
+ {hardware.disks && hardware.disks.length > 0 && ( +
+
Disks
+ + + {hardware.disks.map((d) => ( + + + + + ))} + +
{d.mount} + {formatBytes(d.usedBytes)} / {formatBytes(d.sizeBytes)} + {usageBar(d.usedBytes, d.sizeBytes)} +
+
+ )} + + )} + + {ipAddresses.length > 0 && ( +
+
IP addresses
+
    + {ipAddresses.map((ip) => { + const matches = dnsMatches.filter((m) => m.ip === ip); + return ( +
  • + {ip} + {matches.length > 0 && ( + + {" "} + — {matches.map((m) => `${m.recordName} (${m.providerName})`).join(", ")} + + )} +
  • + ); + })} +
+
+ )} +
+
+
+ +
+
+
+

Identity

+
+
+
+
Hostname
+
{server.hostname ?? "—"}
+
OS type
+
{server.osType}
+
Token
+
{server.apiTokenPrefix}…
+
Created
+
{formatDateTime(new Date(server.createdAt))}
+
Last seen
+
{server.lastSeenAt ? formatDateTime(new Date(server.lastSeenAt)) : "never"}
+
+
+
+
+
+ +
+

Tasks

+ {canEditTasks && ( + + )} +
+ + {taskForm && ( +
+
+

{editingTask ? "Edit task" : "Add a task manually"}

+
+
+
+
+ + +
+
+ + setTaskForm({ ...taskForm, name: e.target.value })} + /> +
+
+ + setTaskForm({ ...taskForm, command: e.target.value })} + /> +
+
+ + setTaskForm({ ...taskForm, scheduleExpression: e.target.value })} + /> +
+
+ +
+
+
+ + +
+
+
+ )} + +
+ {tasks && tasks.length === 0 && ( +
No scheduled tasks found for this server yet.
+ )} + {tasks && tasks.length > 0 && ( + + )} +
+ + ); +} diff --git a/web/src/pages/ServersTasks.tsx b/web/src/pages/ServersTasks.tsx index 8f430c6..d706827 100644 --- a/web/src/pages/ServersTasks.tsx +++ b/web/src/pages/ServersTasks.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import cronstrue from "cronstrue"; -import { CronExpressionParser } from "cron-parser"; +import { Link } from "react-router-dom"; import { api, type CurrentUser, @@ -10,18 +9,9 @@ import { type TaskRecord, } from "../api/client"; import CopyButton from "../components/CopyButton"; +import ServerTaskTable from "../components/ServerTaskTable"; import { formatDateTime } from "../utils/date"; -const SCHEDULE_TYPE_LABELS: Record = { - cron: "Cron jobs", - systemd_timer: "systemd timers", - docker: "Docker jobs", - backup: "Backups", - update: "Updates", - n8n_workflow: "n8n workflows", - manual: "Other", -}; - const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [ { value: "docker", label: "Docker" }, { value: "cron", label: "Cron" }, @@ -40,34 +30,6 @@ function uninstallCommand(): string { return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`; } -function describeSchedule(task: TaskRecord): string { - if (!task.scheduleExpression) return "—"; - if (task.scheduleType === "cron") { - try { - return cronstrue.toString(task.scheduleExpression, { verbose: false }); - } catch { - return task.scheduleExpression; - } - } - return task.scheduleExpression; -} - -interface NextRun { - date: Date; - estimated: boolean; -} - -function computeNextRun(task: TaskRecord): NextRun | null { - if (task.nextRunAt) return { date: new Date(task.nextRunAt), estimated: false }; - if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) return null; - try { - const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate(); - return { date, estimated: true }; - } catch { - return null; - } -} - const emptyTaskForm = { scheduleType: "docker" as ScheduleType, name: "", @@ -355,7 +317,9 @@ export default function ServersTasks({ user }: { user: CurrentUser }) { {servers.map((s) => ( - {s.name} + + {s.name} + {s.hostname ?? "—"} {s.apiTokenPrefix}… {s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"} @@ -388,6 +352,35 @@ export default function ServersTasks({ user }: { user: CurrentUser }) { ) : ( <> + {servers.length > 0 && ( +
+ {servers.map((s) => ( +
+ +
+
+
+
{s.name}
+
{s.hostname ?? "no hostname reported"}
+
+ {s.proxmoxIntegrationId ? ( + Proxmox + ) : s.lastSeenAt ? ( + Agent + ) : ( + No data + )} +
+
+ {s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"} +
+
+ +
+ ))} +
+ )} +
@@ -558,99 +551,16 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
)} - {groups.map((group) => { - const byType = new Map(); - for (const task of group.tasks) { - const list = byType.get(task.scheduleType) ?? []; - list.push(task); - byType.set(task.scheduleType, list); - } - return ( -
-
-

{group.serverName}

-
- {[...byType.entries()].map(([scheduleType, groupTasks]) => ( -
-
- {SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType} -
-
- - - - - - - - - {canEditTasks && } - - - - {groupTasks.map((task) => { - const nextRun = computeNextRun(task); - return ( - - - - - - - {canEditTasks && ( - - )} - - ); - })} - -
NameCommandScheduleNext runStatusActions
{task.name}{task.command ?? "—"} -
{describeSchedule(task)}
- {task.scheduleExpression && ( - {task.scheduleExpression} - )} -
- {nextRun ? ( - - {nextRun.estimated ? "~" : ""} - {formatDateTime(nextRun.date)} - - ) : ( - "—" - )} - - {task.isStale ? ( - Missing - ) : task.enabled ? ( - Enabled - ) : ( - Disabled - )} - {task.origin === "manual" && Manual} - - {task.origin === "manual" && ( -
- - -
- )} -
-
-
- ))} + {groups.map((group) => ( +
+
+

+ {group.serverName} +

- ); - })} + +
+ ))} )}