feat(monitor): integração Beszel/Stack Observer, tickets do observer e novos sites no uptime
This commit is contained in:
@@ -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.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<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(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<BeszelSystem[]> {
|
||||
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<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,
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,13 @@ const PING_SITES: { host: string; url: string; marker: string }[] = [
|
||||
{ host: 'familyclinic.pt', url: 'https://familyclinic.pt', marker: 'Family Clinic' },
|
||||
{ host: 'karateclubedegaia.com', url: 'https://karateclubedegaia.com', marker: 'Karate Clube de Gaia' },
|
||||
{ host: 'whatsms.pt', url: 'https://whatsms.pt', marker: 'Todos os canais' },
|
||||
{ host: 'carstuff.pt', url: 'https://carstuff.pt', marker: 'CarStuff' },
|
||||
{ host: 'care.descomplicar.pt', url: 'https://care.descomplicar.pt', marker: 'Care Descomplicar' },
|
||||
{ host: 'ccv.descomplicar.pt', url: 'https://ccv.descomplicar.pt', marker: 'Carvoeiro Caves' },
|
||||
{ host: 'e-commerce.descomplicar.pt', url: 'https://e-commerce.descomplicar.pt', marker: 'e-Commerce Descomplicar' },
|
||||
{ host: 'ecommerce.descomplicar.pt', url: 'https://ecommerce.descomplicar.pt', marker: 'Descomplicar - Template' },
|
||||
{ host: 'ecommerce-demo.descomplicar.pt', url: 'https://ecommerce-demo.descomplicar.pt', marker: 'Descomplicar - Demo' },
|
||||
{ host: 'starter.descomplicar.pt', url: 'https://starter.descomplicar.pt', marker: 'Starter Descomplicar' },
|
||||
]
|
||||
const SITE_SIZE_FLOOR = 5000
|
||||
const SITE_ERROR_SIGNATURES = [
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Stack Observer Service
|
||||
* Reads divergences from observer.db (SQLite)
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import sqlite3 from 'sqlite3'
|
||||
import { open } from 'sqlite'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import os from 'os'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
interface Evaluation {
|
||||
id: number
|
||||
source: string
|
||||
entity_id: string
|
||||
site_sig: string
|
||||
result_real: string
|
||||
result_nominal: string
|
||||
tokens: number
|
||||
reincidence_count: number
|
||||
judge_method: string
|
||||
confidence: number
|
||||
evaluated_at: number
|
||||
category: string
|
||||
}
|
||||
|
||||
interface DivergenceStats {
|
||||
total_divergences: number
|
||||
by_category: Record<string, number>
|
||||
by_judge: Record<string, number>
|
||||
recent_24h: number
|
||||
high_confidence: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Open observer.db connection
|
||||
* DB is located at ~/Dados/Dev/stack-observer/observer.db
|
||||
*/
|
||||
async function openObserverDb() {
|
||||
const dbPath = path.join(
|
||||
os.homedir(),
|
||||
'Dados/Dev/stack-observer/observer.db'
|
||||
)
|
||||
|
||||
return open({
|
||||
filename: dbPath,
|
||||
driver: sqlite3.Database
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all divergences (evaluations)
|
||||
* Filters for result_real != result_nominal
|
||||
*/
|
||||
export async function getDivergences() {
|
||||
const db = await openObserverDb()
|
||||
|
||||
try {
|
||||
// Get divergences with divergence flag
|
||||
const divergences = await db.all<Evaluation[]>(`
|
||||
SELECT
|
||||
id, source, entity_id, site_sig,
|
||||
result_real, result_nominal,
|
||||
tokens, reincidence_count,
|
||||
judge_method, confidence, evaluated_at, category
|
||||
FROM evaluations
|
||||
WHERE result_real IS NOT NULL
|
||||
AND category IN ('automation-liveness', 'automation-failure')
|
||||
ORDER BY evaluated_at DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
|
||||
// Calculate stats
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const day_ago = now - 86400
|
||||
|
||||
const stats: DivergenceStats = {
|
||||
total_divergences: 0,
|
||||
by_category: {},
|
||||
by_judge: {},
|
||||
recent_24h: 0,
|
||||
high_confidence: 0
|
||||
}
|
||||
|
||||
if (divergences) {
|
||||
stats.total_divergences = divergences.length
|
||||
|
||||
for (const div of divergences) {
|
||||
// Count by category
|
||||
stats.by_category[div.category] = (stats.by_category[div.category] ?? 0) + 1
|
||||
|
||||
// Count by judge method
|
||||
stats.by_judge[div.judge_method] = (stats.by_judge[div.judge_method] ?? 0) + 1
|
||||
|
||||
// Count recent
|
||||
if (div.evaluated_at >= day_ago) {
|
||||
stats.recent_24h++
|
||||
}
|
||||
|
||||
// Count high confidence
|
||||
if (div.confidence >= 0.9) {
|
||||
stats.high_confidence++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
divergences: divergences ?? [],
|
||||
stats,
|
||||
generated_at: new Date().toISOString()
|
||||
}
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single divergence by ID
|
||||
*/
|
||||
export async function getDivergenceById(id: string) {
|
||||
const db = await openObserverDb()
|
||||
|
||||
try {
|
||||
const evaluation = await db.get<Evaluation>(`
|
||||
SELECT * FROM evaluations
|
||||
WHERE id = ?
|
||||
`, [id])
|
||||
|
||||
return evaluation ?? null
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get divergence trend (last 7 days)
|
||||
*/
|
||||
export async function getDivergenceTrend() {
|
||||
const db = await openObserverDb()
|
||||
|
||||
try {
|
||||
interface TrendRow {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const trend = await db.all<TrendRow[]>(`
|
||||
SELECT
|
||||
DATE(evaluated_at, 'unixepoch') as date,
|
||||
COUNT(*) as count
|
||||
FROM evaluations
|
||||
WHERE result_real IS NOT NULL
|
||||
AND category IN ('automation-liveness', 'automation-failure')
|
||||
AND evaluated_at >= (unixepoch() - 604800)
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
`)
|
||||
|
||||
return trend ?? []
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of automations (hermes_jobs)
|
||||
* F1b: Colector lê de ~/.hermes/data/action-log.db
|
||||
*/
|
||||
export async function getAutomations() {
|
||||
const db = await openObserverDb()
|
||||
|
||||
try {
|
||||
const automations = await db.all(`
|
||||
SELECT
|
||||
action_id, who, what, where_field, when_ts,
|
||||
result, result_real, why, how, detail, session_id, created_at,
|
||||
ingested_at
|
||||
FROM hermes_jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
|
||||
return automations ?? []
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of gate events
|
||||
* F1a: Colector lê de /tmp/stack-observer/gate-events.queue
|
||||
*/
|
||||
export async function getGateEvents() {
|
||||
const db = await openObserverDb()
|
||||
|
||||
try {
|
||||
const events = await db.all(`
|
||||
SELECT
|
||||
id, gate_id, event_type, payload, timestamp, ingested_at
|
||||
FROM gate_events
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
|
||||
return events ?? []
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user