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:
@@ -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
|
||||
+9
-1
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+87
-4
@@ -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<string, VMConfig> = {
|
||||
@@ -331,16 +350,25 @@ const VMCard = ({ item, config }: { item?: MonitorItem; config: VMConfig }) => {
|
||||
|
||||
export default function Monitor() {
|
||||
const [data, setData] = useState<MonitorData | null>(null)
|
||||
const [fleet, setFleet] = useState<FleetData | null>(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() {
|
||||
<VMCard key={key} item={getVMItem(key)} config={config} />
|
||||
))}
|
||||
</div>
|
||||
{/* Section 2.5: Fleet Beszel */}
|
||||
{fleet && fleet.systems.length > 0 && (
|
||||
<motion.div variants={itemVariants} className="mt-8">
|
||||
<div className="glass-card overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-white/5 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2 uppercase tracking-wide">
|
||||
<Laptop2 className="w-4 h-4 text-cyan-400" />
|
||||
Fleet — {fleet.up}/{fleet.total} activas
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{fleet.down > 0 && (
|
||||
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded-full">{fleet.down} down</span>
|
||||
)}
|
||||
<a
|
||||
href="https://descomplicar-beszel.i7zsqo.easypanel.host/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Abrir Beszel →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{fleet.systems.sort((a, b) => a.name.localeCompare(b.name)).map((sys) => (
|
||||
<div
|
||||
key={sys.name}
|
||||
className={`p-3 rounded-xl border transition-colors ${
|
||||
sys.status === 'up'
|
||||
? 'bg-white/[0.02] border-white/5 hover:bg-white/[0.04]'
|
||||
: 'bg-red-500/5 border-red-500/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<StatusDot status={sys.status} />
|
||||
<span className="text-sm font-medium text-white truncate">{sys.name}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<ProgressBar percent={sys.cpu} size="sm" />
|
||||
<div className="flex justify-between text-[10px] text-zinc-500">
|
||||
<span>RAM {sys.mem}%</span>
|
||||
<span>DK {sys.disk}%</span>
|
||||
</div>
|
||||
{sys.containers > 0 && (
|
||||
<div className="text-[10px] text-zinc-600">{sys.containers} containers</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Section 3: Detail Categories */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-8">
|
||||
|
||||
Reference in New Issue
Block a user