/** * Beszel Fleet Collector * Fetches system metrics from Beszel hub (PocketBase API) and stores in tbl_eal_monitoring. * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { z } from 'zod/v4' import db from '../db.js' // ── Zod schemas for Beszel API responses ──────────────────────────────────── const BeszelSystemInfo = z.object({ cpu: z.coerce.number().default(0), mp: z.coerce.number().default(0), dp: z.coerce.number().default(0), ct: z.coerce.number().default(0), la: z.union([z.number(), z.array(z.number())]).transform(v => Array.isArray(v) ? v[0] || 0 : v).default(0), tu: z.coerce.number().default(0), }).default({ cpu: 0, mp: 0, dp: 0, ct: 0, la: 0, tu: 0 }) const BeszelSystem = z.object({ id: z.string(), name: z.string(), host: z.string(), port: z.coerce.number().default(45876), status: z.string().default('unknown'), info: BeszelSystemInfo, created: z.string().optional(), updated: z.string().optional(), }) const BeszelListResponse = z.object({ items: z.array(BeszelSystem).default([]), totalItems: z.number().default(0), page: z.number().default(1), perPage: z.number().default(30), totalPages: z.number().default(1), }) const BeszelAuthResponse = z.object({ token: z.string(), record: z.object({ id: z.string(), email: z.string().optional(), }).optional(), }) // ── Derived types ─────────────────────────────────────────────────────────── type BeszelSystem = z.infer export interface FleetSummary { total: number up: number down: number systems: FleetSystem[] } export interface FleetSystem { name: string host: string status: string cpu: number mem: number disk: number containers: number load: number uptime: number } // ── Config ────────────────────────────────────────────────────────────────── const BESZEL_URL = process.env.BESZEL_URL || 'https://descomplicar-beszel.i7zsqo.easypanel.host' const BESZEL_EMAIL = process.env.BESZEL_EMAIL || 'emanuel@descomplicar.pt' const BESZEL_PASSWORD = process.env.BESZEL_PASSWORD || 'AcidaOS_26' // Machines to ignore (Tor exit nodes that duplicate real machines) const IGNORE_NAMES = new Set(['black', 'hp', 'kriku']) // ── API Client ────────────────────────────────────────────────────────────── let cachedToken: string | null = null let tokenExpiry = 0 async function authenticate(): Promise { if (cachedToken && Date.now() < tokenExpiry) return cachedToken const resp = await fetch(`${BESZEL_URL}/api/collections/users/auth-with-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identity: BESZEL_EMAIL, password: BESZEL_PASSWORD }), signal: AbortSignal.timeout(30_000), }) if (!resp.ok) throw new Error(`Beszel auth failed: ${resp.status}`) const raw = await resp.json() const parsed = BeszelAuthResponse.safeParse(raw) if (!parsed.success) throw new Error(`Beszel auth response invalid: ${parsed.error.message}`) cachedToken = parsed.data.token tokenExpiry = Date.now() + 55 * 60 * 1000 // 55 min return cachedToken } async function fetchSystems(): Promise { const token = await authenticate() const resp = await fetch(`${BESZEL_URL}/api/collections/systems/records?limit=100`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(30_000), }) if (!resp.ok) throw new Error(`Beszel systems fetch failed: ${resp.status}`) const raw = await resp.json() const parsed = BeszelListResponse.safeParse(raw) if (!parsed.success) throw new Error(`Beszel systems response invalid: ${parsed.error.message}`) return parsed.data.items } // ── Collector ─────────────────────────────────────────────────────────────── async function upsertMonitoring(category: string, name: string, status: string, details: Record): Promise { const detailsJson = JSON.stringify(details) const [result] = await db.query( `UPDATE tbl_eal_monitoring SET status = ?, details = ?, last_check = NOW() WHERE category = ? AND name = ?`, [status, detailsJson, category, name] ) if ((result as { affectedRows: number }).affectedRows === 0) { await db.query( `INSERT INTO tbl_eal_monitoring (category, name, status, details, last_check) VALUES (?, ?, ?, ?, NOW())`, [category, name, status, detailsJson] ) } } /** * Collect all Beszel fleet systems and store as individual monitoring records. * Category: 'fleet' */ export async function collectBeszelFleet(): Promise { const systems = await fetchSystems() const fleet: FleetSystem[] = [] let upCount = 0 let downCount = 0 for (const sys of systems) { // Skip known duplicates (Tor exit nodes) if (IGNORE_NAMES.has(sys.name)) continue const info = sys.info const isUp = sys.status === 'up' if (isUp) upCount++ else downCount++ const status = isUp ? 'ok' : 'down' await upsertMonitoring('fleet', sys.name, status, { host: sys.host, port: sys.port, cpu: info.cpu, mem: info.mp, disk: info.dp, containers: info.ct, load: info.la, uptime: info.tu, beszel_status: sys.status, }) fleet.push({ name: sys.name, host: sys.host, status: sys.status, cpu: info.cpu, mem: info.mp, disk: info.dp, containers: info.ct, load: info.la, uptime: info.tu, }) } const total = upCount + downCount const overallStatus = downCount > 0 ? 'warning' : 'ok' await upsertMonitoring('fleet', '_summary', overallStatus, { total, up: upCount, down: downCount, timestamp: new Date().toISOString(), }) console.log(`[BESZEL] Fleet: ${upCount}/${total} up, ${downCount} down`) return { total, up: upCount, down: downCount, systems: fleet } } /** * Get fleet data from MySQL (already collected). * Used by the /api/beszel route. */ export async function getFleetData(): Promise { const [rows] = await db.query( `SELECT name, status, details, last_check FROM tbl_eal_monitoring WHERE category = 'fleet' ORDER BY name` ) as [{ name: string; status: string; details: string; last_check: string }[], unknown] const systems: FleetSystem[] = [] let summary = { total: 0, up: 0, down: 0 } for (const row of rows) { if (row.name === '_summary') { try { const d = JSON.parse(row.details) as Record summary = { total: Number(d.total) || 0, up: Number(d.up) || 0, down: Number(d.down) || 0, } } catch { /* ignore parse errors */ } continue } try { const d = JSON.parse(row.details) as Record systems.push({ name: row.name, host: String(d.host || ''), status: row.status === 'ok' ? 'up' : 'down', cpu: Number(d.cpu) || 0, mem: Number(d.mem) || 0, disk: Number(d.disk) || 0, containers: Number(d.containers) || 0, load: Number(d.load) || 0, uptime: Number(d.uptime) || 0, }) } catch { /* skip corrupted rows */ } } return { total: summary.total || systems.length, up: summary.up || systems.filter(s => s.status === 'up').length, down: summary.down || systems.filter(s => s.status !== 'up').length, systems, } }