feat(monitoring): add Beszel fleet integration to Monitor page

- 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.
This commit is contained in:
2026-06-23 20:09:12 +01:00
parent 08d2a31dc9
commit 769c63b2a8
4 changed files with 380 additions and 5 deletions
+248
View File
@@ -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<typeof BeszelSystem>
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<string> {
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<BeszelSystem[]> {
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<string, unknown>): Promise<void> {
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<FleetSummary> {
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<FleetSummary> {
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<string, unknown>
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<string, unknown>
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,
}
}