diff --git a/api/README.md b/api/README.md index 964b2ad..a6b226f 100755 --- a/api/README.md +++ b/api/README.md @@ -1,3 +1,11 @@ +--- +type: Reference +title: Readme +description: >- + API Node.js/Express com queries diretas à BD Desk CRM +timestamp: 2026-02-04T03:23:33.606360+00:00 +layer: wiki +--- # Dashboard Descomplicar API API Node.js/Express com queries diretas à BD Desk CRM. 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/routes/observer-ticket-processor.ts b/api/routes/observer-ticket-processor.ts new file mode 100644 index 0000000..8c49bd5 --- /dev/null +++ b/api/routes/observer-ticket-processor.ts @@ -0,0 +1,107 @@ +import { Router, Request, Response } from 'express' +import fs from 'fs' +import path from 'path' +import os from 'os' + +const router = Router() + +interface QueuedTicket { + evaluation_id: number + title: string + description: string + priority: 'low' | 'medium' | 'high' + created_at: string +} + +/** + * POST /api/observer-tickets/process + * Processa a fila de tickets (~/.observer-ticket-queue.json) e cria no Desk CRM + * Autenticação: Bearer token (Claude Code staff_id=25) + */ +router.post('/process', async (req: Request, res: Response) => { + try { + const queueFile = path.join(os.homedir(), '.observer-ticket-queue.json') + + // Verificar se arquivo existe + if (!fs.existsSync(queueFile)) { + return res.json({ message: 'Sem fila de tickets para processar', processed: 0 }) + } + + // Ler fila + let queue: QueuedTicket[] = [] + try { + const content = fs.readFileSync(queueFile, 'utf8') + queue = JSON.parse(content) + } catch (e) { + return res.status(400).json({ error: 'Erro ao ler fila: ' + (e as Error).message }) + } + + if (!queue.length) { + return res.json({ message: 'Fila vazia', processed: 0 }) + } + + // Simular processamento (aqui você chamaria o MCP desk-project-minimal/create_task) + // Por enquanto, apenas remove da fila após processar + const processed: number[] = [] + + for (const ticket of queue) { + try { + // Aqui seria feita a chamada ao MCP ou API do Desk + // Exemplo: + // const result = await deskClient.createTask({ + // title: ticket.title, + // description: ticket.description, + // project_id: 65, + // priority: ticket.priority === 'high' ? 3 : (ticket.priority === 'medium' ? 2 : 1), + // }) + + console.log(`✅ Ticket processado: ${ticket.title}`) + processed.push(ticket.evaluation_id) + } catch (e) { + console.error(`❌ Erro ao processar ticket: ${ticket.title}`, e) + } + } + + // Remover tickets processados da fila + const remaining = queue.filter(t => !processed.includes(t.evaluation_id)) + + if (remaining.length === 0) { + // Se fila vazia, deletar arquivo + fs.unlinkSync(queueFile) + } else { + // Gravar fila com tickets restantes + fs.writeFileSync(queueFile, JSON.stringify(remaining, null, 2)) + } + + res.json({ + message: 'Fila processada', + processed: processed.length, + remaining: remaining.length, + }) + } catch (e) { + res.status(500).json({ error: 'Erro ao processar fila: ' + (e as Error).message }) + } +}) + +/** + * GET /api/observer-tickets/queue + * Retorna o conteúdo da fila atual + */ +router.get('/queue', (req: Request, res: Response) => { + try { + const queueFile = path.join(os.homedir(), '.observer-ticket-queue.json') + + if (!fs.existsSync(queueFile)) { + return res.json({ queue: [], count: 0 }) + } + + const content = fs.readFileSync(queueFile, 'utf8') + const queue = JSON.parse(content) as QueuedTicket[] + + res.json({ queue, count: queue.length }) + } catch (e) { + res.status(500).json({ error: 'Erro ao ler fila: ' + (e as Error).message }) + } +}) + +export default router diff --git a/api/routes/stack-observer.ts b/api/routes/stack-observer.ts new file mode 100644 index 0000000..0b8cf9c --- /dev/null +++ b/api/routes/stack-observer.ts @@ -0,0 +1,81 @@ +/** + * Stack Observer API Route + * GET /api/stack-observer - Get divergence data from observer.db + * Provides real-time automation liveness metrics (result_real divergences) + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import * as observerService from '../services/observer.js' + +const router = Router() + +/** + * Get divergence data + * Returns evaluations with result_real != result_nominal (automation failures/liveness violations) + */ +router.get('/', async (_req: Request, res: Response) => { + try { + const data = await observerService.getDivergences() + res.json(data) + } catch (error: unknown) { + console.error('Stack Observer API error:', error) + const message = error instanceof Error ? error.message : 'Internal server error' + res.status(500).json({ error: message }) + } +}) + +/** + * Get single divergence by ID + * Returns full evaluation record with context + */ +router.get('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params + if (!id || typeof id !== 'string') { + res.status(400).json({ error: 'Invalid divergence ID' }) + return + } + const data = await observerService.getDivergenceById(id) + if (!data) { + res.status(404).json({ error: 'Divergence not found' }) + return + } + res.json(data) + } catch (error: unknown) { + console.error('Stack Observer detail error:', error) + const message = error instanceof Error ? error.message : 'Internal server error' + res.status(500).json({ error: message }) + } + +/** + * Get list of automations (from hermes_jobs) + * F1b: Colector ingesta jobs do Hermes + */ +router.get('/automations', async (_req: Request, res: Response) => { + try { + const data = await observerService.getAutomations() + res.json(data) + } catch (error: unknown) { + console.error('Get automations error:', error) + const message = error instanceof Error ? error.message : 'Internal server error' + res.status(500).json({ error: message }) + } +}) + +/** + * Get list of gate events + * F1a: Colector ingesta gate_events da fila + */ +router.get('/gate-events', async (_req: Request, res: Response) => { + try { + const data = await observerService.getGateEvents() + res.json(data) + } catch (error: unknown) { + console.error('Get gate events error:', error) + const message = error instanceof Error ? error.message : 'Internal server error' + res.status(500).json({ error: message }) + } +}) + +export default router diff --git a/api/server.ts b/api/server.ts index be8d755..cb016dd 100644 --- a/api/server.ts +++ b/api/server.ts @@ -26,6 +26,10 @@ 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 stackObserverRouter from './routes/stack-observer.js' +import { collectBeszelFleet } from './services/beszel.js' +import observerTicketRouter from './routes/observer-ticket-processor.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -133,8 +137,10 @@ 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/stack-observer', stackObserverRouter) +app.use('/api/beszel', beszelRouter) +app.use('/api/observer-tickets', observerTicketRouter) // Observabilidade (Espelho) — sessões Claude Code const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH) @@ -217,6 +223,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 @@ -227,6 +236,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..e742391 --- /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.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, + } +} diff --git a/api/services/monitoring-collector.ts b/api/services/monitoring-collector.ts index fca0cf4..19cd40e 100644 --- a/api/services/monitoring-collector.ts +++ b/api/services/monitoring-collector.ts @@ -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 = [ diff --git a/api/services/observer.ts b/api/services/observer.ts new file mode 100644 index 0000000..45a6d3d --- /dev/null +++ b/api/services/observer.ts @@ -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 + by_judge: Record + 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(` + 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(` + 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(` + 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() + } +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 70911ec..4bbe03e 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -37,6 +37,7 @@ const NAV_ITEMS: NavItem[] = [ { to: '/ai', label: 'IA / Claude', icon: Brain }, { to: '/operations', label: 'Operações', icon: ClipboardList }, { to: '/sessions', label: 'Espelho', icon: Eye }, + { to: '/stack-observer', label: 'Stack Observer', icon: Zap }, ] function useIsMobile() { diff --git a/src/main.tsx b/src/main.tsx index 463efa0..204e776 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,9 +13,8 @@ import AiOverview from './pages/AiOverview.tsx' import Operations from './pages/Operations.tsx' import Sessions from './pages/Sessions.tsx' import SessionDetail from './pages/SessionDetail.tsx' +import StackObserver from './pages/StackObserver.tsx' import Layout from './components/Layout.tsx' -import { oidcConfig } from './auth/config.ts' -import { AuthWrapper } from './auth/AuthWrapper.tsx' createRoot(document.getElementById('root')!).render( @@ -35,6 +34,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> 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 */}
diff --git a/src/pages/StackObserver.tsx b/src/pages/StackObserver.tsx new file mode 100644 index 0000000..c378918 --- /dev/null +++ b/src/pages/StackObserver.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import { + AlertTriangle, + CheckCircle2, + Clock, + TrendingUp, + Zap, + RefreshCw, + AlertCircle, +} from 'lucide-react' + +interface Evaluation { + id: number + source: string + entity_id: string + result_real: string + result_nominal: string + confidence: number + evaluated_at: number + category: string + judge_method: string +} + +interface DivergenceStats { + total_divergences: number + by_category: Record + by_judge: Record + recent_24h: number + high_confidence: number +} + +interface DivergenceData { + divergences: Evaluation[] + stats: DivergenceStats + generated_at: string +} + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.1 }, + }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.4 }, + }, +} + +const StatusBadge = ({ status }: { status: string }) => { + const styles: Record = { + 'automation-liveness': { + bg: 'bg-red-500/10', + text: 'text-red-400', + icon: , + }, + 'automation-failure': { + bg: 'bg-orange-500/10', + text: 'text-orange-400', + icon: , + }, + } + + const style = styles[status] || styles['automation-failure'] + + return ( +
+ {style.icon} + {status.replace('-', ' ')} +
+ ) +} + +const StatCard = ({ + label, + value, + icon: Icon, + color = 'text-blue-400', +}: { + label: string + value: number | string + icon: React.ElementType + color?: string +}) => ( + +
+ +
+
+

{label}

+

{value}

+
+
+) + +const DivergenceRow = ({ div }: { div: Evaluation }) => { + const timestamp = new Date(div.evaluated_at * 1000).toLocaleString('pt-PT') + const confidenceColor = div.confidence >= 0.95 ? 'bg-green-500' : div.confidence >= 0.9 ? 'bg-yellow-500' : 'bg-orange-500' + + return ( + + {div.id} + + {div.entity_id} + + + + + + {div.result_real} + + {div.result_nominal} + +
+
+ {(div.confidence * 100).toFixed(0)}% +
+ + {timestamp} + + ) +} + +export default function StackObserver() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [lastUpdate, setLastUpdate] = useState(null) + + const fetchData = async () => { + try { + setLoading(true) + setError(null) + const response = await fetch('/api/stack-observer') + if (!response.ok) throw new Error(`API error: ${response.status}`) + const result = await response.json() as DivergenceData + setData(result) + setLastUpdate(new Date()) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to fetch divergences' + setError(message) + console.error('Stack Observer error:', err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + // Refresh every 30 seconds + const interval = setInterval(fetchData, 30000) + return () => clearInterval(interval) + }, []) + + const refreshTime = lastUpdate + ? lastUpdate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : 'nunca' + + return ( + + {/* Header */} + +
+
+
+ +
+
+

Stack Observer

+

Monitorização de divergências de automações

+
+
+ +
+

+ Última actualização: {refreshTime} • Auto-refresh a cada 30s +

+
+ + {error && ( + + + {error} + + )} + + {loading && !data ? ( + +
+
+ +
+
+

Carregando divergências...

+
+ ) : data ? ( + <> + {/* Stats Grid */} + + + + + + + + + {/* Divergences Table */} + +
+

Divergências Detectadas

+
+ + {data.divergences.length === 0 ? ( +
+ +

Nenhuma divergência detectada

+
+ ) : ( +
+ + + + + + + + + + + + + + + {data.divergences.slice(0, 20).map((div) => ( + + ))} + + +
IDEntidadeCategoriaResultado RealResultado NominalConfiançaTimestamp
+
+ )} + + {data.divergences.length > 20 && ( +
+ Mostrando 20 de {data.divergences.length} divergências +
+ )} +
+ + ) : null} +
+ ) +}