From 8df587f9e23693a4446dfe48d7ef6dbaed584cf1 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 20 May 2026 20:41:48 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Systemstatus=20und=20Node=20Exporte?= =?UTF-8?q?r=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/system-status.service.ts | 174 ++++++++++++++ backend/src/routes/admin.ts | 16 ++ docker-compose.selfhost.yml | 24 +- docker-compose.yml | 18 ++ frontend/components/MainNav.vue | 5 + frontend/composables/useAdmin.ts | 33 +++ frontend/pages/administration/system.vue | 228 +++++++++++++++++++ 7 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 backend/src/modules/system-status.service.ts create mode 100644 frontend/pages/administration/system.vue diff --git a/backend/src/modules/system-status.service.ts b/backend/src/modules/system-status.service.ts new file mode 100644 index 0000000..0eb8641 --- /dev/null +++ b/backend/src/modules/system-status.service.ts @@ -0,0 +1,174 @@ +import { FastifyInstance } from "fastify" +import { matrixService } from "./matrix.service" + +type MetricSample = { + labels: Record + value: number +} + +const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?|-?Inf|NaN)$/i + +const nodeExporterUrl = () => + (process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "") + +const s3EndpointUrl = () => + (process.env.S3_ENDPOINT || "").replace(/\/+$/, "") + +const parseLabels = (value = "") => { + const labels: Record = {} + const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g + let match: RegExpExecArray | null + + while ((match = labelPattern.exec(value))) { + labels[match[1]] = match[2].replace(/\\"/g, "\"") + } + + return labels +} + +const parsePrometheusMetrics = (text: string) => { + const metrics = new Map() + + for (const line of text.split("\n")) { + if (!line || line.startsWith("#")) continue + + const match = line.match(metricLinePattern) + if (!match) continue + + const value = Number(match[3]) + if (!Number.isFinite(value)) continue + + const samples = metrics.get(match[1]) || [] + samples.push({ + labels: parseLabels(match[2]), + value, + }) + metrics.set(match[1], samples) + } + + return metrics +} + +const firstMetricValue = (metrics: Map, name: string) => + metrics.get(name)?.[0]?.value ?? null + +const findMetricValue = ( + metrics: Map, + name: string, + predicate: (sample: MetricSample) => boolean +) => metrics.get(name)?.find(predicate)?.value ?? null + +const serviceState = (ok: boolean, detail?: Record) => ({ + ok, + status: ok ? "ok" : "error", + ...detail, +}) + +const checkHttp = async (url: string, timeoutMs = 3000) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { signal: controller.signal }) + return serviceState(response.ok, { + httpStatus: response.status, + url, + }) + } catch (err: any) { + return serviceState(false, { + url, + error: err?.message || "HTTP-Abfrage fehlgeschlagen", + }) + } finally { + clearTimeout(timeout) + } +} + +export const buildSystemStatus = async (server: FastifyInstance) => { + const checkedAt = new Date() + const nodeExporterMetricsUrl = `${nodeExporterUrl()}/metrics` + let nodeMetrics: Map | null = null + let nodeExporterError: string | null = null + + try { + const response = await fetch(nodeExporterMetricsUrl) + if (!response.ok) { + throw new Error(`Node Exporter antwortet mit ${response.status}`) + } + nodeMetrics = parsePrometheusMetrics(await response.text()) + } catch (err: any) { + nodeExporterError = err?.message || "Node Exporter nicht erreichbar" + } + + const memoryTotal = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemTotal_bytes") : null + const memoryAvailable = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemAvailable_bytes") : null + const rootSize = nodeMetrics + ? findMetricValue(nodeMetrics, "node_filesystem_size_bytes", (sample) => sample.labels.mountpoint === "/") + : null + const rootAvailable = nodeMetrics + ? findMetricValue(nodeMetrics, "node_filesystem_avail_bytes", (sample) => sample.labels.mountpoint === "/") + : null + const bootTime = nodeMetrics ? firstMetricValue(nodeMetrics, "node_boot_time_seconds") : null + const cpuCount = nodeMetrics + ? new Set((nodeMetrics.get("node_cpu_seconds_total") || []) + .filter((sample) => sample.labels.mode === "idle") + .map((sample) => sample.labels.cpu)).size + : null + const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null + + const databaseCheck = await server.db.execute("SELECT NOW() as now") + const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({ + reachable: false, + error: err?.message || "Matrix-Status nicht verfügbar", + })) + const minioUrl = s3EndpointUrl() + + return { + checkedAt: checkedAt.toISOString(), + backend: { + status: "ok", + uptimeSeconds: Math.round(process.uptime()), + nodeVersion: process.version, + environment: process.env.NODE_ENV || "development", + }, + server: { + status: nodeMetrics ? "ok" : "unavailable", + nodeExporterUrl: nodeExporterMetricsUrl, + error: nodeExporterError, + hostname: uname?.nodename || null, + kernel: uname?.release || null, + cpuCount, + load: { + one: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load1") : null, + five: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load5") : null, + fifteen: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load15") : null, + }, + memory: { + totalBytes: memoryTotal, + availableBytes: memoryAvailable, + usedBytes: memoryTotal !== null && memoryAvailable !== null ? memoryTotal - memoryAvailable : null, + usedPercent: memoryTotal ? Math.round(((memoryTotal - (memoryAvailable || 0)) / memoryTotal) * 1000) / 10 : null, + }, + disk: { + rootTotalBytes: rootSize, + rootAvailableBytes: rootAvailable, + rootUsedBytes: rootSize !== null && rootAvailable !== null ? rootSize - rootAvailable : null, + rootUsedPercent: rootSize ? Math.round(((rootSize - (rootAvailable || 0)) / rootSize) * 1000) / 10 : null, + }, + uptimeSeconds: bootTime ? Math.max(0, Math.round(Date.now() / 1000 - bootTime)) : null, + }, + services: { + database: serviceState(true, { + checkedAt: String(databaseCheck.rows?.[0]?.now || checkedAt.toISOString()), + }), + nodeExporter: serviceState(Boolean(nodeMetrics), { + url: nodeExporterMetricsUrl, + error: nodeExporterError, + }), + matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record), + minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, { + error: "S3_ENDPOINT ist nicht gesetzt", + }), + }, + } +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index a35b3c2..3fc766f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -17,6 +17,7 @@ import { sendMail } from "../utils/mailer"; import { ensureTenantBaseData } from "../modules/bootstrap.service"; import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport"; +import { buildSystemStatus } from "../modules/system-status.service"; export default async function adminRoutes(server: FastifyInstance) { const deriveNameFromEmail = (email: string) => { @@ -393,6 +394,21 @@ export default async function adminRoutes(server: FastifyInstance) { } }); + // ------------------------------------------------------------- + // GET /admin/system-status + // ------------------------------------------------------------- + server.get("/admin/system-status", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + return await buildSystemStatus(server); + } catch (err) { + console.error("ERROR /admin/system-status:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + // ------------------------------------------------------------- // POST /admin/users // ------------------------------------------------------------- diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 8b54e9d..d4a156c 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -81,8 +81,7 @@ services: - internal backend: - build: - context: ./backend + image: git.federspiel.tech/flfeders/fedeo/backend:dev container_name: fedeo-backend restart: unless-stopped depends_on: @@ -139,6 +138,7 @@ services: MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service} LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit} LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace} + NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100} labels: - traefik.enable=true - traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`) @@ -152,9 +152,25 @@ services: - web - internal + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: fedeo-node-exporter + restart: unless-stopped + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/rootfs + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/) + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro,rslave + networks: + - internal + frontend: - build: - context: ./frontend + image: git.federspiel.tech/flfeders/fedeo/frontend:dev container_name: fedeo-frontend restart: unless-stopped depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 756336d..04b8f6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: - WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-} - WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-} - WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com} + - NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100} networks: - traefik labels: @@ -74,6 +75,23 @@ services: - "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" # - "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge" - "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip" + + node-exporter: + image: prom/node-exporter:v1.8.2 + restart: unless-stopped + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/rootfs + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/) + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro,rslave + networks: + - traefik + matrix-db: image: postgres:16-alpine restart: unless-stopped diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index c02ef50..6e14a4d 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -355,6 +355,11 @@ const links = computed(() => { to: "/administration/tenants", icon: "i-heroicons-building-office-2", }, + { + label: "Systemstatus", + to: "/administration/system", + icon: "i-heroicons-server-stack", + }, ] : [] const visibleOrganisationChildren = visibleItems(organisationChildren) diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index b0be327..af6b7e6 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -55,6 +55,34 @@ export type TenantImportResult = { files: { restored: number; skipped: number } } +export type SystemStatus = { + checkedAt: string + backend: { + status: string + uptimeSeconds: number + nodeVersion: string + environment: string + } + server: { + status: string + nodeExporterUrl: string + error?: string | null + hostname?: string | null + kernel?: string | null + cpuCount?: number | null + uptimeSeconds?: number | null + load: { one?: number | null; five?: number | null; fifteen?: number | null } + memory: { totalBytes?: number | null; availableBytes?: number | null; usedBytes?: number | null; usedPercent?: number | null } + disk: { rootTotalBytes?: number | null; rootAvailableBytes?: number | null; rootUsedBytes?: number | null; rootUsedPercent?: number | null } + } + services: Record +} + export const useAdmin = () => { const { $api } = useNuxtApp() @@ -130,8 +158,13 @@ export const useAdmin = () => { }) } + const getSystemStatus = async (): Promise => { + return await $api("/api/admin/system-status") + } + return { getOverview, + getSystemStatus, createUser, createUserForProfile, updateUser, diff --git a/frontend/pages/administration/system.vue b/frontend/pages/administration/system.vue new file mode 100644 index 0000000..2bbf80f --- /dev/null +++ b/frontend/pages/administration/system.vue @@ -0,0 +1,228 @@ + + +