From 769c63b2a81c35055c803a911d7258c2037e1b4c Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 23 Jun 2026 20:09:12 +0100 Subject: [PATCH] feat(monitoring): add Beszel fleet integration to Monitor page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New api/services/beszel.ts: Zod-validated Beszel API client with PocketBase auth, system fetching, and MySQL upsert - New api/routes/beszel.ts: GET /api/beszel (read) + POST /api/beszel/collect (trigger) - Updated api/server.ts: register route, add to 5min scheduler - Updated src/pages/Monitor.tsx: Fleet section showing all 17 systems with CPU/RAM/disk bars, status dots, container count, and link to Beszel dashboard Architecture: Beszel hub → API → tbl_eal_monitoring (category='fleet') → Monitor.tsx grid. Collected every 5min alongside existing metrics. --- api/routes/beszel.ts | 36 ++++++ api/server.ts | 10 +- api/services/beszel.ts | 248 +++++++++++++++++++++++++++++++++++++++++ src/pages/Monitor.tsx | 91 ++++++++++++++- 4 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 api/routes/beszel.ts create mode 100644 api/services/beszel.ts diff --git a/api/routes/beszel.ts b/api/routes/beszel.ts new file mode 100644 index 0000000..43d5234 --- /dev/null +++ b/api/routes/beszel.ts @@ -0,0 +1,36 @@ +/** + * Beszel Fleet API Route + * GET /api/beszel — fleet data from MySQL + * POST /api/beszel/collect — trigger fresh collection + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import { getFleetData, collectBeszelFleet } from '../services/beszel.js' + +const router = Router() + +// GET /api/beszel — fleet data +router.get('/', async (_req, res) => { + try { + const data = await getFleetData() + res.json(data) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('[BESZEL API] GET failed:', message) + res.status(500).json({ error: message }) + } +}) + +// POST /api/beszel/collect — trigger collection +router.post('/collect', async (_req, res) => { + try { + const data = await collectBeszelFleet() + res.json({ ok: true, collected: data.total, up: data.up, down: data.down }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('[BESZEL API] Collect failed:', message) + res.status(500).json({ error: message }) + } +}) + +export default router diff --git a/api/server.ts b/api/server.ts index c208986..30f3a3b 100644 --- a/api/server.ts +++ b/api/server.ts @@ -26,6 +26,8 @@ import { openSessionsDb } from './services/sessions/db.js' import { DEFAULT_DB_PATH } from './services/sessions/indexer.js' import { collectAllServerMetrics } from './services/server-metrics.js' import { collectMonitoringData } from './services/monitoring-collector.js' +import beszelRouter from './routes/beszel.js' +import { collectBeszelFleet } from './services/beszel.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -133,8 +135,8 @@ app.use('/api/financial', financialRouter) app.use('/api/mcps', mcpsRouter) app.use('/api/n8n', n8nRouter) app.use('/api/paperclip', paperclipRouter) -app.use('/api/ai', aiRouter) app.use('/api/operations', operationsRouter) +app.use('/api/beszel', beszelRouter) // Observabilidade (Espelho) — sessões Claude Code const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH) @@ -215,6 +217,9 @@ app.listen(PORT, () => { collectMonitoringData().catch(err => console.error('[SCHEDULER] Initial monitoring collection failed:', err.message) ) + collectBeszelFleet().catch(err => + console.error('[SCHEDULER] Initial Beszel fleet collection failed:', err instanceof Error ? err.message : err) + ) }, 30000) // Recurring collection @@ -225,6 +230,9 @@ app.listen(PORT, () => { collectMonitoringData().catch(err => console.error('[SCHEDULER] Monitoring collection failed:', err.message) ) + collectBeszelFleet().catch(err => + console.error('[SCHEDULER] Beszel fleet collection failed:', err instanceof Error ? err.message : err) + ) }, INTERVAL) } }) diff --git a/api/services/beszel.ts b/api/services/beszel.ts new file mode 100644 index 0000000..45f6193 --- /dev/null +++ b/api/services/beszel.ts @@ -0,0 +1,248 @@ +/** + * 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.number().default(0), + mp: z.number().default(0), + dp: z.number().default(0), + ct: z.number().default(0), + la: z.number().default(0), + tu: z.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.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(10_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(15_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, + } +} diff --git a/src/pages/Monitor.tsx b/src/pages/Monitor.tsx index b5913ad..e259a12 100644 --- a/src/pages/Monitor.tsx +++ b/src/pages/Monitor.tsx @@ -18,6 +18,7 @@ import { Cpu, MemoryStick, MonitorDot, + Laptop2, } from 'lucide-react' // Types @@ -60,6 +61,24 @@ interface VMConfig { accentBg: string accentBorder: string } +interface FleetSystem { + name: string + host: string + status: string + cpu: number + mem: number + disk: number + containers: number + load: number + uptime: number +} + +interface FleetData { + total: number + up: number + down: number + systems: FleetSystem[] +} // VM definitions matching cluster architecture const VM_CONFIG: Record = { @@ -331,16 +350,25 @@ const VMCard = ({ item, config }: { item?: MonitorItem; config: VMConfig }) => { export default function Monitor() { const [data, setData] = useState(null) + const [fleet, setFleet] = useState(null) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const fetchData = useCallback(async () => { setRefreshing(true) try { - const response = await fetch('/api/monitor') - if (!response.ok) throw new Error('Failed') - const json = await response.json() - setData(json) + const [monitorRes, fleetRes] = await Promise.all([ + fetch('/api/monitor'), + fetch('/api/beszel'), + ]) + if (monitorRes.ok) { + const json = await monitorRes.json() + setData(json) + } + if (fleetRes.ok) { + const json = await fleetRes.json() + setFleet(json) + } } catch { if (import.meta.env.DEV) { setData(getMockData()) @@ -421,6 +449,61 @@ export default function Monitor() { ))} + {/* Section 2.5: Fleet Beszel */} + {fleet && fleet.systems.length > 0 && ( + +
+
+

+ + Fleet — {fleet.up}/{fleet.total} activas +

+
+ {fleet.down > 0 && ( + {fleet.down} down + )} + + Abrir Beszel → + +
+
+
+
+ {fleet.systems.sort((a, b) => a.name.localeCompare(b.name)).map((sys) => ( +
+
+ + {sys.name} +
+
+ +
+ RAM {sys.mem}% + DK {sys.disk}% +
+ {sys.containers > 0 && ( +
{sys.containers} containers
+ )} +
+
+ ))} +
+
+
+
+ )} {/* Section 3: Detail Categories */}