import { FastifyInstance } from "fastify" import { HeadBucketCommand } from "@aws-sdk/client-s3" import { s3 } from "../utils/s3" import { secrets } from "../utils/secrets" 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 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, }) export const checkFileStorage = async (client = s3, bucket = secrets.S3_BUCKET, timeoutMs = 3000) => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { if (!client || !bucket) return serviceState(false, { error: "S3-Dateispeicher ist nicht eingerichtet" }) await client.send(new HeadBucketCommand({ Bucket: bucket }), { abortSignal: controller.signal }) return serviceState(true, { provider: process.env.FEDEO_STORAGE_PROVIDER === "seaweedfs" ? "seaweedfs" : "s3" }) } catch { return serviceState(false, { error: controller.signal.aborted ? "S3-Prüfung hat das Zeitlimit überschritten" : "S3-Bucket nicht erreichbar oder Zugriff verweigert", }) } 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") 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, }), // Preserve the response key for older clients; check the authenticated S3 API for every provider. minio: await checkFileStorage(), }, } }