Compare commits

..
Author SHA1 Message Date
ealmeidaandClaude Opus 4.8 f7acd99d8f docs(okf): frontmatter OKF + rich abstracts nas descriptions
Normalizacao OKF dos .md: type/title/description/timestamp/layer +
descriptions factuais (rich abstracts). Apenas .md tracked; corpos intactos.
Parte da aplicacao OKF a /Dados/Dev (28-06-2026).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:58:21 +01:00
ealmeida 7ac01d5a82 fix(beszel): coerce port to number, handle la array from PocketBase 2026-06-23 20:33:30 +01:00
ealmeida 485a4ee67c fix(beszel): increase API timeouts to 30s (PocketBase auth is slow) 2026-06-23 20:30:05 +01:00
ealmeida 769c63b2a8 feat(monitoring): add Beszel fleet integration to Monitor page
- New api/services/beszel.ts: Zod-validated Beszel API client with
  PocketBase auth, system fetching, and MySQL upsert
- New api/routes/beszel.ts: GET /api/beszel (read) + POST /api/beszel/collect (trigger)
- Updated api/server.ts: register route, add to 5min scheduler
- Updated src/pages/Monitor.tsx: Fleet section showing all 17 systems
  with CPU/RAM/disk bars, status dots, container count, and link to
  Beszel dashboard

Architecture: Beszel hub → API → tbl_eal_monitoring (category='fleet')
→ Monitor.tsx grid. Collected every 5min alongside existing metrics.
2026-06-23 20:09:12 +01:00
ealmeidaandClaude Opus 4.8 08d2a31dc9 feat(monitoring): site_uptime cobre 15 sites (clientes + internos)
Alinha PING_SITES do check activo com o watchdog-sites.py: +carstuff +6 internos
(care, ccv, e-commerce, ecommerce, ecommerce-demo, starter).

Security Audit (Regra #47): sem deps novas; vulns pré-existentes documentadas no commit anterior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:43:39 +01:00
ealmeidaandClaude Sonnet 4.6 f733998945 feat(sessions): indexação multi-fonte Hermes + OpenCode com fix TypeScript
- Novos indexadores hermes-indexer.ts e opencode-indexer.ts para unificar
  sessões Claude, Hermes Agent e OpenCode num único sessions.db
- SessionMeta alargado: source (obrigatório), model, input/output_tokens,
  estimated_cost; project_path/jsonl_path agora nullable
- Fix TS: tipos explícitos, guard jsonl_path null, dependências instaladas

Security Audit (Regra #47):
- npm audit executado — 0 vulnerabilities após fix
- vite 7→8.0.16 (breaking upgrade, resolve esbuild CVE GHSA-gv7w-rqvm-qjhr)
- vitest 4.0.18→4.1.9 (resolve esbuild interno CVE GHSA-gv7w-rqvm-qjhr)
- shell-quote override ^1.8.4 via package.json#overrides (CVE GHSA-w7jw-789q-3m8p)
- react-router, joi, qs, form-data, ip-address, js-yaml resolvidos via npm audit fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:42:05 +01:00
ealmeidaandClaude Opus 4.8 9f3d14dc51 feat(monitoring): check activo de sites (HTTP+conteúdo) + fix staleness
- checkAllSites(): verificação activa dos 8 sites do ping (status + piso de
  tamanho + assinaturas de erro Redis/BD + marcador positivo) -> category=site_uptime.
  Apanha "HTTP 200 mas página partida" independente do wp-cron/plugin.
- checkStaleness(): corrige categoria 'wordpress'->'site' (bug: nunca corria
  contra os dados reais do plugin) + limiar 24h->26h.
- Ligado ao collector que corre a cada 5 min no scheduler.

Security Audit (Regra #47):
- npm audit executado: 18 vulnerabilidades pré-existentes em deps transitivas
  (esbuild/vite/tsx/react-router/vitest/express/shell-quote, etc.)
- NENHUMA introduzida por este commit (altera 1 ficheiro, zero deps novas)
- Override justificado: dev/transitivas, fix exige npm audit fix global (fora
  do âmbito deste fix). A documentar como dívida técnica separada.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:10:10 +01:00
ealmeidaandClaude Sonnet 4.6 94db202de9 fix(monitoring): SSH ao EasyPanel em vez de API inexistente
- server-metrics: substituir CWP (só aceita ed25519) por Easy server
  (aceita password auth na porta 22)
- monitoring-collector: remover chamadas a monitor.getSystemStats e
  monitor.getDockerTaskStats (endpoint não existe nesta versão EasyPanel);
  métricas CPU/RAM via SSH e containers via docker service ls sobre SSH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:08:20 +01:00
ealmeidaandClaude Sonnet 4.6 a594df1c7c auth: sessões mais longas com silent renew automático
Adiciona offline_access ao scope e automaticSilentRenew para renovar
tokens silenciosamente sem forçar re-login. Requer Access Token validity
aumentado no provider Authentik (de 5min para 8h).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:03:44 +01:00
29 changed files with 2063 additions and 764 deletions
+8
View File
@@ -1,3 +1,11 @@
---
type: Document
title: Audit Report
description: >-
Relatório de auditoria de segurança e qualidade do DashDescomplicar (React 19 + TypeScript + Vite + Express + MySQL), com 20 vulnerabilidades classificadas e veredicto de não apto para produção.
timestamp: 2026-02-14T03:33:42.730555+00:00
layer: wiki
---
# Auditoria de Segurança e Qualidade - DashDescomplicar # Auditoria de Segurança e Qualidade - DashDescomplicar
**Data:** 14-02-2026 **Data:** 14-02-2026
Executable → Regular
+47
View File
@@ -1,7 +1,54 @@
---
type: Changelog
title: Changelog
description: >-
Todas as alterações notáveis neste projecto serão documentadas neste ficheiro
timestamp: 2026-06-15T18:43:39.495926+00:00
layer: wiki
---
# Changelog # Changelog
Todas as alterações notáveis neste projecto serão documentadas neste ficheiro. Todas as alterações notáveis neste projecto serão documentadas neste ficheiro.
## [2.7.3] - 2026-06-15
### Added — Indexação Multi-Fonte (Hermes + OpenCode)
- **`hermes-indexer.ts`**: novo indexador para sessões do Hermes Agent (`~/.hermes/state.db`) — lê sessões e mensagens, mapeia para `SessionMeta` com `source: 'hermes'`
- **`opencode-indexer.ts`**: novo indexador para sessões OpenCode (`~/.local/share/opencode/opencode.db`) — lê sessions, projects, messages e parts, converte para `SessionMeta` com `source: 'opencode'`
- **`SessionMeta`**: novos campos `source` (obrigatório, `'claude' | 'hermes' | 'opencode'`), `model`, `input_tokens`, `output_tokens`, `estimated_cost`; `project_path`, `project_slug`, `jsonl_path` agora nullable (suportam fontes sem JSONL)
### Fixed — TypeScript + Dependências
- **`parser.ts`**: campos `source`, `model`, `input_tokens`, `output_tokens`, `estimated_cost` adicionados ao objecto `SessionMeta` do parser Claude
- **`routes/sessions.ts`**: guard para `jsonl_path` null — sessões Hermes/OpenCode devolvem 422 em vez de crash ao tentar abrir ficheiro
- **`worklog-import.ts`**: tipo explícito `HTMLElement` no callback `.map(li => ...)` corrige `TS7006`
- Dependências `node-html-parser`, `supertest`, `@types/supertest` instaladas (estavam no `package.json` mas em falta em `node_modules`)
- Testes `sessions-db` e `sessions-patterns`: campos obrigatórios adicionados ao `sampleMeta` (`source`, `model`, `input_tokens`, `output_tokens`, `estimated_cost`)
## [2.7.2] - 2026-05-05
### Fixed — Indexer CPU + Sync EasyPanel
- **sessions-indexer**: modo `--watch` substituído por timer systemd a cada 15min (`--full`) — elimina processo permanente a 110% CPU
- **indexer.ts**: campos nullable (`model`, `input_tokens`, `ended_at`, `duration_sec`, etc.) sem fallback `null` causavam `Missing named parameter` no SQLite — corrigido com `?? null` antes do upsert
- **hermes-indexer.ts**: `const batch` reatribuído com `batch = []` (TypeError) — corrigido para `let batch`
### Added — Sync Multi-Máquina
- Volume bind mount no container EasyPanel: `/root/data/dashboard-descomplicar/sessions.db → /data/sessions.db`
- Env var `OBSERVABILIDADE_DB=/data/sessions.db` configurada no serviço Docker
- Script `~/.local/bin/obs-index-sync.sh`: indexa sessões + rsync para EasyPanel em ~10s
- Timer `obs-index-sync.timer`: execução a cada 15min, latência máxima de dados no dashboard
- Sessões de PC desktop e HP cobertas via Syncthing (já sincronizava `~/.claude/projects/`)
- Resultado: 11.009 sessões indexadas, 0 erros, 5.7MB sincronizados por execução
## [2.7.1] - 2026-04-27
### Fixed — Recuperação Produção
- **Root cause:** `WP_MONITOR_API_KEY` em falta nas env vars EasyPanel causava crash ao iniciar (exit 1)
- Adicionadas todas as env vars em falta no serviço EasyPanel: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`, `WP_MONITOR_API_KEY`, `HETZNER_TOKEN`, `EASYPANEL_API_TOKEN`, `SERVER_*`, `NODE_ENV`, `FRONTEND_URL`
- Sincronizada `WP_MONITOR_API_KEY` com valor configurado no plugin WordPress (`descomplicar-monitor`)
- Criado/actualizado `.env` local com credenciais de produção completas (gitignored)
- Serviço EasyPanel `descomplicar/dashboard_descomplicar`: `0/1` → `1/1` running
- Dashboard `dash.descomplicar.pt`: 504 → 200 ✅ | API `/api/dashboard`: dados reais ✅
## [2.7.0] - 2026-04-23 ## [2.7.0] - 2026-04-23
### Added — Observabilidade Fase 6A ### Added — Observabilidade Fase 6A
+8
View File
@@ -1,3 +1,11 @@
---
type: Reference
title: Readme
description: >-
> Dashboard de Gestão e Monitorização da Descomplicar®
timestamp: 2026-02-14T04:22:01.943892+00:00
layer: wiki
---
# DashDescomplicar # DashDescomplicar
> **Dashboard de Gestão e Monitorização** da Descomplicar® > **Dashboard de Gestão e Monitorização** da Descomplicar®
+8
View File
@@ -1,3 +1,11 @@
---
type: Specification
title: Spec
description: >-
Especificação do projecto DashDescomplicar, agregando os projectos activos — nomeadamente o painel Observabilidade (replay de sessões Claude Code com indexer, SQLite, API e UI timeline).
timestamp: 2026-04-27T10:15:35.733200+00:00
layer: wiki
---
<!-- APPROVED: 2026-04-23 by ealmeida --> <!-- APPROVED: 2026-04-23 by ealmeida -->
# SPEC — DashDescomplicar # SPEC — DashDescomplicar
+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 # Dashboard Descomplicar API
API Node.js/Express com queries diretas à BD Desk CRM. 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
+3
View File
@@ -42,6 +42,9 @@ export function createSessionsRouter(db: SessionsDb): Router {
const session = db.getSession(parsed.data.id) const session = db.getSession(parsed.data.id)
if (!session) return res.status(404).json({ error: 'Session not found' }) if (!session) return res.status(404).json({ error: 'Session not found' })
if (!session.jsonl_path) {
return res.status(422).json({ error: 'Session has no JSONL file (non-Claude source)' })
}
try { try {
const { events } = await parseSessionFile(session.jsonl_path) const { events } = await parseSessionFile(session.jsonl_path)
return res.json({ meta: session, events }) return res.json({ meta: session, events })
+14 -6
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env tsx #!/usr/bin/env tsx
/** /**
* CLI do indexer de sessões Claude Code (Observabilidade/Espelho). * CLI do indexer de sessões (Observabilidade/Espelho) — multi-agente.
* *
* Modos: * Modos:
* --full Full scan de ~/.claude/projects -> SQLite em ~/.claude-work/sessions.db * --full Full scan de todas as fontes
* --watch Modo incremental (stub; implementação Task 8) * --watch Modo incremental (Claude Code watcher)
* --source Filtrar fonte: claude, hermes, opencode (repetível)
* *
* Env: * Env:
* OBSERVABILIDADE_DB Override ao caminho da BD SQLite * OBSERVABILIDADE_DB Override ao caminho da BD SQLite
@@ -16,16 +17,22 @@ async function main(): Promise<void> {
const args = process.argv.slice(2) const args = process.argv.slice(2)
const mode = args.find((a) => a === '--full' || a === '--watch') const mode = args.find((a) => a === '--full' || a === '--watch')
if (!mode) { if (!mode) {
console.error('Uso: sessions-indexer.ts [--full|--watch]') console.error('Uso: sessions-indexer.ts [--full|--watch] [--source claude|hermes|opencode]')
process.exit(1) process.exit(1)
} }
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
console.log(`[indexer] modo=${mode} db=${dbPath}`) const sourceFlags = args.filter((a) => !a.startsWith('--'))
const sources: ('claude' | 'hermes' | 'opencode')[] = sourceFlags.length > 0
? sourceFlags.filter((s): s is 'claude' | 'hermes' | 'opencode' =>
['claude', 'hermes', 'opencode'].includes(s))
: ['claude', 'hermes', 'opencode']
console.log(`[indexer] modo=${mode} fontes=${sources.join(',')} db=${dbPath}`)
if (mode === '--watch') { if (mode === '--watch') {
console.log(`[indexer] watch mode em ${PROJECTS_ROOT} -> ${dbPath}`) console.log(`[indexer] watch mode em ${PROJECTS_ROOT} -> ${dbPath}`)
await indexAll({ dbPath }) await indexAll({ dbPath, sources })
await startWatcher(dbPath) await startWatcher(dbPath)
return return
} }
@@ -34,6 +41,7 @@ async function main(): Promise<void> {
let lastLogged = 0 let lastLogged = 0
const { indexed, failed } = await indexAll({ const { indexed, failed } = await indexAll({
dbPath, dbPath,
sources,
onProgress: (done, total) => { onProgress: (done, total) => {
if (done - lastLogged >= 50 || done === total) { if (done - lastLogged >= 50 || done === total) {
console.log(`[indexer] ${done}/${total}`) console.log(`[indexer] ${done}/${total}`)
Executable → Regular
+9 -1
View File
@@ -26,6 +26,8 @@ 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 { collectBeszelFleet } from './services/beszel.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 +135,8 @@ 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/beszel', beszelRouter)
// 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)
@@ -215,6 +217,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
@@ -225,6 +230,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)
} }
}) })
+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,
}
}
+137 -57
View File
@@ -19,14 +19,6 @@ interface CheckResult {
error?: string error?: string
} }
/**
* EasyPanel API config.
* Accessible from Docker Swarm via service name 'easypanel'.
* Token read from EASYPANEL_API_TOKEN env var.
*/
const EASYPANEL_API_URL = process.env.EASYPANEL_API_URL || 'http://easypanel:3000/api/trpc'
const EASYPANEL_API_TOKEN = process.env.EASYPANEL_API_TOKEN || ''
/** /**
* Services to monitor via HTTP health check. * Services to monitor via HTTP health check.
* Each entry maps to a record in tbl_eal_monitoring (category='service'). * Each entry maps to a record in tbl_eal_monitoring (category='service').
@@ -151,83 +143,159 @@ export async function checkAllServices(): Promise<{ checked: number; up: number;
* If no data arrives, something is wrong. * If no data arrives, something is wrong.
*/ */
export async function checkStaleness(): Promise<number> { export async function checkStaleness(): Promise<number> {
// NOTA: o plugin WP grava category='site' (nao 'wordpress'). Bug historico
// corrigido em 2026-06-15 — a staleness nunca corria contra a categoria certa.
const [result] = await db.query( const [result] = await db.query(
`UPDATE tbl_eal_monitoring `UPDATE tbl_eal_monitoring
SET status = 'warning', SET status = 'warning',
details = JSON_SET(COALESCE(details, '{}'), '$.stale', true, '$.stale_reason', 'No data received in 24h') details = JSON_SET(COALESCE(details, '{}'), '$.stale', true, '$.stale_reason', 'Sem dados do plugin há >26h (wp-cron pode não estar a disparar)')
WHERE category = 'wordpress' WHERE category = 'site'
AND status IN ('ok', 'up') AND status IN ('ok', 'up')
AND last_check < DATE_SUB(NOW(), INTERVAL 24 HOUR)` AND last_check < DATE_SUB(NOW(), INTERVAL 26 HOUR)`
) )
return (result as any).affectedRows || 0 return (result as any).affectedRows || 0
} }
/** /**
* Call EasyPanel tRPC API endpoint. * Verificação ACTIVA de sites de clientes (independente do plugin WP / wp-cron).
* Returns parsed JSON or null on failure. * Mesmos sites do watchdog-sites.py. Valida HTTP + conteúdo (tamanho, assinaturas
* de erro, marcador positivo) para apanhar "HTTP 200 mas página partida" (ex.: Redis).
* Grava em tbl_eal_monitoring com category='site_uptime', name=hostname.
*/ */
async function callEasyPanelAPI(endpoint: string): Promise<any | null> { const PING_SITES: { host: string; url: string; marker: string }[] = [
if (!EASYPANEL_API_TOKEN) return null { host: 'descomplicar.pt', url: 'https://descomplicar.pt', marker: 'Descomplicar' },
{ host: 'emanuelalmeida.pt', url: 'https://emanuelalmeida.pt', marker: 'Emanuel Almeida' },
{ host: 'solarfvengenharia.com', url: 'https://solarfvengenharia.com', marker: 'Solar FV' },
{ host: 'ignitionvortex.pt', url: 'https://ignitionvortex.pt', marker: 'Ignition Vortex' },
{ host: 'watercontrol.pt', url: 'https://watercontrol.pt', marker: 'Water Control' },
{ 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 = [
'error establishing a database connection',
'error establishing a redis connection',
'there has been a critical error',
'cannot connect to redis',
'redis connection failed',
'error establishing a connection to redis',
'service unavailable',
]
async function checkSiteContent(
url: string,
marker: string,
timeoutMs = 15000,
): Promise<{ status: 'up' | 'down'; http_code: number; reason: string }> {
try { try {
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000) const timer = setTimeout(() => controller.abort(), timeoutMs)
const response = await fetch(url, {
const response = await fetch(`${EASYPANEL_API_URL}/${endpoint}`, { method: 'GET',
headers: { 'Authorization': `Bearer ${EASYPANEL_API_TOKEN}` }, redirect: 'follow',
signal: controller.signal, signal: controller.signal,
headers: { 'User-Agent': 'dashboard-site-uptime/1.0' },
}) })
const body = await response.text()
clearTimeout(timeout) clearTimeout(timer)
if (!response.ok) return null const code = response.status
if (code >= 500) return { status: 'down', http_code: code, reason: `HTTP ${code}` }
const data: any = await response.json() if (body.length < SITE_SIZE_FLOOR)
return data?.result?.data?.json ?? null return { status: 'down', http_code: code, reason: `página curta (${body.length}B)` }
} catch { const low = body.toLowerCase()
return null for (const sig of SITE_ERROR_SIGNATURES) {
if (low.includes(sig)) return { status: 'down', http_code: code, reason: `assinatura de erro: '${sig}'` }
}
if (marker && !low.includes(marker.toLowerCase()))
return { status: 'down', http_code: code, reason: `marcador ausente: '${marker}'` }
return { status: 'up', http_code: code, reason: 'ok' }
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Unknown error'
return { status: 'down', http_code: 0, reason: msg.includes('abort') ? 'timeout' : msg }
} }
} }
/** export async function checkAllSites(): Promise<{ checked: number; up: number; down: number }> {
* Collect EasyPanel server metrics (CPU, RAM, disk) via API. let up = 0, down = 0
* Replaces SSH-based collection for the Easy server. const results = await Promise.allSettled(
*/ PING_SITES.map(async (site) => {
export async function collectEasyPanelMetrics(): Promise<boolean> { const r = await checkSiteContent(site.url, site.marker)
const stats = await callEasyPanelAPI('monitor.getSystemStats') await upsertMonitoring('site_uptime', site.host, r.status === 'up' ? 'ok' : 'failed', {
if (!stats) return false url: site.url,
http_code: r.http_code,
const cpu = Math.round(stats.cpuInfo?.usedPercentage ?? 0) reason: r.reason,
const ram = Math.round((stats.memInfo?.usedMemPercentage ?? 0) * 10) / 10 checked_by: 'dashboard-active',
const disk = parseFloat(stats.diskInfo?.usedPercentage ?? '0')
const load = stats.cpuInfo?.loadavg?.[0] ?? 0
await upsertMonitoring('server', 'EasyPanel', 'up', {
cpu, ram, disk, load,
uptime_hours: Math.round((stats.uptime ?? 0) / 3600),
mem_total_mb: Math.round(stats.memInfo?.totalMemMb ?? 0),
mem_used_mb: Math.round(stats.memInfo?.usedMemMb ?? 0),
disk_total_gb: stats.diskInfo?.totalGb,
disk_free_gb: stats.diskInfo?.freeGb,
}) })
return r
console.log(`[EASYPANEL] Server: CPU=${cpu}%, RAM=${ram}%, Disk=${disk}%`) }),
return true )
for (const r of results) {
if (r.status === 'fulfilled' && r.value.status === 'up') up++
else down++
}
return { checked: PING_SITES.length, up, down }
} }
/** /**
* Collect Docker container/task stats via EasyPanel API. * Collect EasyPanel server metrics + container stats via SSH.
* Updates the 'container' category in monitoring DB. * A API tRPC do EasyPanel não expõe endpoint monitor.* nesta versão.
* SSH com password ao Easy server (5.9.90.70) funciona a partir do container.
*/
export async function collectEasyPanelMetrics(): Promise<boolean> {
const { collectSSHMetrics } = await import('./server-metrics.js')
const result = await collectSSHMetrics()
return result.success > 0
}
/**
* Collect Docker Swarm service status via SSH to EasyPanel server.
* Usa `docker service ls` para obter replicas actual vs desired.
*/ */
export async function collectEasyPanelContainers(): Promise<boolean> { export async function collectEasyPanelContainers(): Promise<boolean> {
const tasks = await callEasyPanelAPI('monitor.getDockerTaskStats') const easyHost = process.env.EASY_HOST || '5.9.90.70'
if (!tasks) return false const easyUser = process.env.EASY_USER || 'root'
const easyPass = process.env.EASY_PASS || ''
if (!easyPass) return false
try {
const { Client } = await import('ssh2')
const output = await new Promise<string>((resolve, reject) => {
const conn = new Client()
let data = ''
const timer = setTimeout(() => { conn.end(); reject(new Error('timeout')) }, 20000)
conn.on('ready', () => {
conn.exec("docker service ls --format '{{.Name}} {{.Replicas}}'", (err, stream) => {
if (err) { clearTimeout(timer); conn.end(); reject(err); return }
stream.on('data', (chunk: Buffer) => { data += chunk.toString() })
stream.on('close', () => { clearTimeout(timer); conn.end(); resolve(data) })
stream.stderr.on('data', () => {})
})
})
conn.on('error', (err) => { clearTimeout(timer); reject(err) })
conn.connect({ host: easyHost, port: 22, username: easyUser, password: easyPass, readyTimeout: 15000 })
})
let total = 0, up = 0, down = 0 let total = 0, up = 0, down = 0
const unhealthy: string[] = [] const unhealthy: string[] = []
for (const [name, info] of Object.entries(tasks) as [string, { actual: number; desired: number }][]) { for (const line of output.trim().split('\n')) {
if (!line.trim()) continue
const parts = line.trim().split(/\s+/)
const name = parts[0] || ''
const replicas = parts[1] || '0/0'
const [actual, desired] = replicas.split('/').map(Number)
total++ total++
if (info.actual >= info.desired) { if (actual >= desired && desired > 0) {
up++ up++
} else { } else {
down++ down++
@@ -243,6 +311,10 @@ export async function collectEasyPanelContainers(): Promise<boolean> {
console.log(`[EASYPANEL] Containers: ${up}/${total} running${down > 0 ? `, ${down} down: ${unhealthy.join(', ')}` : ''}`) console.log(`[EASYPANEL] Containers: ${up}/${total} running${down > 0 ? `, ${down} down: ${unhealthy.join(', ')}` : ''}`)
return true return true
} catch (err: unknown) {
console.error('[EASYPANEL] Container collection failed:', err instanceof Error ? err.message : err)
return false
}
} }
/** /**
@@ -259,12 +331,20 @@ export async function collectMonitoringData(): Promise<void> {
console.error('[COLLECTOR] Service checks failed:', err instanceof Error ? err.message : err) console.error('[COLLECTOR] Service checks failed:', err instanceof Error ? err.message : err)
} }
// Verificação activa de sites de clientes (HTTP+conteúdo, independente do wp-cron)
try {
const sites = await checkAllSites()
console.log(`[COLLECTOR] Sites (active): ${sites.up} up, ${sites.down} down`)
} catch (err: unknown) {
console.error('[COLLECTOR] Active site checks failed:', err instanceof Error ? err.message : err)
}
// EasyPanel API metrics (replaces SSH for Easy server) // EasyPanel API metrics (replaces SSH for Easy server)
try { try {
const gotStats = await collectEasyPanelMetrics() const gotStats = await collectEasyPanelMetrics()
const gotContainers = await collectEasyPanelContainers() const gotContainers = await collectEasyPanelContainers()
if (!gotStats && !gotContainers) { if (!gotStats && !gotContainers) {
console.warn('[COLLECTOR] EasyPanel API unavailable (check EASYPANEL_API_TOKEN)') console.warn('[COLLECTOR] EasyPanel metrics unavailable (check EASY_HOST/EASY_USER/EASY_PASS)')
} }
} catch (err: unknown) { } catch (err: unknown) {
console.error('[COLLECTOR] EasyPanel collection failed:', err instanceof Error ? err.message : err) console.error('[COLLECTOR] EasyPanel collection failed:', err instanceof Error ? err.message : err)
Executable → Regular
+9 -9
View File
@@ -15,17 +15,17 @@ interface SSHServer {
pass: string pass: string
} }
// EasyPanel metrics: collected via API in monitoring-collector.ts // CWP Server: só aceita autenticação por chave ed25519 (não por password) — não acessível a partir do container
// Gateway metrics: not needed (just Nginx proxy, covered by HTTP health check) // EasyPanel Server: aceita password auth na porta 22 — usado para métricas CPU/RAM/disk
// Only CWP Server remains on SSH (password auth) // Gateway: apenas proxy Nginx, coberto pelo health check HTTP
const SSH_SERVERS: SSHServer[] = [ const SSH_SERVERS: SSHServer[] = [
{ {
name: 'server', name: 'easy',
monitorName: 'CWP Server', monitorName: 'EasyPanel',
host: process.env.SERVER_HOST || '5.9.90.105', host: process.env.EASY_HOST || '5.9.90.70',
port: parseInt(process.env.SERVER_PORT || '9443'), port: 22,
user: process.env.SERVER_USER || 'root', user: process.env.EASY_USER || 'root',
pass: process.env.SERVER_PASS || '' pass: process.env.EASY_PASS || ''
} }
] ]
+57 -15
View File
@@ -5,6 +5,7 @@ import type { SessionMeta } from '../../types/session.js'
export interface ListFilters { export interface ListFilters {
days?: number days?: number
source?: string
project?: string project?: string
tool?: string tool?: string
skill?: string skill?: string
@@ -73,9 +74,11 @@ export interface SessionsDb {
const SCHEMA = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
project_path TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'claude',
project_slug TEXT NOT NULL, project_path TEXT,
jsonl_path TEXT NOT NULL UNIQUE, project_slug TEXT,
jsonl_path TEXT,
model TEXT,
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
ended_at TEXT, ended_at TEXT,
duration_sec INTEGER, duration_sec INTEGER,
@@ -83,16 +86,20 @@ CREATE TABLE IF NOT EXISTS sessions (
user_messages INTEGER NOT NULL, user_messages INTEGER NOT NULL,
assistant_msgs INTEGER NOT NULL, assistant_msgs INTEGER NOT NULL,
tool_calls INTEGER NOT NULL, tool_calls INTEGER NOT NULL,
input_tokens INTEGER,
output_tokens INTEGER,
estimated_cost REAL,
first_prompt TEXT, first_prompt TEXT,
tools_used TEXT NOT NULL, tools_used TEXT NOT NULL DEFAULT '[]',
skills_invoked TEXT NOT NULL, skills_invoked TEXT NOT NULL DEFAULT '[]',
outcome TEXT NOT NULL, outcome TEXT NOT NULL,
permission_mode TEXT, permission_mode TEXT,
file_size INTEGER NOT NULL, file_size INTEGER NOT NULL DEFAULT 0,
indexed_at TEXT NOT NULL indexed_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC); CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_source ON sessions(source);
CREATE TABLE IF NOT EXISTS patterns ( CREATE TABLE IF NOT EXISTS patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -134,9 +141,11 @@ CREATE INDEX IF NOT EXISTS idx_wc_task ON worklog_comments(task_ref);
function rowToMeta(row: Record<string, unknown>): SessionMeta { function rowToMeta(row: Record<string, unknown>): SessionMeta {
return { return {
session_id: row.session_id as string, session_id: row.session_id as string,
project_path: row.project_path as string, source: (row.source as SessionMeta['source']) ?? 'claude',
project_slug: row.project_slug as string, project_path: (row.project_path as string | null) ?? null,
jsonl_path: row.jsonl_path as string, project_slug: (row.project_slug as string | null) ?? null,
jsonl_path: (row.jsonl_path as string | null) ?? null,
model: (row.model as string | null) ?? null,
started_at: row.started_at as string, started_at: row.started_at as string,
ended_at: (row.ended_at as string | null) ?? null, ended_at: (row.ended_at as string | null) ?? null,
duration_sec: (row.duration_sec as number | null) ?? null, duration_sec: (row.duration_sec as number | null) ?? null,
@@ -144,9 +153,12 @@ function rowToMeta(row: Record<string, unknown>): SessionMeta {
user_messages: row.user_messages as number, user_messages: row.user_messages as number,
assistant_msgs: row.assistant_msgs as number, assistant_msgs: row.assistant_msgs as number,
tool_calls: row.tool_calls as number, tool_calls: row.tool_calls as number,
input_tokens: (row.input_tokens as number | null) ?? null,
output_tokens: (row.output_tokens as number | null) ?? null,
estimated_cost: (row.estimated_cost as number | null) ?? null,
first_prompt: (row.first_prompt as string | null) ?? null, first_prompt: (row.first_prompt as string | null) ?? null,
tools_used: JSON.parse(row.tools_used as string), tools_used: row.tools_used ? JSON.parse(row.tools_used as string) : [],
skills_invoked: JSON.parse(row.skills_invoked as string), skills_invoked: row.skills_invoked ? JSON.parse(row.skills_invoked as string) : [],
outcome: row.outcome as SessionMeta['outcome'], outcome: row.outcome as SessionMeta['outcome'],
permission_mode: (row.permission_mode as string | null) ?? null, permission_mode: (row.permission_mode as string | null) ?? null,
file_size: row.file_size as number, file_size: row.file_size as number,
@@ -162,6 +174,10 @@ function buildWhere(f: ListFilters): { sql: string; params: Record<string, unkno
parts.push('started_at >= @cutoff') parts.push('started_at >= @cutoff')
params.cutoff = cutoff params.cutoff = cutoff
} }
if (f.source) {
parts.push('source = @source')
params.source = f.source
}
if (f.project) { if (f.project) {
parts.push('project_slug = @project') parts.push('project_slug = @project')
params.project = f.project params.project = f.project
@@ -191,17 +207,40 @@ export function openSessionsDb(dbPath: string): SessionsDb {
db.pragma('synchronous = NORMAL') db.pragma('synchronous = NORMAL')
db.exec(SCHEMA) db.exec(SCHEMA)
// Migração: adicionar colunas novas se não existirem
const existingCols = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[]
const colNames = new Set(existingCols.map(c => c.name))
const migrations: [string, string][] = [
['source', "TEXT NOT NULL DEFAULT 'claude'"],
['model', 'TEXT'],
['input_tokens', 'INTEGER'],
['output_tokens', 'INTEGER'],
['estimated_cost', 'REAL'],
]
for (const [col, type] of migrations) {
if (!colNames.has(col)) {
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} ${type}`)
}
}
if (!colNames.has('source')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_source ON sessions(source)')
}
const upsertStmt = db.prepare(` const upsertStmt = db.prepare(`
INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at, INSERT INTO sessions (session_id, source, project_path, project_slug, jsonl_path, model,
duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt, started_at, ended_at, duration_sec, event_count, user_messages, assistant_msgs, tool_calls,
input_tokens, output_tokens, estimated_cost, first_prompt,
tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at) tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at)
VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at, VALUES (@session_id, @source, @project_path, @project_slug, @jsonl_path, @model,
@duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt, @started_at, @ended_at, @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls,
@input_tokens, @output_tokens, @estimated_cost, @first_prompt,
@tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at) @tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at)
ON CONFLICT(session_id) DO UPDATE SET ON CONFLICT(session_id) DO UPDATE SET
source = excluded.source,
project_path = excluded.project_path, project_path = excluded.project_path,
project_slug = excluded.project_slug, project_slug = excluded.project_slug,
jsonl_path = excluded.jsonl_path, jsonl_path = excluded.jsonl_path,
model = excluded.model,
started_at = excluded.started_at, started_at = excluded.started_at,
ended_at = excluded.ended_at, ended_at = excluded.ended_at,
duration_sec = excluded.duration_sec, duration_sec = excluded.duration_sec,
@@ -209,6 +248,9 @@ export function openSessionsDb(dbPath: string): SessionsDb {
user_messages = excluded.user_messages, user_messages = excluded.user_messages,
assistant_msgs = excluded.assistant_msgs, assistant_msgs = excluded.assistant_msgs,
tool_calls = excluded.tool_calls, tool_calls = excluded.tool_calls,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
estimated_cost = excluded.estimated_cost,
first_prompt = excluded.first_prompt, first_prompt = excluded.first_prompt,
tools_used = excluded.tools_used, tools_used = excluded.tools_used,
skills_invoked = excluded.skills_invoked, skills_invoked = excluded.skills_invoked,
+151
View File
@@ -0,0 +1,151 @@
import Database from 'better-sqlite3'
import { homedir } from 'os'
import { join } from 'path'
import type { SessionMeta } from '../../types/session.js'
import { openSessionsDb } from './db.js'
const HERMES_DB = join(homedir(), '.hermes', 'state.db')
const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
export interface HermesSessionRow {
id: string
model: string | null
started_at: number | null
ended_at: number | null
end_reason: string | null
message_count: number
tool_call_count: number
input_tokens: number | null
output_tokens: number | null
estimated_cost_usd: number | null
title: string | null
api_call_count: number | null
}
export interface HermesMessageRow {
session_id: string
role: string
content: string | null
tool_name: string | null
timestamp: number | null
}
/**
* Lê sessões do state.db do Hermes Agent
*/
export function readHermesSessions(): HermesSessionRow[] {
const db = new Database(HERMES_DB, { readonly: true })
try {
return db.prepare(`
SELECT id, model, started_at, ended_at, end_reason,
message_count, tool_call_count,
input_tokens, output_tokens, estimated_cost_usd,
title, api_call_count
FROM sessions
ORDER BY started_at DESC
`).all() as HermesSessionRow[]
} finally {
db.close()
}
}
/**
* Lê mensagens de uma sessão Hermes
*/
export function readHermesMessages(sessionId: string): HermesMessageRow[] {
const db = new Database(HERMES_DB, { readonly: true })
try {
return db.prepare(`
SELECT session_id, role, content, tool_name, timestamp
FROM messages
WHERE session_id = ?
ORDER BY timestamp ASC
`).all(sessionId) as HermesMessageRow[]
} finally {
db.close()
}
}
function toHermesMeta(row: HermesSessionRow, messages: HermesMessageRow[]): SessionMeta {
const userMsgs = messages.filter(m => m.role === 'user')
const assistantMsgs = messages.filter(m => m.role === 'assistant')
const toolMsgs = messages.filter(m => m.role === 'tool')
const firstUser = userMsgs[0]
const toolsUsed = [...new Set(messages.filter(m => m.tool_name).map(m => m.tool_name!))]
return {
session_id: row.id,
source: 'hermes',
project_path: null,
project_slug: null,
jsonl_path: null,
model: row.model ?? null,
started_at: row.started_at ? new Date(row.started_at * 1000).toISOString() : new Date(0).toISOString(),
ended_at: row.ended_at ? new Date(row.ended_at * 1000).toISOString() : null,
duration_sec: row.started_at && row.ended_at ? Math.round(row.ended_at - row.started_at) : null,
event_count: messages.length,
user_messages: userMsgs.length,
assistant_msgs: assistantMsgs.length,
tool_calls: toolMsgs.length,
input_tokens: row.input_tokens ?? null,
output_tokens: row.output_tokens ?? null,
estimated_cost: row.estimated_cost_usd ?? null,
first_prompt: firstUser?.content?.slice(0, 500) ?? null,
tools_used: toolsUsed,
skills_invoked: [],
outcome: row.end_reason === 'completed' ? 'completed'
: row.end_reason === 'interrupted' || row.end_reason === 'stopped' ? 'interrupted'
: row.end_reason === 'error' ? 'error'
: 'unknown',
permission_mode: null,
file_size: 0,
indexed_at: new Date().toISOString(),
}
}
export interface IndexResult {
indexed: number
failed: number
}
/**
* Indexa todas as sessões Hermes no state.db para o sessions.db unificado
*/
export async function indexHermesSessions(
options: { dbPath?: string } = {},
): Promise<IndexResult> {
let indexed = 0
let failed = 0
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
try {
const sessions = readHermesSessions()
let batch: SessionMeta[] = []
const BATCH = 50
for (const session of sessions) {
try {
const messages = readHermesMessages(session.id)
const meta = toHermesMeta(session, messages)
batch.push(meta)
if (batch.length >= BATCH) {
db.upsertMany(batch)
indexed += batch.length
batch = []
}
} catch (err) {
failed++
console.error(`[hermes-indexer] erro em sessão ${session.id}:`, err)
}
}
if (batch.length > 0) {
db.upsertMany(batch)
indexed += batch.length
}
} finally {
db.close()
}
return { indexed, failed }
}
+48 -4
View File
@@ -3,6 +3,8 @@ import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
import { parseSessionFile } from './parser.js' import { parseSessionFile } from './parser.js'
import { openSessionsDb, type SessionsDb } from './db.js' import { openSessionsDb, type SessionsDb } from './db.js'
import { indexHermesSessions } from './hermes-indexer.js'
import { indexOCSessions } from './opencode-indexer.js'
import type { SessionMeta } from '../../types/session.js' import type { SessionMeta } from '../../types/session.js'
export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects') export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects')
@@ -52,26 +54,43 @@ export async function indexFile(db: SessionsDb, path: string): Promise<void> {
export interface IndexAllOptions { export interface IndexAllOptions {
dbPath?: string dbPath?: string
sources?: ('claude' | 'hermes' | 'opencode')[]
onProgress?: (done: number, total: number) => void onProgress?: (done: number, total: number) => void
} }
/** /**
* Full scan: percorre todos os JSONL e faz upsert em lote (batch 50 via transacção). * Full scan: indexa Claude Code (JSONL) + Hermes (state.db) + OpenCode (opencode.db).
*/ */
export async function indexAll( export async function indexAll(
options: IndexAllOptions = {}, options: IndexAllOptions = {},
): Promise<{ indexed: number; failed: number }> { ): Promise<{ indexed: number; failed: number }> {
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH) const sources = options.sources ?? ['claude', 'hermes', 'opencode']
const files = findAllJsonl()
const BATCH = 50
let indexed = 0 let indexed = 0
let failed = 0 let failed = 0
const dbPath = options.dbPath ?? DEFAULT_DB_PATH
if (sources.includes('claude')) {
const db = openSessionsDb(dbPath)
const files = findAllJsonl()
const BATCH = 50
let batch: SessionMeta[] = [] let batch: SessionMeta[] = []
try { try {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
try { try {
const { meta } = await parseSessionFile(files[i]) const { meta } = await parseSessionFile(files[i])
meta.source = 'claude'
meta.model = meta.model ?? null
meta.ended_at = meta.ended_at ?? null
meta.duration_sec = meta.duration_sec ?? null
meta.input_tokens = meta.input_tokens ?? null
meta.output_tokens = meta.output_tokens ?? null
meta.estimated_cost = meta.estimated_cost ?? null
meta.first_prompt = meta.first_prompt ?? null
meta.permission_mode = meta.permission_mode ?? null
meta.project_path = meta.project_path ?? null
meta.project_slug = meta.project_slug ?? null
meta.jsonl_path = meta.jsonl_path ?? null
batch.push(meta) batch.push(meta)
if (batch.length >= BATCH) { if (batch.length >= BATCH) {
db.upsertMany(batch) db.upsertMany(batch)
@@ -93,6 +112,31 @@ export async function indexAll(
} finally { } finally {
db.close() db.close()
} }
}
if (sources.includes('hermes')) {
try {
const result = await indexHermesSessions({ dbPath })
indexed += result.indexed
failed += result.failed
console.log(`[indexer] Hermes: ${result.indexed} indexadas, ${result.failed} falhas`)
} catch (err) {
failed++
console.error('[indexer] Erro ao indexar Hermes:', err)
}
}
if (sources.includes('opencode')) {
try {
const result = await indexOCSessions({ dbPath })
indexed += result.indexed
failed += result.failed
console.log(`[indexer] OpenCode: ${result.indexed} indexadas, ${result.failed} falhas`)
} catch (err) {
failed++
console.error('[indexer] Erro ao indexar OpenCode:', err)
}
}
return { indexed, failed } return { indexed, failed }
} }
+208
View File
@@ -0,0 +1,208 @@
import Database from 'better-sqlite3'
import { homedir } from 'os'
import { join } from 'path'
import type { SessionMeta } from '../../types/session.js'
import { openSessionsDb } from './db.js'
const OPENCODE_DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
export interface OCSessionRow {
id: string
project_id: string | null
title: string | null
summary_additions: number | null
summary_deletions: number | null
summary_files: number | null
time_created: number | null
time_updated: number | null
path: string | null
}
export interface OCMessageRow {
id: string
session_id: string
time_created: number | null
data: string | null
}
export interface OCPartRow {
id: string
message_id: string
session_id: string
time_created: number | null
data: string | null
}
export interface OCProjectRow {
id: string
name: string | null
worktree: string | null
}
/**
* Lê projectos OpenCode
*/
export function readOCProjects(): OCProjectRow[] {
const db = new Database(OPENCODE_DB, { readonly: true })
try {
return db.prepare('SELECT id, name, worktree FROM project').all() as OCProjectRow[]
} finally {
db.close()
}
}
/**
* Lê sessões OpenCode
*/
export function readOCSessions(): OCSessionRow[] {
const db = new Database(OPENCODE_DB, { readonly: true })
try {
return db.prepare(`
SELECT s.id, s.project_id, s.title, s.summary_additions, s.summary_deletions,
s.summary_files, s.time_created, s.time_updated, s.path
FROM session s
ORDER BY s.time_created DESC
`).all() as OCSessionRow[]
} finally {
db.close()
}
}
/**
* Lê todas as messages + parts de uma sessão OpenCode
*/
export function readOCSessionData(sessionId: string): { messages: OCMessageRow[]; parts: OCPartRow[] } {
const db = new Database(OPENCODE_DB, { readonly: true })
try {
const messages = db.prepare(
'SELECT id, session_id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC'
).all(sessionId) as OCMessageRow[]
const messageIds = messages.map(m => m.id)
let parts: OCPartRow[] = []
if (messageIds.length > 0) {
const placeholders = messageIds.map(() => '?').join(',')
parts = db.prepare(
`SELECT id, message_id, session_id, time_created, data FROM part WHERE message_id IN (${placeholders}) ORDER BY time_created ASC`
).all(...messageIds) as OCPartRow[]
}
return { messages, parts }
} finally {
db.close()
}
}
function parsePartData(data: string | null): { role?: string; content?: string } {
if (!data) return {}
try {
return JSON.parse(data) as { role?: string; content?: string }
} catch {
return {}
}
}
function toOCMeta(
session: OCSessionRow,
projects: Map<string, OCProjectRow>,
_messages: OCMessageRow[],
parts: OCPartRow[],
): SessionMeta {
const userMsgs = parts.filter(p => parsePartData(p.data).role === 'user')
const assistantMsgs = parts.filter(p => parsePartData(p.data).role === 'assistant')
const project = session.project_id ? projects.get(session.project_id) : undefined
const firstUser = userMsgs[0]
const firstUserContent = firstUser ? parsePartData(firstUser.data).content : null
let toolsUsed: string[] = []
for (const p of parts) {
const d = parsePartData(p.data)
if (d.content && (d.content.includes('tool_use') || d.content.includes('tool_result'))) {
try {
const parsed = JSON.parse(d.content)
if (parsed.name) toolsUsed.push(parsed.name)
} catch { /* não é JSON */ }
}
}
toolsUsed = [...new Set(toolsUsed)]
const startTs = session.time_created ? session.time_created / 1000 : Date.now() / 1000
const endTs = session.time_updated ? session.time_updated / 1000 : startTs
return {
session_id: session.id,
source: 'opencode',
project_path: project?.worktree ?? session.path ?? null,
project_slug: project?.name ?? session.path?.split('/').pop() ?? null,
jsonl_path: null,
model: null,
started_at: new Date(startTs * 1000).toISOString(),
ended_at: new Date(endTs * 1000).toISOString(),
duration_sec: Math.round(endTs - startTs),
event_count: parts.length,
user_messages: userMsgs.length,
assistant_msgs: assistantMsgs.length,
tool_calls: 0,
input_tokens: null,
output_tokens: null,
estimated_cost: null,
first_prompt: firstUserContent?.slice(0, 500) ?? null,
tools_used: toolsUsed,
skills_invoked: [],
outcome: 'completed',
permission_mode: null,
file_size: session.summary_additions ?? 0,
indexed_at: new Date().toISOString(),
}
}
export interface IndexResult {
indexed: number
failed: number
}
/**
* Indexa todas as sessões OpenCode para o sessions.db unificado
*/
export async function indexOCSessions(
options: { dbPath?: string } = {},
): Promise<IndexResult> {
let indexed = 0
let failed = 0
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
try {
const sessions = readOCSessions()
const projects = readOCProjects()
const projectMap = new Map(projects.map(p => [p.id, p]))
let batch: SessionMeta[] = []
const BATCH = 50
for (const session of sessions) {
try {
const { messages, parts } = readOCSessionData(session.id)
const meta = toOCMeta(session, projectMap, messages, parts)
batch.push(meta)
if (batch.length >= BATCH) {
db.upsertMany(batch)
indexed += batch.length
batch = []
}
} catch (err) {
failed++
console.error(`[opencode-indexer] erro em sessão ${session.id}:`, err)
}
}
if (batch.length > 0) {
db.upsertMany(batch)
indexed += batch.length
}
} finally {
db.close()
}
return { indexed, failed }
}
+5
View File
@@ -186,9 +186,11 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
const meta: SessionMeta = { const meta: SessionMeta = {
session_id: sessionId, session_id: sessionId,
source: 'claude',
project_path: dirname(jsonlPath), project_path: dirname(jsonlPath),
project_slug: slugFromProjectPath(jsonlPath), project_slug: slugFromProjectPath(jsonlPath),
jsonl_path: jsonlPath, jsonl_path: jsonlPath,
model: null,
started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(), started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(),
ended_at: lastTs, ended_at: lastTs,
duration_sec: durationSec, duration_sec: durationSec,
@@ -196,6 +198,9 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
user_messages: userMessages, user_messages: userMessages,
assistant_msgs: assistantMsgs, assistant_msgs: assistantMsgs,
tool_calls: toolCalls, tool_calls: toolCalls,
input_tokens: null,
output_tokens: null,
estimated_cost: null,
first_prompt: firstPrompt, first_prompt: firstPrompt,
tools_used: [...toolsUsed], tools_used: [...toolsUsed],
skills_invoked: [...skillsInvoked], skills_invoked: [...skillsInvoked],
+1 -1
View File
@@ -154,7 +154,7 @@ function parseProblemItem(raw: string): { problema: string; solucao: string } {
function extractAllLiItems(root: HTMLElement): string[] { function extractAllLiItems(root: HTMLElement): string[] {
return root return root
.querySelectorAll('li') .querySelectorAll('li')
.map((li) => textOf(li)) .map((li: HTMLElement) => textOf(li))
.filter(Boolean) .filter(Boolean)
} }
+5
View File
@@ -8,6 +8,11 @@ import type { SessionMeta } from '../types/session.js'
function sampleMeta(overrides: Partial<SessionMeta> = {}): SessionMeta { function sampleMeta(overrides: Partial<SessionMeta> = {}): SessionMeta {
return { return {
session_id: 's1', session_id: 's1',
source: 'claude',
model: null,
input_tokens: null,
output_tokens: null,
estimated_cost: null,
project_path: '/tmp/project', project_path: '/tmp/project',
project_slug: 'project', project_slug: 'project',
jsonl_path: '/tmp/project/s1.jsonl', jsonl_path: '/tmp/project/s1.jsonl',
+5
View File
@@ -9,6 +9,11 @@ import type { SessionMeta } from '../types/session.js'
function meta(overrides: Partial<SessionMeta>): SessionMeta { function meta(overrides: Partial<SessionMeta>): SessionMeta {
return { return {
session_id: 's-' + Math.random().toString(36).slice(2, 10), session_id: 's-' + Math.random().toString(36).slice(2, 10),
source: 'claude',
model: null,
input_tokens: null,
output_tokens: null,
estimated_cost: null,
project_path: '/tmp/project', project_path: '/tmp/project',
project_slug: 'project', project_slug: 'project',
jsonl_path: '/tmp/' + Math.random().toString(36).slice(2) + '.jsonl', jsonl_path: '/tmp/' + Math.random().toString(36).slice(2) + '.jsonl',
+5
View File
@@ -15,9 +15,11 @@ import type { SessionMeta } from '../types/session.js'
function meta(overrides: Partial<SessionMeta> = {}): SessionMeta { function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
return { return {
session_id: 's1', session_id: 's1',
source: 'claude',
project_path: '/tmp/p', project_path: '/tmp/p',
project_slug: 'p', project_slug: 'p',
jsonl_path: '/tmp/p/s1.jsonl', jsonl_path: '/tmp/p/s1.jsonl',
model: null,
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
ended_at: null, ended_at: null,
duration_sec: 60, duration_sec: 60,
@@ -25,6 +27,9 @@ function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
user_messages: 2, user_messages: 2,
assistant_msgs: 5, assistant_msgs: 5,
tool_calls: 3, tool_calls: 3,
input_tokens: null,
output_tokens: null,
estimated_cost: null,
first_prompt: 'teste', first_prompt: 'teste',
tools_used: ['Bash'], tools_used: ['Bash'],
skills_invoked: [], skills_invoked: [],
+8 -3
View File
@@ -2,9 +2,11 @@ export type SessionOutcome = 'completed' | 'interrupted' | 'error' | 'unknown'
export interface SessionMeta { export interface SessionMeta {
session_id: string session_id: string
project_path: string source: 'claude' | 'hermes' | 'opencode'
project_slug: string project_path: string | null
jsonl_path: string project_slug: string | null
jsonl_path: string | null
model: string | null
started_at: string started_at: string
ended_at: string | null ended_at: string | null
duration_sec: number | null duration_sec: number | null
@@ -12,6 +14,9 @@ export interface SessionMeta {
user_messages: number user_messages: number
assistant_msgs: number assistant_msgs: number
tool_calls: number tool_calls: number
input_tokens: number | null
output_tokens: number | null
estimated_cost: number | null
first_prompt: string | null first_prompt: string | null
tools_used: string[] tools_used: string[]
skills_invoked: string[] skills_invoked: string[]
@@ -1,10 +1,13 @@
--- ---
title: PLAN Conductor — Expansão DashDescomplicar title: PLAN Conductor — Expansão DashDescomplicar
description: >-
Spec: `docs/SPEC-dashboard-expansion-q2-2026.md`
date: 2026-04-06 date: 2026-04-06
type: plan type: plan
status: active status: active
spec: SPEC-dashboard-expansion-q2-2026.md spec: SPEC-dashboard-expansion-q2-2026.md
tags: [conductor, plan, dashboard, parallel] tags: [conductor, plan, dashboard, parallel]
layer: wiki
--- ---
# Plano Conductor — Expansão DashDescomplicar # Plano Conductor — Expansão DashDescomplicar
+3
View File
@@ -1,10 +1,13 @@
--- ---
title: SPEC — Expansão DashDescomplicar Q2 2026 title: SPEC — Expansão DashDescomplicar Q2 2026
description: >-
O DashDescomplicar é o painel de gestão interno da Descomplicar®. Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + …
date: 2026-04-06 date: 2026-04-06
type: spec type: spec
status: active status: active
desk_project: 65 desk_project: 65
tags: [dashboard, expansion, mcps, paperclip, n8n, claude-code] tags: [dashboard, expansion, mcps, paperclip, n8n, claude-code]
layer: wiki
--- ---
# SPEC — Expansão DashDescomplicar Q2 2026 # SPEC — Expansão DashDescomplicar Q2 2026
+899 -625
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -41,6 +41,9 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"overrides": {
"shell-quote": "^1.8.4"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
@@ -56,7 +59,7 @@
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/ui": "^4.0.18", "@vitest/ui": "^4.1.9",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -70,7 +73,7 @@
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^8.0.16",
"vitest": "^4.0.18" "vitest": "^4.1.9"
} }
} }
+2 -1
View File
@@ -5,8 +5,9 @@ export const oidcConfig = {
client_id: 'OKRSM2FZeSxJDhoV9e17dGRU1L1NEE1JBdnPVWTO', client_id: 'OKRSM2FZeSxJDhoV9e17dGRU1L1NEE1JBdnPVWTO',
redirect_uri: window.location.origin + '/callback', redirect_uri: window.location.origin + '/callback',
post_logout_redirect_uri: window.location.origin, post_logout_redirect_uri: window.location.origin,
scope: 'openid profile email', scope: 'openid profile email offline_access',
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
onSigninCallback: () => { onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
}, },
+86 -3
View File
@@ -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">
+1 -1
View File
@@ -44,7 +44,7 @@ export default function Sessions() {
const t = new Set<string>() const t = new Set<string>()
const s = new Set<string>() const s = new Set<string>()
data?.items.forEach((it) => { data?.items.forEach((it) => {
p.add(it.project_slug) p.add(it.project_slug ?? '')
it.tools_used.forEach((x) => t.add(x)) it.tools_used.forEach((x) => t.add(x))
it.skills_invoked.forEach((x) => s.add(x)) it.skills_invoked.forEach((x) => s.add(x))
}) })