Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7acd99d8f | ||
|
|
7ac01d5a82 | ||
|
|
485a4ee67c | ||
|
|
769c63b2a8 | ||
|
|
08d2a31dc9 | ||
|
|
f733998945 | ||
|
|
9f3d14dc51 | ||
|
|
94db202de9 | ||
|
|
a594df1c7c |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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®
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+899
-625
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user