feat(monitor): integração Beszel/Stack Observer, tickets do observer e novos sites no uptime
This commit is contained in:
@@ -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
|
# Dashboard Descomplicar API
|
||||||
|
|
||||||
API Node.js/Express com queries diretas à BD Desk CRM.
|
API Node.js/Express com queries diretas à BD Desk CRM.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+13
-1
@@ -26,6 +26,10 @@ import { openSessionsDb } from './services/sessions/db.js'
|
|||||||
import { DEFAULT_DB_PATH } from './services/sessions/indexer.js'
|
import { DEFAULT_DB_PATH } from './services/sessions/indexer.js'
|
||||||
import { collectAllServerMetrics } from './services/server-metrics.js'
|
import { collectAllServerMetrics } from './services/server-metrics.js'
|
||||||
import { collectMonitoringData } from './services/monitoring-collector.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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
@@ -133,8 +137,10 @@ app.use('/api/financial', financialRouter)
|
|||||||
app.use('/api/mcps', mcpsRouter)
|
app.use('/api/mcps', mcpsRouter)
|
||||||
app.use('/api/n8n', n8nRouter)
|
app.use('/api/n8n', n8nRouter)
|
||||||
app.use('/api/paperclip', paperclipRouter)
|
app.use('/api/paperclip', paperclipRouter)
|
||||||
app.use('/api/ai', aiRouter)
|
|
||||||
app.use('/api/operations', operationsRouter)
|
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
|
// Observabilidade (Espelho) — sessões Claude Code
|
||||||
const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH)
|
const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH)
|
||||||
@@ -217,6 +223,9 @@ app.listen(PORT, () => {
|
|||||||
collectMonitoringData().catch(err =>
|
collectMonitoringData().catch(err =>
|
||||||
console.error('[SCHEDULER] Initial monitoring collection failed:', err.message)
|
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)
|
}, 30000)
|
||||||
|
|
||||||
// Recurring collection
|
// Recurring collection
|
||||||
@@ -227,6 +236,9 @@ app.listen(PORT, () => {
|
|||||||
collectMonitoringData().catch(err =>
|
collectMonitoringData().catch(err =>
|
||||||
console.error('[SCHEDULER] Monitoring collection failed:', err.message)
|
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)
|
}, 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.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: 'familyclinic.pt', url: 'https://familyclinic.pt', marker: 'Family Clinic' },
|
||||||
{ host: 'karateclubedegaia.com', url: 'https://karateclubedegaia.com', marker: 'Karate Clube de Gaia' },
|
{ host: 'karateclubedegaia.com', url: 'https://karateclubedegaia.com', marker: 'Karate Clube de Gaia' },
|
||||||
{ host: 'whatsms.pt', url: 'https://whatsms.pt', marker: 'Todos os canais' },
|
{ 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_SIZE_FLOOR = 5000
|
||||||
const SITE_ERROR_SIGNATURES = [
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: '/ai', label: 'IA / Claude', icon: Brain },
|
{ to: '/ai', label: 'IA / Claude', icon: Brain },
|
||||||
{ to: '/operations', label: 'Operações', icon: ClipboardList },
|
{ to: '/operations', label: 'Operações', icon: ClipboardList },
|
||||||
{ to: '/sessions', label: 'Espelho', icon: Eye },
|
{ to: '/sessions', label: 'Espelho', icon: Eye },
|
||||||
|
{ to: '/stack-observer', label: 'Stack Observer', icon: Zap },
|
||||||
]
|
]
|
||||||
|
|
||||||
function useIsMobile() {
|
function useIsMobile() {
|
||||||
|
|||||||
+2
-2
@@ -13,9 +13,8 @@ import AiOverview from './pages/AiOverview.tsx'
|
|||||||
import Operations from './pages/Operations.tsx'
|
import Operations from './pages/Operations.tsx'
|
||||||
import Sessions from './pages/Sessions.tsx'
|
import Sessions from './pages/Sessions.tsx'
|
||||||
import SessionDetail from './pages/SessionDetail.tsx'
|
import SessionDetail from './pages/SessionDetail.tsx'
|
||||||
|
import StackObserver from './pages/StackObserver.tsx'
|
||||||
import Layout from './components/Layout.tsx'
|
import Layout from './components/Layout.tsx'
|
||||||
import { oidcConfig } from './auth/config.ts'
|
|
||||||
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -35,6 +34,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="/sessions" element={<Sessions />} />
|
<Route path="/sessions" element={<Sessions />} />
|
||||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||||
<Route path="/callback" element={<App />} />
|
<Route path="/callback" element={<App />} />
|
||||||
|
<Route path="/stack-observer" element={<StackObserver />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthWrapper>
|
</AuthWrapper>
|
||||||
|
|||||||
+86
-3
@@ -18,6 +18,7 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
MonitorDot,
|
MonitorDot,
|
||||||
|
Laptop2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -60,6 +61,24 @@ interface VMConfig {
|
|||||||
accentBg: string
|
accentBg: string
|
||||||
accentBorder: 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
|
// VM definitions matching cluster architecture
|
||||||
const VM_CONFIG: Record<string, VMConfig> = {
|
const VM_CONFIG: Record<string, VMConfig> = {
|
||||||
@@ -331,16 +350,25 @@ const VMCard = ({ item, config }: { item?: MonitorItem; config: VMConfig }) => {
|
|||||||
|
|
||||||
export default function Monitor() {
|
export default function Monitor() {
|
||||||
const [data, setData] = useState<MonitorData | null>(null)
|
const [data, setData] = useState<MonitorData | null>(null)
|
||||||
|
const [fleet, setFleet] = useState<FleetData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/monitor')
|
const [monitorRes, fleetRes] = await Promise.all([
|
||||||
if (!response.ok) throw new Error('Failed')
|
fetch('/api/monitor'),
|
||||||
const json = await response.json()
|
fetch('/api/beszel'),
|
||||||
|
])
|
||||||
|
if (monitorRes.ok) {
|
||||||
|
const json = await monitorRes.json()
|
||||||
setData(json)
|
setData(json)
|
||||||
|
}
|
||||||
|
if (fleetRes.ok) {
|
||||||
|
const json = await fleetRes.json()
|
||||||
|
setFleet(json)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
setData(getMockData())
|
setData(getMockData())
|
||||||
@@ -421,6 +449,61 @@ export default function Monitor() {
|
|||||||
<VMCard key={key} item={getVMItem(key)} config={config} />
|
<VMCard key={key} item={getVMItem(key)} config={config} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Section 3: Detail Categories */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-8">
|
||||||
|
|||||||
@@ -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<string, number>
|
||||||
|
by_judge: Record<string, number>
|
||||||
|
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<string, { bg: string; text: string; icon: React.ReactNode }> = {
|
||||||
|
'automation-liveness': {
|
||||||
|
bg: 'bg-red-500/10',
|
||||||
|
text: 'text-red-400',
|
||||||
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
'automation-failure': {
|
||||||
|
bg: 'bg-orange-500/10',
|
||||||
|
text: 'text-orange-400',
|
||||||
|
icon: <AlertCircle className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = styles[status] || styles['automation-failure']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${style.bg} text-sm ${style.text}`}>
|
||||||
|
{style.icon}
|
||||||
|
{status.replace('-', ' ')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
color = 'text-blue-400',
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number | string
|
||||||
|
icon: React.ElementType
|
||||||
|
color?: string
|
||||||
|
}) => (
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="glass-card p-4 rounded-xl flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className={`${color} p-3 rounded-lg bg-white/5`}>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white/60">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{value}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.tr
|
||||||
|
variants={itemVariants}
|
||||||
|
className="border-b border-white/10 hover:bg-white/[0.03] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/80 font-mono">{div.id}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<code className="bg-white/5 px-2 py-1 rounded text-white/90">{div.entity_id}</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<StatusBadge status={div.category} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<span className="text-red-400">{div.result_real}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/60">{div.result_nominal}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${confidenceColor}`} />
|
||||||
|
<span className="text-white/80">{(div.confidence * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-white/40">{timestamp}</td>
|
||||||
|
</motion.tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StackObserver() {
|
||||||
|
const [data, setData] = useState<DivergenceData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(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 (
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 rounded-lg bg-red-500/10">
|
||||||
|
<Zap className="w-6 h-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Stack Observer</h1>
|
||||||
|
<p className="text-white/60">Monitorização de divergências de automações</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/40">
|
||||||
|
Última actualização: {refreshTime} • Auto-refresh a cada 30s
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !data ? (
|
||||||
|
<motion.div variants={itemVariants} className="text-center py-12">
|
||||||
|
<div className="inline-block">
|
||||||
|
<div className="animate-spin">
|
||||||
|
<Zap className="w-8 h-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-white/60">Carregando divergências...</p>
|
||||||
|
</motion.div>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<motion.div variants={containerVariants} className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
|
||||||
|
<StatCard
|
||||||
|
label="Total Divergências"
|
||||||
|
value={data.stats.total_divergences}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
color="text-red-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Últimas 24h"
|
||||||
|
value={data.stats.recent_24h}
|
||||||
|
icon={TrendingUp}
|
||||||
|
color="text-orange-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Alta Confiança (>90%)"
|
||||||
|
value={data.stats.high_confidence}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
color="text-yellow-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Por Método"
|
||||||
|
value={Object.keys(data.stats.by_judge).length}
|
||||||
|
icon={Zap}
|
||||||
|
color="text-blue-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Por Categoria"
|
||||||
|
value={Object.keys(data.stats.by_category).length}
|
||||||
|
icon={Clock}
|
||||||
|
color="text-purple-400"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Divergences Table */}
|
||||||
|
<motion.div variants={itemVariants} className="glass-card rounded-lg overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-white/10">
|
||||||
|
<h2 className="text-xl font-bold text-white">Divergências Detectadas</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.divergences.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-green-400 mx-auto mb-4 opacity-60" />
|
||||||
|
<p className="text-white/60">Nenhuma divergência detectada</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-white/5 border-b border-white/10">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">ID</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Entidade</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Categoria</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Resultado Real</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Resultado Nominal</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Confiança</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<motion.td
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
{data.divergences.slice(0, 20).map((div) => (
|
||||||
|
<DivergenceRow key={div.id} div={div} />
|
||||||
|
))}
|
||||||
|
</motion.td>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.divergences.length > 20 && (
|
||||||
|
<div className="px-6 py-3 bg-white/5 border-t border-white/10 text-xs text-white/40">
|
||||||
|
Mostrando 20 de {data.divergences.length} divergências
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user