feat(monitor): integração Beszel/Stack Observer, tickets do observer e novos sites no uptime

This commit is contained in:
2026-10-28 16:00:59 +00:00
parent 2bbc12e8b4
commit ca9a07b1d9
12 changed files with 1110 additions and 7 deletions
+8
View File
@@ -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.
+36
View File
@@ -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
+107
View File
@@ -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
+81
View File
@@ -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
View File
@@ -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)
}
})
+248
View File
@@ -0,0 +1,248 @@
/**
* Beszel Fleet Collector
* Fetches system metrics from Beszel hub (PocketBase API) and stores in tbl_eal_monitoring.
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { z } from 'zod/v4'
import db from '../db.js'
// ── Zod schemas for Beszel API responses ────────────────────────────────────
const BeszelSystemInfo = z.object({
cpu: z.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,
}
}
+7
View File
@@ -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 = [
+211
View File
@@ -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()
}
}