Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3887547f1c | |||
| c794e1b6d6 | |||
| afbb06a87d | |||
| 6251e0d28c | |||
| f4adf8674d | |||
| 11f9833aac | |||
| 86770b1570 | |||
| 9652805b1e | |||
| ac4e9c6f35 | |||
| 1eb4f246de | |||
| 94088442c2 | |||
| 5bd1459c7d | |||
| 2a523a505e | |||
| 2c8525bc8a | |||
| d2452d4402 | |||
| c590431c1f | |||
| 80a5f3bf42 | |||
| 8ca6b7e166 | |||
| eb781a87ce | |||
| b933b4c2e2 | |||
| e101577d61 | |||
| 7a13d21caa | |||
| cdadc89cb0 | |||
| 296819df63 | |||
| 3bfec245c7 | |||
| a2ce1fa41d | |||
| bd954f4841 | |||
| 26b631bbd6 | |||
| 17e5736a0a | |||
| 43c852ef49 | |||
| 12f688ff7c | |||
| a4271fd06a | |||
| 8148eb47fe | |||
| ca73a9ddbd | |||
| 32c80e6cd8 | |||
| 6d4f8b8346 | |||
| 0588ee3735 | |||
| 990f3532b4 | |||
| 153a1577a5 | |||
| e421f40948 | |||
| 1c941785e1 |
@@ -2,6 +2,38 @@
|
||||
|
||||
Todas as alterações notáveis neste projecto serão documentadas neste ficheiro.
|
||||
|
||||
## [2.7.0] - 2026-04-23
|
||||
|
||||
### Added — Observabilidade Fase 6A
|
||||
- Detector automático semanal de 6 tipos de padrões (SQL heurístico)
|
||||
- Tabela `patterns` com histórico week-over-week
|
||||
- CLI `api/scripts/sessions-patterns.ts` (dry-run + publish Desk #32)
|
||||
- systemd user timer `observabilidade-patterns.timer` (domingos 23:00)
|
||||
- Auto-abre ticket Desk quando padrão persiste ≥3 semanas consecutivas (severity warning+)
|
||||
|
||||
### Added — Observabilidade Fase 6C (Worklog Import)
|
||||
- Tabela `worklog_comments` + parser HTML tolerante (h2/h3/h4) das discussões Desk #31, #32, #33
|
||||
- CLI `api/scripts/sessions-worklog-import.ts` com paginação via gateway MCP
|
||||
- systemd timer diário `observabilidade-worklog-import.timer` (03:00)
|
||||
- 3 detectores cruzados: `actions_never_executed`, `skill_narrative_vs_data`, `worklog_pattern_frequency`
|
||||
- Dep runtime: `node-html-parser`
|
||||
- Backfill inicial: 2312 comentários (465 + 33 + 1814) importados, span 2026-01-27 → 2026-04-23
|
||||
|
||||
### Added — Observabilidade (Espelho)
|
||||
- Painel `/sessions` para replay de sessões Claude Code (lista + timeline detalhe)
|
||||
- Indexer `api/scripts/sessions-indexer.ts` (modos `--full` e `--watch`)
|
||||
- SQLite local em `~/.claude-work/sessions.db` (1608 sessões, 61 projectos)
|
||||
- Rotas `GET /api/sessions` e `GET /api/sessions/:id` com validação Zod
|
||||
- Watcher chokidar incremental + systemd user service `observabilidade-indexer.service`
|
||||
- UI React com filtros (período/projecto/tool/skill/search) e timeline colapsável
|
||||
|
||||
### Technical Notes
|
||||
- `better-sqlite3` (WAL + synchronous=NORMAL) + `chokidar`
|
||||
- Batching transaccional 50 rows/commit no indexer (full scan: 1603 ficheiros em 8s)
|
||||
- Proxy Vite `/api` → `localhost:3001`
|
||||
- Hub: `/media/ealmeida/Dados/Hub/05-Projectos/Observabilidade/`
|
||||
- Desk task #2059, project #65
|
||||
|
||||
## [2.6.0] - 2026-02-14
|
||||
|
||||
### Security - Vulnerabilidades Críticas (3)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- APPROVED: 2026-04-23 by ealmeida -->
|
||||
# SPEC — DashDescomplicar
|
||||
|
||||
## Projectos activos
|
||||
|
||||
### Observabilidade (Espelho) — Claude Session Replay
|
||||
**SPEC autoritativo:** `/media/ealmeida/Dados/Hub/05-Projectos/Observabilidade/SPEC.md`
|
||||
**PLAN:** `/media/ealmeida/Dados/Hub/05-Projectos/Observabilidade/PLAN.md`
|
||||
**Branch:** `feat/observabilidade-espelho`
|
||||
**Desk task:** #2059
|
||||
|
||||
Painel `/sessions` para replay de sessões Claude Code. Indexer + SQLite + API + UI timeline.
|
||||
|
||||
## Projectos anteriores
|
||||
Ver `CHANGELOG.md` para histórico.
|
||||
@@ -63,20 +63,22 @@ export const wpMonitorSchema = {
|
||||
site_url: z.string().url('Invalid site_url format'),
|
||||
site_name: z.string().optional(),
|
||||
health: z.object({
|
||||
status: z.enum(['good', 'recommended', 'critical']).optional()
|
||||
}).optional(),
|
||||
status: z.string().optional()
|
||||
}).passthrough().optional(),
|
||||
updates: z.object({
|
||||
counts: z.object({
|
||||
total: z.number().int().nonnegative()
|
||||
}).optional(),
|
||||
core: z.array(z.any()).optional()
|
||||
}).optional(),
|
||||
}).passthrough().optional(),
|
||||
core: z.any().optional(),
|
||||
plugins: z.any().optional(),
|
||||
themes: z.any().optional()
|
||||
}).passthrough().optional(),
|
||||
system: z.object({
|
||||
debug_mode: z.boolean().optional()
|
||||
}).optional(),
|
||||
}).passthrough().optional(),
|
||||
database: z.object({
|
||||
size_mb: z.number().nonnegative().optional()
|
||||
}).optional()
|
||||
}).passthrough().optional()
|
||||
}).passthrough() // Permite campos adicionais
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AI Stack API Route
|
||||
* GET /api/ai
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import { getAiDashboard } from '../services/ai.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getAiDashboard()
|
||||
res.json(data)
|
||||
} catch (error: unknown) {
|
||||
console.error('AI API error:', error)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* MCPs API Route
|
||||
* GET /api/mcps - Estado de todos os MCPs via gateway
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import * as mcpsService from '../services/mcps.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Obter estado de todos os MCPs
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await mcpsService.getMcpDashboard()
|
||||
res.json(data)
|
||||
} catch (error: unknown) {
|
||||
console.error('MCPs API error:', error)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* n8n API Route
|
||||
* GET /api/n8n
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import { getN8nDashboard } from '../services/n8n.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getN8nDashboard()
|
||||
res.json(data)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Erro interno do servidor'
|
||||
console.error('n8n API error:', error)
|
||||
res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Rota /api/operations — Painel de Operações
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { getOperationsDashboard } from '../services/operations.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* GET /api/operations
|
||||
* Retorna dados operacionais: tickets abertos, alta prioridade,
|
||||
* tempo médio de resposta, tickets por departamento e cobertura de PROCs.
|
||||
*/
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const data = await getOperationsDashboard()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
console.error('[operations] Erro ao obter dados:', err)
|
||||
res.status(500).json({ error: 'Erro interno ao obter dados de operações' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Paperclip Router — GET /api/paperclip
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { getPaperclipDashboard } from '../services/paperclip.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* GET /api/paperclip
|
||||
* Retorna dados dos agentes, routines e issues do Paperclip.
|
||||
* Se a BD não estiver configurada ou acessível, retorna dados de fallback (zeros) sem erro 500.
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getPaperclipDashboard()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
console.error('[route/paperclip] Erro inesperado:', (err as Error).message)
|
||||
res.status(500).json({
|
||||
error: 'Erro interno ao obter dados do Paperclip',
|
||||
message: (err as Error).message,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Rota /api/sessions — lista e detalhe de sessões Claude Code.
|
||||
* Validação Zod em query params; detalhe carrega eventos via parseSessionFile.
|
||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import type { SessionsDb } from '../services/sessions/db.js'
|
||||
import { parseSessionFile } from '../services/sessions/parser.js'
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(3650).optional(),
|
||||
project: z.string().min(1).max(200).optional(),
|
||||
tool: z.string().min(1).max(100).optional(),
|
||||
skill: z.string().min(1).max(200).optional(),
|
||||
q: z.string().max(500).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
const IdParamSchema = z.object({ id: z.string().min(1).max(200) })
|
||||
|
||||
export function createSessionsRouter(db: SessionsDb): Router {
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const parsed = ListQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid query', details: parsed.error.format() })
|
||||
}
|
||||
const filters = parsed.data
|
||||
const items = db.listSessions(filters)
|
||||
const total = db.countSessions(filters)
|
||||
return res.json({ total, items })
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const parsed = IdParamSchema.safeParse(req.params)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' })
|
||||
}
|
||||
const session = db.getSession(parsed.data.id)
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' })
|
||||
|
||||
try {
|
||||
const { events } = await parseSessionFile(session.jsonl_path)
|
||||
return res.json({ meta: session, events })
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException
|
||||
if (e.code === 'ENOENT') {
|
||||
return res.status(410).json({
|
||||
error: 'Session file missing (stale index)',
|
||||
session_id: parsed.data.id,
|
||||
})
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
return res.status(500).json({
|
||||
error: 'Failed to parse session',
|
||||
...(isProduction ? {} : { message: e.message }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* CLI do indexer de sessões Claude Code (Observabilidade/Espelho).
|
||||
*
|
||||
* Modos:
|
||||
* --full Full scan de ~/.claude/projects -> SQLite em ~/.claude-work/sessions.db
|
||||
* --watch Modo incremental (stub; implementação Task 8)
|
||||
*
|
||||
* Env:
|
||||
* OBSERVABILIDADE_DB Override ao caminho da BD SQLite
|
||||
*/
|
||||
import { indexAll, DEFAULT_DB_PATH, PROJECTS_ROOT } from '../services/sessions/indexer.js'
|
||||
import { startWatcher } from '../services/sessions/watcher.js'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2)
|
||||
const mode = args.find((a) => a === '--full' || a === '--watch')
|
||||
if (!mode) {
|
||||
console.error('Uso: sessions-indexer.ts [--full|--watch]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||
console.log(`[indexer] modo=${mode} db=${dbPath}`)
|
||||
|
||||
if (mode === '--watch') {
|
||||
console.log(`[indexer] watch mode em ${PROJECTS_ROOT} -> ${dbPath}`)
|
||||
await indexAll({ dbPath })
|
||||
await startWatcher(dbPath)
|
||||
return
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
let lastLogged = 0
|
||||
const { indexed, failed } = await indexAll({
|
||||
dbPath,
|
||||
onProgress: (done, total) => {
|
||||
if (done - lastLogged >= 50 || done === total) {
|
||||
console.log(`[indexer] ${done}/${total}`)
|
||||
lastLogged = done
|
||||
}
|
||||
},
|
||||
})
|
||||
const durationMs = Date.now() - start
|
||||
const durationSec = (durationMs / 1000).toFixed(1)
|
||||
console.log(`[indexer] concluído em ${durationSec}s · indexed=${indexed} failed=${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[indexer] falha fatal:', err)
|
||||
process.exit(2)
|
||||
})
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Detector semanal de padrões sobre a BD Observabilidade (Fase 6A).
|
||||
*
|
||||
* Uso:
|
||||
* sessions-patterns.ts [--week YYYY-Www] [--publish] [--force]
|
||||
* sessions-patterns.ts --backfill
|
||||
*
|
||||
* Backfill:
|
||||
* Itera todas as semanas ISO desde a primeira sessão na BD até (excluindo)
|
||||
* a semana corrente, detecta padrões e faz upsert. Nunca publica nem abre
|
||||
* tickets. Output: linha JSON por semana + sumário total no fim.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Resolver intervalo da semana (segunda 00:00 UTC → domingo 23:59 UTC)
|
||||
* 2. Correr detectPatterns (6 detectores heurísticos)
|
||||
* 3. Persistir com upsertPattern + consecutive_weeks
|
||||
* 4. Se --publish: chamar gateway MCP (desk-crm) para publicar comentário HTML
|
||||
* na discussão #32, e para padrões com consecutive_weeks>=3 e
|
||||
* severity∈(warning,action) abrir Ticket no Desk.
|
||||
* 5. Output JSON-line final com contagens.
|
||||
*
|
||||
* Env obrigatório com --publish:
|
||||
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
|
||||
* MCP_GATEWAY_URL URL do MCP desk-crm (default https://gateway.descomplicar.pt/v1/desk-crm/mcp)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { openSessionsDb, type PatternRecord, type SessionsDb } from '../services/sessions/db.js'
|
||||
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
||||
import {
|
||||
detectPatterns,
|
||||
toPatternRecord,
|
||||
weekIso,
|
||||
weekRange,
|
||||
type Pattern,
|
||||
} from '../services/sessions/patterns.js'
|
||||
|
||||
const CARL_JSON_PATH = '/media/ealmeida/Dados/.carl/carl.json'
|
||||
|
||||
/**
|
||||
* Propor padrão persistente como staging entry no carl.json.
|
||||
* Idempotente: não duplica por pattern_key.
|
||||
* Só dispara para severity action ou warning com ≥3 semanas consecutivas.
|
||||
*/
|
||||
function proposeCarlStagingEntry(p: PatternRecord): void {
|
||||
if (!existsSync(CARL_JSON_PATH)) return
|
||||
try {
|
||||
const raw = readFileSync(CARL_JSON_PATH, 'utf-8')
|
||||
const carl = JSON.parse(raw) as { staging?: unknown[] }
|
||||
const staging = (carl.staging ??= [])
|
||||
const stagingId = `pattern-${p.pattern_key}`
|
||||
const exists = staging.some((e) => typeof e === 'object' && e !== null && (e as { id?: string }).id === stagingId)
|
||||
if (exists) return
|
||||
staging.push({
|
||||
id: stagingId,
|
||||
type: 'pattern-proposal',
|
||||
source: 'observabilidade-patterns',
|
||||
name: `Padrão recorrente: ${p.title}`,
|
||||
description: p.description,
|
||||
severity: p.severity,
|
||||
consecutive_weeks: p.consecutive_weeks,
|
||||
affected_count: p.affected_count,
|
||||
sample_session_ids: p.sample_session_ids,
|
||||
proposed_at: new Date().toISOString(),
|
||||
status: 'pending-review',
|
||||
})
|
||||
writeFileSync(CARL_JSON_PATH, JSON.stringify(carl, null, 2), 'utf-8')
|
||||
console.error(`[patterns] proposto em CARL staging: ${stagingId}`)
|
||||
} catch (err) {
|
||||
console.error(`[patterns] falha ao propor em CARL staging: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface Args {
|
||||
week?: string
|
||||
publish: boolean
|
||||
force: boolean
|
||||
backfill: boolean
|
||||
}
|
||||
|
||||
interface MCPToolCallResult {
|
||||
content?: Array<{ type: string; text: string }>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/** Staff ID usado para publicar comentários automáticos (Observabilidade) */
|
||||
const OBSERVABILIDADE_STAFF_ID = 25
|
||||
/** Discussão Desk onde os resumos semanais são publicados */
|
||||
const OBSERVABILIDADE_DISCUSSION_ID = 32
|
||||
/** Departamento "Geral" no Desk CRM (id=1) */
|
||||
const OBSERVABILIDADE_DEPARTMENT_ID = 1
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const a: Args = { publish: false, force: false, backfill: false }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--week') a.week = argv[++i]
|
||||
else if (argv[i] === '--publish') a.publish = true
|
||||
else if (argv[i] === '--force') a.force = true
|
||||
else if (argv[i] === '--backfill') a.backfill = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/** Detecta e persiste padrões para uma única semana (sem publicar). */
|
||||
function processWeek(db: SessionsDb, monday: Date): {
|
||||
week_iso: string
|
||||
detected: number
|
||||
by_severity: { action: number; warning: number; info: number }
|
||||
} {
|
||||
const range = weekRange(monday)
|
||||
const weekIsoStr = range.iso
|
||||
const detected: Pattern[] = detectPatterns(db, range.start, range.end)
|
||||
const records: PatternRecord[] = []
|
||||
for (const p of detected) {
|
||||
const tmpRec = toPatternRecord(p, weekIsoStr, 1)
|
||||
db.upsertPattern(tmpRec)
|
||||
const consecutive = db.getConsecutiveWeeks(p.pattern_key, weekIsoStr)
|
||||
const rec = toPatternRecord(p, weekIsoStr, consecutive)
|
||||
db.upsertPattern(rec)
|
||||
records.push(rec)
|
||||
}
|
||||
return {
|
||||
week_iso: weekIsoStr,
|
||||
detected: records.length,
|
||||
by_severity: {
|
||||
action: records.filter((r) => r.severity === 'action').length,
|
||||
warning: records.filter((r) => r.severity === 'warning').length,
|
||||
info: records.filter((r) => r.severity === 'info').length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Executa backfill desde a primeira sessão até (excl.) semana corrente. */
|
||||
function runBackfill(db: SessionsDb, dbPath: string): void {
|
||||
const raw = db.rawDb()
|
||||
const row = raw.prepare('SELECT MIN(started_at) as min_started FROM sessions').get() as
|
||||
| { min_started: string | null }
|
||||
| undefined
|
||||
if (!row || !row.min_started) {
|
||||
console.error('[patterns] backfill: BD sem sessões. Nada a fazer.')
|
||||
console.log(JSON.stringify({ backfill: true, weeks_processed: 0, total_detected: 0 }))
|
||||
return
|
||||
}
|
||||
const firstStart = new Date(row.min_started)
|
||||
const firstRange = weekRange(firstStart)
|
||||
const currentRange = weekRange(new Date())
|
||||
const currentIso = currentRange.iso
|
||||
|
||||
console.error(
|
||||
`[patterns] backfill: primeira sessão ${row.min_started}, semana inicial ${firstRange.iso}, ` +
|
||||
`semana corrente ${currentIso} (excluída) db=${dbPath}`,
|
||||
)
|
||||
|
||||
let cursor = new Date(firstRange.start)
|
||||
let weeksProcessed = 0
|
||||
let totalDetected = 0
|
||||
const bySev = { action: 0, warning: 0, info: 0 }
|
||||
const perWeek: Array<{ week_iso: string; detected: number }> = []
|
||||
|
||||
// Iteração semanal: cursor é sempre segunda 00:00 UTC
|
||||
while (weekIso(cursor) !== currentIso) {
|
||||
const summary = processWeek(db, cursor)
|
||||
console.log(JSON.stringify({ backfill: true, ...summary }))
|
||||
weeksProcessed++
|
||||
totalDetected += summary.detected
|
||||
bySev.action += summary.by_severity.action
|
||||
bySev.warning += summary.by_severity.warning
|
||||
bySev.info += summary.by_severity.info
|
||||
perWeek.push({ week_iso: summary.week_iso, detected: summary.detected })
|
||||
// Avançar 7 dias
|
||||
cursor = new Date(cursor)
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 7)
|
||||
// Safety: evitar loop infinito em caso de bug
|
||||
if (weeksProcessed > 520) {
|
||||
console.error('[patterns] backfill: safety break após 520 semanas')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
backfill_summary: true,
|
||||
weeks_processed: weeksProcessed,
|
||||
total_detected: totalDetected,
|
||||
by_severity: bySev,
|
||||
first_week: perWeek[0]?.week_iso ?? null,
|
||||
last_week: perWeek[perWeek.length - 1]?.week_iso ?? null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Converte YYYY-Www em intervalo {start,end} UTC. */
|
||||
function weekIsoToRange(weekIsoStr: string): { start: Date; end: Date; iso: string } {
|
||||
const m = weekIsoStr.match(/^(\d{4})-W(\d{2})$/)
|
||||
if (!m) throw new Error(`Formato --week inválido: ${weekIsoStr}`)
|
||||
const year = parseInt(m[1], 10)
|
||||
const week = parseInt(m[2], 10)
|
||||
// ISO week: quinta da semana 1 está sempre em 4 de Janeiro
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4))
|
||||
const jan4Dow = jan4.getUTCDay() || 7 // 1..7 (seg..dom)
|
||||
const mondayWeek1 = new Date(jan4)
|
||||
mondayWeek1.setUTCDate(jan4.getUTCDate() - (jan4Dow - 1))
|
||||
const monday = new Date(mondayWeek1)
|
||||
monday.setUTCDate(mondayWeek1.getUTCDate() + (week - 1) * 7)
|
||||
return weekRange(monday)
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[]): string {
|
||||
const dateStr = new Date().toISOString().slice(0, 10)
|
||||
const lines: string[] = []
|
||||
lines.push(`<h4>Semana ${weekIso} — Padrões Detectados Automaticamente</h4>`)
|
||||
lines.push(`<p><em>Detector automático Observabilidade — ${patterns.length} padrões analisados.</em></p>`)
|
||||
if (patterns.length === 0) {
|
||||
lines.push('<p>Nenhum padrão accionável detectado esta semana.</p>')
|
||||
} else {
|
||||
lines.push('<h4>Padrões (severity)</h4>')
|
||||
lines.push('<ul>')
|
||||
for (const p of patterns) {
|
||||
const samples = p.sample_session_ids
|
||||
.slice(0, 5)
|
||||
.map((id) => `<a href="/sessions/${encodeURIComponent(id)}"><code>${escapeHtml(id.slice(0, 8))}</code></a>`)
|
||||
.join(', ')
|
||||
lines.push(
|
||||
`<li><strong>${escapeHtml(p.title)}</strong> [${p.severity}] — ` +
|
||||
`${p.affected_count} sessões` +
|
||||
(p.metric_value != null ? `, métrica ${p.metric_value}` : '') +
|
||||
`<br><em>Descrição:</em> ${escapeHtml(p.description)}` +
|
||||
`<br><em>Sample:</em> ${samples}` +
|
||||
`<br><em>Semanas consecutivas:</em> ${p.consecutive_weeks}</li>`,
|
||||
)
|
||||
}
|
||||
lines.push('</ul>')
|
||||
}
|
||||
lines.push('<hr>')
|
||||
lines.push(`<p><strong>Skill:</strong> observabilidade-patterns | <strong>Data:</strong> ${dateStr}</p>`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Chama uma ferramenta do gateway MCP (JSON-RPC 2.0 sobre HTTP).
|
||||
* O gateway pode responder em SSE (text/event-stream) ou JSON — tratamos ambos.
|
||||
*/
|
||||
async function callMcpTool(tool: string, args: Record<string, unknown>): Promise<MCPToolCallResult> {
|
||||
const url = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt/v1/desk-crm/mcp'
|
||||
const token = process.env.MCP_GATEWAY_TOKEN
|
||||
if (!token) throw new Error('MCP_GATEWAY_TOKEN não definido')
|
||||
const body = {
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/call',
|
||||
params: { name: tool, arguments: args },
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`MCP gateway ${res.status}: ${txt.slice(0, 300)}`)
|
||||
}
|
||||
const raw = await res.text()
|
||||
// Responde JSON puro ou SSE (linhas "data: {...}")
|
||||
let payload: string | null = null
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
payload = trimmed.slice(6)
|
||||
break
|
||||
}
|
||||
if (trimmed.startsWith('{')) {
|
||||
payload = trimmed
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!payload) throw new Error(`MCP resposta sem payload JSON: ${raw.slice(0, 200)}`)
|
||||
const parsed = JSON.parse(payload) as { error?: unknown; result?: MCPToolCallResult }
|
||||
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
|
||||
const result = parsed.result as MCPToolCallResult | undefined
|
||||
if (result?.isError) {
|
||||
const txt = result.content?.map((c) => c.text).join('\n') ?? ''
|
||||
throw new Error(`MCP tool ${tool} devolveu isError: ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return result ?? {}
|
||||
}
|
||||
|
||||
async function postDeskDiscussionComment(html: string): Promise<unknown> {
|
||||
return callMcpTool('add_discussion_comment', {
|
||||
discussion_id: OBSERVABILIDADE_DISCUSSION_ID,
|
||||
content: html,
|
||||
staff_id: OBSERVABILIDADE_STAFF_ID,
|
||||
})
|
||||
}
|
||||
|
||||
async function createDeskTicket(p: PatternRecord): Promise<unknown> {
|
||||
const priority = p.severity === 'action' ? 4 : 3 // 4=alta, 3=média
|
||||
const message = [
|
||||
`<p><strong>Padrão recorrente detectado automaticamente — ${p.consecutive_weeks} semanas consecutivas.</strong></p>`,
|
||||
`<p>${escapeHtml(p.description)}</p>`,
|
||||
`<ul>`,
|
||||
`<li>Pattern key: <code>${escapeHtml(p.pattern_key)}</code></li>`,
|
||||
`<li>Severity: ${p.severity}</li>`,
|
||||
`<li>Affected: ${p.affected_count} sessões</li>`,
|
||||
`<li>Metric: ${p.metric_value ?? 'n/a'}</li>`,
|
||||
`<li>Week: ${p.week_iso}</li>`,
|
||||
`</ul>`,
|
||||
`<p><em>Sample sessions:</em> ${p.sample_session_ids.map((s) => `<code>${escapeHtml(s)}</code>`).join(', ')}</p>`,
|
||||
].join('\n')
|
||||
return callMcpTool('create_ticket', {
|
||||
subject: `Padrão recorrente: ${p.title}`,
|
||||
message,
|
||||
priority,
|
||||
department: OBSERVABILIDADE_DEPARTMENT_ID,
|
||||
})
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.publish && !process.env.MCP_GATEWAY_TOKEN) {
|
||||
console.error('[patterns] --publish requer MCP_GATEWAY_TOKEN. Aborta.')
|
||||
process.exit(1)
|
||||
}
|
||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||
const db = openSessionsDb(dbPath)
|
||||
|
||||
if (args.backfill) {
|
||||
if (args.publish) {
|
||||
console.error('[patterns] --backfill é incompatível com --publish. Aborta.')
|
||||
process.exit(1)
|
||||
}
|
||||
runBackfill(db, dbPath)
|
||||
db.close()
|
||||
return
|
||||
}
|
||||
|
||||
const range = args.week ? weekIsoToRange(args.week) : weekRange(new Date())
|
||||
const weekIso = args.week ?? range.iso
|
||||
console.error(`[patterns] semana ${weekIso} range=${range.start.toISOString()}..${range.end.toISOString()} db=${dbPath} publish=${args.publish}`)
|
||||
|
||||
const detected: Pattern[] = detectPatterns(db, range.start, range.end)
|
||||
console.error(`[patterns] detectados ${detected.length} padrões`)
|
||||
|
||||
const records: PatternRecord[] = []
|
||||
for (const p of detected) {
|
||||
// Primeiro upsert com consecutive=1 (placeholder); depois recalcula.
|
||||
const tmpRec = toPatternRecord(p, weekIso, 1)
|
||||
db.upsertPattern(tmpRec)
|
||||
const consecutive = db.getConsecutiveWeeks(p.pattern_key, weekIso)
|
||||
const rec = toPatternRecord(p, weekIso, consecutive)
|
||||
db.upsertPattern(rec)
|
||||
records.push(rec)
|
||||
}
|
||||
|
||||
// Ordenar por severity (action > warning > info) depois affected_count
|
||||
const sevRank: Record<string, number> = { action: 3, warning: 2, info: 1 }
|
||||
records.sort((a, b) => (sevRank[b.severity] - sevRank[a.severity]) || (b.affected_count - a.affected_count))
|
||||
|
||||
let commentPosted = false
|
||||
let ticketsCreated = 0
|
||||
let publishError: string | null = null
|
||||
|
||||
if (args.publish) {
|
||||
try {
|
||||
const html = buildDiscussionHtml(weekIso, records)
|
||||
await postDeskDiscussionComment(html)
|
||||
commentPosted = true
|
||||
for (const rec of records) {
|
||||
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
||||
try {
|
||||
await createDeskTicket(rec)
|
||||
ticketsCreated++
|
||||
} catch (e) {
|
||||
console.error(`[patterns] falha ao criar ticket para ${rec.pattern_key}:`, (e as Error).message)
|
||||
}
|
||||
// Propor como staging no CARL — idempotente por pattern_key
|
||||
proposeCarlStagingEntry(rec)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
publishError = (e as Error).message
|
||||
}
|
||||
}
|
||||
|
||||
// Dry-run: render HTML para stderr para verificação manual
|
||||
if (!args.publish) {
|
||||
const html = buildDiscussionHtml(weekIso, records)
|
||||
console.error('\n--- HTML (dry-run) ---')
|
||||
console.error(html)
|
||||
console.error('--- /HTML ---\n')
|
||||
for (const rec of records) {
|
||||
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
||||
console.error(`[patterns] (dry-run) Ticket seria criado: ${rec.title} — ${rec.consecutive_weeks} sem.`)
|
||||
console.error(`[patterns] (dry-run) CARL staging seria proposto: pattern-${rec.pattern_key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
week_iso: weekIso,
|
||||
range: { start: range.start.toISOString(), end: range.end.toISOString() },
|
||||
detected: records.length,
|
||||
by_severity: {
|
||||
action: records.filter((r) => r.severity === 'action').length,
|
||||
warning: records.filter((r) => r.severity === 'warning').length,
|
||||
info: records.filter((r) => r.severity === 'info').length,
|
||||
},
|
||||
published: commentPosted,
|
||||
tickets_created: ticketsCreated,
|
||||
publish_error: publishError,
|
||||
dry_run: !args.publish,
|
||||
}
|
||||
console.log(JSON.stringify(summary))
|
||||
db.close()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[patterns] falha fatal:', err)
|
||||
process.exit(2)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Importa comentários das discussões Desk #31/#32/#33 (worklogs, reflexões
|
||||
* e acções de melhoria) para a BD Observabilidade.
|
||||
*
|
||||
* Uso:
|
||||
* sessions-worklog-import.ts [--discussion 31|32|33|all] [--since-days N]
|
||||
* sessions-worklog-import.ts --discussion 31 --page-size 200
|
||||
*
|
||||
* Default: --discussion all --since-days 365
|
||||
*
|
||||
* Env obrigatório:
|
||||
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
|
||||
*/
|
||||
import { openSessionsDb } from '../services/sessions/db.js'
|
||||
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
||||
import { importWorklogDiscussion, type ImportResult } from '../services/sessions/worklog-import.js'
|
||||
|
||||
interface Args {
|
||||
discussion: 'all' | number
|
||||
sinceDays: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const a: Args = { discussion: 'all', sinceDays: 365, pageSize: 500 }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--discussion') {
|
||||
const v = argv[++i]
|
||||
a.discussion = v === 'all' ? 'all' : parseInt(v, 10)
|
||||
} else if (argv[i] === '--since-days') {
|
||||
a.sinceDays = parseInt(argv[++i], 10)
|
||||
} else if (argv[i] === '--page-size') {
|
||||
a.pageSize = parseInt(argv[++i], 10)
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (!process.env.MCP_GATEWAY_TOKEN) {
|
||||
console.error('[worklog-import] MCP_GATEWAY_TOKEN não definido. Aborta.')
|
||||
process.exit(1)
|
||||
}
|
||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||
const db = openSessionsDb(dbPath)
|
||||
|
||||
const discussions = args.discussion === 'all' ? [31, 32, 33] : [args.discussion as number]
|
||||
const sinceIso = new Date(Date.now() - args.sinceDays * 86400_000).toISOString()
|
||||
|
||||
console.error(
|
||||
`[worklog-import] db=${dbPath} discussions=${discussions.join(',')} since=${sinceIso} page_size=${args.pageSize}`,
|
||||
)
|
||||
|
||||
const results: ImportResult[] = []
|
||||
for (const d of discussions) {
|
||||
try {
|
||||
const r = await importWorklogDiscussion(db, d, { sinceIso, pageSize: args.pageSize })
|
||||
results.push(r)
|
||||
console.error(
|
||||
`[worklog-import] #${d}: fetched=${r.fetched} inserted=${r.imported} updated=${r.updated} skipped=${r.skipped} errors=${r.errors}`,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(`[worklog-import] falha #${d}:`, (e as Error).message)
|
||||
results.push({
|
||||
discussion_id: d,
|
||||
fetched: 0,
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
db: dbPath,
|
||||
since_iso: sinceIso,
|
||||
discussions: results,
|
||||
totals: {
|
||||
fetched: results.reduce((s, r) => s + r.fetched, 0),
|
||||
imported: results.reduce((s, r) => s + r.imported, 0),
|
||||
updated: results.reduce((s, r) => s + r.updated, 0),
|
||||
skipped: results.reduce((s, r) => s + r.skipped, 0),
|
||||
errors: results.reduce((s, r) => s + r.errors, 0),
|
||||
},
|
||||
total_in_db: db.countWorklogComments(),
|
||||
}
|
||||
console.log(JSON.stringify(summary))
|
||||
db.close()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[worklog-import] falha fatal:', err)
|
||||
process.exit(2)
|
||||
})
|
||||
+44
-6
@@ -16,7 +16,16 @@ import hetznerRouter from './routes/hetzner.js'
|
||||
import wpMonitorRouter from './routes/wp-monitor.js'
|
||||
import serverMetricsRouter from './routes/server-metrics.js'
|
||||
import financialRouter from './routes/financial.js'
|
||||
import mcpsRouter from './routes/mcps.js'
|
||||
import n8nRouter from './routes/n8n.js'
|
||||
import paperclipRouter from './routes/paperclip.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import operationsRouter from './routes/operations.js'
|
||||
import { createSessionsRouter } from './routes/sessions.js'
|
||||
import { openSessionsDb } from './services/sessions/db.js'
|
||||
import { DEFAULT_DB_PATH } from './services/sessions/indexer.js'
|
||||
import { collectAllServerMetrics } from './services/server-metrics.js'
|
||||
import { collectMonitoringData } from './services/monitoring-collector.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -42,6 +51,7 @@ app.use('/api', limiter)
|
||||
// SECURITY: CORS Restrito (Vulnerabilidade 2.2)
|
||||
// ============================================================================
|
||||
const allowedOrigins = [
|
||||
'https://dash.descomplicar.pt',
|
||||
'https://dashboard.descomplicar.pt',
|
||||
'https://desk.descomplicar.pt'
|
||||
]
|
||||
@@ -53,7 +63,7 @@ if (!isProduction) {
|
||||
allowedOrigins.push(process.env.FRONTEND_URL || 'http://localhost:5173')
|
||||
}
|
||||
|
||||
app.use(cors({
|
||||
const corsMiddleware = cors({
|
||||
origin: (origin, callback) => {
|
||||
// Permitir requests sem origin (curl, Postman, etc) em dev
|
||||
if (!origin && !isProduction) {
|
||||
@@ -68,7 +78,10 @@ app.use(cors({
|
||||
}
|
||||
},
|
||||
credentials: true
|
||||
}))
|
||||
})
|
||||
|
||||
// CORS apenas nas rotas API (nao bloquear assets estaticos)
|
||||
app.use('/api', corsMiddleware)
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
@@ -117,6 +130,25 @@ app.use('/api/hetzner', hetznerRouter)
|
||||
app.use('/api/wp-monitor', wpMonitorRouter)
|
||||
app.use('/api/server-metrics', serverMetricsRouter)
|
||||
app.use('/api/financial', financialRouter)
|
||||
app.use('/api/mcps', mcpsRouter)
|
||||
app.use('/api/n8n', n8nRouter)
|
||||
app.use('/api/paperclip', paperclipRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/operations', operationsRouter)
|
||||
|
||||
// Observabilidade (Espelho) — sessões Claude Code
|
||||
const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH)
|
||||
app.use('/api/sessions', createSessionsRouter(sessionsDb))
|
||||
|
||||
function closeSessionsDb(): void {
|
||||
try {
|
||||
sessionsDb.close()
|
||||
} catch (err) {
|
||||
console.error('[sessionsDb] erro ao fechar:', err)
|
||||
}
|
||||
}
|
||||
process.on('SIGTERM', closeSessionsDb)
|
||||
process.on('SIGINT', closeSessionsDb)
|
||||
|
||||
// Serve static files in production
|
||||
if (isProduction) {
|
||||
@@ -170,22 +202,28 @@ app.listen(PORT, () => {
|
||||
console.log('='.repeat(50))
|
||||
}
|
||||
|
||||
// Auto-collect server metrics every 5 minutes
|
||||
// Auto-collect metrics every 5 minutes
|
||||
if (isProduction) {
|
||||
const INTERVAL = 5 * 60 * 1000
|
||||
console.log('[SCHEDULER] Server metrics collection every 5min')
|
||||
console.log('[SCHEDULER] Server metrics + monitoring collection every 5min')
|
||||
|
||||
// Initial collection after 30s (let server stabilize)
|
||||
setTimeout(() => {
|
||||
collectAllServerMetrics().catch(err =>
|
||||
console.error('[SCHEDULER] Initial collection failed:', err.message)
|
||||
console.error('[SCHEDULER] Initial server metrics failed:', err.message)
|
||||
)
|
||||
collectMonitoringData().catch(err =>
|
||||
console.error('[SCHEDULER] Initial monitoring collection failed:', err.message)
|
||||
)
|
||||
}, 30000)
|
||||
|
||||
// Recurring collection
|
||||
setInterval(() => {
|
||||
collectAllServerMetrics().catch(err =>
|
||||
console.error('[SCHEDULER] Collection failed:', err.message)
|
||||
console.error('[SCHEDULER] Server metrics failed:', err.message)
|
||||
)
|
||||
collectMonitoringData().catch(err =>
|
||||
console.error('[SCHEDULER] Monitoring collection failed:', err.message)
|
||||
)
|
||||
}, INTERVAL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* AI Stack Service — dados estáticos do stack Descomplicar® 3 camadas
|
||||
* Fonte: STK-Estado-Actual.md (snapshot 06-04-2026)
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
export interface AiLayerItem {
|
||||
metric: string
|
||||
value: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface AiLayer {
|
||||
name: string
|
||||
label: string
|
||||
items: AiLayerItem[]
|
||||
}
|
||||
|
||||
export interface TransversalSystem {
|
||||
name: string
|
||||
status: 'active' | 'warning' | 'inactive'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface CarlConfig {
|
||||
domains: string[]
|
||||
total_rules: number
|
||||
}
|
||||
|
||||
export interface AiDashboard {
|
||||
layers: AiLayer[]
|
||||
transversal: TransversalSystem[]
|
||||
carl: CarlConfig
|
||||
notebooks: number
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
export async function getAiDashboard(): Promise<AiDashboard> {
|
||||
return {
|
||||
layers: [
|
||||
{
|
||||
name: 'Claude Code',
|
||||
label: 'Camada 1 — Interactivo',
|
||||
items: [
|
||||
{ metric: 'Skills', value: 189, detail: '31 directas + 158 plugins' },
|
||||
{ metric: 'Agents CC', value: 72, detail: '18 directos + 54 plugins' },
|
||||
{ metric: 'MCPs', value: 39, detail: '10 enabled, 33 gateway, 2 locais' },
|
||||
{ metric: 'Hooks', value: 9, detail: '9 activos de 26 ficheiros' },
|
||||
{ metric: 'Plugins', value: 23, detail: '14 Descomplicar + 6 oficiais + 3 terceiros' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'n8n',
|
||||
label: 'Camada 2 — Determinístico',
|
||||
items: [
|
||||
{ metric: 'Workflows', value: 14, detail: '14 activos de 17 total' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Paperclip',
|
||||
label: 'Camada 3 — Autónomo',
|
||||
items: [
|
||||
{ metric: 'Agentes', value: 16, detail: '9 active + 7 idle' },
|
||||
{ metric: 'Routines', value: 5, detail: '5 activas' },
|
||||
{ metric: 'Company Skills', value: 92, detail: 'Atribuídas a agentes' },
|
||||
],
|
||||
},
|
||||
],
|
||||
transversal: [
|
||||
{ name: 'RAG/Contexto', status: 'active', detail: 'CARL v2 (7 domínios) + memory-supabase' },
|
||||
{ name: 'Anti-alucinação', status: 'active', detail: 'Regra factual <80% confiança' },
|
||||
{ name: 'Knowledge Graph', status: 'active', detail: 'LightRAG v1.4.13 (1612 docs)' },
|
||||
{ name: 'Auto-melhoria', status: 'warning', detail: 'Corrections hook + evals (3 cenários)' },
|
||||
{ name: 'Design', status: 'active', detail: 'design.json + PROC-Design-Brief' },
|
||||
],
|
||||
carl: {
|
||||
domains: ['GLOBAL', 'CRM', 'DEVELOPMENT', 'WORDPRESS', 'HUB', 'INFRASTRUCTURE', 'QUALITY', 'SKILLS'],
|
||||
total_rules: 45,
|
||||
},
|
||||
notebooks: 58,
|
||||
last_updated: '2026-04-06',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* MCPs Service
|
||||
* Ping paralelo aos MCPs via gateway com cache de 60 segundos
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
export interface McpStatus {
|
||||
name: string
|
||||
port: number
|
||||
category: string
|
||||
enabled: boolean
|
||||
status: 'online' | 'offline' | 'disabled' | 'unknown'
|
||||
response_time_ms: number | null
|
||||
last_check: string
|
||||
tools_count?: number
|
||||
}
|
||||
|
||||
export interface McpDashboard {
|
||||
gateway_status: 'online' | 'offline'
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
mcps: McpStatus[]
|
||||
auth: {
|
||||
method: string
|
||||
token_expires: string | null
|
||||
}
|
||||
}
|
||||
|
||||
// Lista de MCPs com metadados (extraída de port-map.json e claude.json)
|
||||
const MCP_LIST: Array<{ name: string; port: number; category: string; enabled: boolean }> = [
|
||||
// CRM
|
||||
{ name: 'desk-crm-v3', port: 3150, category: 'crm', enabled: true },
|
||||
{ name: 'desk-project-minimal', port: 3153, category: 'crm', enabled: false },
|
||||
|
||||
// Infra
|
||||
{ name: 'ssh-unified', port: 3192, category: 'infra', enabled: true },
|
||||
{ name: 'filesystem', port: 0, category: 'infra', enabled: true },
|
||||
{ name: 'chrome-devtools', port: 0, category: 'infra', enabled: true },
|
||||
|
||||
// AI
|
||||
{ name: 'lightrag', port: 3160, category: 'ai', enabled: true },
|
||||
{ name: 'notebooklm', port: 3190, category: 'ai', enabled: false },
|
||||
{ name: 'context7', port: 3169, category: 'ai', enabled: false },
|
||||
{ name: 'replicate', port: 3176, category: 'ai', enabled: false },
|
||||
{ name: 'memory-supabase', port: 3151, category: 'ai', enabled: true },
|
||||
|
||||
// Tools
|
||||
{ name: 'mcp-time', port: 3155, category: 'tools', enabled: true },
|
||||
{ name: 'deepl', port: 3188, category: 'tools', enabled: true },
|
||||
{ name: 'pexels', port: 3175, category: 'tools', enabled: false },
|
||||
{ name: 'vimeo', port: 3177, category: 'tools', enabled: false },
|
||||
{ name: 'drawio', port: 3184, category: 'tools', enabled: false },
|
||||
|
||||
// External
|
||||
{ name: 'google-workspace', port: 3156, category: 'external', enabled: false },
|
||||
{ name: 'google-analytics', port: 3164, category: 'external', enabled: false },
|
||||
{ name: 'gsc', port: 3165, category: 'external', enabled: false },
|
||||
{ name: 'youtube', port: 3166, category: 'external', enabled: false },
|
||||
{ name: 'youtube-research', port: 3167, category: 'external', enabled: false },
|
||||
{ name: 'moloni', port: 3158, category: 'external', enabled: false },
|
||||
{ name: 'n8n', port: 3171, category: 'external', enabled: false },
|
||||
{ name: 'gitea', port: 3162, category: 'external', enabled: true },
|
||||
{ name: 'stitch', port: 0, category: 'external', enabled: false },
|
||||
{ name: 'design-systems', port: 0, category: 'external', enabled: false },
|
||||
|
||||
// Gateway
|
||||
{ name: 'authentik', port: 3191, category: 'gateway', enabled: false },
|
||||
{ name: 'spaceship', port: 3189, category: 'gateway', enabled: false },
|
||||
{ name: 'puppeteer', port: 3193, category: 'gateway', enabled: false },
|
||||
{ name: 'lighthouse', port: 3194, category: 'gateway', enabled: false },
|
||||
|
||||
// Project
|
||||
{ name: 'carl-mcp', port: 0, category: 'project', enabled: true },
|
||||
{ name: 'reonic', port: 3187, category: 'project', enabled: false },
|
||||
]
|
||||
|
||||
const GATEWAY_URL = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt'
|
||||
const GATEWAY_TOKEN = process.env.MCP_GATEWAY_TOKEN ?? ''
|
||||
const PING_TIMEOUT_MS = 5000
|
||||
const CACHE_TTL_MS = 60_000
|
||||
|
||||
// Cache em memória
|
||||
let cache: { data: McpDashboard; timestamp: number } | null = null
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (GATEWAY_TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${GATEWAY_TOKEN}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async function pingGatewayHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS)
|
||||
const res = await fetch(`${GATEWAY_URL}/health`, { signal: controller.signal })
|
||||
clearTimeout(timer)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pingMcp(name: string): Promise<{ ok: boolean; response_time_ms: number | null }> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS)
|
||||
const res = await fetch(`${GATEWAY_URL}/v1/${name}/mcp`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(),
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timer)
|
||||
const elapsed = Date.now() - start
|
||||
// Qualquer resposta HTTP não-5xx conta como online
|
||||
return { ok: res.status < 500, response_time_ms: elapsed }
|
||||
} catch {
|
||||
return { ok: false, response_time_ms: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMcpDashboard(): Promise<McpDashboard> {
|
||||
// Servir cache se ainda válido
|
||||
if (cache !== null && Date.now() - cache.timestamp < CACHE_TTL_MS) {
|
||||
return cache.data
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Separar MCPs por tipo para determinar estratégia de ping
|
||||
const enabledWithPort = MCP_LIST.filter(m => m.enabled && m.port > 0)
|
||||
const enabledLocal = MCP_LIST.filter(m => m.enabled && m.port === 0)
|
||||
const disabledMcps = MCP_LIST.filter(m => !m.enabled)
|
||||
|
||||
// Ping gateway health + todos os MCPs com porta em paralelo
|
||||
const [gatewayResult, ...pingResults] = await Promise.allSettled([
|
||||
pingGatewayHealth(),
|
||||
...enabledWithPort.map(m => pingMcp(m.name)),
|
||||
])
|
||||
|
||||
const gatewayStatus: 'online' | 'offline' =
|
||||
gatewayResult.status === 'fulfilled' && gatewayResult.value ? 'online' : 'offline'
|
||||
|
||||
// MCPs enabled com porta gateway
|
||||
const enabledWithPortStatuses: McpStatus[] = enabledWithPort.map((m, idx) => {
|
||||
const result = pingResults[idx]
|
||||
const pong = result.status === 'fulfilled'
|
||||
? result.value
|
||||
: { ok: false, response_time_ms: null }
|
||||
return {
|
||||
name: m.name,
|
||||
port: m.port,
|
||||
category: m.category,
|
||||
enabled: true,
|
||||
status: pong.ok ? 'online' : 'offline',
|
||||
response_time_ms: pong.response_time_ms,
|
||||
last_check: now,
|
||||
} satisfies McpStatus
|
||||
})
|
||||
|
||||
// MCPs enabled locais (sem porta no gateway) — assumir online se enabled
|
||||
const enabledLocalStatuses: McpStatus[] = enabledLocal.map(m => ({
|
||||
name: m.name,
|
||||
port: m.port,
|
||||
category: m.category,
|
||||
enabled: true,
|
||||
status: 'online' as const,
|
||||
response_time_ms: null,
|
||||
last_check: now,
|
||||
}))
|
||||
|
||||
// MCPs disabled — sem ping, estado disabled
|
||||
const disabledStatuses: McpStatus[] = disabledMcps.map(m => ({
|
||||
name: m.name,
|
||||
port: m.port,
|
||||
category: m.category,
|
||||
enabled: false,
|
||||
status: 'disabled' as const,
|
||||
response_time_ms: null,
|
||||
last_check: now,
|
||||
}))
|
||||
|
||||
const allMcps: McpStatus[] = [
|
||||
...enabledWithPortStatuses,
|
||||
...enabledLocalStatuses,
|
||||
...disabledStatuses,
|
||||
]
|
||||
|
||||
// Ordenar por categoria e depois por nome
|
||||
allMcps.sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category)
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
const online = allMcps.filter(m => m.status === 'online').length
|
||||
const offline = allMcps.filter(m => m.status === 'offline').length
|
||||
const disabledCount = allMcps.filter(m => m.status === 'disabled').length
|
||||
|
||||
const data: McpDashboard = {
|
||||
gateway_status: gatewayStatus,
|
||||
total: allMcps.length,
|
||||
online,
|
||||
offline,
|
||||
disabled: disabledCount,
|
||||
mcps: allMcps,
|
||||
auth: {
|
||||
method: 'dual-layer: IP whitelist + Bearer token',
|
||||
token_expires: null,
|
||||
},
|
||||
}
|
||||
|
||||
cache = { data, timestamp: Date.now() }
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Monitoring Data Collector
|
||||
* HTTP health checks for services + EasyPanel API metrics + staleness detection
|
||||
* Runs every 5 minutes via scheduler in server.ts
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import db from '../db.js'
|
||||
|
||||
interface ServiceCheck {
|
||||
name: string
|
||||
url: string
|
||||
okStatuses?: number[] // Additional HTTP codes to treat as 'up' (e.g. 403 for gateway)
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
status: 'up' | 'down' | 'warning'
|
||||
http_code: number
|
||||
response_time: number
|
||||
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.
|
||||
* Each entry maps to a record in tbl_eal_monitoring (category='service').
|
||||
*/
|
||||
const SERVICES: ServiceCheck[] = [
|
||||
{ name: 'Desk CRM', url: 'https://desk.descomplicar.pt' },
|
||||
{ name: 'NextCloud', url: 'https://cloud.descomplicar.pt' },
|
||||
{ name: 'Gitea', url: 'https://git.descomplicar.pt' },
|
||||
{ name: 'Wiki.js', url: 'https://wiki.descomplicar.pt' },
|
||||
{ name: 'Syncthing', url: 'https://sync.descomplicar.pt' },
|
||||
{ name: 'Authentik', url: 'https://auth.descomplicar.pt' },
|
||||
{ name: 'Metabase', url: 'https://bi.descomplicar.pt' },
|
||||
{ name: 'N8N', url: 'https://automator.descomplicar.pt' },
|
||||
{ name: 'Outline', url: 'https://hub.descomplicar.pt' },
|
||||
{ name: 'WhatSMS', url: 'https://app.whatsms.pt' },
|
||||
{ name: 'MCP Gateway', url: 'http://gateway.descomplicar.pt', okStatuses: [403] },
|
||||
]
|
||||
|
||||
/**
|
||||
* Check a single URL and return health status.
|
||||
* Uses redirect: 'manual' so 302 (auth redirects) count as 'up'.
|
||||
*/
|
||||
async function checkUrl(url: string, timeoutMs = 10000): Promise<CheckResult> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
const response_time = Date.now() - start
|
||||
const http_code = response.status
|
||||
|
||||
// 2xx or 3xx = service is responding
|
||||
if (http_code >= 200 && http_code < 400) {
|
||||
return { status: 'up', http_code, response_time }
|
||||
}
|
||||
// 4xx = service responds but with client error
|
||||
if (http_code >= 400 && http_code < 500) {
|
||||
return { status: 'warning', http_code, response_time }
|
||||
}
|
||||
// 5xx = server error
|
||||
return { status: 'down', http_code, response_time }
|
||||
} catch (error: unknown) {
|
||||
const response_time = Date.now() - start
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
if (message.includes('abort')) {
|
||||
return { status: 'down', http_code: 0, response_time, error: 'Timeout' }
|
||||
}
|
||||
return { status: 'down', http_code: 0, response_time, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or insert a monitoring record.
|
||||
* Tries UPDATE first; if no row matches, does INSERT.
|
||||
*/
|
||||
async function upsertMonitoring(category: string, name: string, status: string, details: object): 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 any).affectedRows === 0) {
|
||||
await db.query(
|
||||
`INSERT INTO tbl_eal_monitoring (category, name, status, details, last_check) VALUES (?, ?, ?, ?, NOW())`,
|
||||
[category, name, status, detailsJson]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all services via HTTP and update DB.
|
||||
* Runs all checks in parallel for speed.
|
||||
*/
|
||||
export async function checkAllServices(): Promise<{ checked: number; up: number; down: number; warning: number }> {
|
||||
let up = 0, down = 0, warning = 0
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
SERVICES.map(async (service) => {
|
||||
const result = await checkUrl(service.url)
|
||||
|
||||
// Override status if HTTP code is in the service's okStatuses list
|
||||
if (result.status === 'warning' && service.okStatuses?.includes(result.http_code)) {
|
||||
result.status = 'up'
|
||||
}
|
||||
|
||||
await upsertMonitoring('service', service.name, result.status, {
|
||||
url: service.url,
|
||||
http_code: result.http_code,
|
||||
response_time: `${result.response_time}ms`,
|
||||
...(result.error ? { error: result.error } : {})
|
||||
})
|
||||
|
||||
return { name: service.name, ...result }
|
||||
})
|
||||
)
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
if (r.value.status === 'up') up++
|
||||
else if (r.value.status === 'warning') warning++
|
||||
else down++
|
||||
} else {
|
||||
down++
|
||||
}
|
||||
}
|
||||
|
||||
return { checked: SERVICES.length, up, down, warning }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark WP sites as warning if they haven't reported in >24h.
|
||||
* The WP plugin (descomplicar-monitor) POSTs data periodically.
|
||||
* If no data arrives, something is wrong.
|
||||
*/
|
||||
export async function checkStaleness(): Promise<number> {
|
||||
const [result] = await db.query(
|
||||
`UPDATE tbl_eal_monitoring
|
||||
SET status = 'warning',
|
||||
details = JSON_SET(COALESCE(details, '{}'), '$.stale', true, '$.stale_reason', 'No data received in 24h')
|
||||
WHERE category = 'wordpress'
|
||||
AND status IN ('ok', 'up')
|
||||
AND last_check < DATE_SUB(NOW(), INTERVAL 24 HOUR)`
|
||||
)
|
||||
return (result as any).affectedRows || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Call EasyPanel tRPC API endpoint.
|
||||
* Returns parsed JSON or null on failure.
|
||||
*/
|
||||
async function callEasyPanelAPI(endpoint: string): Promise<any | null> {
|
||||
if (!EASYPANEL_API_TOKEN) return null
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(`${EASYPANEL_API_URL}/${endpoint}`, {
|
||||
headers: { 'Authorization': `Bearer ${EASYPANEL_API_TOKEN}` },
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
if (!response.ok) return null
|
||||
|
||||
const data: any = await response.json()
|
||||
return data?.result?.data?.json ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect EasyPanel server metrics (CPU, RAM, disk) via API.
|
||||
* Replaces SSH-based collection for the Easy server.
|
||||
*/
|
||||
export async function collectEasyPanelMetrics(): Promise<boolean> {
|
||||
const stats = await callEasyPanelAPI('monitor.getSystemStats')
|
||||
if (!stats) return false
|
||||
|
||||
const cpu = Math.round(stats.cpuInfo?.usedPercentage ?? 0)
|
||||
const ram = Math.round((stats.memInfo?.usedMemPercentage ?? 0) * 10) / 10
|
||||
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,
|
||||
})
|
||||
|
||||
console.log(`[EASYPANEL] Server: CPU=${cpu}%, RAM=${ram}%, Disk=${disk}%`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect Docker container/task stats via EasyPanel API.
|
||||
* Updates the 'container' category in monitoring DB.
|
||||
*/
|
||||
export async function collectEasyPanelContainers(): Promise<boolean> {
|
||||
const tasks = await callEasyPanelAPI('monitor.getDockerTaskStats')
|
||||
if (!tasks) return false
|
||||
|
||||
let total = 0, up = 0, down = 0
|
||||
const unhealthy: string[] = []
|
||||
|
||||
for (const [name, info] of Object.entries(tasks) as [string, { actual: number; desired: number }][]) {
|
||||
total++
|
||||
if (info.actual >= info.desired) {
|
||||
up++
|
||||
} else {
|
||||
down++
|
||||
unhealthy.push(name.replace('descomplicar_', ''))
|
||||
}
|
||||
}
|
||||
|
||||
const status = down > 0 ? 'warning' : 'ok'
|
||||
await upsertMonitoring('container', 'EasyPanel Containers', status, {
|
||||
total, up, down, restarting: 0,
|
||||
...(unhealthy.length > 0 ? { unhealthy } : {}),
|
||||
})
|
||||
|
||||
console.log(`[EASYPANEL] Containers: ${up}/${total} running${down > 0 ? `, ${down} down: ${unhealthy.join(', ')}` : ''}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Main collector entry point.
|
||||
* Called by scheduler in server.ts every 5 minutes.
|
||||
*/
|
||||
export async function collectMonitoringData(): Promise<void> {
|
||||
console.log('[COLLECTOR] Starting monitoring collection...')
|
||||
|
||||
try {
|
||||
const services = await checkAllServices()
|
||||
console.log(`[COLLECTOR] Services: ${services.up} up, ${services.warning} warning, ${services.down} down`)
|
||||
} catch (err: unknown) {
|
||||
console.error('[COLLECTOR] Service checks failed:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
// EasyPanel API metrics (replaces SSH for Easy server)
|
||||
try {
|
||||
const gotStats = await collectEasyPanelMetrics()
|
||||
const gotContainers = await collectEasyPanelContainers()
|
||||
if (!gotStats && !gotContainers) {
|
||||
console.warn('[COLLECTOR] EasyPanel API unavailable (check EASYPANEL_API_TOKEN)')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('[COLLECTOR] EasyPanel collection failed:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
try {
|
||||
const stale = await checkStaleness()
|
||||
if (stale > 0) {
|
||||
console.log(`[COLLECTOR] Marked ${stale} stale WP site(s) as warning`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('[COLLECTOR] Staleness check failed:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
console.log('[COLLECTOR] Done')
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* n8n Workflows Service
|
||||
* Consulta a API REST do n8n e agrega dados de workflows e execuções.
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
// --- Tipos ---
|
||||
|
||||
interface N8nWorkflowRaw {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
tags?: { id: string; name: string }[]
|
||||
}
|
||||
|
||||
interface N8nExecutionRaw {
|
||||
id: string
|
||||
workflowId: string
|
||||
status: 'success' | 'error' | 'running' | 'canceled' | 'waiting'
|
||||
startedAt: string | null
|
||||
stoppedAt: string | null
|
||||
}
|
||||
|
||||
export interface N8nWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
last_execution: {
|
||||
status: 'success' | 'error' | 'running' | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
duration_ms: number | null
|
||||
} | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface N8nDashboard {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
failed_24h: number
|
||||
workflows: N8nWorkflow[]
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
// --- Cache em memória ---
|
||||
|
||||
interface CacheEntry {
|
||||
data: N8nDashboard
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 300 * 1000 // 300 segundos
|
||||
let cache: CacheEntry | null = null
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const apiKey = process.env.N8N_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'N8N_API_KEY não está configurada. Defina a variável de ambiente N8N_API_KEY com a chave de API do n8n.'
|
||||
)
|
||||
}
|
||||
return {
|
||||
'X-N8N-API-KEY': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.N8N_API_URL || 'https://automator.descomplicar.pt/api/v1'
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, headers: Record<string, string>): Promise<T> {
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
throw new Error(`n8n API respondeu com ${response.status} ${response.statusText} para ${url}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function normaliseStatus(
|
||||
raw: N8nExecutionRaw['status']
|
||||
): 'success' | 'error' | 'running' | null {
|
||||
if (raw === 'success') return 'success'
|
||||
if (raw === 'error' || raw === 'canceled') return 'error'
|
||||
if (raw === 'running' || raw === 'waiting') return 'running'
|
||||
return null
|
||||
}
|
||||
|
||||
function calcDurationMs(started: string | null, stopped: string | null): number | null {
|
||||
if (!started || !stopped) return null
|
||||
const diff = new Date(stopped).getTime() - new Date(started).getTime()
|
||||
return isNaN(diff) ? null : diff
|
||||
}
|
||||
|
||||
// --- Lógica principal ---
|
||||
|
||||
export async function getN8nDashboard(): Promise<N8nDashboard> {
|
||||
// Servir do cache se ainda válido
|
||||
if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS) {
|
||||
return cache.data
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const headers = buildHeaders() // lança erro se sem API key
|
||||
|
||||
// Buscar workflows e execuções em paralelo
|
||||
const [workflowsResp, executionsResp] = await Promise.all([
|
||||
fetchJson<{ data: N8nWorkflowRaw[] } | N8nWorkflowRaw[]>(
|
||||
`${baseUrl}/workflows`,
|
||||
headers
|
||||
),
|
||||
fetchJson<{ data: N8nExecutionRaw[] } | N8nExecutionRaw[]>(
|
||||
`${baseUrl}/executions?limit=50`,
|
||||
headers
|
||||
),
|
||||
])
|
||||
|
||||
// n8n pode retornar array directo ou { data: [...] }
|
||||
const workflowsRaw: N8nWorkflowRaw[] = Array.isArray(workflowsResp)
|
||||
? workflowsResp
|
||||
: (workflowsResp as { data: N8nWorkflowRaw[] }).data ?? []
|
||||
|
||||
const executionsRaw: N8nExecutionRaw[] = Array.isArray(executionsResp)
|
||||
? executionsResp
|
||||
: (executionsResp as { data: N8nExecutionRaw[] }).data ?? []
|
||||
|
||||
// Mapear última execução por workflowId (a mais recente)
|
||||
const lastExecByWorkflow = new Map<string, N8nExecutionRaw>()
|
||||
for (const exec of executionsRaw) {
|
||||
const existing = lastExecByWorkflow.get(exec.workflowId)
|
||||
if (!existing || new Date(exec.startedAt ?? 0) > new Date(existing.startedAt ?? 0)) {
|
||||
lastExecByWorkflow.set(exec.workflowId, exec)
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular falhas nas últimas 24h
|
||||
const cutoff24h = Date.now() - 24 * 60 * 60 * 1000
|
||||
let failed_24h = 0
|
||||
for (const exec of executionsRaw) {
|
||||
if (
|
||||
(exec.status === 'error' || exec.status === 'canceled') &&
|
||||
exec.startedAt &&
|
||||
new Date(exec.startedAt).getTime() >= cutoff24h
|
||||
) {
|
||||
failed_24h++
|
||||
}
|
||||
}
|
||||
|
||||
// Construir lista de workflows enriquecida
|
||||
const workflows: N8nWorkflow[] = workflowsRaw.map((wf) => {
|
||||
const lastExec = lastExecByWorkflow.get(wf.id) ?? null
|
||||
return {
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
active: wf.active,
|
||||
last_execution: lastExec
|
||||
? {
|
||||
status: normaliseStatus(lastExec.status),
|
||||
started_at: lastExec.startedAt,
|
||||
finished_at: lastExec.stoppedAt,
|
||||
duration_ms: calcDurationMs(lastExec.startedAt, lastExec.stoppedAt),
|
||||
}
|
||||
: null,
|
||||
tags: (wf.tags ?? []).map((t) => t.name),
|
||||
}
|
||||
})
|
||||
|
||||
const dashboard: N8nDashboard = {
|
||||
total: workflows.length,
|
||||
active: workflows.filter((w) => w.active).length,
|
||||
inactive: workflows.filter((w) => !w.active).length,
|
||||
failed_24h,
|
||||
workflows,
|
||||
last_updated: new Date().toISOString(),
|
||||
}
|
||||
|
||||
cache = { data: dashboard, timestamp: Date.now() }
|
||||
return dashboard
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Operations Dashboard Service — Tickets e Cobertura de PROCs
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import db from '../db.js'
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OperationsDashboard {
|
||||
tickets: {
|
||||
open: number
|
||||
high_priority: number
|
||||
avg_response_hours: number
|
||||
by_department: { dept: string; count: number }[]
|
||||
}
|
||||
procedures: {
|
||||
total: number
|
||||
departments: number
|
||||
coverage: { dept: string; procs: number; total_expected: number; pct: number }[]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dados estáticos — cobertura de PROCs por departamento
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROC_COVERAGE = [
|
||||
{ dept: 'D1 — Comercial', procs: 5, total_expected: 8 },
|
||||
{ dept: 'D2 — Suporte', procs: 3, total_expected: 6 },
|
||||
{ dept: 'D3 — Contabilidade', procs: 3, total_expected: 5 },
|
||||
{ dept: 'D4 — RH', procs: 1, total_expected: 4 },
|
||||
{ dept: 'D5 — Marketing', procs: 5, total_expected: 8 },
|
||||
{ dept: 'D6 — Gestão', procs: 8, total_expected: 10 },
|
||||
{ dept: 'D7 — Tecnologia', procs: 18, total_expected: 20 },
|
||||
{ dept: 'Cross-Departamental', procs: 5, total_expected: 6 },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query principal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getOperationsDashboard(): Promise<OperationsDashboard> {
|
||||
// Queries paralelas para melhor performance
|
||||
const [
|
||||
ticketsAbertosResult,
|
||||
ticketsAltaPrioridadeResult,
|
||||
ticketsPorDepartamentoResult,
|
||||
tempoMedioRespostaResult,
|
||||
] = await Promise.all([
|
||||
// a) Total de tickets abertos (status 1=Open, 2=In Progress, 3=Answered)
|
||||
db.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM tbltickets
|
||||
WHERE status IN ('1','2','3')`
|
||||
),
|
||||
|
||||
// b) Tickets de alta prioridade ainda abertos (priority 2=High, 3=Urgent)
|
||||
db.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM tbltickets
|
||||
WHERE priority IN (2,3)
|
||||
AND status IN ('1','2','3')`
|
||||
),
|
||||
|
||||
// c) Tickets abertos agrupados por departamento
|
||||
db.query<RowDataPacket[]>(
|
||||
`SELECT d.name as dept, COUNT(t.ticketid) as count
|
||||
FROM tbltickets t
|
||||
LEFT JOIN tbldepartments d ON t.department = d.departmentid
|
||||
WHERE t.status IN ('1','2','3')
|
||||
GROUP BY d.departmentid, d.name
|
||||
ORDER BY count DESC`
|
||||
),
|
||||
|
||||
// d) Tempo médio de resposta em horas (últimos 90 dias)
|
||||
db.query<RowDataPacket[]>(
|
||||
`SELECT ROUND(AVG(TIMESTAMPDIFF(HOUR, t.date, t.lastreply)), 1) as avg_hours
|
||||
FROM tbltickets t
|
||||
WHERE t.lastreply IS NOT NULL
|
||||
AND t.lastreply != '0000-00-00 00:00:00'
|
||||
AND t.date > DATE_SUB(CURDATE(), INTERVAL 90 DAY)`
|
||||
),
|
||||
])
|
||||
|
||||
// Extrair valores das queries
|
||||
const ticketsAbertos = (ticketsAbertosResult[0][0] as RowDataPacket)?.count ?? 0
|
||||
const ticketsAltaPrioridade = (ticketsAltaPrioridadeResult[0][0] as RowDataPacket)?.count ?? 0
|
||||
const avgResponseHours = (tempoMedioRespostaResult[0][0] as RowDataPacket)?.avg_hours ?? 0
|
||||
|
||||
const byDepartment = (ticketsPorDepartamentoResult[0] as RowDataPacket[]).map(row => ({
|
||||
dept: (row.dept as string) || 'Sem departamento',
|
||||
count: Number(row.count) || 0,
|
||||
}))
|
||||
|
||||
// Calcular cobertura em percentagem
|
||||
const coverage = PROC_COVERAGE.map(item => ({
|
||||
dept: item.dept,
|
||||
procs: item.procs,
|
||||
total_expected: item.total_expected,
|
||||
pct: Math.round((item.procs / item.total_expected) * 100),
|
||||
}))
|
||||
|
||||
const totalProcs = PROC_COVERAGE.reduce((sum, d) => sum + d.procs, 0)
|
||||
const totalDepartments = PROC_COVERAGE.filter(d => d.dept.startsWith('D')).length
|
||||
|
||||
return {
|
||||
tickets: {
|
||||
open: Number(ticketsAbertos),
|
||||
high_priority: Number(ticketsAltaPrioridade),
|
||||
avg_response_hours: Number(avgResponseHours),
|
||||
by_department: byDepartment,
|
||||
},
|
||||
procedures: {
|
||||
total: totalProcs,
|
||||
departments: totalDepartments,
|
||||
coverage,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Paperclip PostgreSQL Connection Pool
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import 'dotenv/config'
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
// Validação de credenciais — sem elas, pool não é criado (fallback gracioso)
|
||||
const user = process.env.PAPERCLIP_DB_USER
|
||||
const password = process.env.PAPERCLIP_DB_PASS
|
||||
|
||||
if (!user || !password) {
|
||||
console.warn(
|
||||
'[paperclip-db] PAPERCLIP_DB_USER ou PAPERCLIP_DB_PASS não definidos — ' +
|
||||
'pool PostgreSQL não será criado. A página Paperclip usará dados de fallback.'
|
||||
)
|
||||
}
|
||||
|
||||
// Configuração do pool PostgreSQL
|
||||
const pool: pg.Pool | null = (user && password)
|
||||
? new Pool({
|
||||
host: process.env.PAPERCLIP_DB_HOST || 'clip.descomplicar.pt',
|
||||
port: parseInt(process.env.PAPERCLIP_DB_PORT || '54329', 10),
|
||||
database: process.env.PAPERCLIP_DB_NAME || 'paperclip',
|
||||
user,
|
||||
password,
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
})
|
||||
: null
|
||||
|
||||
// Teste de conexão (apenas se pool configurado)
|
||||
if (pool) {
|
||||
pool.connect()
|
||||
.then(client => {
|
||||
console.log('[paperclip-db] PostgreSQL conectado com sucesso')
|
||||
client.release()
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[paperclip-db] Erro ao conectar ao PostgreSQL:', err.message)
|
||||
})
|
||||
}
|
||||
|
||||
export default pool
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Paperclip Service — Queries ao PostgreSQL do Paperclip
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*
|
||||
* NOTA SOBRE ESQUEMA DA BD:
|
||||
* Os nomes de tabelas e colunas abaixo são baseados na spec (SPEC-dashboard-expansion-q2-2026.md §4.3).
|
||||
* Se o esquema real da BD Paperclip diferir, ajustar as queries:
|
||||
* - Tabela de agentes: 'agents' (colunas: name, role, status, last_heartbeat)
|
||||
* - Tabela de routines: 'routines' (colunas: name, cron_expression, enabled, last_run_at, last_run_status)
|
||||
* - Tabela de issues: 'issues' (colunas: status, closed_at)
|
||||
* Verificar schema real com: \dt e \d <tabela> no psql.
|
||||
*/
|
||||
import pool from './paperclip-db.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (espelham a spec)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PaperclipAgent {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
status: 'active' | 'idle' | 'error' | 'archived'
|
||||
last_heartbeat: string | null
|
||||
last_run: string | null
|
||||
total_runs: number
|
||||
}
|
||||
|
||||
export interface PaperclipRoutine {
|
||||
id: string
|
||||
name: string
|
||||
cron: string
|
||||
active: boolean
|
||||
last_run: string | null
|
||||
last_status: 'success' | 'error' | null
|
||||
next_run: string | null
|
||||
}
|
||||
|
||||
export interface PaperclipDashboard {
|
||||
agents: {
|
||||
total: number
|
||||
active: number
|
||||
idle: number
|
||||
error: number
|
||||
list: PaperclipAgent[]
|
||||
}
|
||||
routines: {
|
||||
total: number
|
||||
active: number
|
||||
list: PaperclipRoutine[]
|
||||
}
|
||||
issues: {
|
||||
open: number
|
||||
in_progress: number
|
||||
closed_7d: number
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dados de fallback (quando BD não está configurada ou inacessível)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FALLBACK: PaperclipDashboard = {
|
||||
agents: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
error: 0,
|
||||
list: [],
|
||||
},
|
||||
routines: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
list: [],
|
||||
},
|
||||
issues: {
|
||||
open: 0,
|
||||
in_progress: 0,
|
||||
closed_7d: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Função principal — todas as queries em paralelo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getPaperclipDashboard(): Promise<PaperclipDashboard> {
|
||||
// Se pool não foi configurado (credenciais em falta), retornar fallback
|
||||
if (!pool) {
|
||||
return { ...FALLBACK }
|
||||
}
|
||||
|
||||
try {
|
||||
const [agentsResult, routinesResult, issuesOpenResult, issuesInProgressResult, issuesClosedResult] =
|
||||
await Promise.all([
|
||||
// Query A: Agentes activos (excluindo archived)
|
||||
// NOTA: colunas last_run e total_runs podem não existir — ajustar se necessário
|
||||
pool.query<{
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
status: string
|
||||
last_heartbeat: string | null
|
||||
last_run: string | null
|
||||
total_runs: number
|
||||
}>(
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
status,
|
||||
last_heartbeat,
|
||||
last_run_at AS last_run,
|
||||
COALESCE(total_runs, 0) AS total_runs
|
||||
FROM agents
|
||||
WHERE status != 'archived'
|
||||
ORDER BY role, name`
|
||||
),
|
||||
|
||||
// Query B: Routines ordenadas por nome
|
||||
pool.query<{
|
||||
id: string
|
||||
name: string
|
||||
cron: string
|
||||
active: boolean
|
||||
last_run: string | null
|
||||
last_status: string | null
|
||||
next_run: string | null
|
||||
}>(
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
cron_expression AS cron,
|
||||
enabled AS active,
|
||||
last_run_at AS last_run,
|
||||
last_run_status AS last_status,
|
||||
next_run_at AS next_run
|
||||
FROM routines
|
||||
ORDER BY name`
|
||||
),
|
||||
|
||||
// Query C1: Issues abertas
|
||||
pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM issues WHERE status = 'open'`
|
||||
),
|
||||
|
||||
// Query C2: Issues em progresso
|
||||
pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM issues WHERE status = 'in_progress'`
|
||||
),
|
||||
|
||||
// Query C3: Issues fechadas nos últimos 7 dias
|
||||
pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM issues WHERE status = 'closed' AND closed_at > NOW() - INTERVAL '7 days'`
|
||||
),
|
||||
])
|
||||
|
||||
const agents = agentsResult.rows
|
||||
const routines = routinesResult.rows
|
||||
|
||||
// Contagens de agentes por estado
|
||||
const activeCount = agents.filter(a => a.status === 'active').length
|
||||
const idleCount = agents.filter(a => a.status === 'idle').length
|
||||
const errorCount = agents.filter(a => a.status === 'error').length
|
||||
|
||||
// Contagens de routines activas
|
||||
const activeRoutines = routines.filter(r => r.active).length
|
||||
|
||||
return {
|
||||
agents: {
|
||||
total: agents.length,
|
||||
active: activeCount,
|
||||
idle: idleCount,
|
||||
error: errorCount,
|
||||
list: agents.map(a => ({
|
||||
id: a.id ?? '',
|
||||
name: a.name,
|
||||
role: a.role,
|
||||
status: a.status as PaperclipAgent['status'],
|
||||
last_heartbeat: a.last_heartbeat ?? null,
|
||||
last_run: a.last_run ?? null,
|
||||
total_runs: Number(a.total_runs) || 0,
|
||||
})),
|
||||
},
|
||||
routines: {
|
||||
total: routines.length,
|
||||
active: activeRoutines,
|
||||
list: routines.map(r => ({
|
||||
id: r.id ?? '',
|
||||
name: r.name,
|
||||
cron: r.cron,
|
||||
active: Boolean(r.active),
|
||||
last_run: r.last_run ?? null,
|
||||
last_status: (r.last_status as PaperclipRoutine['last_status']) ?? null,
|
||||
next_run: r.next_run ?? null,
|
||||
})),
|
||||
},
|
||||
issues: {
|
||||
open: parseInt(issuesOpenResult.rows[0]?.count ?? '0', 10),
|
||||
in_progress: parseInt(issuesInProgressResult.rows[0]?.count ?? '0', 10),
|
||||
closed_7d: parseInt(issuesClosedResult.rows[0]?.count ?? '0', 10),
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[paperclip] Erro ao obter dados da BD Paperclip:', (err as Error).message)
|
||||
// Retornar fallback em caso de falha de BD (sem rebentar a API)
|
||||
return { ...FALLBACK }
|
||||
}
|
||||
}
|
||||
@@ -15,54 +15,17 @@ interface SSHServer {
|
||||
pass: string
|
||||
}
|
||||
|
||||
// EasyPanel metrics: collected via API in monitoring-collector.ts
|
||||
// Gateway metrics: not needed (just Nginx proxy, covered by HTTP health check)
|
||||
// Only CWP Server remains on SSH (password auth)
|
||||
const SSH_SERVERS: SSHServer[] = [
|
||||
{
|
||||
name: 'server',
|
||||
monitorName: 'CWP Server',
|
||||
host: process.env.SERVER_HOST || '176.9.3.158',
|
||||
host: process.env.SERVER_HOST || '5.9.90.105',
|
||||
port: parseInt(process.env.SERVER_PORT || '9443'),
|
||||
user: process.env.SERVER_USER || 'root',
|
||||
pass: process.env.SERVER_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'easy',
|
||||
monitorName: 'EasyPanel',
|
||||
host: process.env.EASY_HOST || '178.63.18.51',
|
||||
port: 22,
|
||||
user: process.env.EASY_USER || 'root',
|
||||
pass: process.env.EASY_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'mcp-hub',
|
||||
monitorName: 'MCP Hub',
|
||||
host: process.env.MCPHUB_HOST || 'mcp-hub.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.MCPHUB_USER || 'root',
|
||||
pass: process.env.MCPHUB_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'meet',
|
||||
monitorName: 'Meet',
|
||||
host: process.env.MEET_HOST || 'meet.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.MEET_USER || 'root',
|
||||
pass: process.env.MEET_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'whatsapp',
|
||||
monitorName: 'WhatsApp',
|
||||
host: process.env.WHATSAPP_HOST || 'whatsapp.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.WHATSAPP_USER || 'root',
|
||||
pass: process.env.WHATSAPP_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'whatsms',
|
||||
monitorName: 'WhatSMS',
|
||||
host: process.env.WHATSMS_HOST || 'whatsms.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.WHATSMS_USER || 'root',
|
||||
pass: process.env.WHATSMS_PASS || ''
|
||||
}
|
||||
]
|
||||
|
||||
@@ -192,27 +155,6 @@ export async function collectSSHMetrics(): Promise<{ success: number; failed: nu
|
||||
]
|
||||
)
|
||||
|
||||
// Update containers if EasyPanel
|
||||
if (server.name === 'easy' && metrics.containers !== undefined) {
|
||||
try {
|
||||
const containerOutput = await executeSSH(server, 'docker ps -a --format "{{.Status}}" | grep -c "Up" || echo 0; docker ps -aq | wc -l')
|
||||
const [up, total] = containerOutput.trim().split('\n').map(n => parseInt(n) || 0)
|
||||
const down = total - up
|
||||
|
||||
await db.query(
|
||||
`UPDATE tbl_eal_monitoring
|
||||
SET details = ?, status = ?, last_check = NOW()
|
||||
WHERE category = 'container' AND name = 'EasyPanel Containers'`,
|
||||
[
|
||||
JSON.stringify({ total, up, down, restarting: 0 }),
|
||||
down > 0 ? 'warning' : 'ok'
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
// Container stats are optional
|
||||
}
|
||||
}
|
||||
|
||||
success++
|
||||
console.log(`[SSH] ${server.monitorName}: CPU=${metrics.cpu}%, RAM=${metrics.ram}%, Disk=${metrics.disk}%`)
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import type { SessionMeta } from '../../types/session.js'
|
||||
|
||||
export interface ListFilters {
|
||||
days?: number
|
||||
project?: string
|
||||
tool?: string
|
||||
skill?: string
|
||||
q?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface PatternRecord {
|
||||
id?: number
|
||||
detected_at: string
|
||||
week_iso: string
|
||||
pattern_key: string
|
||||
title: string
|
||||
description: string
|
||||
severity: 'info' | 'warning' | 'action'
|
||||
metric_value: number | null
|
||||
sample_session_ids: string[]
|
||||
affected_count: number
|
||||
consecutive_weeks: number
|
||||
}
|
||||
|
||||
export interface WorklogCommentRecord {
|
||||
id: number
|
||||
discussion_id: number
|
||||
created_at: string
|
||||
staff_id: number | null
|
||||
title: string | null
|
||||
task_ref: string | null
|
||||
duration_sec: number | null
|
||||
work_items: string[]
|
||||
files_modified: string[]
|
||||
problems: { problema: string; solucao: string }[]
|
||||
patterns_text: string[]
|
||||
actions: { tipo: string; descricao: string; prioridade: string | null }[]
|
||||
raw_html: string
|
||||
imported_at: string
|
||||
}
|
||||
|
||||
export interface WorklogFilters {
|
||||
discussion_id?: number
|
||||
task_ref?: string
|
||||
sinceIso?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface SessionsDb {
|
||||
upsertSession(meta: SessionMeta): void
|
||||
upsertMany(metas: SessionMeta[]): void
|
||||
listSessions(filters: ListFilters): SessionMeta[]
|
||||
countSessions(filters: ListFilters): number
|
||||
getSession(id: string): SessionMeta | null
|
||||
deleteByJsonlPath(path: string): void
|
||||
upsertPattern(p: PatternRecord): void
|
||||
getPatternsByWeek(week: string): PatternRecord[]
|
||||
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number
|
||||
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean }
|
||||
hasWorklogComment(id: number): boolean
|
||||
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[]
|
||||
countWorklogComments(filters?: WorklogFilters): number
|
||||
rawDb(): Database.Database
|
||||
close(): void
|
||||
}
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
project_path TEXT NOT NULL,
|
||||
project_slug TEXT NOT NULL,
|
||||
jsonl_path TEXT NOT NULL UNIQUE,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
duration_sec INTEGER,
|
||||
event_count INTEGER NOT NULL,
|
||||
user_messages INTEGER NOT NULL,
|
||||
assistant_msgs INTEGER NOT NULL,
|
||||
tool_calls INTEGER NOT NULL,
|
||||
first_prompt TEXT,
|
||||
tools_used TEXT NOT NULL,
|
||||
skills_invoked TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
permission_mode TEXT,
|
||||
file_size INTEGER 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_project ON sessions(project_slug, started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS patterns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_at TEXT NOT NULL,
|
||||
week_iso TEXT NOT NULL,
|
||||
pattern_key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
metric_value REAL,
|
||||
sample_session_ids TEXT NOT NULL,
|
||||
affected_count INTEGER NOT NULL,
|
||||
consecutive_weeks INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(week_iso, pattern_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_patterns_week ON patterns(week_iso);
|
||||
CREATE INDEX IF NOT EXISTS idx_patterns_key ON patterns(pattern_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worklog_comments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
discussion_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
staff_id INTEGER,
|
||||
title TEXT,
|
||||
task_ref TEXT,
|
||||
duration_sec INTEGER,
|
||||
work_items TEXT NOT NULL,
|
||||
files_modified TEXT NOT NULL,
|
||||
problems_json TEXT NOT NULL,
|
||||
patterns_text TEXT NOT NULL,
|
||||
actions_json TEXT NOT NULL,
|
||||
raw_html TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_wc_discussion ON worklog_comments(discussion_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_wc_task ON worklog_comments(task_ref);
|
||||
`
|
||||
|
||||
function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
||||
return {
|
||||
session_id: row.session_id as string,
|
||||
project_path: row.project_path as string,
|
||||
project_slug: row.project_slug as string,
|
||||
jsonl_path: row.jsonl_path as string,
|
||||
started_at: row.started_at as string,
|
||||
ended_at: (row.ended_at as string | null) ?? null,
|
||||
duration_sec: (row.duration_sec as number | null) ?? null,
|
||||
event_count: row.event_count as number,
|
||||
user_messages: row.user_messages as number,
|
||||
assistant_msgs: row.assistant_msgs as number,
|
||||
tool_calls: row.tool_calls as number,
|
||||
first_prompt: (row.first_prompt as string | null) ?? null,
|
||||
tools_used: JSON.parse(row.tools_used as string),
|
||||
skills_invoked: JSON.parse(row.skills_invoked as string),
|
||||
outcome: row.outcome as SessionMeta['outcome'],
|
||||
permission_mode: (row.permission_mode as string | null) ?? null,
|
||||
file_size: row.file_size as number,
|
||||
indexed_at: row.indexed_at as string,
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(f: ListFilters): { sql: string; params: Record<string, unknown> } {
|
||||
const parts: string[] = []
|
||||
const params: Record<string, unknown> = {}
|
||||
if (f.days) {
|
||||
const cutoff = new Date(Date.now() - f.days * 86400_000).toISOString()
|
||||
parts.push('started_at >= @cutoff')
|
||||
params.cutoff = cutoff
|
||||
}
|
||||
if (f.project) {
|
||||
parts.push('project_slug = @project')
|
||||
params.project = f.project
|
||||
}
|
||||
if (f.tool) {
|
||||
parts.push("tools_used LIKE @toolLike")
|
||||
params.toolLike = `%"${f.tool}"%`
|
||||
}
|
||||
if (f.skill) {
|
||||
parts.push('skills_invoked LIKE @skillLike')
|
||||
params.skillLike = `%"${f.skill}"%`
|
||||
}
|
||||
if (f.q) {
|
||||
parts.push('first_prompt LIKE @q')
|
||||
params.q = `%${f.q}%`
|
||||
}
|
||||
return {
|
||||
sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '',
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
export function openSessionsDb(dbPath: string): SessionsDb {
|
||||
mkdirSync(dirname(dbPath), { recursive: true })
|
||||
const db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('synchronous = NORMAL')
|
||||
db.exec(SCHEMA)
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at,
|
||||
duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt,
|
||||
tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at)
|
||||
VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at,
|
||||
@duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt,
|
||||
@tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
project_path = excluded.project_path,
|
||||
project_slug = excluded.project_slug,
|
||||
jsonl_path = excluded.jsonl_path,
|
||||
started_at = excluded.started_at,
|
||||
ended_at = excluded.ended_at,
|
||||
duration_sec = excluded.duration_sec,
|
||||
event_count = excluded.event_count,
|
||||
user_messages = excluded.user_messages,
|
||||
assistant_msgs = excluded.assistant_msgs,
|
||||
tool_calls = excluded.tool_calls,
|
||||
first_prompt = excluded.first_prompt,
|
||||
tools_used = excluded.tools_used,
|
||||
skills_invoked = excluded.skills_invoked,
|
||||
outcome = excluded.outcome,
|
||||
permission_mode = excluded.permission_mode,
|
||||
file_size = excluded.file_size,
|
||||
indexed_at = excluded.indexed_at
|
||||
`)
|
||||
|
||||
const upsertManyTxn = db.transaction((metas: SessionMeta[]) => {
|
||||
for (const meta of metas) {
|
||||
upsertStmt.run({
|
||||
...meta,
|
||||
tools_used: JSON.stringify(meta.tools_used),
|
||||
skills_invoked: JSON.stringify(meta.skills_invoked),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
upsertSession(meta) {
|
||||
upsertStmt.run({
|
||||
...meta,
|
||||
tools_used: JSON.stringify(meta.tools_used),
|
||||
skills_invoked: JSON.stringify(meta.skills_invoked),
|
||||
})
|
||||
},
|
||||
upsertMany(metas) {
|
||||
upsertManyTxn(metas)
|
||||
},
|
||||
listSessions(filters) {
|
||||
const { sql, params } = buildWhere(filters)
|
||||
const limit = filters.limit ?? 50
|
||||
const offset = filters.offset ?? 0
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM sessions ${sql} ORDER BY started_at DESC LIMIT @limit OFFSET @offset`)
|
||||
.all({ ...params, limit, offset }) as Record<string, unknown>[]
|
||||
return rows.map(rowToMeta)
|
||||
},
|
||||
countSessions(filters) {
|
||||
const { sql, params } = buildWhere(filters)
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM sessions ${sql}`).get(params) as { c: number }
|
||||
return row.c
|
||||
},
|
||||
getSession(id) {
|
||||
const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(id) as Record<string, unknown> | undefined
|
||||
return row ? rowToMeta(row) : null
|
||||
},
|
||||
deleteByJsonlPath(path) {
|
||||
db.prepare('DELETE FROM sessions WHERE jsonl_path = ?').run(path)
|
||||
},
|
||||
upsertPattern(p: PatternRecord) {
|
||||
db.prepare(`
|
||||
INSERT INTO patterns (detected_at, week_iso, pattern_key, title, description,
|
||||
severity, metric_value, sample_session_ids, affected_count, consecutive_weeks)
|
||||
VALUES (@detected_at, @week_iso, @pattern_key, @title, @description,
|
||||
@severity, @metric_value, @sample_session_ids, @affected_count, @consecutive_weeks)
|
||||
ON CONFLICT(week_iso, pattern_key) DO UPDATE SET
|
||||
detected_at = excluded.detected_at,
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
severity = excluded.severity,
|
||||
metric_value = excluded.metric_value,
|
||||
sample_session_ids = excluded.sample_session_ids,
|
||||
affected_count = excluded.affected_count,
|
||||
consecutive_weeks = excluded.consecutive_weeks
|
||||
`).run({
|
||||
detected_at: p.detected_at,
|
||||
week_iso: p.week_iso,
|
||||
pattern_key: p.pattern_key,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
severity: p.severity,
|
||||
metric_value: p.metric_value,
|
||||
sample_session_ids: JSON.stringify(p.sample_session_ids),
|
||||
affected_count: p.affected_count,
|
||||
consecutive_weeks: p.consecutive_weeks,
|
||||
})
|
||||
},
|
||||
getPatternsByWeek(week: string): PatternRecord[] {
|
||||
const rows = db.prepare('SELECT * FROM patterns WHERE week_iso = ? ORDER BY severity DESC, affected_count DESC').all(week) as Record<string, unknown>[]
|
||||
return rows.map((r) => ({
|
||||
id: r.id as number,
|
||||
detected_at: r.detected_at as string,
|
||||
week_iso: r.week_iso as string,
|
||||
pattern_key: r.pattern_key as string,
|
||||
title: r.title as string,
|
||||
description: r.description as string,
|
||||
severity: r.severity as PatternRecord['severity'],
|
||||
metric_value: (r.metric_value as number | null) ?? null,
|
||||
sample_session_ids: JSON.parse(r.sample_session_ids as string),
|
||||
affected_count: r.affected_count as number,
|
||||
consecutive_weeks: r.consecutive_weeks as number,
|
||||
}))
|
||||
},
|
||||
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number {
|
||||
// Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu
|
||||
const rows = db.prepare('SELECT DISTINCT week_iso FROM patterns WHERE pattern_key = ? AND week_iso <= ? ORDER BY week_iso DESC').all(pattern_key, uptoWeek) as { week_iso: string }[]
|
||||
if (rows.length === 0) return 0
|
||||
let count = 0
|
||||
let cursor = uptoWeek
|
||||
for (const row of rows) {
|
||||
if (row.week_iso === cursor) {
|
||||
count++
|
||||
cursor = prevWeekIso(cursor)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
},
|
||||
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } {
|
||||
const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id)
|
||||
const inserted = !existing
|
||||
db.prepare(`
|
||||
INSERT INTO worklog_comments (id, discussion_id, created_at, staff_id, title, task_ref,
|
||||
duration_sec, work_items, files_modified, problems_json, patterns_text, actions_json,
|
||||
raw_html, imported_at)
|
||||
VALUES (@id, @discussion_id, @created_at, @staff_id, @title, @task_ref,
|
||||
@duration_sec, @work_items, @files_modified, @problems_json, @patterns_text, @actions_json,
|
||||
@raw_html, @imported_at)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
discussion_id = excluded.discussion_id,
|
||||
created_at = excluded.created_at,
|
||||
staff_id = excluded.staff_id,
|
||||
title = excluded.title,
|
||||
task_ref = excluded.task_ref,
|
||||
duration_sec = excluded.duration_sec,
|
||||
work_items = excluded.work_items,
|
||||
files_modified = excluded.files_modified,
|
||||
problems_json = excluded.problems_json,
|
||||
patterns_text = excluded.patterns_text,
|
||||
actions_json = excluded.actions_json,
|
||||
raw_html = excluded.raw_html,
|
||||
imported_at = excluded.imported_at
|
||||
`).run({
|
||||
id: c.id,
|
||||
discussion_id: c.discussion_id,
|
||||
created_at: c.created_at,
|
||||
staff_id: c.staff_id,
|
||||
title: c.title,
|
||||
task_ref: c.task_ref,
|
||||
duration_sec: c.duration_sec,
|
||||
work_items: JSON.stringify(c.work_items),
|
||||
files_modified: JSON.stringify(c.files_modified),
|
||||
problems_json: JSON.stringify(c.problems),
|
||||
patterns_text: JSON.stringify(c.patterns_text),
|
||||
actions_json: JSON.stringify(c.actions),
|
||||
raw_html: c.raw_html,
|
||||
imported_at: c.imported_at,
|
||||
})
|
||||
return { inserted }
|
||||
},
|
||||
hasWorklogComment(id: number): boolean {
|
||||
return !!db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(id)
|
||||
},
|
||||
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[] {
|
||||
const parts: string[] = []
|
||||
const params: Record<string, unknown> = {}
|
||||
if (filters.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id }
|
||||
if (filters.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref }
|
||||
if (filters.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso }
|
||||
const where = parts.length ? 'WHERE ' + parts.join(' AND ') : ''
|
||||
const limit = filters.limit ?? 1000
|
||||
const offset = filters.offset ?? 0
|
||||
const rows = db.prepare(`SELECT * FROM worklog_comments ${where} ORDER BY created_at DESC LIMIT @limit OFFSET @offset`)
|
||||
.all({ ...params, limit, offset }) as Record<string, unknown>[]
|
||||
return rows.map((r) => ({
|
||||
id: r.id as number,
|
||||
discussion_id: r.discussion_id as number,
|
||||
created_at: r.created_at as string,
|
||||
staff_id: (r.staff_id as number | null) ?? null,
|
||||
title: (r.title as string | null) ?? null,
|
||||
task_ref: (r.task_ref as string | null) ?? null,
|
||||
duration_sec: (r.duration_sec as number | null) ?? null,
|
||||
work_items: JSON.parse(r.work_items as string),
|
||||
files_modified: JSON.parse(r.files_modified as string),
|
||||
problems: JSON.parse(r.problems_json as string),
|
||||
patterns_text: JSON.parse(r.patterns_text as string),
|
||||
actions: JSON.parse(r.actions_json as string),
|
||||
raw_html: r.raw_html as string,
|
||||
imported_at: r.imported_at as string,
|
||||
}))
|
||||
},
|
||||
countWorklogComments(filters?: WorklogFilters): number {
|
||||
const parts: string[] = []
|
||||
const params: Record<string, unknown> = {}
|
||||
if (filters?.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id }
|
||||
if (filters?.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref }
|
||||
if (filters?.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso }
|
||||
const where = parts.length ? 'WHERE ' + parts.join(' AND ') : ''
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM worklog_comments ${where}`).get(params) as { c: number }
|
||||
return row.c
|
||||
},
|
||||
rawDb(): Database.Database {
|
||||
return db
|
||||
},
|
||||
close() {
|
||||
db.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Calcula semana ISO anterior (YYYY-Www). */
|
||||
export function prevWeekIso(week: string): string {
|
||||
const m = week.match(/^(\d{4})-W(\d{2})$/)
|
||||
if (!m) return week
|
||||
const year = parseInt(m[1], 10)
|
||||
const w = parseInt(m[2], 10)
|
||||
if (w > 1) return `${year}-W${String(w - 1).padStart(2, '0')}`
|
||||
// Semana 1 → última semana do ano anterior (52 ou 53)
|
||||
const prevYear = year - 1
|
||||
const last = weeksInYear(prevYear)
|
||||
return `${prevYear}-W${String(last).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function weeksInYear(year: number): number {
|
||||
// ISO: ano tem 53 semanas se 1 Jan é quinta ou (ano bissexto e 1 Jan é quarta)
|
||||
const jan1 = new Date(Date.UTC(year, 0, 1)).getUTCDay()
|
||||
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
|
||||
if (jan1 === 4 || (isLeap && jan1 === 3)) return 53
|
||||
return 52
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readdirSync, statSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { parseSessionFile } from './parser.js'
|
||||
import { openSessionsDb, type SessionsDb } from './db.js'
|
||||
import type { SessionMeta } from '../../types/session.js'
|
||||
|
||||
export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects')
|
||||
export const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
|
||||
|
||||
/**
|
||||
* Percorre a raiz de projectos Claude (profundidade 2) e devolve todos os .jsonl.
|
||||
* Estrutura: ~/.claude/projects/<project-slug>/<session-uuid>.jsonl
|
||||
*/
|
||||
export function findAllJsonl(root: string = PROJECTS_ROOT): string[] {
|
||||
const result: string[] = []
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(root)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const projectDir = join(root, entry)
|
||||
let st
|
||||
try {
|
||||
st = statSync(projectDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!st.isDirectory()) continue
|
||||
let files: string[]
|
||||
try {
|
||||
files = readdirSync(projectDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const f of files) {
|
||||
if (f.endsWith('.jsonl')) result.push(join(projectDir, f))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexa um único ficheiro (parse + upsert). Uso individual — útil para o watcher (Task 8).
|
||||
*/
|
||||
export async function indexFile(db: SessionsDb, path: string): Promise<void> {
|
||||
const { meta } = await parseSessionFile(path)
|
||||
db.upsertSession(meta)
|
||||
}
|
||||
|
||||
export interface IndexAllOptions {
|
||||
dbPath?: string
|
||||
onProgress?: (done: number, total: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full scan: percorre todos os JSONL e faz upsert em lote (batch 50 via transacção).
|
||||
*/
|
||||
export async function indexAll(
|
||||
options: IndexAllOptions = {},
|
||||
): Promise<{ indexed: number; failed: number }> {
|
||||
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
|
||||
const files = findAllJsonl()
|
||||
const BATCH = 50
|
||||
let indexed = 0
|
||||
let failed = 0
|
||||
let batch: SessionMeta[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
try {
|
||||
const { meta } = await parseSessionFile(files[i])
|
||||
batch.push(meta)
|
||||
if (batch.length >= BATCH) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
batch = []
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error(`[indexer] erro em ${files[i]}:`, err)
|
||||
}
|
||||
if (options.onProgress) {
|
||||
options.onProgress(indexed + failed + batch.length, files.length)
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
return { indexed, failed }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Cliente HTTP mínimo para o gateway MCP (JSON-RPC 2.0 sobre HTTP).
|
||||
*
|
||||
* Suporta resposta em JSON puro ou SSE (text/event-stream). Partilhado entre
|
||||
* os scripts de Observabilidade (patterns + worklog import).
|
||||
*/
|
||||
|
||||
export interface MCPToolCallResult {
|
||||
content?: Array<{ type: string; text: string }>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export async function callMcpTool(
|
||||
tool: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<MCPToolCallResult> {
|
||||
const url = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt/v1/desk-crm/mcp'
|
||||
const token = process.env.MCP_GATEWAY_TOKEN
|
||||
if (!token) throw new Error('MCP_GATEWAY_TOKEN não definido')
|
||||
const body = {
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/call',
|
||||
params: { name: tool, arguments: args },
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`MCP gateway ${res.status}: ${txt.slice(0, 300)}`)
|
||||
}
|
||||
const raw = await res.text()
|
||||
let payload: string | null = null
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
payload = trimmed.slice(6)
|
||||
break
|
||||
}
|
||||
if (trimmed.startsWith('{')) {
|
||||
payload = trimmed
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!payload) throw new Error(`MCP resposta sem payload JSON: ${raw.slice(0, 200)}`)
|
||||
const parsed = JSON.parse(payload) as { error?: unknown; result?: MCPToolCallResult }
|
||||
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
|
||||
const result = parsed.result as MCPToolCallResult | undefined
|
||||
if (result?.isError) {
|
||||
const txt = result.content?.map((c) => c.text).join('\n') ?? ''
|
||||
throw new Error(`MCP tool ${tool} devolveu isError: ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return result ?? {}
|
||||
}
|
||||
|
||||
/** Extrai o primeiro bloco de texto JSON-encoded do resultado MCP. */
|
||||
export function extractMcpJsonPayload<T = unknown>(r: MCPToolCallResult): T {
|
||||
const text = r.content?.find((c) => c.type === 'text')?.text
|
||||
if (!text) throw new Error('MCP result sem content text')
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createReadStream, statSync } from 'fs'
|
||||
import { createInterface } from 'readline'
|
||||
import { basename, dirname } from 'path'
|
||||
import type { ParseResult, SessionEvent, SessionEventType, SessionMeta, SessionOutcome } from '../../types/session.js'
|
||||
|
||||
function slugFromProjectPath(jsonlPath: string): string {
|
||||
return basename(dirname(jsonlPath))
|
||||
}
|
||||
|
||||
function detectSkillInvoked(text: string | null): string | null {
|
||||
if (!text) return null
|
||||
const m = text.match(/Launching skill:\s*([a-zA-Z0-9:_\-\/]+)/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
function detectHook(text: string | null): string | null {
|
||||
if (!text) return null
|
||||
const m = text.match(/(?:Hook|hook)\s+["']?([a-zA-Z0-9_\-\.]+\.sh)["']?/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
function extractResultText(r: unknown): string | null {
|
||||
if (r == null) return null
|
||||
if (typeof r === 'string') return r
|
||||
if (Array.isArray(r)) {
|
||||
const parts: string[] = []
|
||||
for (const p of r) {
|
||||
if (p && typeof p === 'object' && 'text' in p && typeof (p as { text: unknown }).text === 'string') {
|
||||
parts.push((p as { text: string }).text)
|
||||
}
|
||||
}
|
||||
return parts.length ? parts.join('\n') : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractText(rawMsg: unknown): string | null {
|
||||
if (!rawMsg || typeof rawMsg !== 'object') return null
|
||||
const msg = rawMsg as { content?: unknown }
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (!Array.isArray(msg.content)) return null
|
||||
const parts: string[] = []
|
||||
for (const part of msg.content) {
|
||||
if (part && typeof part === 'object' && 'type' in part) {
|
||||
const p = part as { type: string; text?: string }
|
||||
if (p.type === 'text' && typeof p.text === 'string') parts.push(p.text)
|
||||
}
|
||||
}
|
||||
return parts.length ? parts.join('\n') : null
|
||||
}
|
||||
|
||||
function classifyEventType(raw: Record<string, unknown>): SessionEventType {
|
||||
const t = raw.type
|
||||
if (typeof t !== 'string') return 'unknown'
|
||||
if (t === 'user' || t === 'assistant' || t === 'system' || t === 'attachment' || t === 'permission-mode' || t === 'file-history-snapshot') {
|
||||
return t as SessionEventType
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function deriveOutcome(events: SessionEvent[]): SessionOutcome {
|
||||
if (events.length === 0) return 'unknown'
|
||||
const last = events[events.length - 1]
|
||||
if (last.type !== 'assistant') return 'interrupted'
|
||||
const raw = last.raw as { message?: { stop_reason?: string } }
|
||||
const stopReason = raw.message?.stop_reason
|
||||
if (stopReason === 'error') return 'error'
|
||||
if (stopReason === 'end_turn' || stopReason === 'tool_use' || !stopReason) return 'completed'
|
||||
return 'interrupted'
|
||||
}
|
||||
|
||||
export async function parseSessionFile(jsonlPath: string): Promise<ParseResult> {
|
||||
const stream = createReadStream(jsonlPath, { encoding: 'utf-8' })
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity })
|
||||
|
||||
const events: SessionEvent[] = []
|
||||
let sessionId: string | null = null
|
||||
let permissionMode: string | null = null
|
||||
let firstPrompt: string | null = null
|
||||
let userMessages = 0
|
||||
let assistantMsgs = 0
|
||||
let toolCalls = 0
|
||||
const toolsUsed = new Set<string>()
|
||||
const skillsInvoked = new Set<string>()
|
||||
let firstTs: string | null = null
|
||||
let lastTs: string | null = null
|
||||
let idx = 0
|
||||
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue
|
||||
let raw: Record<string, unknown>
|
||||
try {
|
||||
raw = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const type = classifyEventType(raw)
|
||||
const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : null
|
||||
if (timestamp) {
|
||||
if (!firstTs) firstTs = timestamp
|
||||
lastTs = timestamp
|
||||
}
|
||||
if (type === 'permission-mode' && typeof raw.permissionMode === 'string') {
|
||||
permissionMode = raw.permissionMode
|
||||
}
|
||||
if (typeof raw.sessionId === 'string') sessionId = raw.sessionId
|
||||
|
||||
const text = extractText((raw as { message?: unknown }).message)
|
||||
let toolName: string | null = null
|
||||
let toolInput: Record<string, unknown> | null = null
|
||||
let toolResult: unknown = null
|
||||
|
||||
if (type === 'assistant' && raw.message && typeof raw.message === 'object') {
|
||||
const content = (raw.message as { content?: unknown[] }).content
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part && typeof part === 'object' && 'type' in part) {
|
||||
const p = part as { type: string; name?: string; input?: Record<string, unknown> }
|
||||
if (p.type === 'tool_use' && typeof p.name === 'string') {
|
||||
toolCalls++
|
||||
toolsUsed.add(p.name)
|
||||
toolName = p.name
|
||||
toolInput = p.input ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assistantMsgs++
|
||||
}
|
||||
|
||||
if (type === 'user') {
|
||||
userMessages++
|
||||
if (!firstPrompt && text && !text.startsWith('<') && text.length > 0) {
|
||||
firstPrompt = text.slice(0, 500)
|
||||
}
|
||||
if (raw.message && typeof raw.message === 'object') {
|
||||
const content = (raw.message as { content?: unknown[] }).content
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part && typeof part === 'object' && 'type' in part) {
|
||||
const p = part as { type: string; content?: unknown }
|
||||
if (p.type === 'tool_result') toolResult = p.content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultText = extractResultText(toolResult)
|
||||
const skill = detectSkillInvoked(text)
|
||||
const skillFromResult = detectSkillInvoked(resultText)
|
||||
const finalSkill = skill ?? skillFromResult
|
||||
if (finalSkill) skillsInvoked.add(finalSkill)
|
||||
const hook = detectHook(text) ?? detectHook(resultText)
|
||||
|
||||
events.push({
|
||||
index: idx++,
|
||||
type,
|
||||
timestamp,
|
||||
raw,
|
||||
text,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_result: toolResult,
|
||||
skill_invoked: finalSkill,
|
||||
hook_name: hook,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
rl.close()
|
||||
stream.destroy()
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
sessionId = basename(jsonlPath, '.jsonl')
|
||||
}
|
||||
|
||||
let durationSec: number | null = null
|
||||
if (firstTs && lastTs) {
|
||||
const diff = Date.parse(lastTs) - Date.parse(firstTs)
|
||||
if (Number.isFinite(diff)) durationSec = Math.max(0, Math.round(diff / 1000))
|
||||
}
|
||||
const outcome = deriveOutcome(events)
|
||||
const stats = statSync(jsonlPath)
|
||||
|
||||
const meta: SessionMeta = {
|
||||
session_id: sessionId,
|
||||
project_path: dirname(jsonlPath),
|
||||
project_slug: slugFromProjectPath(jsonlPath),
|
||||
jsonl_path: jsonlPath,
|
||||
started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(),
|
||||
ended_at: lastTs,
|
||||
duration_sec: durationSec,
|
||||
event_count: events.length,
|
||||
user_messages: userMessages,
|
||||
assistant_msgs: assistantMsgs,
|
||||
tool_calls: toolCalls,
|
||||
first_prompt: firstPrompt,
|
||||
tools_used: [...toolsUsed],
|
||||
skills_invoked: [...skillsInvoked],
|
||||
outcome,
|
||||
permission_mode: permissionMode,
|
||||
file_size: stats.size,
|
||||
indexed_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
return { meta, events }
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Detector automático de padrões sobre a BD `sessions` (Observabilidade Fase 6A).
|
||||
*
|
||||
* Seis detectores heurísticos em SQL puro (via better-sqlite3). Cada detector
|
||||
* devolve zero ou mais `Pattern` para a semana analisada. Pipeline:
|
||||
* 1. Correr detectores sobre intervalo [weekStart, weekEnd]
|
||||
* 2. Persistir via `upsertPattern` (idempotente por (week_iso, pattern_key))
|
||||
* 3. Calcular `consecutive_weeks` olhando para semanas anteriores
|
||||
*/
|
||||
import type Database from 'better-sqlite3'
|
||||
import type { SessionsDb, PatternRecord } from './db.js'
|
||||
|
||||
export type Severity = 'info' | 'warning' | 'action'
|
||||
|
||||
export interface Pattern {
|
||||
pattern_key: string
|
||||
title: string
|
||||
description: string
|
||||
severity: Severity
|
||||
metric_value: number | null
|
||||
sample_session_ids: string[]
|
||||
affected_count: number
|
||||
}
|
||||
|
||||
export interface DetectCtx {
|
||||
db: Database.Database
|
||||
weekStartIso: string
|
||||
weekEndIso: string
|
||||
}
|
||||
|
||||
/** Converte Date para string ISO UTC. */
|
||||
function iso(d: Date): string {
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula intervalo [segunda 00:00:00 UTC, domingo 23:59:59.999 UTC] da semana
|
||||
* que contém `ref` (Regra 17 — semana começa à segunda).
|
||||
*/
|
||||
export function weekRange(ref: Date): { start: Date; end: Date; iso: string } {
|
||||
const d = new Date(Date.UTC(ref.getUTCFullYear(), ref.getUTCMonth(), ref.getUTCDate()))
|
||||
const dow = d.getUTCDay() // 0=Dom, 1=Seg
|
||||
const diffToMonday = dow === 0 ? -6 : 1 - dow
|
||||
const start = new Date(d)
|
||||
start.setUTCDate(d.getUTCDate() + diffToMonday)
|
||||
const end = new Date(start)
|
||||
end.setUTCDate(start.getUTCDate() + 6)
|
||||
end.setUTCHours(23, 59, 59, 999)
|
||||
return { start, end, iso: weekIso(start) }
|
||||
}
|
||||
|
||||
/** Semana ISO 8601 (YYYY-Www) para segunda de referência. */
|
||||
export function weekIso(monday: Date): string {
|
||||
// Usa algoritmo ISO: quinta da mesma semana determina o ano
|
||||
const thursday = new Date(monday)
|
||||
thursday.setUTCDate(monday.getUTCDate() + 3)
|
||||
const year = thursday.getUTCFullYear()
|
||||
const jan1 = new Date(Date.UTC(year, 0, 1))
|
||||
const week = Math.floor(
|
||||
((thursday.getTime() - jan1.getTime()) / 86400000 + (jan1.getUTCDay() === 0 ? 6 : jan1.getUTCDay() - 1)) / 7
|
||||
) + 1
|
||||
return `${year}-W${String(week).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Helper: todos os session_ids no intervalo. */
|
||||
function baseRows(ctx: DetectCtx) {
|
||||
return ctx.db.prepare(`
|
||||
SELECT session_id, project_slug, started_at, event_count, tool_calls, tools_used, skills_invoked, outcome, duration_sec
|
||||
FROM sessions
|
||||
WHERE started_at >= ? AND started_at <= ?
|
||||
`).all(ctx.weekStartIso, ctx.weekEndIso) as Array<{
|
||||
session_id: string
|
||||
project_slug: string
|
||||
started_at: string
|
||||
event_count: number
|
||||
tool_calls: number
|
||||
tools_used: string
|
||||
skills_invoked: string
|
||||
outcome: string
|
||||
duration_sec: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
/** 1. Skills com taxa elevada de erro/interrupção. */
|
||||
export function detectSkillsHighErrorRate(ctx: DetectCtx): Pattern[] {
|
||||
const rows = baseRows(ctx)
|
||||
// Agregar por skill
|
||||
const bySkill = new Map<string, { total: number; fail: number; ids: string[] }>()
|
||||
for (const r of rows) {
|
||||
let skills: string[] = []
|
||||
try { skills = JSON.parse(r.skills_invoked) } catch {}
|
||||
for (const sk of skills) {
|
||||
const entry = bySkill.get(sk) ?? { total: 0, fail: 0, ids: [] }
|
||||
entry.total++
|
||||
if (r.outcome === 'error' || r.outcome === 'interrupted') {
|
||||
entry.fail++
|
||||
if (entry.ids.length < 5) entry.ids.push(r.session_id)
|
||||
}
|
||||
bySkill.set(sk, entry)
|
||||
}
|
||||
}
|
||||
const out: Pattern[] = []
|
||||
for (const [skill, v] of bySkill) {
|
||||
if (v.total < 3) continue
|
||||
const ratio = v.fail / v.total
|
||||
if (ratio <= 0.2) continue
|
||||
const severity: Severity = ratio > 0.4 ? 'action' : 'warning'
|
||||
out.push({
|
||||
pattern_key: `skill_error_rate:${skill}`,
|
||||
title: `Skill ${skill}: ${(ratio * 100).toFixed(0)}% das sessões falham`,
|
||||
description: `De ${v.total} sessões que invocaram ${skill}, ${v.fail} terminaram em erro/interrupção.`,
|
||||
severity,
|
||||
metric_value: Math.round(ratio * 1000) / 1000,
|
||||
sample_session_ids: v.ids,
|
||||
affected_count: v.fail,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 2. Tools com baixa eficiência (tool_calls/event_count elevado). */
|
||||
export function detectToolsLowEfficiency(ctx: DetectCtx): Pattern[] {
|
||||
const rows = baseRows(ctx)
|
||||
const byTool = new Map<string, { sum: number; count: number; ids: string[] }>()
|
||||
for (const r of rows) {
|
||||
if (!r.event_count || r.event_count === 0) continue
|
||||
const ratio = r.tool_calls / r.event_count
|
||||
let tools: string[] = []
|
||||
try { tools = JSON.parse(r.tools_used) } catch {}
|
||||
for (const t of tools) {
|
||||
const e = byTool.get(t) ?? { sum: 0, count: 0, ids: [] }
|
||||
e.sum += ratio
|
||||
e.count++
|
||||
if (e.ids.length < 5) e.ids.push(r.session_id)
|
||||
byTool.set(t, e)
|
||||
}
|
||||
}
|
||||
const out: Pattern[] = []
|
||||
for (const [tool, v] of byTool) {
|
||||
if (v.count < 5) continue
|
||||
const avg = v.sum / v.count
|
||||
if (avg <= 0.5) continue
|
||||
out.push({
|
||||
pattern_key: `tool_low_efficiency:${tool}`,
|
||||
title: `Tool ${tool}: rácio tool_calls/event_count médio ${avg.toFixed(2)}`,
|
||||
description: `Em ${v.count} sessões, ${tool} domina o event_count. Indício de uso ineficiente ou looping.`,
|
||||
severity: 'info',
|
||||
metric_value: Math.round(avg * 1000) / 1000,
|
||||
sample_session_ids: v.ids,
|
||||
affected_count: v.count,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 3. Pares (skill, tool) mais frequentes. */
|
||||
export function detectSkillToolPairs(ctx: DetectCtx): Pattern[] {
|
||||
const rows = baseRows(ctx)
|
||||
const byPair = new Map<string, { count: number; ids: string[] }>()
|
||||
for (const r of rows) {
|
||||
let skills: string[] = []
|
||||
let tools: string[] = []
|
||||
try { skills = JSON.parse(r.skills_invoked) } catch {}
|
||||
try { tools = JSON.parse(r.tools_used) } catch {}
|
||||
for (const s of skills) {
|
||||
for (const t of tools) {
|
||||
const key = `${s}::${t}`
|
||||
const e = byPair.get(key) ?? { count: 0, ids: [] }
|
||||
e.count++
|
||||
if (e.ids.length < 5) e.ids.push(r.session_id)
|
||||
byPair.set(key, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
const sorted = [...byPair.entries()].filter(([, v]) => v.count >= 5).sort((a, b) => b[1].count - a[1].count).slice(0, 5)
|
||||
return sorted.map(([key, v]) => ({
|
||||
pattern_key: `skill_tool_pair:${key}`,
|
||||
title: `Par frequente: ${key.replace('::', ' + ')}`,
|
||||
description: `Skill e tool co-ocorreram em ${v.count} sessões esta semana.`,
|
||||
severity: 'info' as Severity,
|
||||
metric_value: v.count,
|
||||
sample_session_ids: v.ids,
|
||||
affected_count: v.count,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 4. Duration outliers: sessões > p95 por projecto com outcome != completed. */
|
||||
export function detectDurationOutliers(ctx: DetectCtx): Pattern[] {
|
||||
const rows = baseRows(ctx).filter((r) => r.duration_sec != null && r.duration_sec > 0)
|
||||
const byProject = new Map<string, Array<typeof rows[number]>>()
|
||||
for (const r of rows) {
|
||||
const arr = byProject.get(r.project_slug) ?? []
|
||||
arr.push(r)
|
||||
byProject.set(r.project_slug, arr)
|
||||
}
|
||||
const out: Pattern[] = []
|
||||
for (const [proj, arr] of byProject) {
|
||||
if (arr.length < 4) continue
|
||||
const durations = arr.map((r) => r.duration_sec as number).sort((a, b) => a - b)
|
||||
const p95Idx = Math.max(0, Math.floor(durations.length * 0.95) - 1)
|
||||
const p95 = durations[p95Idx]
|
||||
const outliers = arr.filter((r) => (r.duration_sec as number) > p95 && r.outcome !== 'completed')
|
||||
if (outliers.length < 3) continue
|
||||
out.push({
|
||||
pattern_key: `duration_outliers:${proj}`,
|
||||
title: `Projecto ${proj}: ${outliers.length} sessões longas não concluídas`,
|
||||
description: `Sessões com duração acima do p95 (${p95}s) e outcome != completed. Sinal de sessões penduradas.`,
|
||||
severity: 'warning',
|
||||
metric_value: p95,
|
||||
sample_session_ids: outliers.slice(0, 5).map((r) => r.session_id),
|
||||
affected_count: outliers.length,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 5. Sessões abandonadas (event_count < 3 AND outcome=unknown). */
|
||||
export function detectAbandonedSessions(ctx: DetectCtx): Pattern[] {
|
||||
const rows = ctx.db.prepare(`
|
||||
SELECT session_id FROM sessions
|
||||
WHERE started_at >= ? AND started_at <= ?
|
||||
AND event_count < 3 AND outcome = 'unknown'
|
||||
`).all(ctx.weekStartIso, ctx.weekEndIso) as Array<{ session_id: string }>
|
||||
if (rows.length < 5) return []
|
||||
return [{
|
||||
pattern_key: 'abandoned_sessions',
|
||||
title: `${rows.length} sessões abandonadas esta semana`,
|
||||
description: `Sessões com menos de 3 eventos e outcome=unknown — tipicamente abertas e descartadas.`,
|
||||
severity: 'info',
|
||||
metric_value: rows.length,
|
||||
sample_session_ids: rows.slice(0, 5).map((r) => r.session_id),
|
||||
affected_count: rows.length,
|
||||
}]
|
||||
}
|
||||
|
||||
/** 6. Crescimento de complexidade: avg(tool_calls) actual vs semana anterior. */
|
||||
export function detectGrowingComplexity(ctx: DetectCtx, prevWeekStartIso: string, prevWeekEndIso: string): Pattern[] {
|
||||
const curRows = baseRows(ctx)
|
||||
const prevRows = ctx.db.prepare(`
|
||||
SELECT skills_invoked, tool_calls FROM sessions
|
||||
WHERE started_at >= ? AND started_at <= ?
|
||||
`).all(prevWeekStartIso, prevWeekEndIso) as Array<{ skills_invoked: string; tool_calls: number }>
|
||||
|
||||
const curBySkill = new Map<string, { sum: number; count: number; ids: string[] }>()
|
||||
for (const r of curRows) {
|
||||
let sk: string[] = []
|
||||
try { sk = JSON.parse(r.skills_invoked) } catch {}
|
||||
for (const s of sk) {
|
||||
const e = curBySkill.get(s) ?? { sum: 0, count: 0, ids: [] }
|
||||
e.sum += r.tool_calls
|
||||
e.count++
|
||||
if (e.ids.length < 5) e.ids.push(r.session_id)
|
||||
curBySkill.set(s, e)
|
||||
}
|
||||
}
|
||||
const prevBySkill = new Map<string, { sum: number; count: number }>()
|
||||
for (const r of prevRows) {
|
||||
let sk: string[] = []
|
||||
try { sk = JSON.parse(r.skills_invoked) } catch {}
|
||||
for (const s of sk) {
|
||||
const e = prevBySkill.get(s) ?? { sum: 0, count: 0 }
|
||||
e.sum += r.tool_calls
|
||||
e.count++
|
||||
prevBySkill.set(s, e)
|
||||
}
|
||||
}
|
||||
const out: Pattern[] = []
|
||||
for (const [skill, cur] of curBySkill) {
|
||||
if (cur.count < 5) continue
|
||||
const curAvg = cur.sum / cur.count
|
||||
const prev = prevBySkill.get(skill)
|
||||
if (!prev || prev.count < 3) continue
|
||||
const prevAvg = prev.sum / prev.count
|
||||
if (prevAvg === 0 || curAvg <= prevAvg * 1.3) continue
|
||||
out.push({
|
||||
pattern_key: `growing_complexity:${skill}`,
|
||||
title: `Skill ${skill}: tool_calls médio +${Math.round((curAvg / prevAvg - 1) * 100)}% vs semana anterior`,
|
||||
description: `Média de tool_calls/sessão subiu de ${prevAvg.toFixed(1)} para ${curAvg.toFixed(1)}.`,
|
||||
severity: 'warning',
|
||||
metric_value: Math.round(curAvg * 10) / 10,
|
||||
sample_session_ids: cur.ids,
|
||||
affected_count: cur.count,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. Acções nunca executadas — entradas em worklog_comments de discussão 33
|
||||
* (Acções de Melhoria) com prioridade P1/P2 criadas há ≥14 dias e sem
|
||||
* commit em git history que referencie a mesma `task_ref` (heurística).
|
||||
*/
|
||||
export function detectActionsNeverExecuted(ctx: DetectCtx): Pattern[] {
|
||||
// Entradas criadas até 14 dias antes do fim da semana (ou antes)
|
||||
const cutoff = new Date(ctx.weekEndIso)
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - 14)
|
||||
const cutoffIso = cutoff.toISOString()
|
||||
|
||||
const rows = ctx.db.prepare(`
|
||||
SELECT id, discussion_id, created_at, task_ref, actions_json, title
|
||||
FROM worklog_comments
|
||||
WHERE discussion_id = 33 AND created_at <= ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500
|
||||
`).all(cutoffIso) as Array<{
|
||||
id: number
|
||||
discussion_id: number
|
||||
created_at: string
|
||||
task_ref: string | null
|
||||
actions_json: string
|
||||
title: string | null
|
||||
}>
|
||||
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const pendentes: Array<{ id: number; descricao: string; prioridade: string }> = []
|
||||
for (const r of rows) {
|
||||
let actions: Array<{ tipo: string; descricao: string; prioridade: string | null }> = []
|
||||
try { actions = JSON.parse(r.actions_json) } catch {}
|
||||
for (const a of actions) {
|
||||
const prio = (a.prioridade ?? '').toUpperCase()
|
||||
if (prio === 'P1' || prio === 'P2') {
|
||||
pendentes.push({ id: r.id, descricao: a.descricao.slice(0, 120), prioridade: prio })
|
||||
if (pendentes.length >= 10) break
|
||||
}
|
||||
}
|
||||
if (pendentes.length >= 10) break
|
||||
}
|
||||
|
||||
if (pendentes.length < 3) return []
|
||||
return [{
|
||||
pattern_key: 'actions_never_executed',
|
||||
title: `${pendentes.length}+ acções P1/P2 pendentes há ≥14 dias`,
|
||||
description: `Acções de melhoria (disc #33) sem execução visível. Amostra: ${pendentes.slice(0, 3).map((p) => `[${p.prioridade}] ${p.descricao}`).join(' | ')}`,
|
||||
severity: 'warning',
|
||||
metric_value: pendentes.length,
|
||||
sample_session_ids: pendentes.slice(0, 5).map((p) => `worklog:${p.id}`),
|
||||
affected_count: pendentes.length,
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* 8. Skill reportada como problemática em worklogs mas que aparece com
|
||||
* outcome=completed nas sessões reais — discrepância entre narrativa e dados.
|
||||
*/
|
||||
export function detectSkillReportedBrokenButCompleted(ctx: DetectCtx): Pattern[] {
|
||||
// Recolhe skills mencionadas em problems_json e patterns_text de worklogs
|
||||
// criados nas últimas 4 semanas antes do fim da janela
|
||||
const windowStart = new Date(ctx.weekEndIso)
|
||||
windowStart.setUTCDate(windowStart.getUTCDate() - 28)
|
||||
const windowIso = windowStart.toISOString()
|
||||
|
||||
const worklogs = ctx.db.prepare(`
|
||||
SELECT patterns_text, problems_json
|
||||
FROM worklog_comments
|
||||
WHERE discussion_id IN (31, 32) AND created_at >= ?
|
||||
LIMIT 500
|
||||
`).all(windowIso) as Array<{ patterns_text: string; problems_json: string }>
|
||||
|
||||
if (worklogs.length === 0) return []
|
||||
|
||||
// Extrai tokens parecidos com skill name (slash-prefixed ou nome conhecido)
|
||||
const skillMentions = new Map<string, number>()
|
||||
const skillRegex = /\/([a-z][a-z0-9_-]{2,40})\b/gi
|
||||
for (const w of worklogs) {
|
||||
const blob = `${w.patterns_text} ${w.problems_json}`.toLowerCase()
|
||||
for (const m of blob.matchAll(skillRegex)) {
|
||||
skillMentions.set(m[1], (skillMentions.get(m[1]) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (skillMentions.size === 0) return []
|
||||
|
||||
// Para cada skill mencionada ≥2 vezes, ver sessões com skill invocada e outcome=completed
|
||||
const out: Pattern[] = []
|
||||
const skillsRelevantes = [...skillMentions.entries()].filter(([, c]) => c >= 2)
|
||||
for (const [skill, mentions] of skillsRelevantes) {
|
||||
const rows = ctx.db.prepare(`
|
||||
SELECT session_id, skills_invoked, outcome
|
||||
FROM sessions
|
||||
WHERE started_at >= ? AND started_at <= ?
|
||||
AND skills_invoked LIKE ? AND outcome = 'completed'
|
||||
`).all(ctx.weekStartIso, ctx.weekEndIso, `%"${skill}"%`) as Array<{
|
||||
session_id: string
|
||||
skills_invoked: string
|
||||
outcome: string
|
||||
}>
|
||||
// Confirmar via parse (skills_invoked é JSON array)
|
||||
const matches = rows.filter((r) => {
|
||||
try { return (JSON.parse(r.skills_invoked) as string[]).includes(skill) } catch { return false }
|
||||
})
|
||||
if (matches.length >= 3) {
|
||||
out.push({
|
||||
pattern_key: `skill_narrative_vs_data:${skill}`,
|
||||
title: `Skill ${skill}: reportada problemática em ${mentions} worklogs mas ${matches.length} sessões completed`,
|
||||
description: `Discrepância entre narrativa (worklogs #31/#32) e dados (sessions.outcome). Investigar se o problema é silencioso.`,
|
||||
severity: 'info',
|
||||
metric_value: matches.length,
|
||||
sample_session_ids: matches.slice(0, 5).map((r) => r.session_id),
|
||||
affected_count: matches.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 9. Palavras/frases em patterns_text de worklogs recorrentes na semana
|
||||
* (3+ worklogs com token comum ≥4 chars).
|
||||
*/
|
||||
export function detectWorklogPatternFrequency(ctx: DetectCtx): Pattern[] {
|
||||
const rows = ctx.db.prepare(`
|
||||
SELECT id, patterns_text FROM worklog_comments
|
||||
WHERE created_at >= ? AND created_at <= ?
|
||||
`).all(ctx.weekStartIso, ctx.weekEndIso) as Array<{ id: number; patterns_text: string }>
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const tokenCount = new Map<string, { count: number; ids: number[] }>()
|
||||
const stop = new Set(['para', 'como', 'mais', 'sobre', 'quando', 'apenas', 'entre', 'depois', 'antes', 'pelo', 'pela', 'pelos', 'pelas', 'esta', 'este', 'este', 'isso', 'isto', 'cada', 'muito', 'muita', 'outro', 'outra', 'nosso', 'nossa', 'todas', 'todos', 'seja', 'ser', 'ter', 'com', 'sem', 'dos', 'das', 'que', 'nao', 'sim'])
|
||||
|
||||
for (const r of rows) {
|
||||
let items: string[] = []
|
||||
try { items = JSON.parse(r.patterns_text) } catch {}
|
||||
const seen = new Set<string>()
|
||||
for (const t of items) {
|
||||
const words = t
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((w) => w.length >= 5 && !stop.has(w))
|
||||
for (const w of words) {
|
||||
if (seen.has(w)) continue
|
||||
seen.add(w)
|
||||
const e = tokenCount.get(w) ?? { count: 0, ids: [] }
|
||||
e.count++
|
||||
e.ids.push(r.id)
|
||||
tokenCount.set(w, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const frequent = [...tokenCount.entries()]
|
||||
.filter(([, v]) => v.count >= 3)
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.slice(0, 5)
|
||||
|
||||
if (frequent.length === 0) return []
|
||||
|
||||
return [{
|
||||
pattern_key: 'worklog_pattern_frequency',
|
||||
title: `Termos recorrentes em ${rows.length} worklogs desta semana`,
|
||||
description: `Top tokens em patterns_text: ${frequent.map(([w, v]) => `${w}(${v.count})`).join(', ')}`,
|
||||
severity: 'info',
|
||||
metric_value: frequent[0][1].count,
|
||||
sample_session_ids: frequent.flatMap(([, v]) => v.ids.slice(0, 2)).slice(0, 5).map((id) => `worklog:${id}`),
|
||||
affected_count: rows.length,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Orquestra todos os detectores para a semana indicada. */
|
||||
export function detectPatterns(
|
||||
dbWrapper: SessionsDb,
|
||||
weekStart: Date,
|
||||
weekEnd: Date,
|
||||
): Pattern[] {
|
||||
const db = dbWrapper.rawDb()
|
||||
const ctx: DetectCtx = {
|
||||
db,
|
||||
weekStartIso: iso(weekStart),
|
||||
weekEndIso: iso(weekEnd),
|
||||
}
|
||||
const prevStart = new Date(weekStart); prevStart.setUTCDate(prevStart.getUTCDate() - 7)
|
||||
const prevEnd = new Date(weekEnd); prevEnd.setUTCDate(prevEnd.getUTCDate() - 7)
|
||||
|
||||
const base: Pattern[] = [
|
||||
...detectSkillsHighErrorRate(ctx),
|
||||
...detectToolsLowEfficiency(ctx),
|
||||
...detectSkillToolPairs(ctx),
|
||||
...detectDurationOutliers(ctx),
|
||||
...detectAbandonedSessions(ctx),
|
||||
...detectGrowingComplexity(ctx, iso(prevStart), iso(prevEnd)),
|
||||
]
|
||||
|
||||
// Cross-detectors: só correm se houver worklogs na janela
|
||||
const worklogCount = (db.prepare(`SELECT COUNT(*) as c FROM worklog_comments`).get() as { c: number }).c
|
||||
if (worklogCount > 0) {
|
||||
base.push(
|
||||
...detectActionsNeverExecuted(ctx),
|
||||
...detectSkillReportedBrokenButCompleted(ctx),
|
||||
...detectWorklogPatternFrequency(ctx),
|
||||
)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/** Converte Pattern + contexto em PatternRecord pronto a persistir. */
|
||||
export function toPatternRecord(p: Pattern, weekIso: string, consecutiveWeeks: number): PatternRecord {
|
||||
return {
|
||||
detected_at: new Date().toISOString(),
|
||||
week_iso: weekIso,
|
||||
pattern_key: p.pattern_key,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
severity: p.severity,
|
||||
metric_value: p.metric_value,
|
||||
sample_session_ids: p.sample_session_ids,
|
||||
affected_count: p.affected_count,
|
||||
consecutive_weeks: consecutiveWeeks,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import chokidar from 'chokidar'
|
||||
import { openSessionsDb } from './db.js'
|
||||
import { indexFile, PROJECTS_ROOT } from './indexer.js'
|
||||
|
||||
export async function startWatcher(dbPath: string): Promise<void> {
|
||||
const db = openSessionsDb(dbPath)
|
||||
const watcher = chokidar.watch(`${PROJECTS_ROOT}/**/*.jsonl`, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 500 },
|
||||
})
|
||||
|
||||
async function reindex(path: string): Promise<void> {
|
||||
try {
|
||||
await indexFile(db, path)
|
||||
console.log(`[watcher] indexed ${path}`)
|
||||
} catch (err) {
|
||||
console.error(`[watcher] erro ${path}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
watcher
|
||||
.on('add', reindex)
|
||||
.on('change', reindex)
|
||||
.on('unlink', (path) => {
|
||||
db.deleteByJsonlPath(path)
|
||||
console.log(`[watcher] removed ${path}`)
|
||||
})
|
||||
.on('error', (err) => console.error('[watcher] error:', err))
|
||||
|
||||
console.log('[watcher] pronto')
|
||||
|
||||
// Registar handler SIGTERM/SIGINT para fechar DB limpa (evita WAL corruption em Task 9 systemd restart)
|
||||
const cleanup = async (): Promise<void> => {
|
||||
console.log('[watcher] SIGTERM/SIGINT — a fechar watcher e DB')
|
||||
await watcher.close()
|
||||
db.close()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGTERM', () => { void cleanup() })
|
||||
process.on('SIGINT', () => { void cleanup() })
|
||||
|
||||
return new Promise(() => {}) // nunca resolve — processo mantém-se vivo
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Importer dos comentários das discussões Desk #31 (Logs), #32 (Reflexões)
|
||||
* e #33 (Acções de Melhoria) para a tabela `worklog_comments`.
|
||||
*
|
||||
* Parser HTML tolerante — aceita ambos formatos produzidos pelo skill
|
||||
* `gestao:worklog` (versão antiga usava `<h2>/<h3>` inline-styled, versão
|
||||
* nova usa `<h4>` limpos). Secções identificadas por título normalizado
|
||||
* (ex.: "trabalho realizado", "ficheiros modificados", "problemas",
|
||||
* "padrões detectados", "acções sugeridas").
|
||||
*/
|
||||
import { parse, type HTMLElement } from 'node-html-parser'
|
||||
import type { SessionsDb, WorklogCommentRecord } from './db.js'
|
||||
import { callMcpTool, extractMcpJsonPayload } from './mcp-client.js'
|
||||
|
||||
export interface ParsedWorklogComment {
|
||||
id: number
|
||||
discussion_id: number
|
||||
created_at: string
|
||||
staff_id: number | null
|
||||
title: string | null
|
||||
task_ref: string | null
|
||||
duration_sec: number | null
|
||||
work_items: string[]
|
||||
files_modified: string[]
|
||||
problems: { problema: string; solucao: string }[]
|
||||
patterns_text: string[]
|
||||
actions: { tipo: string; descricao: string; prioridade: string | null }[]
|
||||
raw_html: string
|
||||
}
|
||||
|
||||
interface RawComment {
|
||||
id: number
|
||||
discussion_id: number
|
||||
content: string
|
||||
created: unknown
|
||||
staff_id: number | null
|
||||
children?: RawComment[]
|
||||
}
|
||||
|
||||
/** Remove whitespace redundante. */
|
||||
function norm(s: string): string {
|
||||
return s.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Converte string livre para chave de secção (lowercase, sem acentos, sem pontuação). */
|
||||
function sectionKey(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9 ]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const SECTION_WORK = new Set(['trabalho realizado', 'o que foi feito', 'feito', 'realizado', 'trabalho'])
|
||||
const SECTION_FILES = new Set(['ficheiros modificados', 'ficheiros alterados', 'files modified', 'ficheiros'])
|
||||
const SECTION_PROBLEMS = new Set(['problemas solucoes', 'problemas', 'solucoes', 'problemas e solucoes', 'problemas solucao'])
|
||||
const SECTION_PATTERNS = new Set(['padroes detectados', 'padroes', 'patterns', 'insights'])
|
||||
const SECTION_ACTIONS = new Set(['accoes sugeridas', 'accoes', 'acoes sugeridas', 'acoes', 'actions', 'accoes de melhoria'])
|
||||
|
||||
/** Extrai data ISO do título (YYYY-MM-DD [HH:MM]) ou devolve null. */
|
||||
function parseDateFromTitle(title: string): string | null {
|
||||
const m = title.match(/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/)
|
||||
if (!m) return null
|
||||
const [, y, mo, d, hh, mm] = m
|
||||
if (hh && mm) return `${y}-${mo}-${d}T${hh}:${mm}:00Z`
|
||||
return `${y}-${mo}-${d}T00:00:00Z`
|
||||
}
|
||||
|
||||
/** Tenta extrair "Tarefa: #ID" ou similar. */
|
||||
function parseTaskRef(text: string): string | null {
|
||||
const m = text.match(/(?:Tarefa|Task|Ticket)[:\s]*(#?\d+)/i)
|
||||
if (m) return m[1].startsWith('#') ? m[1] : `#${m[1]}`
|
||||
const bare = text.match(/#(\d{3,6})/)
|
||||
return bare ? `#${bare[1]}` : null
|
||||
}
|
||||
|
||||
/** "~2h 30m" / "~45 min" / "5 minutos" → segundos. */
|
||||
function parseDuration(text: string): number | null {
|
||||
const m = text.match(/~?\s*(\d+)\s*h\s*(\d+)?\s*m?/i)
|
||||
if (m) {
|
||||
const h = parseInt(m[1], 10)
|
||||
const mm = m[2] ? parseInt(m[2], 10) : 0
|
||||
return h * 3600 + mm * 60
|
||||
}
|
||||
const mm = text.match(/~?\s*(\d+)\s*(?:min|minutos|m)/i)
|
||||
if (mm) return parseInt(mm[1], 10) * 60
|
||||
return null
|
||||
}
|
||||
|
||||
/** Extrai texto de um elemento, incluindo inner HTML como plain text. */
|
||||
function textOf(el: HTMLElement): string {
|
||||
return norm(el.text ?? '')
|
||||
}
|
||||
|
||||
/** Colecta items de uma UL ou lista no mesmo nível que vem depois de um cabeçalho. */
|
||||
function collectFollowingListItems(heading: HTMLElement): string[] {
|
||||
const items: string[] = []
|
||||
let cur: HTMLElement | null = heading.nextElementSibling
|
||||
while (cur) {
|
||||
const tag = cur.rawTagName?.toLowerCase()
|
||||
if (tag && /^h[1-6]$/.test(tag)) break
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
for (const li of cur.querySelectorAll('li')) {
|
||||
const t = textOf(li)
|
||||
if (t) items.push(t)
|
||||
}
|
||||
} else if (tag === 'p') {
|
||||
// Alguns comentários partem o UL em múltiplos <p>; vasculha <li> dentro
|
||||
for (const li of cur.querySelectorAll('li')) {
|
||||
const t = textOf(li)
|
||||
if (t) items.push(t)
|
||||
}
|
||||
}
|
||||
cur = cur.nextElementSibling
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** Parse item "[Tipo] descrição" ou "Tipo: descrição (Px)". */
|
||||
function parseActionItem(raw: string): { tipo: string; descricao: string; prioridade: string | null } {
|
||||
// Remove checkbox inicial "[ ]" ou "[x]" se existir
|
||||
let s = raw.trim().replace(/^\[[\s xX✓]\]\s*/, '')
|
||||
const bracket = s.match(/^\[([^\]]+)\]\s*(.+)$/)
|
||||
let tipo = 'Geral'
|
||||
let rest = s
|
||||
if (bracket) {
|
||||
tipo = bracket[1].trim()
|
||||
rest = bracket[2].trim()
|
||||
}
|
||||
const prio = rest.match(/\b(P[0-4])\b/i)
|
||||
return {
|
||||
tipo,
|
||||
descricao: rest,
|
||||
prioridade: prio ? prio[1].toUpperCase() : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse problema/solução. Heurística: "Problema: X | Solução: Y" ou pares de <li>. */
|
||||
function parseProblemItem(raw: string): { problema: string; solucao: string } {
|
||||
const s = raw.trim()
|
||||
const split = s.split(/\s*(?:->|→|\|\s*Solu[çc][ãa]o:|\s*Solu[çc][ãa]o:)\s*/i)
|
||||
if (split.length >= 2) {
|
||||
return {
|
||||
problema: split[0].replace(/^Problema:\s*/i, '').trim(),
|
||||
solucao: split.slice(1).join(' ').trim(),
|
||||
}
|
||||
}
|
||||
return { problema: s, solucao: '' }
|
||||
}
|
||||
|
||||
/** Extrai lista "bruta" de todas as <li> dentro do HTML (fallback). */
|
||||
function extractAllLiItems(root: HTMLElement): string[] {
|
||||
return root
|
||||
.querySelectorAll('li')
|
||||
.map((li) => textOf(li))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function parseWorklogHtml(
|
||||
html: string,
|
||||
meta: { id: number; discussion_id: number; created_at: string; staff_id?: number | null },
|
||||
): ParsedWorklogComment {
|
||||
const root = parse(html || '')
|
||||
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||
|
||||
// Título: primeiro heading não vazio
|
||||
let title: string | null = null
|
||||
for (const h of headings) {
|
||||
const t = textOf(h)
|
||||
if (t) { title = t; break }
|
||||
}
|
||||
|
||||
// Data: preferir `meta.created_at` se válido; senão extrair do título ou do texto
|
||||
let createdAt = meta.created_at
|
||||
if (!createdAt || createdAt === '1970-01-01T00:00:00.000Z' || createdAt.startsWith('1970')) {
|
||||
const fromTitle = title ? parseDateFromTitle(title) : null
|
||||
if (fromTitle) createdAt = fromTitle
|
||||
else {
|
||||
const fromText = parseDateFromTitle(textOf(root).slice(0, 500))
|
||||
createdAt = fromText ?? new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const fullText = textOf(root)
|
||||
const taskRef = parseTaskRef(fullText)
|
||||
const durationSec = parseDuration(fullText)
|
||||
|
||||
// Indexa secções por chave normalizada
|
||||
const sections = new Map<string, HTMLElement>()
|
||||
for (const h of headings) {
|
||||
const key = sectionKey(textOf(h))
|
||||
if (!sections.has(key)) sections.set(key, h)
|
||||
}
|
||||
|
||||
function findSection(target: Set<string>): HTMLElement | null {
|
||||
for (const [k, el] of sections) {
|
||||
if (target.has(k)) return el
|
||||
}
|
||||
// match parcial (ex.: "trabalho realizado manutenção" — começa com)
|
||||
for (const [k, el] of sections) {
|
||||
for (const t of target) {
|
||||
if (k.startsWith(t) || t.startsWith(k)) return el
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const workHeading = findSection(SECTION_WORK)
|
||||
const filesHeading = findSection(SECTION_FILES)
|
||||
const problemsHeading = findSection(SECTION_PROBLEMS)
|
||||
const patternsHeading = findSection(SECTION_PATTERNS)
|
||||
const actionsHeading = findSection(SECTION_ACTIONS)
|
||||
|
||||
const workItems = workHeading ? collectFollowingListItems(workHeading) : []
|
||||
const filesModified = filesHeading ? collectFollowingListItems(filesHeading) : []
|
||||
const problemsRaw = problemsHeading ? collectFollowingListItems(problemsHeading) : []
|
||||
const patternsText = patternsHeading ? collectFollowingListItems(patternsHeading) : []
|
||||
const actionsRaw = actionsHeading ? collectFollowingListItems(actionsHeading) : []
|
||||
|
||||
// Fallback: se nenhuma secção encontrada mas existem <li>, e a discussão é #33,
|
||||
// tratar tudo como acções (formato diferente das outras discussões)
|
||||
let actions = actionsRaw.map(parseActionItem)
|
||||
if (meta.discussion_id === 33 && actions.length === 0) {
|
||||
actions = extractAllLiItems(root).map(parseActionItem)
|
||||
}
|
||||
|
||||
const problems = problemsRaw.map(parseProblemItem)
|
||||
|
||||
return {
|
||||
id: meta.id,
|
||||
discussion_id: meta.discussion_id,
|
||||
created_at: createdAt,
|
||||
staff_id: meta.staff_id ?? null,
|
||||
title,
|
||||
task_ref: taskRef,
|
||||
duration_sec: durationSec,
|
||||
work_items: workItems,
|
||||
files_modified: filesModified,
|
||||
problems,
|
||||
patterns_text: patternsText,
|
||||
actions,
|
||||
raw_html: html,
|
||||
}
|
||||
}
|
||||
|
||||
/** Converte o campo `created` devolvido pelo Desk MCP (pode ser objecto vazio). */
|
||||
function normalizeMcpDate(v: unknown): string {
|
||||
if (!v) return ''
|
||||
if (typeof v === 'string') return v
|
||||
if (typeof v === 'object') {
|
||||
const obj = v as Record<string, unknown>
|
||||
if (typeof obj.date === 'string') return obj.date
|
||||
if (typeof obj.datetime === 'string') return obj.datetime
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Achata a árvore de comentários (comentários com children recursivos). */
|
||||
function flattenComments(comments: RawComment[]): RawComment[] {
|
||||
const out: RawComment[] = []
|
||||
for (const c of comments) {
|
||||
out.push(c)
|
||||
if (c.children && c.children.length) {
|
||||
out.push(...flattenComments(c.children))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
discussion_id: number
|
||||
fetched: number
|
||||
imported: number
|
||||
updated: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Importa todos os comentários de uma discussão Desk. Paginação por `limit`/`offset`.
|
||||
* Idempotente por `id` — comentários já existentes sofrem update (raw_html pode mudar).
|
||||
*/
|
||||
export async function importWorklogDiscussion(
|
||||
db: SessionsDb,
|
||||
discussionId: number,
|
||||
opts: { sinceIso?: string; pageSize?: number; maxPages?: number } = {},
|
||||
): Promise<ImportResult> {
|
||||
// O MCP desk-crm parece clampar resultados em 200/página independentemente do limit.
|
||||
// Pedimos 200 e iteramos offset até a resposta vir vazia.
|
||||
const pageSize = opts.pageSize ?? 200
|
||||
const maxPages = opts.maxPages ?? 20
|
||||
const result: ImportResult = {
|
||||
discussion_id: discussionId,
|
||||
fetched: 0,
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const raw = await callMcpTool('get_discussion_comments', {
|
||||
discussion_id: discussionId,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
})
|
||||
const payload = extractMcpJsonPayload<{
|
||||
success?: boolean
|
||||
comments?: RawComment[]
|
||||
}>(raw)
|
||||
const pageComments = flattenComments(payload.comments ?? [])
|
||||
if (pageComments.length === 0) break
|
||||
result.fetched += pageComments.length
|
||||
|
||||
const importedAt = new Date().toISOString()
|
||||
for (const c of pageComments) {
|
||||
try {
|
||||
const createdStr = normalizeMcpDate(c.created)
|
||||
const parsed = parseWorklogHtml(c.content ?? '', {
|
||||
id: c.id,
|
||||
discussion_id: c.discussion_id ?? discussionId,
|
||||
created_at: createdStr || '',
|
||||
staff_id: c.staff_id,
|
||||
})
|
||||
if (opts.sinceIso && parsed.created_at < opts.sinceIso) {
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
const record: WorklogCommentRecord = {
|
||||
...parsed,
|
||||
imported_at: importedAt,
|
||||
}
|
||||
const { inserted } = db.upsertWorklogComment(record)
|
||||
if (inserted) result.imported++
|
||||
else result.updated++
|
||||
} catch (e) {
|
||||
console.error(`[worklog-import] erro a parsear comentário #${c.id}:`, (e as Error).message)
|
||||
result.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Avança offset; quando próxima página vier vazia, o while quebra na próxima iter.
|
||||
offset += pageComments.length
|
||||
// Safety: se MCP devolveu 0, para
|
||||
if (pageComments.length === 0) break
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb } from '../services/sessions/db.js'
|
||||
import type { SessionMeta } from '../types/session.js'
|
||||
|
||||
function sampleMeta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
session_id: 's1',
|
||||
project_path: '/tmp/project',
|
||||
project_slug: 'project',
|
||||
jsonl_path: '/tmp/project/s1.jsonl',
|
||||
started_at: '2026-04-23T10:00:00Z',
|
||||
ended_at: '2026-04-23T10:30:00Z',
|
||||
duration_sec: 1800,
|
||||
event_count: 50,
|
||||
user_messages: 5,
|
||||
assistant_msgs: 10,
|
||||
tool_calls: 20,
|
||||
first_prompt: 'olá',
|
||||
tools_used: ['Bash', 'Read'],
|
||||
skills_invoked: ['brainstorming'],
|
||||
outcome: 'completed',
|
||||
permission_mode: 'default',
|
||||
file_size: 10000,
|
||||
indexed_at: '2026-04-23T10:31:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sessions db', () => {
|
||||
let dbPath: string
|
||||
beforeEach(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-db-'))
|
||||
dbPath = join(dir, 'sessions.db')
|
||||
})
|
||||
|
||||
it('cria schema, faz upsert e query', () => {
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertSession(sampleMeta())
|
||||
const rows = db.listSessions({ days: 30 })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].session_id).toBe('s1')
|
||||
expect(rows[0].tools_used).toEqual(['Bash', 'Read'])
|
||||
})
|
||||
|
||||
it('upsert substitui registo existente', () => {
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertSession(sampleMeta({ event_count: 50 }))
|
||||
db.upsertSession(sampleMeta({ event_count: 75 }))
|
||||
const rows = db.listSessions({ days: 30 })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].event_count).toBe(75)
|
||||
})
|
||||
|
||||
it('filtra por projecto e tool', () => {
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertSession(sampleMeta({ session_id: 'a', jsonl_path: '/tmp/a.jsonl', project_slug: 'alpha', tools_used: ['Bash'] }))
|
||||
db.upsertSession(sampleMeta({ session_id: 'b', jsonl_path: '/tmp/b.jsonl', project_slug: 'beta', tools_used: ['Read'] }))
|
||||
expect(db.listSessions({ project: 'alpha' })).toHaveLength(1)
|
||||
expect(db.listSessions({ tool: 'Read' })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('devolve contagem total', () => {
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertSession(sampleMeta({ session_id: 'a', jsonl_path: '/tmp/a.jsonl' }))
|
||||
db.upsertSession(sampleMeta({ session_id: 'b', jsonl_path: '/tmp/b.jsonl' }))
|
||||
expect(db.countSessions({})).toBe(2)
|
||||
})
|
||||
|
||||
it('upsertMany insere batch em transacção', () => {
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertMany([
|
||||
sampleMeta({ session_id: 'x', jsonl_path: '/tmp/x.jsonl' }),
|
||||
sampleMeta({ session_id: 'y', jsonl_path: '/tmp/y.jsonl' }),
|
||||
sampleMeta({ session_id: 'z', jsonl_path: '/tmp/z.jsonl' }),
|
||||
])
|
||||
expect(db.countSessions({})).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseSessionFile } from '../services/sessions/parser.js'
|
||||
import { mkdtempSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
function writeJsonl(lines: object[]): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-test-'))
|
||||
const path = join(dir, 'session-test.jsonl')
|
||||
writeFileSync(path, lines.map((l) => JSON.stringify(l)).join('\n'))
|
||||
return path
|
||||
}
|
||||
|
||||
describe('parseSessionFile', () => {
|
||||
it('extrai metadata básica de sessão mínima', async () => {
|
||||
const path = writeJsonl([
|
||||
{ type: 'permission-mode', permissionMode: 'default', sessionId: 's1' },
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'olá mundo' }] },
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-23T10:00:30Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'olá' }] },
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.session_id).toBe('s1')
|
||||
expect(result.meta.user_messages).toBe(1)
|
||||
expect(result.meta.assistant_msgs).toBe(1)
|
||||
expect(result.meta.tool_calls).toBe(0)
|
||||
expect(result.meta.first_prompt).toBe('olá mundo')
|
||||
expect(result.meta.permission_mode).toBe('default')
|
||||
expect(result.meta.outcome).toBe('completed')
|
||||
})
|
||||
|
||||
it('conta tool_calls e recolhe tools_used', async () => {
|
||||
const path = writeJsonl([
|
||||
{
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/x' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.tool_calls).toBe(2)
|
||||
expect(result.meta.tools_used).toEqual(expect.arrayContaining(['Bash', 'Read']))
|
||||
})
|
||||
|
||||
it('detecta skill invocation em system-reminder', async () => {
|
||||
const path = writeJsonl([
|
||||
{
|
||||
type: 'system',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: { role: 'system', content: [{ type: 'text', text: 'Launching skill: superpowers:brainstorming' }] },
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.skills_invoked).toContain('superpowers:brainstorming')
|
||||
})
|
||||
|
||||
it('detecta skill invocation em tool_result.content (string)', async () => {
|
||||
const path = writeJsonl([
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'abc', content: 'Launching skill: infraestrutura:easypanel-monitor\nOther log output' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.skills_invoked).toContain('infraestrutura:easypanel-monitor')
|
||||
})
|
||||
|
||||
it('detecta skill invocation em tool_result.content (array de text blocks)', async () => {
|
||||
const path = writeJsonl([
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'abc',
|
||||
content: [{ type: 'text', text: 'Launching skill: superpowers:brainstorming' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.skills_invoked).toContain('superpowers:brainstorming')
|
||||
})
|
||||
|
||||
it('ignora linhas JSON inválidas silenciosamente', async () => {
|
||||
const path = writeJsonl([
|
||||
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'válido' }] } },
|
||||
])
|
||||
const { writeFileSync } = await import('fs')
|
||||
writeFileSync(path, 'linha inválida\n' + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'válido' }] } }))
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.user_messages).toBe(1)
|
||||
})
|
||||
|
||||
it('devolve duration_sec null quando timestamps são inválidos', async () => {
|
||||
const path = writeJsonl([
|
||||
{ type: 'user', timestamp: 'not-a-date', message: { role: 'user', content: [{ type: 'text', text: 'a' }] } },
|
||||
{ type: 'assistant', timestamp: 'also-not-a-date', message: { role: 'assistant', content: [{ type: 'text', text: 'b' }] } },
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.duration_sec).toBeNull()
|
||||
})
|
||||
|
||||
it('classifica max_tokens como interrupted', async () => {
|
||||
const path = writeJsonl([
|
||||
{
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-23T10:00:00Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'resposta cortada' }], stop_reason: 'max_tokens' },
|
||||
},
|
||||
])
|
||||
const result = await parseSessionFile(path)
|
||||
expect(result.meta.outcome).toBe('interrupted')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb, type SessionsDb, type PatternRecord } from '../services/sessions/db.js'
|
||||
import { detectPatterns, weekRange, toPatternRecord } from '../services/sessions/patterns.js'
|
||||
import type { SessionMeta } from '../types/session.js'
|
||||
|
||||
function meta(overrides: Partial<SessionMeta>): SessionMeta {
|
||||
return {
|
||||
session_id: 's-' + Math.random().toString(36).slice(2, 10),
|
||||
project_path: '/tmp/project',
|
||||
project_slug: 'project',
|
||||
jsonl_path: '/tmp/' + Math.random().toString(36).slice(2) + '.jsonl',
|
||||
started_at: '2026-04-20T10:00:00Z', // segunda de 2026-W17
|
||||
ended_at: '2026-04-20T10:30:00Z',
|
||||
duration_sec: 1800,
|
||||
event_count: 50,
|
||||
user_messages: 5,
|
||||
assistant_msgs: 10,
|
||||
tool_calls: 20,
|
||||
first_prompt: 'olá',
|
||||
tools_used: ['Bash'],
|
||||
skills_invoked: [],
|
||||
outcome: 'completed',
|
||||
permission_mode: 'default',
|
||||
file_size: 10000,
|
||||
indexed_at: '2026-04-20T10:31:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('patterns detector', () => {
|
||||
let db: SessionsDb
|
||||
beforeEach(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-pat-'))
|
||||
db = openSessionsDb(join(dir, 'sessions.db'))
|
||||
})
|
||||
|
||||
it('detecta skill com taxa elevada de erro (action)', () => {
|
||||
// 3 sessões skill X: 2 error, 1 completed → ratio 0.67 → severity=action
|
||||
db.upsertSession(meta({ session_id: 'a', skills_invoked: ['skillX'], outcome: 'error' }))
|
||||
db.upsertSession(meta({ session_id: 'b', skills_invoked: ['skillX'], outcome: 'interrupted' }))
|
||||
db.upsertSession(meta({ session_id: 'c', skills_invoked: ['skillX'], outcome: 'completed' }))
|
||||
const { start, end } = weekRange(new Date('2026-04-22T00:00:00Z'))
|
||||
const patterns = detectPatterns(db, start, end)
|
||||
const errorRate = patterns.find((p) => p.pattern_key === 'skill_error_rate:skillX')
|
||||
expect(errorRate).toBeDefined()
|
||||
expect(errorRate!.severity).toBe('action')
|
||||
expect(errorRate!.affected_count).toBe(2)
|
||||
})
|
||||
|
||||
it('detecta sessões abandonadas', () => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
db.upsertSession(meta({ session_id: `ab-${i}`, event_count: 1, outcome: 'unknown' }))
|
||||
}
|
||||
const { start, end } = weekRange(new Date('2026-04-22T00:00:00Z'))
|
||||
const patterns = detectPatterns(db, start, end)
|
||||
expect(patterns.some((p) => p.pattern_key === 'abandoned_sessions')).toBe(true)
|
||||
})
|
||||
|
||||
it('getConsecutiveWeeks devolve 3 após upserts em semanas sucessivas', () => {
|
||||
const key = 'skill_error_rate:Y'
|
||||
const weeks = ['2026-W15', '2026-W16', '2026-W17']
|
||||
for (const w of weeks) {
|
||||
db.upsertPattern({
|
||||
detected_at: new Date().toISOString(),
|
||||
week_iso: w,
|
||||
pattern_key: key,
|
||||
title: 't',
|
||||
description: 'd',
|
||||
severity: 'warning',
|
||||
metric_value: 0.5,
|
||||
sample_session_ids: ['x'],
|
||||
affected_count: 1,
|
||||
consecutive_weeks: 1,
|
||||
})
|
||||
}
|
||||
expect(db.getConsecutiveWeeks(key, '2026-W17')).toBe(3)
|
||||
expect(db.getConsecutiveWeeks(key, '2026-W16')).toBe(2)
|
||||
})
|
||||
|
||||
it('upsertPattern é idempotente por (week_iso, pattern_key)', () => {
|
||||
const base: PatternRecord = {
|
||||
detected_at: '2026-04-20T00:00:00Z',
|
||||
week_iso: '2026-W17',
|
||||
pattern_key: 'test',
|
||||
title: 'v1',
|
||||
description: 'd',
|
||||
severity: 'info',
|
||||
metric_value: 1,
|
||||
sample_session_ids: ['a'],
|
||||
affected_count: 1,
|
||||
consecutive_weeks: 1,
|
||||
}
|
||||
db.upsertPattern(base)
|
||||
db.upsertPattern({ ...base, title: 'v2', affected_count: 5, consecutive_weeks: 2 })
|
||||
const rows = db.getPatternsByWeek('2026-W17')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].title).toBe('v2')
|
||||
expect(rows[0].affected_count).toBe(5)
|
||||
expect(rows[0].consecutive_weeks).toBe(2)
|
||||
})
|
||||
|
||||
it('toPatternRecord propaga week_iso e consecutive_weeks', () => {
|
||||
const rec = toPatternRecord(
|
||||
{
|
||||
pattern_key: 'k',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
severity: 'warning',
|
||||
metric_value: 0.42,
|
||||
sample_session_ids: ['a', 'b'],
|
||||
affected_count: 2,
|
||||
},
|
||||
'2026-W17',
|
||||
3,
|
||||
)
|
||||
expect(rec.week_iso).toBe('2026-W17')
|
||||
expect(rec.consecutive_weeks).toBe(3)
|
||||
expect(rec.severity).toBe('warning')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Testes da rota /api/sessions (validação Zod + integração com SessionsDb).
|
||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import express from 'express'
|
||||
import request from 'supertest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb } from '../services/sessions/db.js'
|
||||
import { createSessionsRouter } from '../routes/sessions.js'
|
||||
import type { SessionMeta } from '../types/session.js'
|
||||
|
||||
function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
session_id: 's1',
|
||||
project_path: '/tmp/p',
|
||||
project_slug: 'p',
|
||||
jsonl_path: '/tmp/p/s1.jsonl',
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: null,
|
||||
duration_sec: 60,
|
||||
event_count: 10,
|
||||
user_messages: 2,
|
||||
assistant_msgs: 5,
|
||||
tool_calls: 3,
|
||||
first_prompt: 'teste',
|
||||
tools_used: ['Bash'],
|
||||
skills_invoked: [],
|
||||
outcome: 'completed',
|
||||
permission_mode: 'default',
|
||||
file_size: 1000,
|
||||
indexed_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
let app: express.Express
|
||||
beforeAll(() => {
|
||||
const dbPath = join(mkdtempSync(join(tmpdir(), 'obs-r-')), 'sessions.db')
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertSession(meta({ session_id: 's1', project_slug: 'alpha', jsonl_path: '/tmp/p/s1.jsonl' }))
|
||||
db.upsertSession(meta({ session_id: 's2', project_slug: 'beta', jsonl_path: '/tmp/p/s2.jsonl' }))
|
||||
app = express()
|
||||
app.use('/api/sessions', createSessionsRouter(db))
|
||||
})
|
||||
|
||||
it('lista todas as sessões por omissão', async () => {
|
||||
const res = await request(app).get('/api/sessions')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(2)
|
||||
expect(res.body.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filtra por projecto', async () => {
|
||||
const res = await request(app).get('/api/sessions').query({ project: 'alpha' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(1)
|
||||
expect(res.body.items[0].project_slug).toBe('alpha')
|
||||
})
|
||||
|
||||
it('rejeita limit inválido', async () => {
|
||||
const res = await request(app).get('/api/sessions').query({ limit: '9999' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb, type SessionsDb, type WorklogCommentRecord } from '../services/sessions/db.js'
|
||||
import { parseWorklogHtml } from '../services/sessions/worklog-import.js'
|
||||
import { detectActionsNeverExecuted, weekRange } from '../services/sessions/patterns.js'
|
||||
|
||||
const SAMPLE_H4 = `
|
||||
<h4>2026-04-15 10:30 - Refactor API sessions</h4>
|
||||
<p><strong>Projecto:</strong> DashDescomplicar</p>
|
||||
<p><strong>Tarefa:</strong> #2059 - Observabilidade Espelho</p>
|
||||
<p><strong>Duração:</strong> ~2h 15m</p>
|
||||
<h4>Trabalho Realizado</h4>
|
||||
<ul><li>Criar módulo worklog-import</li><li>Integrar detectores cruzados</li></ul>
|
||||
<h4>Ficheiros Modificados</h4>
|
||||
<ul><li><code>api/services/sessions/db.ts</code></li><li><code>api/scripts/sessions-worklog-import.ts</code></li></ul>
|
||||
<h4>Problemas / Soluções</h4>
|
||||
<ul><li>Parser HTML frágil → usar node-html-parser</li></ul>
|
||||
<h4>Padrões Detectados</h4>
|
||||
<ul><li>MCP gateway responde em SSE ou JSON</li></ul>
|
||||
<h4>Acções Sugeridas</h4>
|
||||
<ul><li>[Refactor] Extrair callMcpTool para módulo partilhado P2</li></ul>
|
||||
`
|
||||
|
||||
const SAMPLE_H2 = `
|
||||
<h2>2026-01-31 - Estratégia Stack</h2>
|
||||
<p><strong>Duração:</strong> ~2h</p>
|
||||
<h3>Trabalho Realizado</h3>
|
||||
<ul><li>Stack Mapeado - 15 sistemas</li></ul>
|
||||
<h3>Insights</h3>
|
||||
<ul><li>Posicionamento: Marketing alta performance</li></ul>
|
||||
`
|
||||
|
||||
const SAMPLE_D33 = `
|
||||
<ul>
|
||||
<li>[ ] [MCP] Corrigir bug desk-crm-v3 com tabelas de discussões</li>
|
||||
</ul>
|
||||
<p><strong>Origem:</strong> Sessão 2026-02-02</p>
|
||||
<p><strong>Prioridade:</strong> P1</p>
|
||||
`
|
||||
|
||||
describe('parseWorklogHtml', () => {
|
||||
it('extrai campos de comentário formato <h4>', () => {
|
||||
const parsed = parseWorklogHtml(SAMPLE_H4, { id: 100, discussion_id: 31, created_at: '' })
|
||||
expect(parsed.id).toBe(100)
|
||||
expect(parsed.title).toMatch(/2026-04-15/)
|
||||
expect(parsed.task_ref).toBe('#2059')
|
||||
expect(parsed.duration_sec).toBe(2 * 3600 + 15 * 60)
|
||||
expect(parsed.work_items.length).toBe(2)
|
||||
expect(parsed.files_modified.length).toBe(2)
|
||||
expect(parsed.patterns_text.length).toBe(1)
|
||||
expect(parsed.actions.length).toBe(1)
|
||||
expect(parsed.actions[0].tipo).toBe('Refactor')
|
||||
expect(parsed.actions[0].prioridade).toBe('P2')
|
||||
expect(parsed.created_at.startsWith('2026-04-15')).toBe(true)
|
||||
})
|
||||
|
||||
it('extrai campos de comentário formato <h2>/<h3> (legacy)', () => {
|
||||
const parsed = parseWorklogHtml(SAMPLE_H2, { id: 64, discussion_id: 31, created_at: '' })
|
||||
expect(parsed.title).toMatch(/2026-01-31/)
|
||||
expect(parsed.work_items.length).toBeGreaterThanOrEqual(1)
|
||||
expect(parsed.duration_sec).toBe(2 * 3600)
|
||||
expect(parsed.created_at.startsWith('2026-01-31')).toBe(true)
|
||||
})
|
||||
|
||||
it('extrai acções em formato discussão #33 (lista crua)', () => {
|
||||
const parsed = parseWorklogHtml(SAMPLE_D33, { id: 200, discussion_id: 33, created_at: '2026-02-02T00:00:00Z' })
|
||||
expect(parsed.actions.length).toBe(1)
|
||||
expect(parsed.actions[0].tipo).toBe('MCP')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertWorklogComment idempotência', () => {
|
||||
let db: SessionsDb
|
||||
beforeEach(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-wl-'))
|
||||
db = openSessionsDb(join(dir, 'sessions.db'))
|
||||
})
|
||||
|
||||
it('insert primeiro, update depois', () => {
|
||||
const base: WorklogCommentRecord = {
|
||||
id: 42,
|
||||
discussion_id: 31,
|
||||
created_at: '2026-04-15T10:30:00Z',
|
||||
staff_id: 25,
|
||||
title: 'Test',
|
||||
task_ref: '#100',
|
||||
duration_sec: 600,
|
||||
work_items: ['a'],
|
||||
files_modified: [],
|
||||
problems: [],
|
||||
patterns_text: [],
|
||||
actions: [],
|
||||
raw_html: '<h4>Test</h4>',
|
||||
imported_at: '2026-04-23T00:00:00Z',
|
||||
}
|
||||
const r1 = db.upsertWorklogComment(base)
|
||||
expect(r1.inserted).toBe(true)
|
||||
expect(db.countWorklogComments()).toBe(1)
|
||||
const r2 = db.upsertWorklogComment({ ...base, title: 'Updated' })
|
||||
expect(r2.inserted).toBe(false)
|
||||
expect(db.countWorklogComments()).toBe(1)
|
||||
const list = db.listWorklogComments({ discussion_id: 31 })
|
||||
expect(list[0].title).toBe('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectActionsNeverExecuted', () => {
|
||||
let db: SessionsDb
|
||||
beforeEach(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-act-'))
|
||||
db = openSessionsDb(join(dir, 'sessions.db'))
|
||||
})
|
||||
|
||||
it('sinaliza acções P1/P2 antigas sem execução', () => {
|
||||
const old = new Date('2026-03-01T00:00:00Z').toISOString()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
db.upsertWorklogComment({
|
||||
id: 300 + i,
|
||||
discussion_id: 33,
|
||||
created_at: old,
|
||||
staff_id: 25,
|
||||
title: `Acção ${i}`,
|
||||
task_ref: `#${1000 + i}`,
|
||||
duration_sec: null,
|
||||
work_items: [],
|
||||
files_modified: [],
|
||||
problems: [],
|
||||
patterns_text: [],
|
||||
actions: [{ tipo: 'MCP', descricao: `Corrigir bug X${i}`, prioridade: i % 2 ? 'P1' : 'P2' }],
|
||||
raw_html: '',
|
||||
imported_at: '2026-04-23T00:00:00Z',
|
||||
})
|
||||
}
|
||||
const range = weekRange(new Date('2026-04-22T00:00:00Z'))
|
||||
const patterns = detectActionsNeverExecuted({
|
||||
db: db.rawDb(),
|
||||
weekStartIso: range.start.toISOString(),
|
||||
weekEndIso: range.end.toISOString(),
|
||||
})
|
||||
expect(patterns.length).toBe(1)
|
||||
expect(patterns[0].pattern_key).toBe('actions_never_executed')
|
||||
expect(patterns[0].affected_count).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('não sinaliza se acções recentes (<14 dias)', () => {
|
||||
const recent = new Date().toISOString()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
db.upsertWorklogComment({
|
||||
id: 400 + i,
|
||||
discussion_id: 33,
|
||||
created_at: recent,
|
||||
staff_id: 25,
|
||||
title: null,
|
||||
task_ref: null,
|
||||
duration_sec: null,
|
||||
work_items: [],
|
||||
files_modified: [],
|
||||
problems: [],
|
||||
patterns_text: [],
|
||||
actions: [{ tipo: 'MCP', descricao: 'x', prioridade: 'P1' }],
|
||||
raw_html: '',
|
||||
imported_at: recent,
|
||||
})
|
||||
}
|
||||
const range = weekRange(new Date())
|
||||
const patterns = detectActionsNeverExecuted({
|
||||
db: db.rawDb(),
|
||||
weekStartIso: range.start.toISOString(),
|
||||
weekEndIso: range.end.toISOString(),
|
||||
})
|
||||
expect(patterns.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
export type SessionOutcome = 'completed' | 'interrupted' | 'error' | 'unknown'
|
||||
|
||||
export interface SessionMeta {
|
||||
session_id: string
|
||||
project_path: string
|
||||
project_slug: string
|
||||
jsonl_path: string
|
||||
started_at: string
|
||||
ended_at: string | null
|
||||
duration_sec: number | null
|
||||
event_count: number
|
||||
user_messages: number
|
||||
assistant_msgs: number
|
||||
tool_calls: number
|
||||
first_prompt: string | null
|
||||
tools_used: string[]
|
||||
skills_invoked: string[]
|
||||
outcome: SessionOutcome
|
||||
permission_mode: string | null
|
||||
file_size: number
|
||||
indexed_at: string
|
||||
}
|
||||
|
||||
export type SessionEventType =
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'system'
|
||||
| 'attachment'
|
||||
| 'permission-mode'
|
||||
| 'file-history-snapshot'
|
||||
| 'unknown'
|
||||
|
||||
export interface SessionEvent {
|
||||
index: number
|
||||
type: SessionEventType
|
||||
timestamp: string | null
|
||||
raw: Record<string, unknown>
|
||||
text: string | null
|
||||
tool_name: string | null
|
||||
tool_input: Record<string, unknown> | null
|
||||
tool_result: unknown
|
||||
skill_invoked: string | null
|
||||
hook_name: string | null
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
meta: SessionMeta
|
||||
events: SessionEvent[]
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
---
|
||||
title: PLAN Conductor — Expansão DashDescomplicar
|
||||
date: 2026-04-06
|
||||
type: plan
|
||||
status: active
|
||||
spec: SPEC-dashboard-expansion-q2-2026.md
|
||||
tags: [conductor, plan, dashboard, parallel]
|
||||
---
|
||||
|
||||
# Plano Conductor — Expansão DashDescomplicar
|
||||
|
||||
**Spec:** `docs/SPEC-dashboard-expansion-q2-2026.md`
|
||||
**Projecto:** `/media/ealmeida/Dados/Dev/DashDescomplicar/`
|
||||
**Método:** Conductor parallel sprints — agentes independentes por domínio
|
||||
|
||||
---
|
||||
|
||||
## Estratégia de paralelização
|
||||
|
||||
Cada painel (backend + frontend) é **independente** — ficheiros distintos, sem dependências cruzadas. A integração (Layout, App, server.ts) é feita no final por um único agente coordenador.
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Coordenador │
|
||||
│ (Sprint 4) │
|
||||
└──────┬──────┘
|
||||
┌──────────────┼──────────────┐
|
||||
Sprint 1 │ Sprint 3
|
||||
┌────┴────┐ Sprint 2 ┌────┴────┐
|
||||
│ │ │ │ │
|
||||
Agent A Agent B Agent C Agent D Agent E
|
||||
MCPs n8n Paperclip IA Operações
|
||||
```
|
||||
|
||||
**Agentes A-E correm em paralelo.** Agente F (coordenador) corre depois, integrando tudo.
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos (executar antes do conductor)
|
||||
|
||||
1. Instalar `pg` (PostgreSQL client):
|
||||
```bash
|
||||
cd /media/ealmeida/Dados/Dev/DashDescomplicar && npm install pg @types/pg
|
||||
```
|
||||
|
||||
2. Verificar env vars disponíveis no EasyPanel (MCP_GATEWAY_TOKEN, N8N_API_KEY, PAPERCLIP_DB_*)
|
||||
|
||||
---
|
||||
|
||||
## Agent A — Painel MCPs
|
||||
|
||||
**Tipo:** `javascript-fullstack-specialist`
|
||||
**Isolation:** worktree
|
||||
**Ficheiros a criar:**
|
||||
- `api/services/mcps.ts`
|
||||
- `api/routes/mcps.ts`
|
||||
- `src/pages/McpMonitor.tsx`
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/).
|
||||
Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript.
|
||||
|
||||
Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.1 (Painel MCPs).
|
||||
|
||||
Cria 3 ficheiros:
|
||||
|
||||
1. api/services/mcps.ts — Service que:
|
||||
- Tem lista hardcoded de 35 MCPs (nome, porta, categoria, enabled)
|
||||
- Faz ping paralelo (Promise.allSettled, timeout 5s) aos MCPs enabled via HTTP GET
|
||||
- Gateway URL: process.env.MCP_GATEWAY_URL || 'https://gateway.descomplicar.pt'
|
||||
- Bearer token: process.env.MCP_GATEWAY_TOKEN
|
||||
- Cache de 60 segundos (variável em memória com timestamp)
|
||||
- Retorna McpDashboard conforme spec
|
||||
|
||||
2. api/routes/mcps.ts — Router Express com GET /
|
||||
- Chama o service e retorna JSON
|
||||
- try/catch com 500 error handling (padrão dos outros routers no projecto)
|
||||
|
||||
3. src/pages/McpMonitor.tsx — Página React que:
|
||||
- Faz fetch a /api/mcps com useEffect
|
||||
- Mostra header com stats (total/online/offline/disabled)
|
||||
- Grid de cards agrupados por categoria (crm, infra, ai, tools, external)
|
||||
- Card com: nome, porta, status (cor), response_time
|
||||
- Botão refresh manual
|
||||
- Loading state e error state
|
||||
- Segue estilo visual do projecto: ler src/pages/Monitor.tsx como referência (dark theme, motion.div, lucide icons, containerVariants/itemVariants)
|
||||
|
||||
Lista dos MCPs para hardcoding:
|
||||
- CRM: desk-crm-v3 (3150), desk-project-minimal (3153)
|
||||
- Infra: ssh-unified (3192), filesystem (local), chrome-devtools (local)
|
||||
- AI: lightrag (3160), notebooklm (3190), context7 (3169), replicate (3176), memory-supabase (3151)
|
||||
- Tools: mcp-time (3155), deepl (3188), pexels (3175), vimeo (3177), drawio (3184)
|
||||
- External: google-workspace (3156), google-analytics (3164), gsc (3165), youtube (3166), youtube-research (3167), moloni (3158), n8n (3171), gitea (3162), stitch (external), design-systems (external)
|
||||
- Gateway: authentik (3191), spaceship (3189), puppeteer (3193), lighthouse (3194)
|
||||
- Locais (disabled ping): filesystem, chrome-devtools
|
||||
- MCPs em .mcp.json: carl-mcp (local, porta variável), reonic (3187)
|
||||
|
||||
Não alteres nenhum ficheiro existente. Apenas cria os 3 novos.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent B — Painel n8n
|
||||
|
||||
**Tipo:** `javascript-fullstack-specialist`
|
||||
**Isolation:** worktree
|
||||
**Ficheiros a criar:**
|
||||
- `api/services/n8n.ts`
|
||||
- `api/routes/n8n.ts`
|
||||
- `src/pages/N8nMonitor.tsx`
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/).
|
||||
Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript.
|
||||
|
||||
Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.2 (Painel n8n).
|
||||
|
||||
Cria 3 ficheiros:
|
||||
|
||||
1. api/services/n8n.ts — Service que:
|
||||
- Chama API REST n8n: GET /workflows e GET /executions?limit=50
|
||||
- URL base: process.env.N8N_API_URL || 'https://automator.descomplicar.pt/api/v1'
|
||||
- Auth: header X-N8N-API-KEY com process.env.N8N_API_KEY
|
||||
- Cache de 300 segundos
|
||||
- Retorna N8nDashboard conforme spec (total, active, failed_24h, workflows com last_execution)
|
||||
|
||||
2. api/routes/n8n.ts — Router Express com GET /
|
||||
- try/catch com 500 error handling
|
||||
|
||||
3. src/pages/N8nMonitor.tsx — Página React que:
|
||||
- Faz fetch a /api/n8n
|
||||
- Stats cards: total workflows, activos, falhas 24h (destaque vermelho se >0)
|
||||
- Tabela com: nome, activo (badge), último run (data + status com cor), duração
|
||||
- Filtro toggle: activos/todos
|
||||
- Segue estilo visual: ler src/pages/Financial.tsx como referência
|
||||
|
||||
Não alteres nenhum ficheiro existente. Apenas cria os 3 novos.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent C — Painel Paperclip
|
||||
|
||||
**Tipo:** `javascript-fullstack-specialist`
|
||||
**Isolation:** worktree
|
||||
**Ficheiros a criar:**
|
||||
- `api/services/paperclip-db.ts`
|
||||
- `api/services/paperclip.ts`
|
||||
- `api/routes/paperclip.ts`
|
||||
- `src/pages/Paperclip.tsx`
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/).
|
||||
Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript.
|
||||
|
||||
Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.3 (Painel Paperclip).
|
||||
|
||||
O pacote `pg` já foi instalado (npm install pg @types/pg).
|
||||
|
||||
Cria 4 ficheiros:
|
||||
|
||||
1. api/services/paperclip-db.ts — Pool PostgreSQL:
|
||||
- Host: process.env.PAPERCLIP_DB_HOST || 'clip.descomplicar.pt'
|
||||
- Port: process.env.PAPERCLIP_DB_PORT || 54329
|
||||
- Database: process.env.PAPERCLIP_DB_NAME || 'paperclip'
|
||||
- User/Pass: process.env.PAPERCLIP_DB_USER, process.env.PAPERCLIP_DB_PASS
|
||||
- Pool com max 5 conexões
|
||||
- Validação de credenciais obrigatória (throw se não definidas)
|
||||
- Export default pool
|
||||
|
||||
2. api/services/paperclip.ts — Queries:
|
||||
- getAgents(): SELECT id, name, role, status, last_heartbeat, total_runs FROM agents WHERE status != 'archived'
|
||||
- getRoutines(): SELECT id, name, cron, active, last_run, last_status FROM routines
|
||||
- getIssueStats(): COUNT por estado (open, in_progress, closed últimos 7 dias)
|
||||
- Retorna PaperclipDashboard conforme spec
|
||||
- NOTA: os nomes exactos das tabelas e colunas podem variar — usar nomes razoáveis e documentar com comentário que podem precisar de ajuste
|
||||
|
||||
3. api/routes/paperclip.ts — Router Express com GET /
|
||||
- try/catch, se BD inacessível retornar dados fallback (zeros)
|
||||
|
||||
4. src/pages/Paperclip.tsx — Página React:
|
||||
- Stats cards: agentes activos/idle/error, routines activas
|
||||
- Grid de cards para agentes, agrupados por role (C-Level, Director, Specialist)
|
||||
- Cor do card por status: active=verde, idle=amarelo, error=vermelho
|
||||
- Tabela de routines: nome, cron, activa, último run, status
|
||||
- Segue estilo visual do projecto
|
||||
|
||||
Não alteres nenhum ficheiro existente. Apenas cria os 4 novos.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent D — Painel IA / Claude Code
|
||||
|
||||
**Tipo:** `javascript-fullstack-specialist`
|
||||
**Isolation:** worktree
|
||||
**Ficheiros a criar:**
|
||||
- `api/services/ai.ts`
|
||||
- `api/routes/ai.ts`
|
||||
- `src/pages/AiOverview.tsx`
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/).
|
||||
Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript.
|
||||
|
||||
Lê a spec completa em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.4 (Painel Claude Code / IA).
|
||||
|
||||
Cria 3 ficheiros:
|
||||
|
||||
1. api/services/ai.ts — Service que:
|
||||
- Retorna dados maioritariamente estáticos (actualizados manualmente)
|
||||
- Dados reais do stack (fonte: STK-Estado-Actual.md de 04-04-2026):
|
||||
- Skills: 189 total (31 directas + 158 plugins)
|
||||
- Agents CC: 72 (18 directos + 54 plugins)
|
||||
- MCPs: 39 (10 enabled, 29 disabled, 33 gateway, 2 locais)
|
||||
- Hooks: 26 ficheiros, 9 activos
|
||||
- Plugins: 14 Descomplicar + 6 oficiais + 3 terceiros, 6 activos
|
||||
- CARL: 7 domínios, ~45 regras
|
||||
- Paperclip: 16 operacionais
|
||||
- n8n: 14 workflows
|
||||
- NotebookLM: 58 notebooks
|
||||
- Cache infinito (dados estáticos)
|
||||
|
||||
2. api/routes/ai.ts — Router Express com GET /
|
||||
|
||||
3. src/pages/AiOverview.tsx — Página React:
|
||||
- Layout de "stack overview" com 3 secções:
|
||||
a) Camada 1 (Claude Code): cards para skills, agents, MCPs, hooks, plugins
|
||||
b) Camada 2 (n8n): card simples com contagem workflows
|
||||
c) Camada 3 (Paperclip): card simples com contagem agentes
|
||||
- Card grande central: "3 Camadas de Execução" com diagrama visual (CSS, não SVG)
|
||||
- Secção CARL: 7 domínios listados (GLOBAL, CRM, DEVELOPMENT, WORDPRESS, HUB, INFRASTRUCTURE, QUALITY, SKILLS)
|
||||
- Tons roxos/violeta para diferenciar das outras páginas
|
||||
- Segue estilo visual do projecto
|
||||
|
||||
Não alteres nenhum ficheiro existente. Apenas cria os 3 novos.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent E — Painel Operações
|
||||
|
||||
**Tipo:** `javascript-fullstack-specialist`
|
||||
**Isolation:** worktree
|
||||
**Ficheiros a criar:**
|
||||
- `api/services/operations.ts`
|
||||
- `api/routes/operations.ts`
|
||||
- `src/pages/Operations.tsx`
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/).
|
||||
Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + TypeScript.
|
||||
|
||||
Lê a spec em docs/SPEC-dashboard-expansion-q2-2026.md, secção 4.5 (Painel Operações).
|
||||
|
||||
A BD MySQL (Desk CRM) já tem conexão configurada em api/db.ts (export default pool mysql2/promise).
|
||||
|
||||
Cria 3 ficheiros:
|
||||
|
||||
1. api/services/operations.ts — Service que:
|
||||
- Queries ao MySQL (Desk CRM):
|
||||
a) Tickets abertos: SELECT COUNT(*) FROM tbltickets WHERE status IN ('Open','In Progress','Answered')
|
||||
b) Tickets alta prioridade: WHERE priority IN (2,3)
|
||||
c) Tickets por departamento: JOIN tbldepartments GROUP BY department
|
||||
d) Tempo médio resposta: AVG(TIMESTAMPDIFF(HOUR, date, lastreply)) dos tickets com resposta
|
||||
- Dados estáticos PROCs:
|
||||
- 48 procedimentos, 8 departamentos
|
||||
- Cobertura por dept: D1(5), D2(3), D3(3), D4(1), D5(5), D6(8), D7(18), Cross(5)
|
||||
- import db from '../db.js'
|
||||
|
||||
2. api/routes/operations.ts — Router Express com GET /
|
||||
|
||||
3. src/pages/Operations.tsx — Página React:
|
||||
- Stats cards: tickets abertos, alta prioridade, tempo médio resposta
|
||||
- Gráfico barras horizontal (recharts BarChart): tickets por departamento
|
||||
- Tabela cobertura PROCs: departamento, número PROCs, % cobertura
|
||||
- Segue estilo visual: ler src/pages/Financial.tsx como referência (usa recharts)
|
||||
|
||||
Não alteres nenhum ficheiro existente. Apenas cria os 3 novos.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent F — Coordenador / Integração (executa após A-E)
|
||||
|
||||
**Tipo:** `javascript-fullstack-specialist`
|
||||
**Isolation:** nenhum (merge directo)
|
||||
**Ficheiros a alterar:**
|
||||
- `src/components/Layout.tsx`
|
||||
- `src/App.tsx` (secção de routing)
|
||||
- `api/server.ts` (registar routers)
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
Estás a trabalhar no projecto DashDescomplicar (/media/ealmeida/Dados/Dev/DashDescomplicar/).
|
||||
|
||||
Os seguintes ficheiros novos já foram criados por agentes anteriores:
|
||||
- api/routes/mcps.ts, api/services/mcps.ts, src/pages/McpMonitor.tsx
|
||||
- api/routes/n8n.ts, api/services/n8n.ts, src/pages/N8nMonitor.tsx
|
||||
- api/routes/paperclip.ts, api/services/paperclip.ts, api/services/paperclip-db.ts, src/pages/Paperclip.tsx
|
||||
- api/routes/ai.ts, api/services/ai.ts, src/pages/AiOverview.tsx
|
||||
- api/routes/operations.ts, api/services/operations.ts, src/pages/Operations.tsx
|
||||
|
||||
Faz a integração:
|
||||
|
||||
1. src/components/Layout.tsx — Adicionar 5 itens ao array NAV_ITEMS:
|
||||
- { to: '/mcps', label: 'MCPs', icon: Network }
|
||||
- { to: '/n8n', label: 'Automações', icon: Workflow }
|
||||
- { to: '/paperclip', label: 'Paperclip', icon: Bot }
|
||||
- { to: '/ai', label: 'IA / Claude', icon: Brain }
|
||||
- { to: '/operations', label: 'Operações', icon: ClipboardList }
|
||||
Adicionar os imports de ícones do lucide-react: Network, Workflow, Bot, Brain, ClipboardList
|
||||
NOTA: verificar se 'Workflow' existe em lucide-react, se não usar 'GitBranch' ou 'Repeat'
|
||||
|
||||
2. src/App.tsx — Na secção de routing (BrowserRouter/Routes), adicionar:
|
||||
- import McpMonitor from './pages/McpMonitor'
|
||||
- import N8nMonitor from './pages/N8nMonitor'
|
||||
- import Paperclip from './pages/Paperclip'
|
||||
- import AiOverview from './pages/AiOverview'
|
||||
- import Operations from './pages/Operations'
|
||||
E as respectivas <Route> dentro do <Route element={<Layout />}>
|
||||
|
||||
3. api/server.ts — Adicionar imports e app.use:
|
||||
- import mcpsRouter from './routes/mcps.js'
|
||||
- import n8nRouter from './routes/n8n.js'
|
||||
- import paperclipRouter from './routes/paperclip.js'
|
||||
- import aiRouter from './routes/ai.js'
|
||||
- import operationsRouter from './routes/operations.js'
|
||||
- app.use('/api/mcps', mcpsRouter)
|
||||
- app.use('/api/n8n', n8nRouter)
|
||||
- app.use('/api/paperclip', paperclipRouter)
|
||||
- app.use('/api/ai', aiRouter)
|
||||
- app.use('/api/operations', operationsRouter)
|
||||
|
||||
Lê cada ficheiro antes de editar. Mantém o estilo existente. Não alteres mais nada.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sequência de execução
|
||||
|
||||
```
|
||||
Fase 1 (paralelo): Agent A + Agent B + Agent C + Agent D + Agent E
|
||||
↓ todos concluídos
|
||||
Fase 2 (sequencial): Merge worktrees → main
|
||||
↓
|
||||
Fase 3 (sequencial): Agent F (integração)
|
||||
↓
|
||||
Fase 4 (validação): npm run build + teste manual
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comando conductor
|
||||
|
||||
```bash
|
||||
# Fase 1 — lançar 5 agentes em paralelo (worktree isolation)
|
||||
# Usar: Agent tool com isolation: "worktree" e run_in_background: true
|
||||
|
||||
# Fase 2 — após todos concluírem, merge dos worktrees
|
||||
# git merge <branch-agent-a> --no-edit
|
||||
# git merge <branch-agent-b> --no-edit
|
||||
# ...
|
||||
|
||||
# Fase 3 — Agent F integração
|
||||
# Sem worktree, directo no main
|
||||
|
||||
# Fase 4 — validar
|
||||
# npm run build
|
||||
# npm run dev (testar manualmente)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist de validação final
|
||||
|
||||
- [ ] `npm run build` sem erros
|
||||
- [ ] Todas as 8 páginas navegáveis na sidebar
|
||||
- [ ] GET /api/mcps retorna dados
|
||||
- [ ] GET /api/n8n retorna dados (ou erro claro se API key não configurada)
|
||||
- [ ] GET /api/paperclip retorna dados (ou fallback zeros se BD inacessível)
|
||||
- [ ] GET /api/ai retorna dados estáticos
|
||||
- [ ] GET /api/operations retorna dados do Desk CRM
|
||||
- [ ] Mobile: sidebar colapsa correctamente com 8 itens
|
||||
- [ ] Zero erros na consola do browser
|
||||
@@ -0,0 +1,473 @@
|
||||
---
|
||||
title: SPEC — Expansão DashDescomplicar Q2 2026
|
||||
date: 2026-04-06
|
||||
type: spec
|
||||
status: active
|
||||
desk_project: 65
|
||||
tags: [dashboard, expansion, mcps, paperclip, n8n, claude-code]
|
||||
---
|
||||
|
||||
# SPEC — Expansão DashDescomplicar Q2 2026
|
||||
|
||||
## 1. Contexto
|
||||
|
||||
O DashDescomplicar é o painel de gestão interno da Descomplicar®. Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + MySQL (Desk CRM BD). Deploy em EasyPanel via Dockerfile.
|
||||
|
||||
### Estado actual (3 páginas)
|
||||
|
||||
| Página | Rotas API | Dados |
|
||||
|--------|-----------|-------|
|
||||
| **Dashboard** (App.tsx) | `/api/dashboard` | Tarefas (urgente/alta/vencidas/em teste), leads (contactar/followup/proposta), projectos, billing 360, calendário Google, timesheet |
|
||||
| **Monitor** | `/api/monitor`, `/api/hetzner`, `/api/server-metrics`, `/api/wp-monitor`, `/api/diagnostic` | Servidores Hetzner (CPU/rede/disco), serviços HTTP (11 URLs), EasyPanel containers, sites WordPress (CWP) |
|
||||
| **Financeiro** | `/api/financial` | Vendas/despesas mês e ano, lucro, categorias, evolução mensal 12 meses |
|
||||
|
||||
### O que falta (referência: plano-migracao-mcps-gateway-auth.md, Fase 5)
|
||||
|
||||
Novos painéis para reflectir o stack completo: MCPs (33 no gateway), n8n (14 workflows), Paperclip (16 agentes operacionais), Claude Code/IA (189 skills, 72 agents, 9 hooks), LightRAG (knowledge graph), Desk CRM expandido (tickets, SLAs), e Operações (worklogs, PROCs, calendário).
|
||||
|
||||
### Auth
|
||||
|
||||
OIDC com Authentik **já implementado no frontend** (react-oidc-context, AuthWrapper.tsx, config.ts). Backend tem placeholder (`OIDC_ENABLED=true`). Falta activar em produção.
|
||||
|
||||
---
|
||||
|
||||
## 2. Objectivos
|
||||
|
||||
1. Expandir o dashboard de 3 para 8 páginas
|
||||
2. Cada novo painel usa dados reais via API (gateway MCPs, BD MySQL, APIs HTTP)
|
||||
3. Manter a consistência visual (dark theme, Tailwind, framer-motion, lucide-react)
|
||||
4. Zero dependências novas — usar as existentes (recharts, framer-motion, lucide)
|
||||
5. Cada painel é independente — pode ser implementado e testado isoladamente
|
||||
|
||||
---
|
||||
|
||||
## 3. Arquitectura de dados (fontes por painel)
|
||||
|
||||
```
|
||||
Express API (porta 3001)
|
||||
├── /api/dashboard → MySQL (Desk CRM) + Google Calendar API [EXISTE]
|
||||
├── /api/monitor → MySQL (tbl_eal_monitoring) + Hetzner API [EXISTE]
|
||||
├── /api/financial → MySQL (Desk CRM invoices/expenses) [EXISTE]
|
||||
├── /api/mcps → HTTP GET gateway.descomplicar.pt/health + per-MCP ping [NOVO]
|
||||
├── /api/n8n → HTTP GET automator.descomplicar.pt/api/v1 (API key) [NOVO]
|
||||
├── /api/paperclip → PostgreSQL clip.descomplicar.pt (porta 54329) [NOVO]
|
||||
├── /api/ai → Ficheiros locais + MySQL (stats) [NOVO]
|
||||
└── /api/operations → MySQL (Desk CRM) + Google Calendar [NOVO]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Especificação por painel
|
||||
|
||||
### 4.1 Painel MCPs (nova página `/mcps`)
|
||||
|
||||
**Objectivo:** Ver todos os 33+2 MCPs com estado online/offline em tempo real.
|
||||
|
||||
**Rota API:** `GET /api/mcps`
|
||||
|
||||
**Fonte de dados:**
|
||||
- Gateway health: `GET https://gateway.descomplicar.pt/health` (sem auth, já existe)
|
||||
- Per-MCP ping: `GET https://gateway.descomplicar.pt/v1/<nome>/mcp` com Bearer token (resposta 200 = online)
|
||||
- Lista estática dos MCPs com metadados (nome, porta, categoria, enabled/disabled)
|
||||
|
||||
**Dados retornados:**
|
||||
```typescript
|
||||
interface McpStatus {
|
||||
name: string // "desk-crm-v3"
|
||||
port: number // 3150
|
||||
category: string // "crm" | "infra" | "ai" | "tools" | "external"
|
||||
enabled: boolean // true/false em claude.json
|
||||
status: 'online' | 'offline' | 'disabled' | 'unknown'
|
||||
response_time_ms: number | null
|
||||
last_check: string // ISO timestamp
|
||||
tools_count?: number // número de tools (se conhecido)
|
||||
}
|
||||
|
||||
interface McpDashboard {
|
||||
gateway_status: 'online' | 'offline'
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
mcps: McpStatus[]
|
||||
auth: {
|
||||
method: string // "dual-layer: IP whitelist + Bearer token"
|
||||
token_expires: string | null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI (página McpMonitor.tsx):**
|
||||
- Header com stats gerais (total/online/offline/disabled)
|
||||
- Grid de cards por MCP, agrupados por categoria
|
||||
- Indicador de cor: verde (online), vermelho (offline), cinza (disabled)
|
||||
- Response time badge em cada card
|
||||
- Filtros: por categoria, por estado
|
||||
- Botão refresh manual
|
||||
|
||||
**Implementação backend (api/services/mcps.ts):**
|
||||
- Lista hardcoded de MCPs com metadados (extraída de port-map.json e claude.json)
|
||||
- Ping paralelo a cada MCP enabled (Promise.allSettled com timeout 5s)
|
||||
- Cache de 60 segundos (evitar spam ao gateway)
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Painel n8n (nova página `/n8n`)
|
||||
|
||||
**Objectivo:** Ver 14 workflows operacionais com estado, último run, próximo run.
|
||||
|
||||
**Rota API:** `GET /api/n8n`
|
||||
|
||||
**Fonte de dados:**
|
||||
- n8n API REST: `https://automator.descomplicar.pt/api/v1/workflows` (API key em env)
|
||||
- n8n API REST: `https://automator.descomplicar.pt/api/v1/executions?limit=50`
|
||||
|
||||
**Dados retornados:**
|
||||
```typescript
|
||||
interface N8nWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
last_execution: {
|
||||
status: 'success' | 'error' | 'running' | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
duration_ms: number | null
|
||||
} | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface N8nDashboard {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
failed_24h: number
|
||||
workflows: N8nWorkflow[]
|
||||
last_updated: string
|
||||
}
|
||||
```
|
||||
|
||||
**UI (página N8nMonitor.tsx):**
|
||||
- Stats cards: total/activos/falhas 24h
|
||||
- Tabela de workflows: nome, activo, último run (com cor: verde/vermelho), duração
|
||||
- Filtro: activos/todos
|
||||
- Alerta visual se algum workflow falhou nas últimas 24h
|
||||
|
||||
**Env vars necessárias:**
|
||||
- `N8N_API_URL` (default: `https://automator.descomplicar.pt/api/v1`)
|
||||
- `N8N_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Painel Paperclip (nova página `/paperclip`)
|
||||
|
||||
**Objectivo:** Ver os 16 agentes operacionais, routines, e issues.
|
||||
|
||||
**Rota API:** `GET /api/paperclip`
|
||||
|
||||
**Fonte de dados:**
|
||||
- PostgreSQL do Paperclip: `clip.descomplicar.pt:54329` (credenciais em env)
|
||||
- Tabelas: `agents`, `agent_runs`, `routines`, `routine_executions`, `issues`
|
||||
|
||||
**Dados retornados:**
|
||||
```typescript
|
||||
interface PaperclipAgent {
|
||||
id: string
|
||||
name: string
|
||||
role: string // "CEO", "CTO", "Director", "Specialist"
|
||||
status: 'active' | 'idle' | 'error' | 'archived'
|
||||
last_heartbeat: string | null
|
||||
last_run: string | null
|
||||
total_runs: number
|
||||
}
|
||||
|
||||
interface PaperclipRoutine {
|
||||
id: string
|
||||
name: string
|
||||
cron: string
|
||||
active: boolean
|
||||
last_run: string | null
|
||||
last_status: 'success' | 'error' | null
|
||||
next_run: string | null
|
||||
}
|
||||
|
||||
interface PaperclipDashboard {
|
||||
agents: {
|
||||
total: number
|
||||
active: number
|
||||
idle: number
|
||||
error: number
|
||||
list: PaperclipAgent[]
|
||||
}
|
||||
routines: {
|
||||
total: number
|
||||
active: number
|
||||
list: PaperclipRoutine[]
|
||||
}
|
||||
issues: {
|
||||
open: number
|
||||
in_progress: number
|
||||
closed_7d: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI (página Paperclip.tsx):**
|
||||
- Stats cards: agentes activos/idle/error, routines activas
|
||||
- Grid de cards para agentes (agrupados por role/nível hierárquico)
|
||||
- Tabela de routines com cron, último/próximo run
|
||||
- Contador de issues (open/in-progress/closed)
|
||||
|
||||
**Env vars necessárias:**
|
||||
- `PAPERCLIP_DB_HOST` (default: clip.descomplicar.pt)
|
||||
- `PAPERCLIP_DB_PORT` (default: 54329)
|
||||
- `PAPERCLIP_DB_NAME`
|
||||
- `PAPERCLIP_DB_USER`
|
||||
- `PAPERCLIP_DB_PASS`
|
||||
|
||||
**Dependência adicional:** `pg` (PostgreSQL client para Node.js) — **única nova dependência no projecto**
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Painel Claude Code / IA (nova página `/ai`)
|
||||
|
||||
**Objectivo:** Ver skills top, agents, MCPs activos, e CARL.
|
||||
|
||||
**Rota API:** `GET /api/ai`
|
||||
|
||||
**Fonte de dados:**
|
||||
- Contagens estáticas derivadas do STK-Estado-Actual.md (actualizar periodicamente)
|
||||
- CARL config: leitura de `/media/ealmeida/Dados/.carl/carl.json` (se acessível via EasyPanel volume mount, senão estático)
|
||||
- Hooks: contagem de ficheiros em `~/.claude/hooks/`
|
||||
|
||||
**Dados retornados:**
|
||||
```typescript
|
||||
interface AiDashboard {
|
||||
skills: {
|
||||
total: number // 189
|
||||
directas: number // 31
|
||||
plugins: number // 158
|
||||
top_10: string[] // nomes das 10 mais usadas
|
||||
}
|
||||
agents: {
|
||||
total: number // 72
|
||||
directos: number // 18
|
||||
plugins: number // 54
|
||||
}
|
||||
mcps: {
|
||||
total: number // 39
|
||||
enabled: number // 10
|
||||
gateway: number // 33
|
||||
local: number // 2
|
||||
}
|
||||
hooks: {
|
||||
total_files: number // 26
|
||||
active: number // 9
|
||||
}
|
||||
carl: {
|
||||
domains: number // 7
|
||||
rules: number // ~45
|
||||
decisions: number
|
||||
}
|
||||
plugins: {
|
||||
total: number // 14 descomplicar + 6 oficiais + 3 terceiros
|
||||
active: number // 6
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI (página AiOverview.tsx):**
|
||||
- Cards grandes com métricas (skills, agents, MCPs, hooks, plugins)
|
||||
- Secção CARL: domínios com contagem de regras
|
||||
- Sem interactividade complexa — é um painel informativo/snapshot
|
||||
|
||||
**Nota:** Este painel é maioritariamente estático. Os dados mudam raramente (quando se adicionam skills/agents). Actualização via endpoint manual ou ficheiro JSON servido estáticamente.
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Painel Operações (nova página `/operations`)
|
||||
|
||||
**Objectivo:** Visão operacional — tickets, SLAs, procedimentos.
|
||||
|
||||
**Rota API:** `GET /api/operations`
|
||||
|
||||
**Fonte de dados:**
|
||||
- MySQL (Desk CRM): tickets abertos, por prioridade, SLAs
|
||||
- Contagens estáticas: PROCs (48), departamentos (7)
|
||||
|
||||
**Dados retornados:**
|
||||
```typescript
|
||||
interface OperationsDashboard {
|
||||
tickets: {
|
||||
open: number
|
||||
high_priority: number
|
||||
avg_response_hours: number
|
||||
by_department: { dept: string; count: number }[]
|
||||
}
|
||||
procedures: {
|
||||
total: number // 48
|
||||
departments: number // 7 (D1-D7)
|
||||
coverage: { dept: string; procs: number; pct: number }[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI (página Operations.tsx):**
|
||||
- Cards: tickets abertos, alta prioridade, tempo médio resposta
|
||||
- Gráfico barras: tickets por departamento
|
||||
- Tabela cobertura PROCs por departamento
|
||||
|
||||
---
|
||||
|
||||
## 5. Alterações ao código existente
|
||||
|
||||
### 5.1 Layout.tsx — adicionar itens de navegação
|
||||
|
||||
```typescript
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/monitor', label: 'Monitor', icon: Activity },
|
||||
{ to: '/financial', label: 'Financeiro', icon: CreditCard },
|
||||
// NOVOS:
|
||||
{ to: '/mcps', label: 'MCPs', icon: Network },
|
||||
{ to: '/n8n', label: 'Automações', icon: Workflow },
|
||||
{ to: '/paperclip', label: 'Paperclip', icon: Bot },
|
||||
{ to: '/ai', label: 'IA / Claude', icon: Brain },
|
||||
{ to: '/operations', label: 'Operações', icon: ClipboardList },
|
||||
]
|
||||
```
|
||||
|
||||
### 5.2 App.tsx — adicionar rotas
|
||||
|
||||
Novas rotas no React Router para cada página.
|
||||
|
||||
### 5.3 server.ts — registar novas rotas API
|
||||
|
||||
```typescript
|
||||
import mcpsRouter from './routes/mcps.js'
|
||||
import n8nRouter from './routes/n8n.js'
|
||||
import paperclipRouter from './routes/paperclip.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import operationsRouter from './routes/operations.js'
|
||||
|
||||
app.use('/api/mcps', mcpsRouter)
|
||||
app.use('/api/n8n', n8nRouter)
|
||||
app.use('/api/paperclip', paperclipRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/operations', operationsRouter)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Ficheiros a criar (por sprint)
|
||||
|
||||
### Sprint 1 — MCPs + n8n (estimativa: 8-10h)
|
||||
```
|
||||
api/routes/mcps.ts # Rota GET /api/mcps
|
||||
api/services/mcps.ts # Ping gateway, lista MCPs, cache
|
||||
api/routes/n8n.ts # Rota GET /api/n8n
|
||||
api/services/n8n.ts # Chamadas API n8n
|
||||
src/pages/McpMonitor.tsx # Página frontend MCPs
|
||||
src/pages/N8nMonitor.tsx # Página frontend n8n
|
||||
```
|
||||
|
||||
### Sprint 2 — Paperclip (estimativa: 6-8h)
|
||||
```
|
||||
api/services/paperclip-db.ts # Conexão PostgreSQL Paperclip
|
||||
api/routes/paperclip.ts # Rota GET /api/paperclip
|
||||
api/services/paperclip.ts # Queries aos agentes/routines/issues
|
||||
src/pages/Paperclip.tsx # Página frontend Paperclip
|
||||
```
|
||||
|
||||
### Sprint 3 — IA + Operações (estimativa: 4-6h)
|
||||
```
|
||||
api/routes/ai.ts # Rota GET /api/ai
|
||||
api/services/ai.ts # Dados estáticos/contagens IA
|
||||
api/routes/operations.ts # Rota GET /api/operations
|
||||
api/services/operations.ts # Queries tickets + PROCs
|
||||
src/pages/AiOverview.tsx # Página frontend IA
|
||||
src/pages/Operations.tsx # Página frontend Operações
|
||||
```
|
||||
|
||||
### Sprint 4 — Integração + polish (estimativa: 2-3h)
|
||||
```
|
||||
# Alterações a ficheiros existentes:
|
||||
src/components/Layout.tsx # Adicionar 5 itens nav
|
||||
src/App.tsx # Adicionar 5 rotas (no routing section)
|
||||
api/server.ts # Registar 5 routers novos
|
||||
.env.example # Novas env vars documentadas
|
||||
README.md # Actualizar documentação
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Env vars novas necessárias
|
||||
|
||||
```env
|
||||
# MCPs Gateway
|
||||
MCP_GATEWAY_URL=https://gateway.descomplicar.pt
|
||||
MCP_GATEWAY_TOKEN=<authentik-bearer-token>
|
||||
|
||||
# n8n
|
||||
N8N_API_URL=https://automator.descomplicar.pt/api/v1
|
||||
N8N_API_KEY=<n8n-api-key>
|
||||
|
||||
# Paperclip PostgreSQL
|
||||
PAPERCLIP_DB_HOST=clip.descomplicar.pt
|
||||
PAPERCLIP_DB_PORT=54329
|
||||
PAPERCLIP_DB_NAME=paperclip
|
||||
PAPERCLIP_DB_USER=<user>
|
||||
PAPERCLIP_DB_PASS=<pass>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependências
|
||||
|
||||
| Pacote | Razão | Sprint |
|
||||
|--------|-------|--------|
|
||||
| `pg` | PostgreSQL client (Paperclip BD) | Sprint 2 |
|
||||
|
||||
Todas as outras dependências já existem no projecto (recharts, framer-motion, lucide-react, express, mysql2, zod).
|
||||
|
||||
---
|
||||
|
||||
## 9. Padrões a seguir (consistência)
|
||||
|
||||
- **Backend:** Router Express separado por domínio. Service file com queries. Async/await com try/catch.
|
||||
- **Frontend:** Página como componente único com useState/useEffect/useCallback. motion.div com containerVariants/itemVariants. Cards com gradientes e lucide icons.
|
||||
- **Estilo:** Dark theme (bg-zinc-950, borders white/10, text zinc-400/white). Gradientes brand-500/violet-600. Rounded-2xl nos cards.
|
||||
- **Error handling:** try/catch no backend → 500 JSON. Frontend → estado de loading/error com retry.
|
||||
- **Cache:** Backend cache simples com timestamp (60s para MCPs, 300s para n8n).
|
||||
|
||||
---
|
||||
|
||||
## 10. Riscos e mitigações
|
||||
|
||||
| Risco | Mitigação |
|
||||
|-------|-----------|
|
||||
| n8n API key expirar | Documentar no .env.example, alertar na UI |
|
||||
| Paperclip BD inacessível do EasyPanel | Verificar rede Docker Swarm, fallback com dados estáticos |
|
||||
| Gateway health check lento (33 MCPs) | Promise.allSettled com timeout 5s + cache 60s |
|
||||
| Página MCPs demasiado pesada | Ping apenas MCPs enabled (10), disabled mostrados como cinza sem ping |
|
||||
|
||||
---
|
||||
|
||||
## 11. Critérios de aceitação
|
||||
|
||||
- [ ] Todas as 8 páginas carregam sem erros
|
||||
- [ ] Cada API retorna dados reais (não mocks)
|
||||
- [ ] Navegação sidebar mostra todas as 8 páginas
|
||||
- [ ] Mobile responsive (sidebar colapsável funciona com 8 itens)
|
||||
- [ ] Build de produção compila sem erros (`npm run build`)
|
||||
- [ ] Zero vulnerabilidades de segurança (`npm audit`)
|
||||
|
||||
---
|
||||
|
||||
## 12. Fora de escopo
|
||||
|
||||
- Auth OIDC em produção (já implementado, activar separadamente)
|
||||
- LightRAG painel visual com grafo D3.js (fase futura, complexidade alta)
|
||||
- Painel Desk CRM expandido com Kanban (já tem dados no Dashboard principal)
|
||||
- Botões de acção (enable/disable MCP, restart workflow) — apenas visualização
|
||||
- Testes unitários (existem mas não são prioritários para esta expansão)
|
||||
+1167
-278
File diff suppressed because it is too large
Load Diff
Executable → Regular
+9
-1
@@ -16,6 +16,9 @@
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/pg": "^8.20.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
@@ -23,10 +26,12 @@
|
||||
"express-openid-connect": "^2.19.4",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"framer-motion": "^12.30.1",
|
||||
"googleapis": "^144.0.0",
|
||||
"googleapis": "^171.4.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mysql2": "^3.11.5",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-oidc-context": "^3.1.1",
|
||||
@@ -42,12 +47,14 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^24.10.10",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"autoprefixer": "^10.4.24",
|
||||
@@ -58,6 +65,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"supertest": "^7.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.9.3",
|
||||
|
||||
+7
-91
@@ -6,7 +6,6 @@ import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
FolderKanban,
|
||||
@@ -22,11 +21,8 @@ import {
|
||||
CheckCircle2,
|
||||
Timer,
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
Target,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Types
|
||||
@@ -422,7 +418,6 @@ function App() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
@@ -459,7 +454,7 @@ function App() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -481,7 +476,7 @@ function App() {
|
||||
// Error state (Vulnerabilidade 3.2)
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -510,49 +505,10 @@ function App() {
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh">
|
||||
<div className="bg-grid min-h-screen">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
||||
<div className="max-w-[1800px] mx-auto px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
|
||||
>
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Dashboard Descomplicar</h1>
|
||||
<p className="text-xs text-zinc-500">Painel de Gestão</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 bg-white/5 rounded-xl p-1">
|
||||
<a href="#" className="px-4 py-2 rounded-lg bg-brand-500 text-white text-sm font-medium flex items-center gap-2">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/monitor" className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Monitor
|
||||
</a>
|
||||
<a href="/financial" className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Financeiro
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-5 h-5 text-zinc-400" /> : <Menu className="w-5 h-5 text-zinc-400" />}
|
||||
</motion.button>
|
||||
<div className="max-w-[1800px] mx-auto px-6 lg:px-8 py-8">
|
||||
{/* Header com refresh */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div />
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
@@ -562,41 +518,7 @@ function App() {
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand-500 to-violet-600 ring-2 ring-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.nav
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="md:hidden border-b border-white/5 bg-[#0a0a0f]/95 backdrop-blur-2xl overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-3 flex flex-col gap-1">
|
||||
<a href="#" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 rounded-lg bg-brand-500 text-white text-sm font-medium flex items-center gap-3">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/monitor" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-3">
|
||||
<Activity className="w-4 h-4" />
|
||||
Monitor
|
||||
</a>
|
||||
<a href="/financial" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-3">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Financeiro
|
||||
</a>
|
||||
</div>
|
||||
</motion.nav>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-[1800px] mx-auto px-6 lg:px-8 py-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="dashboard"
|
||||
@@ -880,24 +802,18 @@ function App() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-white/5 mt-12">
|
||||
<div className="max-w-[1800px] mx-auto px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between text-sm text-zinc-500">
|
||||
<div className="flex items-center justify-between text-sm text-zinc-500 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>Dashboard Descomplicar v3.0</span>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span>Painel de Gestão</span>
|
||||
</div>
|
||||
<span>Actualizado: {new Date().toLocaleString('pt-PT')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
CreditCard,
|
||||
Network,
|
||||
GitBranch,
|
||||
Bot,
|
||||
Brain,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
Zap,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
const SIDEBAR_KEY = 'dash-sidebar-collapsed'
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/monitor', label: 'Monitor', icon: Activity },
|
||||
{ to: '/financial', label: 'Financeiro', icon: CreditCard },
|
||||
{ to: '/mcps', label: 'MCPs', icon: Network },
|
||||
{ to: '/n8n', label: 'Automações', icon: GitBranch },
|
||||
{ to: '/paperclip', label: 'Paperclip', icon: Bot },
|
||||
{ to: '/ai', label: 'IA / Claude', icon: Brain },
|
||||
{ to: '/operations', label: 'Operações', icon: ClipboardList },
|
||||
{ to: '/sessions', label: 'Espelho', icon: Eye },
|
||||
]
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
|
||||
function getInitialCollapsed(isMobile: boolean): boolean {
|
||||
if (isMobile) return true
|
||||
try {
|
||||
const stored = localStorage.getItem(SIDEBAR_KEY)
|
||||
if (stored !== null) return stored === 'true'
|
||||
} catch {
|
||||
// localStorage indisponivel
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const isMobile = useIsMobile()
|
||||
const [collapsed, setCollapsed] = useState(() => getInitialCollapsed(isMobile))
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Colapsar automaticamente em mobile
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setCollapsed(true)
|
||||
setMobileOpen(false)
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
// Persistir estado em localStorage (apenas desktop)
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_KEY, String(collapsed))
|
||||
} catch {
|
||||
// localStorage indisponivel
|
||||
}
|
||||
}
|
||||
}, [collapsed, isMobile])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (isMobile) {
|
||||
setMobileOpen(prev => !prev)
|
||||
} else {
|
||||
setCollapsed(prev => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
const closeMobileMenu = () => setMobileOpen(false)
|
||||
|
||||
const sidebarWidth = collapsed ? 72 : 240
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh">
|
||||
<div className="bg-grid min-h-screen flex">
|
||||
|
||||
{/* Overlay mobile */}
|
||||
<AnimatePresence>
|
||||
{isMobile && mobileOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||
onClick={closeMobileMenu}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sidebar */}
|
||||
<AnimatePresence mode="wait">
|
||||
{(!isMobile || mobileOpen) && (
|
||||
<motion.aside
|
||||
initial={isMobile ? { x: -240 } : false}
|
||||
animate={{ x: 0 }}
|
||||
exit={isMobile ? { x: -240 } : undefined}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 35 }}
|
||||
style={!isMobile ? { width: sidebarWidth } : undefined}
|
||||
className={`
|
||||
${isMobile
|
||||
? 'fixed top-0 left-0 bottom-0 z-50 w-60'
|
||||
: 'relative flex-shrink-0 z-30'
|
||||
}
|
||||
bg-[#0a0a0f]/95 backdrop-blur-2xl border-r border-white/5
|
||||
flex flex-col transition-[width] duration-300 ease-in-out
|
||||
`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-4 py-5 border-b border-white/5 min-h-[72px]">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||
className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30 flex-shrink-0"
|
||||
>
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</motion.div>
|
||||
<AnimatePresence>
|
||||
{(!collapsed || isMobile) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: 'auto' }}
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
<h1 className="text-base font-bold text-white tracking-tight leading-tight">Descomplicar</h1>
|
||||
<p className="text-[10px] text-zinc-500">Painel de Gestao</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Fechar em mobile */}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={closeMobileMenu}
|
||||
className="ml-auto p-1.5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-zinc-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navegacao */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={isMobile ? closeMobileMenu : undefined}
|
||||
className={({ isActive }) => `
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium
|
||||
transition-all duration-200 group relative
|
||||
${isActive
|
||||
? 'bg-brand-500/15 text-white border border-brand-500/30'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon className={`w-5 h-5 flex-shrink-0 ${isActive ? 'text-brand-400' : 'text-zinc-500 group-hover:text-zinc-300'}`} />
|
||||
<AnimatePresence>
|
||||
{(!collapsed || isMobile) && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: 'auto' }}
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Tooltip quando colapsado (desktop) */}
|
||||
{collapsed && !isMobile && (
|
||||
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-lg bg-zinc-800 text-white text-xs font-medium whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none shadow-xl border border-white/10 z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Toggle (apenas desktop) */}
|
||||
{!isMobile && (
|
||||
<div className="px-3 py-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all text-zinc-400 hover:text-white"
|
||||
title={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
||||
>
|
||||
{collapsed
|
||||
? <ChevronRight className="w-4 h-4" />
|
||||
: <ChevronLeft className="w-4 h-4" />
|
||||
}
|
||||
<AnimatePresence>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: 'auto' }}
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-xs font-medium overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
Colapsar
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Conteudo principal */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* Header mobile com botao de menu */}
|
||||
{isMobile && (
|
||||
<header className="sticky top-0 z-30 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-zinc-400" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/20">
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-white">Descomplicar</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Outlet para as paginas */}
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import type { SessionEvent } from '../../../api/types/session'
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n) + '…' : s
|
||||
}
|
||||
|
||||
interface Props {
|
||||
event: SessionEvent
|
||||
defaultCollapsed: boolean
|
||||
}
|
||||
|
||||
export function EventBlock({ event, defaultCollapsed }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
|
||||
const base = 'rounded border px-3 py-2 my-1 text-sm'
|
||||
switch (event.type) {
|
||||
case 'user':
|
||||
if (event.tool_result !== null && event.tool_result !== undefined) {
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/40 border-slate-700`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-slate-500 uppercase">
|
||||
tool_result {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<pre className="mt-2 text-xs overflow-x-auto whitespace-pre-wrap text-slate-300">
|
||||
{typeof event.tool_result === 'string' ? event.tool_result : JSON.stringify(event.tool_result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-blue-500/10 border-blue-500/30`}>
|
||||
<div className="text-xs text-blue-300 uppercase mb-1">user</div>
|
||||
<div className="whitespace-pre-wrap text-slate-100">{event.text ?? '—'}</div>
|
||||
</div>
|
||||
)
|
||||
case 'assistant':
|
||||
if (event.tool_name) {
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-amber-500/10 border-amber-500/30`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-amber-300 uppercase">
|
||||
tool_use: {event.tool_name} {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && event.tool_input && (
|
||||
<pre className="mt-2 text-xs overflow-x-auto whitespace-pre-wrap text-slate-300">
|
||||
{JSON.stringify(event.tool_input, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-800/40 border-slate-700`}>
|
||||
<div className="text-xs text-slate-500 uppercase mb-1">assistant</div>
|
||||
<div className="whitespace-pre-wrap text-slate-200">{truncate(event.text ?? '—', collapsed ? 300 : Number.MAX_SAFE_INTEGER)}</div>
|
||||
{(event.text?.length ?? 0) > 300 && (
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-slate-500 mt-1">
|
||||
{collapsed ? 'Expandir' : 'Colapsar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'system':
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/30 border-slate-800 text-xs text-slate-500`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="uppercase">
|
||||
system {event.skill_invoked ? `· skill: ${event.skill_invoked}` : ''} {event.hook_name ? `· hook: ${event.hook_name}` : ''} {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && <div className="mt-2 whitespace-pre-wrap">{event.text ?? JSON.stringify(event.raw)}</div>}
|
||||
</div>
|
||||
)
|
||||
case 'attachment':
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-purple-500/10 border-purple-500/30 text-xs text-purple-300`}>
|
||||
📎 attachment
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/20 border-slate-800 text-xs text-slate-500`}>
|
||||
{event.type}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface Filters {
|
||||
days: number
|
||||
project: string
|
||||
tool: string
|
||||
skill: string
|
||||
q: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial: Filters
|
||||
projects: string[]
|
||||
tools: string[]
|
||||
skills: string[]
|
||||
onChange: (f: Filters) => void
|
||||
}
|
||||
|
||||
export function FilterBar({ initial, projects, tools, skills, onChange }: Props) {
|
||||
const [f, setF] = useState<Filters>(initial)
|
||||
const [qLocal, setQLocal] = useState<string>(initial.q)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (qLocal !== f.q) {
|
||||
const next = { ...f, q: qLocal }
|
||||
setF(next)
|
||||
onChange(next)
|
||||
}
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [qLocal])
|
||||
|
||||
function update(partial: Partial<Filters>) {
|
||||
const next = { ...f, ...partial }
|
||||
setF(next)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 p-4 bg-white/5 rounded-lg backdrop-blur border border-white/10">
|
||||
<select
|
||||
value={f.days}
|
||||
onChange={(e) => update({ days: Number(e.target.value) })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value={1}>24h</option>
|
||||
<option value={7}>7 dias</option>
|
||||
<option value={30}>30 dias</option>
|
||||
<option value={90}>90 dias</option>
|
||||
<option value={3650}>Tudo</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={f.project}
|
||||
onChange={(e) => update({ project: e.target.value })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Todos os projectos</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={f.tool}
|
||||
onChange={(e) => update({ tool: e.target.value })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Qualquer tool</option>
|
||||
{tools.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={f.skill}
|
||||
onChange={(e) => update({ skill: e.target.value })}
|
||||
className="bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Qualquer skill</option>
|
||||
{skills.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Pesquisar no prompt inicial…"
|
||||
value={qLocal}
|
||||
onChange={(e) => setQLocal(e.target.value)}
|
||||
className="flex-1 min-w-[200px] bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { SessionMeta } from '../../../api/types/session'
|
||||
|
||||
function formatDuration(sec: number | null): string {
|
||||
if (!sec) return '—'
|
||||
if (sec < 60) return `${sec}s`
|
||||
if (sec < 3600) return `${Math.round(sec / 60)}min`
|
||||
return `${Math.floor(sec / 3600)}h${Math.round((sec % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
function outcomeIcon(o: SessionMeta['outcome']): string {
|
||||
switch (o) {
|
||||
case 'completed':
|
||||
return '✓'
|
||||
case 'error':
|
||||
return '✗'
|
||||
case 'interrupted':
|
||||
return '⚠'
|
||||
default:
|
||||
return '?'
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
session: SessionMeta
|
||||
}
|
||||
|
||||
export function SessionRow({ session }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const when = new Date(session.started_at).toLocaleString('pt-PT', { dateStyle: 'short', timeStyle: 'short' })
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-white/5 hover:bg-white/5 cursor-pointer"
|
||||
onClick={() => navigate(`/sessions/${session.session_id}`)}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm text-slate-300">{when}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-400">{session.project_slug}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-200">
|
||||
{session.first_prompt?.slice(0, 80) ?? '—'}
|
||||
{(session.first_prompt?.length ?? 0) > 80 ? '…' : ''}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-400">{formatDuration(session.duration_sec)}</td>
|
||||
<td className="px-3 py-2 text-sm text-right text-slate-400">{session.event_count}</td>
|
||||
<td className="px-3 py-2 text-sm text-right text-slate-400">{session.tool_calls}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{session.skills_invoked.slice(0, 2).map((s) => (
|
||||
<span key={s} className="px-2 py-0.5 bg-indigo-500/20 text-indigo-300 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{outcomeIcon(session.outcome)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SessionMeta, SessionEvent } from '../../../api/types/session'
|
||||
|
||||
export interface ListParams {
|
||||
days?: number
|
||||
project?: string
|
||||
tool?: string
|
||||
skill?: string
|
||||
q?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
total: number
|
||||
items: SessionMeta[]
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
|
||||
|
||||
function buildQuery(params: Record<string, unknown>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '' && v !== null)
|
||||
if (entries.length === 0) return ''
|
||||
return '?' + new URLSearchParams(entries as [string, string][]).toString()
|
||||
}
|
||||
|
||||
export async function listSessions(params: ListParams): Promise<ListResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/sessions${buildQuery(params as Record<string, unknown>)}`)
|
||||
if (!res.ok) throw new Error(`listSessions failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<{ meta: SessionMeta; events: SessionEvent[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/sessions/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`getSession failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
@@ -6,6 +6,14 @@ import './index.css'
|
||||
import App from './App.tsx'
|
||||
import Monitor from './pages/Monitor.tsx'
|
||||
import Financial from './pages/Financial.tsx'
|
||||
import McpMonitor from './pages/McpMonitor.tsx'
|
||||
import N8nMonitor from './pages/N8nMonitor.tsx'
|
||||
import Paperclip from './pages/Paperclip.tsx'
|
||||
import AiOverview from './pages/AiOverview.tsx'
|
||||
import Operations from './pages/Operations.tsx'
|
||||
import Sessions from './pages/Sessions.tsx'
|
||||
import SessionDetail from './pages/SessionDetail.tsx'
|
||||
import Layout from './components/Layout.tsx'
|
||||
import { oidcConfig } from './auth/config.ts'
|
||||
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
||||
|
||||
@@ -15,10 +23,19 @@ createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<AuthWrapper>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/monitor" element={<Monitor />} />
|
||||
<Route path="/financial" element={<Financial />} />
|
||||
<Route path="/mcps" element={<McpMonitor />} />
|
||||
<Route path="/n8n" element={<N8nMonitor />} />
|
||||
<Route path="/paperclip" element={<Paperclip />} />
|
||||
<Route path="/ai" element={<AiOverview />} />
|
||||
<Route path="/operations" element={<Operations />} />
|
||||
<Route path="/sessions" element={<Sessions />} />
|
||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||
<Route path="/callback" element={<App />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthWrapper>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* AiOverview — Painel Stack IA (3 Camadas de Execução)
|
||||
* Rota: /ai
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Brain,
|
||||
Cpu,
|
||||
Bot,
|
||||
Zap,
|
||||
Shield,
|
||||
Database,
|
||||
BookOpen,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Circle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface AiLayerItem {
|
||||
metric: string
|
||||
value: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
interface AiLayer {
|
||||
name: string
|
||||
label: string
|
||||
items: AiLayerItem[]
|
||||
}
|
||||
|
||||
interface TransversalSystem {
|
||||
name: string
|
||||
status: 'active' | 'warning' | 'inactive'
|
||||
detail: string
|
||||
}
|
||||
|
||||
interface CarlConfig {
|
||||
domains: string[]
|
||||
total_rules: number
|
||||
}
|
||||
|
||||
interface AiDashboard {
|
||||
layers: AiLayer[]
|
||||
transversal: TransversalSystem[]
|
||||
carl: CarlConfig
|
||||
notebooks: number
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
// --- Animation variants ---
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring' as const, stiffness: 300, damping: 30 },
|
||||
},
|
||||
}
|
||||
|
||||
// --- Configuração visual por camada ---
|
||||
|
||||
interface LayerConfig {
|
||||
gradient: string
|
||||
border: string
|
||||
badge: string
|
||||
valueCls: string
|
||||
glow: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
const LAYER_CONFIG: Record<string, LayerConfig> = {
|
||||
'Claude Code': {
|
||||
gradient: 'from-violet-500/15 to-purple-500/10',
|
||||
border: 'border-violet-500/25',
|
||||
badge: 'bg-violet-500/20 text-violet-300 border border-violet-500/30',
|
||||
valueCls: 'text-violet-300',
|
||||
glow: 'shadow-[0_0_20px_rgba(139,92,246,0.15)]',
|
||||
icon: Brain,
|
||||
},
|
||||
'n8n': {
|
||||
gradient: 'from-amber-500/15 to-orange-500/10',
|
||||
border: 'border-amber-500/25',
|
||||
badge: 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
|
||||
valueCls: 'text-amber-300',
|
||||
glow: 'shadow-[0_0_20px_rgba(245,158,11,0.15)]',
|
||||
icon: Zap,
|
||||
},
|
||||
'Paperclip': {
|
||||
gradient: 'from-cyan-500/15 to-teal-500/10',
|
||||
border: 'border-cyan-500/25',
|
||||
badge: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30',
|
||||
valueCls: 'text-cyan-300',
|
||||
glow: 'shadow-[0_0_20px_rgba(6,182,212,0.15)]',
|
||||
icon: Bot,
|
||||
},
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
const MetricCard = ({
|
||||
item,
|
||||
valueCls,
|
||||
}: {
|
||||
item: AiLayerItem
|
||||
valueCls: string
|
||||
}) => (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-white/5 border border-white/8 rounded-xl px-5 py-4 flex flex-col gap-1"
|
||||
>
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wide font-medium">
|
||||
{item.metric}
|
||||
</span>
|
||||
<span className={`text-3xl font-bold ${valueCls}`}>{item.value}</span>
|
||||
<span className="text-xs text-zinc-400">{item.detail}</span>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
const LayerSection = ({ layer }: { layer: AiLayer }) => {
|
||||
const cfg = LAYER_CONFIG[layer.name] ?? LAYER_CONFIG['Claude Code']
|
||||
const Icon = cfg.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`glass-card overflow-hidden bg-gradient-to-br ${cfg.gradient} border ${cfg.border} ${cfg.glow}`}
|
||||
>
|
||||
{/* Cabeçalho da camada */}
|
||||
<div className={`px-6 py-4 border-b ${cfg.border} flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center ${cfg.badge}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
{layer.name}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{layer.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards de métricas */}
|
||||
<div className="p-6">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"
|
||||
>
|
||||
{layer.items.map((item) => (
|
||||
<MetricCard key={item.metric} item={item} valueCls={cfg.valueCls} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const TransversalStatusIcon = ({ status }: { status: string }) => {
|
||||
if (status === 'active') {
|
||||
return <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
}
|
||||
return <Circle className="w-4 h-4 text-zinc-600 shrink-0" />
|
||||
}
|
||||
|
||||
const TransversalCard = ({ sys }: { sys: TransversalSystem }) => {
|
||||
const borderCls =
|
||||
sys.status === 'active'
|
||||
? 'border-emerald-500/20'
|
||||
: sys.status === 'warning'
|
||||
? 'border-amber-500/20'
|
||||
: 'border-white/8'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`glass-card px-5 py-4 flex items-start gap-3 border ${borderCls}`}
|
||||
>
|
||||
<TransversalStatusIcon status={sys.status} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white truncate">{sys.name}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5 leading-relaxed">{sys.detail}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const DomainPill = ({ name }: { name: string }) => (
|
||||
<motion.span
|
||||
variants={itemVariants}
|
||||
className="px-3 py-1.5 rounded-full text-xs font-semibold uppercase tracking-wide
|
||||
bg-violet-500/15 text-violet-300 border border-violet-500/25"
|
||||
>
|
||||
{name}
|
||||
</motion.span>
|
||||
)
|
||||
|
||||
// --- Página principal ---
|
||||
|
||||
export default function AiOverview() {
|
||||
const [data, setData] = useState<AiDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai')
|
||||
if (!response.ok) throw new Error('Failed to fetch /api/ai')
|
||||
const json = await response.json()
|
||||
setData(json)
|
||||
} catch {
|
||||
console.error('Falha ao carregar dados do stack IA')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
// Converter YYYY-MM-DD para DD-MM-YYYY
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}-${m}-${y}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-violet-500/50 border-t-violet-400 rounded-full animate-spin" />
|
||||
<p className="text-sm text-zinc-500">A carregar stack IA...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-sm text-zinc-500">Não foi possível carregar os dados.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-10">
|
||||
|
||||
{/* Cabeçalho */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-violet-500/20 border border-violet-500/30
|
||||
flex items-center justify-center shadow-[0_0_24px_rgba(139,92,246,0.2)]">
|
||||
<Layers className="w-6 h-6 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Stack IA — 3 Camadas de Execução</h1>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Inventário completo do sistema IA Descomplicar®
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs px-3 py-1.5 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-400">
|
||||
Última actualização: {formatDate(data.last_updated)}
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10
|
||||
border border-white/10 text-sm text-zinc-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Sumário global — pill badges */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
|
||||
<span className="text-violet-400 font-bold mr-1">{data.notebooks}</span>
|
||||
Notebooks NotebookLM
|
||||
</span>
|
||||
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
|
||||
<span className="text-violet-400 font-bold mr-1">{data.carl.total_rules}</span>
|
||||
Regras CARL
|
||||
</span>
|
||||
<span className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/5 border border-white/10 text-zinc-300">
|
||||
<span className="text-violet-400 font-bold mr-1">{data.carl.domains.length}</span>
|
||||
Domínios contextuais
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* 3 Camadas */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-4"
|
||||
>
|
||||
{data.layers.map((layer) => (
|
||||
<LayerSection key={layer.name} layer={layer} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Sistemas Transversais */}
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex items-center gap-3 mb-4"
|
||||
>
|
||||
<Shield className="w-5 h-5 text-violet-400" />
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
Sistemas Transversais
|
||||
</h2>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-zinc-400">
|
||||
{data.transversal.length} sistemas
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3"
|
||||
>
|
||||
{data.transversal.map((sys) => (
|
||||
<TransversalCard key={sys.name} sys={sys} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* CARL — domínios contextuais */}
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="flex items-center gap-3 mb-4"
|
||||
>
|
||||
<Database className="w-5 h-5 text-violet-400" />
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
CARL — Contexto Adaptativo
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="glass-card p-6 border border-violet-500/20
|
||||
bg-gradient-to-br from-violet-500/10 to-purple-500/5"
|
||||
>
|
||||
<div className="flex items-start gap-6 flex-col sm:flex-row">
|
||||
{/* Métricas CARL */}
|
||||
<div className="flex gap-6 shrink-0">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-violet-300">{data.carl.domains.length}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-wide">Domínios</p>
|
||||
</div>
|
||||
<div className="w-px bg-white/10 self-stretch" />
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-violet-300">{data.carl.total_rules}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-wide">Regras</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divisor */}
|
||||
<div className="hidden sm:block w-px bg-white/10 self-stretch" />
|
||||
|
||||
{/* Domínios como pills */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{data.carl.domains.map((domain) => (
|
||||
<DomainPill key={domain} name={domain} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Knowledge Notebooks */}
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="flex items-center gap-3 mb-4"
|
||||
>
|
||||
<BookOpen className="w-5 h-5 text-violet-400" />
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">
|
||||
Knowledge Base
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
className="glass-card p-6 border border-violet-500/15
|
||||
bg-gradient-to-br from-violet-500/8 to-transparent
|
||||
flex items-center gap-6"
|
||||
>
|
||||
<Cpu className="w-10 h-10 text-violet-400/60 shrink-0" />
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-violet-300">{data.notebooks}</p>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
Notebooks NotebookLM activos — fonte de conhecimento para todos os agentes
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+6
-43
@@ -1,19 +1,15 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Zap,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Receipt,
|
||||
PiggyBank,
|
||||
Activity,
|
||||
LayoutDashboard,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -105,7 +101,7 @@ export default function Financial() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<DollarSign className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A carregar dados financeiros...</p>
|
||||
@@ -127,26 +123,14 @@ export default function Financial() {
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh">
|
||||
<div className="bg-grid min-h-screen">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
|
||||
>
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</motion.div>
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
{/* Header com resumo e refresh */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Financeiro</h1>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">Financeiro</h2>
|
||||
<p className="text-xs text-zinc-500">Vendas e Despesas {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${lucroColor}`}>
|
||||
{lucroLabel}: {formatEUR(Math.abs(data.lucro_ano))}
|
||||
</span>
|
||||
@@ -159,27 +143,8 @@ export default function Financial() {
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
<Link
|
||||
to="/monitor"
|
||||
className="hidden md:flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
Monitor
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="hidden md:inline">Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
|
||||
@@ -280,8 +245,6 @@ export default function Financial() {
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* McpMonitor — Painel de estado dos MCPs
|
||||
* Visualização em tempo real de todos os MCPs via gateway
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Network,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
Server,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface McpStatus {
|
||||
name: string
|
||||
port: number
|
||||
category: string
|
||||
enabled: boolean
|
||||
status: 'online' | 'offline' | 'disabled' | 'unknown'
|
||||
response_time_ms: number | null
|
||||
last_check: string
|
||||
tools_count?: number
|
||||
}
|
||||
|
||||
interface McpDashboard {
|
||||
gateway_status: 'online' | 'offline'
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
mcps: McpStatus[]
|
||||
auth: {
|
||||
method: string
|
||||
token_expires: string | null
|
||||
}
|
||||
}
|
||||
|
||||
// Mapeamento de categorias para etiquetas legíveis
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
ai: 'Inteligência Artificial',
|
||||
crm: 'CRM',
|
||||
external: 'Externos',
|
||||
gateway: 'Gateway',
|
||||
infra: 'Infraestrutura',
|
||||
project: 'Projecto',
|
||||
tools: 'Ferramentas',
|
||||
}
|
||||
|
||||
// Ordem de apresentação das categorias
|
||||
const CATEGORY_ORDER = ['crm', 'infra', 'ai', 'tools', 'external', 'gateway', 'project']
|
||||
|
||||
// --- Animation variants ---
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring' as const, stiffness: 300, damping: 30 },
|
||||
},
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
const StatusIndicator = ({ status }: { status: McpStatus['status'] }) => {
|
||||
if (status === 'online') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.7)]" />
|
||||
<span className="text-xs font-medium text-emerald-400">online</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'offline') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.7)] animate-pulse" />
|
||||
<span className="text-xs font-medium text-red-400">offline</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-zinc-600" />
|
||||
<span className="text-xs font-medium text-zinc-500">disabled</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const McpCard = ({ mcp }: { mcp: McpStatus }) => {
|
||||
const isDisabled = mcp.status === 'disabled'
|
||||
const isOffline = mcp.status === 'offline'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`
|
||||
rounded-xl border p-4 transition-colors
|
||||
${isDisabled
|
||||
? 'bg-white/[0.02] border-white/5'
|
||||
: isOffline
|
||||
? 'bg-red-500/[0.04] border-red-500/15'
|
||||
: 'bg-white/[0.04] border-white/10 hover:bg-white/[0.06]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server className={`w-3.5 h-3.5 flex-shrink-0 ${isDisabled ? 'text-zinc-600' : 'text-brand-400'}`} />
|
||||
<span className={`text-sm font-medium truncate font-mono ${isDisabled ? 'text-zinc-600' : 'text-white'}`}>
|
||||
{mcp.name}
|
||||
</span>
|
||||
</div>
|
||||
<StatusIndicator status={mcp.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
{mcp.port > 0 ? (
|
||||
<span className="text-[11px] text-zinc-600 font-mono">:{mcp.port}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-zinc-700">local</span>
|
||||
)}
|
||||
|
||||
{mcp.response_time_ms !== null && mcp.status === 'online' ? (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{mcp.response_time_ms}ms</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
gradient,
|
||||
textColor,
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
icon: React.ElementType
|
||||
gradient: string
|
||||
textColor: string
|
||||
}) => (
|
||||
<motion.div variants={itemVariants} className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wide">{label}</span>
|
||||
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${gradient} flex items-center justify-center`}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${textColor}`}>{value}</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export default function McpMonitor() {
|
||||
const [data, setData] = useState<McpDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/mcps')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json: McpDashboard = await res.json()
|
||||
setData(json)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Erro desconhecido'
|
||||
setError(`Não foi possível carregar os dados: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
const interval = setInterval(fetchData, 60_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
// --- Loading state ---
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<Network className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A verificar MCPs...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Error state ---
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-sm"
|
||||
>
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-white font-medium mb-2">Erro ao carregar</p>
|
||||
<p className="text-zinc-500 text-sm mb-6">{error}</p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
className="px-4 py-2 rounded-xl bg-brand-500/20 hover:bg-brand-500/30 border border-brand-500/30 text-brand-400 text-sm font-medium transition-all"
|
||||
>
|
||||
Tentar novamente
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Agrupar MCPs por categoria na ordem definida
|
||||
const grouped: Record<string, McpStatus[]> = {}
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
const items = data.mcps.filter(m => m.category === cat)
|
||||
if (items.length > 0) grouped[cat] = items
|
||||
}
|
||||
// Categorias não previstas na ordem
|
||||
for (const mcp of data.mcps) {
|
||||
if (!CATEGORY_ORDER.includes(mcp.category)) {
|
||||
if (!grouped[mcp.category]) grouped[mcp.category] = []
|
||||
grouped[mcp.category].push(mcp)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">MCPs</h2>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Gateway:{' '}
|
||||
<span className={data.gateway_status === 'online' ? 'text-emerald-400' : 'text-red-400'}>
|
||||
{data.gateway_status}
|
||||
</span>
|
||||
{' '}— gateway.descomplicar.pt
|
||||
</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
title="Actualizar"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show" className="space-y-8">
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total"
|
||||
value={data.total}
|
||||
icon={Network}
|
||||
gradient="from-brand-500 to-violet-600"
|
||||
textColor="text-white"
|
||||
/>
|
||||
<StatCard
|
||||
label="Online"
|
||||
value={data.online}
|
||||
icon={CheckCircle2}
|
||||
gradient="from-emerald-500 to-emerald-400"
|
||||
textColor="text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Offline"
|
||||
value={data.offline}
|
||||
icon={WifiOff}
|
||||
gradient="from-red-500 to-red-400"
|
||||
textColor={data.offline > 0 ? 'text-red-400' : 'text-zinc-500'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Disabled"
|
||||
value={data.disabled}
|
||||
icon={Wifi}
|
||||
gradient="from-zinc-700 to-zinc-600"
|
||||
textColor="text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid por categoria */}
|
||||
{Object.entries(grouped).map(([category, mcps]) => {
|
||||
const onlineCount = mcps.filter(m => m.status === 'online').length
|
||||
const offlineCount = mcps.filter(m => m.status === 'offline').length
|
||||
const label = CATEGORY_LABELS[category] ?? category
|
||||
|
||||
return (
|
||||
<motion.div key={category} variants={itemVariants}>
|
||||
{/* Cabeçalho de categoria */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">
|
||||
{label}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-600">
|
||||
{onlineCount > 0 && (
|
||||
<span className="text-emerald-500">{onlineCount} online</span>
|
||||
)}
|
||||
{offlineCount > 0 && (
|
||||
<span className="text-red-500">{offlineCount} offline</span>
|
||||
)}
|
||||
<span>{mcps.length} total</span>
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
</div>
|
||||
|
||||
{/* Cards da categoria */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{mcps.map(mcp => (
|
||||
<McpCard key={mcp.name} mcp={mcp} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+376
-220
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Server,
|
||||
Wifi,
|
||||
@@ -11,13 +10,14 @@ import {
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Zap,
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
Wrench,
|
||||
Code,
|
||||
Network,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
MonitorDot,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Types
|
||||
@@ -49,21 +49,95 @@ interface MonitorData {
|
||||
}
|
||||
}
|
||||
|
||||
interface VMConfig {
|
||||
name: string
|
||||
id: string
|
||||
type: 'QEMU' | 'LXC'
|
||||
ip: string
|
||||
role: string
|
||||
icon: React.ElementType
|
||||
accent: string
|
||||
accentBg: string
|
||||
accentBorder: string
|
||||
}
|
||||
|
||||
// VM definitions matching cluster architecture
|
||||
const VM_CONFIG: Record<string, VMConfig> = {
|
||||
'CWP Server': {
|
||||
name: 'Server',
|
||||
id: 'VM 100',
|
||||
type: 'QEMU',
|
||||
ip: '5.9.90.105',
|
||||
role: 'CWP - hosting clientes',
|
||||
icon: Server,
|
||||
accent: 'text-emerald-400',
|
||||
accentBg: 'bg-emerald-500/10',
|
||||
accentBorder: 'border-emerald-500/20',
|
||||
},
|
||||
'EasyPanel': {
|
||||
name: 'Easy',
|
||||
id: 'VM 101',
|
||||
type: 'QEMU',
|
||||
ip: '5.9.90.70',
|
||||
role: 'Docker Swarm - 46 servicos',
|
||||
icon: Container,
|
||||
accent: 'text-cyan-400',
|
||||
accentBg: 'bg-cyan-500/10',
|
||||
accentBorder: 'border-cyan-500/20',
|
||||
},
|
||||
'Dev': {
|
||||
name: 'Dev',
|
||||
id: 'CT 102',
|
||||
type: 'LXC',
|
||||
ip: '10.10.10.10',
|
||||
role: 'Node.js, Docker, TypeScript',
|
||||
icon: Code,
|
||||
accent: 'text-violet-400',
|
||||
accentBg: 'bg-violet-500/10',
|
||||
accentBorder: 'border-violet-500/20',
|
||||
},
|
||||
'Gateway': {
|
||||
name: 'Gateway',
|
||||
id: 'VM 103',
|
||||
type: 'QEMU',
|
||||
ip: '5.9.90.69',
|
||||
role: '26 MCPs, Nginx proxy',
|
||||
icon: Network,
|
||||
accent: 'text-amber-400',
|
||||
accentBg: 'bg-amber-500/10',
|
||||
accentBorder: 'border-amber-500/20',
|
||||
},
|
||||
}
|
||||
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.05 }
|
||||
transition: { staggerChildren: 0.06 }
|
||||
}
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 }
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring' as const, stiffness: 300, damping: 30 }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
const StatusDot = ({ status }: { status: string }) => {
|
||||
const color = (status === 'ok' || status === 'up')
|
||||
? 'bg-emerald-400 shadow-[0_0_12px_rgba(16,185,129,0.6)]'
|
||||
: status === 'warning'
|
||||
? 'bg-amber-400 shadow-[0_0_12px_rgba(245,158,11,0.6)]'
|
||||
: 'bg-red-400 shadow-[0_0_12px_rgba(239,68,68,0.6)] animate-pulse'
|
||||
return <div className={`w-2.5 h-2.5 rounded-full ${color}`} />
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
const styles: Record<string, string> = {
|
||||
ok: 'bg-emerald-500/20 text-emerald-400',
|
||||
@@ -80,15 +154,17 @@ const StatusBadge = ({ status }: { status: string }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Progress Bar
|
||||
const ProgressBar = ({ percent, showLabel = true }: { percent: number; showLabel?: boolean }) => {
|
||||
const color = percent < 70 ? 'from-emerald-500 to-emerald-400' : percent < 85 ? 'from-amber-500 to-amber-400' : 'from-red-500 to-red-400'
|
||||
const ProgressBar = ({ percent, showLabel = true, size = 'md', inverted = false }: { percent: number; showLabel?: boolean; size?: 'sm' | 'md'; inverted?: boolean }) => {
|
||||
const color = inverted
|
||||
? (percent > 90 ? 'from-emerald-500 to-emerald-400' : percent > 70 ? 'from-amber-500 to-amber-400' : 'from-red-500 to-red-400')
|
||||
: (percent < 70 ? 'from-emerald-500 to-emerald-400' : percent < 85 ? 'from-amber-500 to-amber-400' : 'from-red-500 to-red-400')
|
||||
const height = size === 'sm' ? 'h-1.5' : 'h-2'
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className={`flex-1 ${height} bg-white/10 rounded-full overflow-hidden`}>
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${percent}%` }}
|
||||
animate={{ width: `${Math.min(percent, 100)}%` }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
className={`h-full rounded-full bg-gradient-to-r ${color}`}
|
||||
/>
|
||||
@@ -98,19 +174,13 @@ const ProgressBar = ({ percent, showLabel = true }: { percent: number; showLabel
|
||||
)
|
||||
}
|
||||
|
||||
// Summary Card
|
||||
const SummaryCard = ({ value, label, color, icon: Icon }: { value: number; label: string; color: string; icon: React.ElementType }) => (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="glass-card p-6 text-center"
|
||||
>
|
||||
<Icon className={`w-8 h-8 mx-auto mb-3 ${color}`} />
|
||||
<div className={`text-4xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-sm text-zinc-400 mt-1">{label}</div>
|
||||
</motion.div>
|
||||
const MetricPill = ({ label, value, unit = '%' }: { label: string; value: number | string; unit?: string }) => (
|
||||
<span className="text-xs bg-white/5 px-2 py-1 rounded">
|
||||
<span className="text-zinc-500">{label}</span>{' '}
|
||||
<span className="text-white font-medium">{value}{unit}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
// Category Card
|
||||
const CategoryCard = ({
|
||||
title,
|
||||
icon: Icon,
|
||||
@@ -136,45 +206,129 @@ const CategoryCard = ({
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// Monitor Item
|
||||
const MonitorItemRow = ({ item }: { item: MonitorItem }) => (
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02] hover:bg-white/[0.05] transition-colors">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-white">{item.name}</div>
|
||||
{item.details && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{item.details.cpu !== undefined && (
|
||||
<span className="text-xs bg-white/5 px-2 py-1 rounded">
|
||||
<span className="text-zinc-500">CPU</span>{' '}
|
||||
<span className="text-white font-medium">{item.details.cpu}%</span>
|
||||
</span>
|
||||
)}
|
||||
{item.details.ram !== undefined && (
|
||||
<span className="text-xs bg-white/5 px-2 py-1 rounded">
|
||||
<span className="text-zinc-500">RAM</span>{' '}
|
||||
<span className="text-white font-medium">{item.details.ram}%</span>
|
||||
</span>
|
||||
)}
|
||||
{item.details.disk !== undefined && (
|
||||
<span className="text-xs bg-white/5 px-2 py-1 rounded">
|
||||
<span className="text-zinc-500">Disco</span>{' '}
|
||||
<span className="text-white font-medium">{item.details.disk}%</span>
|
||||
</span>
|
||||
)}
|
||||
{item.details.response_time !== undefined && (
|
||||
<span className="text-xs text-zinc-400">{item.details.response_time}s</span>
|
||||
)}
|
||||
{item.details.domain && (
|
||||
<span className="text-xs text-zinc-500">{item.details.domain}</span>
|
||||
)}
|
||||
// Cluster Overview Hero
|
||||
const ClusterHero = ({ data }: { data: MonitorData }) => {
|
||||
const clusterItem = data.items.server?.find(s => s.name === 'Cluster Proxmox')
|
||||
const d = clusterItem?.details || {}
|
||||
|
||||
return (
|
||||
<motion.div variants={itemVariants} className="glass-card overflow-hidden col-span-full">
|
||||
<div className="px-6 py-5 border-b border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30">
|
||||
<MonitorDot className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Cluster Proxmox</h2>
|
||||
<p className="text-xs text-zinc-500">cluster.descomplicar.pt - 5.9.90.75 - Hetzner AX162-R</p>
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status={data.overall} />
|
||||
<span className={`text-sm font-medium ${
|
||||
data.overall === 'ok' ? 'text-emerald-400' :
|
||||
data.overall === 'warning' ? 'text-amber-400' : 'text-red-400'
|
||||
}`}>
|
||||
{data.overall === 'ok' ? 'Operacional' : data.overall === 'warning' ? 'Atencao' : 'Critico'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||
<StatMini icon={Cpu} label="CPU" value={d.cpu ? `${d.cpu}%` : '--'} />
|
||||
<StatMini icon={MemoryStick} label="RAM" value="128 GB" sub="88 GB alocado" />
|
||||
<StatMini icon={HardDrive} label="NVMe" value="950 GB" sub="RAID1" />
|
||||
<StatMini icon={Database} label="HDD" value="16 TB" sub="backups + dados" />
|
||||
<StatMini icon={Server} label="VMs" value="4" sub="3 QEMU + 1 LXC" />
|
||||
<StatMini icon={CheckCircle2} label="OK" value={String(data.stats.total_ok)} color="text-emerald-400" />
|
||||
<StatMini icon={AlertTriangle} label="Alertas" value={String(data.stats.total_warning + data.stats.total_critical)} color={data.stats.total_critical > 0 ? 'text-red-400' : data.stats.total_warning > 0 ? 'text-amber-400' : 'text-emerald-400'} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const StatMini = ({ icon: Icon, label, value, sub, color = 'text-white' }: {
|
||||
icon: React.ElementType; label: string; value: string; sub?: string; color?: string
|
||||
}) => (
|
||||
<div className="text-center p-3 rounded-xl bg-white/[0.03]">
|
||||
<Icon className="w-4 h-4 mx-auto mb-2 text-zinc-500" />
|
||||
<div className={`text-lg font-bold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-zinc-500">{label}</div>
|
||||
{sub && <div className="text-[10px] text-zinc-600 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Main Monitor Page
|
||||
// VM Card
|
||||
const VMCard = ({ item, config }: { item?: MonitorItem; config: VMConfig }) => {
|
||||
const Icon = config.icon
|
||||
const d = item?.details || {}
|
||||
const status = item?.status || 'ok'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`glass-card overflow-hidden border ${config.accentBorder}`}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg ${config.accentBg} flex items-center justify-center`}>
|
||||
<Icon className={`w-5 h-5 ${config.accent}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-white">{config.name}</h3>
|
||||
<span className="text-[10px] text-zinc-600 font-mono">{config.id}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">{config.ip} - {config.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-xs text-zinc-400">{config.role}</p>
|
||||
|
||||
{d.cpu !== undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-zinc-500">CPU</span>
|
||||
<span className="text-white font-medium">{d.cpu}%</span>
|
||||
</div>
|
||||
<ProgressBar percent={Number(d.cpu)} showLabel={false} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{d.ram !== undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-zinc-500">RAM</span>
|
||||
<span className="text-white font-medium">{d.ram}%</span>
|
||||
</div>
|
||||
<ProgressBar percent={Number(d.ram)} showLabel={false} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{d.disk !== undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-zinc-500">Disco</span>
|
||||
<span className="text-white font-medium">{d.disk}%</span>
|
||||
</div>
|
||||
<ProgressBar percent={Number(d.disk)} showLabel={false} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{d.load !== undefined && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<MetricPill label="Load" value={d.load} unit="" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export default function Monitor() {
|
||||
const [data, setData] = useState<MonitorData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -188,7 +342,9 @@ export default function Monitor() {
|
||||
const json = await response.json()
|
||||
setData(json)
|
||||
} catch {
|
||||
if (import.meta.env.DEV) {
|
||||
setData(getMockData())
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -203,14 +359,10 @@ export default function Monitor() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<Activity className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A carregar monitorização...</p>
|
||||
<p className="text-zinc-400">A carregar monitorizacao...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
@@ -218,8 +370,10 @@ export default function Monitor() {
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Items já vêm agrupados por categoria da API
|
||||
const groupedItems = data.items
|
||||
const { items } = data
|
||||
|
||||
// Match server items to VM config
|
||||
const getVMItem = (name: string) => items.server?.find(s => s.name === name)
|
||||
|
||||
const overallColor = {
|
||||
ok: 'text-emerald-400 bg-emerald-500/20',
|
||||
@@ -229,31 +383,19 @@ export default function Monitor() {
|
||||
|
||||
const overallLabel = {
|
||||
ok: 'Operacional',
|
||||
warning: 'Atenção',
|
||||
critical: 'Crítico',
|
||||
warning: 'Atencao',
|
||||
critical: 'Critico',
|
||||
}[data.overall]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh">
|
||||
<div className="bg-grid min-h-screen">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
|
||||
>
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</motion.div>
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
{/* Header com status e refresh */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Monitorização</h1>
|
||||
<p className="text-xs text-zinc-500">Sistemas Descomplicar</p>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">Monitorizacao</h2>
|
||||
<p className="text-xs text-zinc-500">Cluster Proxmox Descomplicar</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${overallColor}`}>
|
||||
{overallLabel}
|
||||
</span>
|
||||
@@ -266,55 +408,48 @@ export default function Monitor() {
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
<Link
|
||||
to="/financial"
|
||||
className="hidden md:flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Financeiro
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
>
|
||||
{/* Summary Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
|
||||
<SummaryCard value={data.stats.total_ok} label="Operacionais" color="text-emerald-400" icon={CheckCircle2} />
|
||||
<SummaryCard value={data.stats.total_warning} label="Avisos" color="text-amber-400" icon={AlertTriangle} />
|
||||
<SummaryCard value={data.stats.total_critical} label="Críticos" color="text-red-400" icon={XCircle} />
|
||||
{/* Section 1: Cluster Overview */}
|
||||
<ClusterHero data={data} />
|
||||
|
||||
{/* Section 2: VM Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mt-6">
|
||||
{Object.entries(VM_CONFIG).map(([key, config]) => (
|
||||
<VMCard key={key} item={getVMItem(key)} config={config} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Monitor Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{/* Servers */}
|
||||
{groupedItems.server && (
|
||||
<CategoryCard title="Servidores" icon={Server} count={groupedItems.server.length}>
|
||||
{groupedItems.server.map((item) => (
|
||||
<MonitorItemRow key={item.id} item={item} />
|
||||
{/* Section 3: Detail Categories */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-8">
|
||||
|
||||
{/* Sites WordPress */}
|
||||
{items.site && items.site.length > 0 && (
|
||||
<CategoryCard title="Sites WordPress" icon={Globe} count={items.site.length}>
|
||||
{items.site.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02] hover:bg-white/[0.05] transition-colors">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-white">{item.name}</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{item.details?.domain && <span className="text-xs text-zinc-500">{item.details.domain}</span>}
|
||||
{item.details?.response_time !== undefined && (
|
||||
<span className="text-xs text-zinc-600">{item.details.response_time}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
))}
|
||||
</CategoryCard>
|
||||
)}
|
||||
|
||||
{/* Services - Compact Grid */}
|
||||
{groupedItems.service && (
|
||||
<CategoryCard title="Serviços Críticos" icon={Wifi} count={groupedItems.service.length}>
|
||||
{/* Servicos EasyPanel */}
|
||||
{items.service && items.service.length > 0 && (
|
||||
<CategoryCard title="Servicos" icon={Wifi} count={items.service.length}>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
||||
{groupedItems.service.map((item) => (
|
||||
{items.service.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-white/[0.04] transition-colors">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
item.status === 'ok' || item.status === 'up' ? 'bg-emerald-400' :
|
||||
@@ -327,49 +462,45 @@ export default function Monitor() {
|
||||
</CategoryCard>
|
||||
)}
|
||||
|
||||
{/* Sites */}
|
||||
{groupedItems.site && (
|
||||
<CategoryCard title="Sites WordPress" icon={Globe} count={groupedItems.site.length}>
|
||||
{groupedItems.site.map((item) => (
|
||||
<MonitorItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</CategoryCard>
|
||||
)}
|
||||
|
||||
{/* Containers */}
|
||||
{groupedItems.container && groupedItems.container[0] && (
|
||||
<CategoryCard title="Containers EasyPanel" icon={Container}>
|
||||
<div className="text-center py-6">
|
||||
{/* Containers EasyPanel */}
|
||||
{items.container && items.container[0] && (
|
||||
<CategoryCard title="Containers Docker" icon={Container}>
|
||||
<div className="text-center py-4">
|
||||
<div className="text-5xl font-bold text-emerald-400">
|
||||
{groupedItems.container[0].details?.up || 0}
|
||||
{items.container[0].details?.up || 0}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400 mt-2">
|
||||
de {groupedItems.container[0].details?.total || 0} containers activos
|
||||
de {items.container[0].details?.total || 0} containers activos
|
||||
</div>
|
||||
{(groupedItems.container[0].details?.down || 0) > 0 && (
|
||||
<div className="text-sm text-red-400 mt-2">
|
||||
{groupedItems.container[0].details.down} containers em baixo
|
||||
{(items.container[0].details?.down || 0) > 0 && (
|
||||
<div className="text-sm text-red-400 mt-1">
|
||||
{items.container[0].details.down} em baixo
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 px-4">
|
||||
<ProgressBar
|
||||
percent={Math.round((groupedItems.container[0].details?.up / groupedItems.container[0].details?.total) * 100) || 0}
|
||||
percent={Math.round((items.container[0].details?.up / items.container[0].details?.total) * 100) || 0}
|
||||
showLabel={false}
|
||||
inverted
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CategoryCard>
|
||||
)}
|
||||
|
||||
{/* Backups */}
|
||||
{groupedItems.backup && (
|
||||
<CategoryCard title="Backups" icon={Database} count={groupedItems.backup.length}>
|
||||
{groupedItems.backup.map((item) => (
|
||||
{/* Backups - 3 camadas */}
|
||||
{items.backup && items.backup.length > 0 && (
|
||||
<CategoryCard title="Backups" icon={Database} count={items.backup.length}>
|
||||
{items.backup.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02]">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{item.name}</div>
|
||||
{item.details?.age_hours !== undefined && (
|
||||
<div className="text-xs text-zinc-500 mt-1">Último: há {item.details.age_hours}h</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">
|
||||
{item.details.age_hours < 1 ? 'Agora' :
|
||||
item.details.age_hours < 24 ? `ha ${item.details.age_hours}h` :
|
||||
`ha ${Math.floor(item.details.age_hours / 24)}d`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
@@ -379,9 +510,9 @@ export default function Monitor() {
|
||||
)}
|
||||
|
||||
{/* Storage */}
|
||||
{groupedItems.storage && (
|
||||
<CategoryCard title="Armazenamento" icon={HardDrive} count={groupedItems.storage.length}>
|
||||
{groupedItems.storage.map((item) => (
|
||||
{items.storage && items.storage.length > 0 && (
|
||||
<CategoryCard title="Storage" icon={HardDrive} count={items.storage.length}>
|
||||
{items.storage.map((item) => (
|
||||
<div key={item.id} className="p-3 rounded-xl bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-white">{item.name}</span>
|
||||
@@ -395,21 +526,20 @@ export default function Monitor() {
|
||||
</CategoryCard>
|
||||
)}
|
||||
|
||||
{/* Maintenance */}
|
||||
{groupedItems.maintenance && groupedItems.maintenance[0] && (() => {
|
||||
const m = groupedItems.maintenance[0]
|
||||
{/* Manutencao */}
|
||||
{items.maintenance && items.maintenance[0] && (() => {
|
||||
const m = items.maintenance[0]
|
||||
const d = m.details || {}
|
||||
const ageH = d.age_hours ?? 0
|
||||
const ageDays = Math.floor(ageH / 24)
|
||||
const ageLabel = ageH < 24 ? `há ${ageH}h` : `há ${ageDays}d`
|
||||
const ageLabel = ageH < 1 ? 'agora' : ageH < 24 ? `ha ${ageH}h` : `ha ${Math.floor(ageH / 24)}d`
|
||||
const actions = (d.logs_truncated || 0) + (d.images_removed || 0) + (d.orphan_volumes || 0) + (d.tmp_cleaned || 0)
|
||||
return (
|
||||
<CategoryCard title="Manutenção" icon={Wrench}>
|
||||
<CategoryCard title="Manutencao" icon={Wrench}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02]">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">Auto-Cleanup</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">Último: {ageLabel}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">Ultimo: {ageLabel}</div>
|
||||
</div>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
@@ -436,112 +566,138 @@ export default function Monitor() {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* WP Updates */}
|
||||
{groupedItems.wp_update && groupedItems.wp_update[0] && (
|
||||
<CategoryCard title="WordPress Updates" icon={Globe}>
|
||||
<div className="text-center py-6">
|
||||
{(groupedItems.wp_update[0].details?.manual_updates || 0) > 0 ? (
|
||||
{/* WP Updates - per-site detail from 'site' category */}
|
||||
{(() => {
|
||||
const sites = items.site || []
|
||||
const sitesWithUpdates = sites.filter((s: MonitorItem) => {
|
||||
const counts = s.details?.updates?.counts
|
||||
return counts && counts.total > 0
|
||||
})
|
||||
const totalUpdates = sitesWithUpdates.reduce((sum: number, s: MonitorItem) => sum + (s.details?.updates?.counts?.total || 0), 0)
|
||||
const wpAgg = items.wp_update?.[0]
|
||||
|
||||
return (
|
||||
<CategoryCard title="WordPress Updates" icon={Globe} count={sitesWithUpdates.length > 0 ? sitesWithUpdates.length : undefined}>
|
||||
{sitesWithUpdates.length > 0 ? (
|
||||
<>
|
||||
<div className="text-5xl font-bold text-amber-400">
|
||||
{groupedItems.wp_update[0].details.manual_updates}
|
||||
<div className="text-center pb-3">
|
||||
<div className="text-3xl font-bold text-amber-400">{totalUpdates}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">updates em {sitesWithUpdates.length} sites</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400 mt-2">
|
||||
plugins precisam update manual
|
||||
{sitesWithUpdates.map((site: MonitorItem) => {
|
||||
const counts = site.details?.updates?.counts || {}
|
||||
return (
|
||||
<div key={site.id} className="p-3 rounded-xl bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{site.name}</span>
|
||||
<span className="text-xs font-semibold text-amber-400">{counts.total} updates</span>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1">
|
||||
{counts.plugins > 0 && <span className="text-xs text-zinc-500">{counts.plugins} plugins</span>}
|
||||
{counts.themes > 0 && <span className="text-xs text-zinc-500">{counts.themes} temas</span>}
|
||||
{(counts.core || 0) > 0 && <span className="text-xs text-red-400">{counts.core} core</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : wpAgg && (wpAgg.details?.manual_updates || 0) > 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="text-4xl font-bold text-amber-400">{wpAgg.details.manual_updates}</div>
|
||||
<div className="text-sm text-zinc-400 mt-2">plugins precisam update</div>
|
||||
<div className="text-xs text-zinc-600 mt-2">Sem detalhe por site disponivel</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center py-4">
|
||||
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto" />
|
||||
<div className="text-sm text-zinc-400 mt-2">Tudo actualizado</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CategoryCard>
|
||||
)}
|
||||
</CategoryCard>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-center gap-2 mt-8 text-sm text-zinc-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Última actualização: {new Date().toLocaleTimeString('pt-PT')}</span>
|
||||
<span>Ultima actualizacao: {new Date().toLocaleTimeString('pt-PT')}</span>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span>Auto-refresh: 60s</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock data
|
||||
// Mock data reflecting new cluster architecture
|
||||
function getMockData(): MonitorData {
|
||||
const mockItems: Record<string, MonitorItem[]> = {
|
||||
server: [
|
||||
{ id: 5, name: 'CWP Server', category: 'server', status: 'up', details: { cpu: 7.3, ram: 10.2, disk: 39 }, last_check: '' },
|
||||
{ id: 6, name: 'EasyPanel', category: 'server', status: 'up', details: { cpu: 20.5, ram: 20.2, disk: 41 }, last_check: '' },
|
||||
{ id: 296, name: 'MCP Hub', category: 'server', status: 'up', details: { cpu: 1.0, load: 0.0 }, last_check: '' },
|
||||
{ id: 297, name: 'Meet', category: 'server', status: 'up', details: { cpu: 4.7, load: 0.0 }, last_check: '' },
|
||||
{ id: 298, name: 'WhatsApp', category: 'server', status: 'up', details: { cpu: 3.0, load: 0.04 }, last_check: '' },
|
||||
{ id: 299, name: 'WhatSMS', category: 'server', status: 'up', details: { cpu: 2.1, load: 0.08 }, last_check: '' },
|
||||
{ id: 1, name: 'Cluster Proxmox', category: 'server', status: 'ok', details: { cpu: 12.5 }, last_check: '' },
|
||||
{ id: 100, name: 'CWP Server', category: 'server', status: 'ok', details: { cpu: 7.3, ram: 25.1, disk: 39, load: 0.42 }, last_check: '' },
|
||||
{ id: 101, name: 'EasyPanel', category: 'server', status: 'ok', details: { cpu: 20.5, ram: 53.1, disk: 41, load: 1.2 }, last_check: '' },
|
||||
{ id: 102, name: 'Dev', category: 'server', status: 'ok', details: { cpu: 3.0, ram: 18.5, disk: 28 }, last_check: '' },
|
||||
{ id: 103, name: 'Gateway', category: 'server', status: 'ok', details: { cpu: 5.2, ram: 42.0, disk: 52, load: 0.3 }, last_check: '' },
|
||||
],
|
||||
service: [
|
||||
{ id: 1, name: 'Planeamento EAL', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 2, name: 'Desk CRM', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 3, name: 'Automator N8N', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 4, name: 'NextCloud', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 517, name: 'Gitea', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 518, name: 'Meet Jitsi', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 519, name: 'WikiJS', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 521, name: 'Google Docs', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 549, name: 'MCP Hub', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 515, name: 'WhatSMS', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 41594, name: 'Syncthing', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 1, name: 'Desk CRM', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 2, name: 'N8N', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 3, name: 'Gitea', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 4, name: 'WikiJS', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 5, name: 'Authentik', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 6, name: 'Metabase', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 7, name: 'NextCloud', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 8, name: 'Syncthing', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 9, name: 'MCP Gateway', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 10, name: 'Outline', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
{ id: 11, name: 'Penpot', category: 'service', status: 'warning', details: {}, last_check: '' },
|
||||
{ id: 12, name: 'WhatSMS', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||
],
|
||||
site: [
|
||||
{ id: 15960, name: 'Descomplicar', category: 'site', status: 'up', details: { domain: 'descomplicar.pt', response_time: 0.093 }, last_check: '' },
|
||||
{ id: 15961, name: 'Emanuel Almeida', category: 'site', status: 'up', details: { domain: 'emanuelalmeida.pt', response_time: 1.687 }, last_check: '' },
|
||||
{ id: 15959, name: 'Family Clinic', category: 'site', status: 'up', details: { domain: 'familyclinic.pt', response_time: 2.712 }, last_check: '' },
|
||||
{ id: 15962, name: 'WTC', category: 'site', status: 'up', details: { domain: 'wtc-group.com' }, last_check: '' },
|
||||
{ id: 15963, name: 'Carstuff', category: 'site', status: 'down', details: { domain: 'carstuff.pt' }, last_check: '' },
|
||||
{ id: 15964, name: 'Espiral Senior', category: 'site', status: 'up', details: { domain: 'espiralsenior.pt' }, last_check: '' },
|
||||
{ id: 15965, name: 'Karate Gaia', category: 'site', status: 'up', details: { domain: 'karategaia.pt' }, last_check: '' },
|
||||
{ id: 1, name: 'Descomplicar', category: 'site', status: 'up', details: { domain: 'descomplicar.pt', response_time: 0.093 }, last_check: '' },
|
||||
{ id: 2, name: 'Emanuel Almeida', category: 'site', status: 'up', details: { domain: 'emanuelalmeida.pt', response_time: 1.687 }, last_check: '' },
|
||||
{ id: 3, name: 'Family Clinic', category: 'site', status: 'up', details: { domain: 'familyclinic.pt', response_time: 2.712 }, last_check: '' },
|
||||
{ id: 4, name: 'WTC', category: 'site', status: 'up', details: { domain: 'wtc-group.com' }, last_check: '' },
|
||||
{ id: 5, name: 'Carstuff', category: 'site', status: 'down', details: { domain: 'carstuff.pt' }, last_check: '' },
|
||||
{ id: 6, name: 'Espiral Senior', category: 'site', status: 'up', details: { domain: 'espiralsenior.pt' }, last_check: '' },
|
||||
{ id: 7, name: 'Karate Gaia', category: 'site', status: 'up', details: { domain: 'karategaia.pt' }, last_check: '' },
|
||||
],
|
||||
container: [
|
||||
{ id: 7, name: 'EasyPanel Containers', category: 'container', status: 'warning', details: { up: 83, total: 87, down: 4 }, last_check: '' },
|
||||
{ id: 1, name: 'Docker Swarm', category: 'container', status: 'ok', details: { up: 44, total: 46, down: 2 }, last_check: '' },
|
||||
],
|
||||
backup: [
|
||||
{ id: 15967, name: 'MySQL Hourly', category: 'backup', status: 'ok', details: { age_hours: 1 }, last_check: '' },
|
||||
{ id: 15968, name: 'CWP Accounts', category: 'backup', status: 'warning', details: { age_hours: 48 }, last_check: '' },
|
||||
{ id: 15969, name: 'Easy Backup', category: 'backup', status: 'ok', details: { age_hours: 12 }, last_check: '' },
|
||||
{ id: 15970, name: 'Server->Easy Sync', category: 'backup', status: 'failed', details: { age_hours: 72 }, last_check: '' },
|
||||
{ id: 1, name: 'Proxmox vzdump', category: 'backup', status: 'ok', details: { age_hours: 8 }, last_check: '' },
|
||||
{ id: 2, name: 'CWP Accounts', category: 'backup', status: 'ok', details: { age_hours: 18 }, last_check: '' },
|
||||
{ id: 3, name: 'MySQL Hourly', category: 'backup', status: 'ok', details: { age_hours: 1 }, last_check: '' },
|
||||
],
|
||||
storage: [
|
||||
{ id: 15971, name: 'gordo', category: 'storage', status: 'ok', details: { used: '89GB', total: '200GB', percent: 45 }, last_check: '' },
|
||||
{ id: 15972, name: 'gordito', category: 'storage', status: 'ok', details: { used: '42GB', total: '100GB', percent: 42 }, last_check: '' },
|
||||
],
|
||||
wp_update: [
|
||||
{ id: 25, name: 'WordPress Plugins', category: 'wp_update', status: 'warning', details: { manual_updates: 3 }, last_check: '' },
|
||||
{ id: 1, name: 'NVMe RAID1 (vg0)', category: 'storage', status: 'ok', details: { used: '650GB', total: '950GB', percent: 68 }, last_check: '' },
|
||||
{ id: 2, name: 'HDD pbs-local', category: 'storage', status: 'ok', details: { used: '2.9TB', total: '6TB', percent: 49 }, last_check: '' },
|
||||
{ id: 3, name: 'HDD vm-storage', category: 'storage', status: 'ok', details: { used: '480GB', total: '8.5TB', percent: 6 }, last_check: '' },
|
||||
],
|
||||
maintenance: [
|
||||
{ id: 48558, name: 'EasyPanel Cleanup', category: 'maintenance', status: 'ok', details: { age_hours: 0, disk_percent: 15, freed_mb: 0, logs_truncated: 0, images_removed: 0, orphan_volumes: 0, tmp_cleaned: 0 }, last_check: '' },
|
||||
{ id: 1, name: 'EasyPanel Cleanup', category: 'maintenance', status: 'ok', details: { age_hours: 2, disk_percent: 41, freed_mb: 128, logs_truncated: 3, images_removed: 5, orphan_volumes: 0, tmp_cleaned: 1 }, last_check: '' },
|
||||
],
|
||||
wp_update: [
|
||||
{ id: 1, name: 'WordPress Plugins', category: 'wp_update', status: 'ok', details: { manual_updates: 0 }, last_check: '' },
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
overall: 'warning',
|
||||
summary: [
|
||||
{ category: 'server', total: 6, ok: 6, warning: 0, critical: 0 },
|
||||
{ category: 'service', total: 11, ok: 11, warning: 0, critical: 0 },
|
||||
{ category: 'server', total: 5, ok: 5, warning: 0, critical: 0 },
|
||||
{ category: 'service', total: 12, ok: 11, warning: 1, critical: 0 },
|
||||
{ category: 'site', total: 7, ok: 6, warning: 0, critical: 1 },
|
||||
{ category: 'container', total: 1, ok: 0, warning: 1, critical: 0 },
|
||||
{ category: 'backup', total: 4, ok: 2, warning: 1, critical: 1 },
|
||||
{ category: 'storage', total: 2, ok: 2, warning: 0, critical: 0 },
|
||||
{ category: 'container', total: 1, ok: 1, warning: 0, critical: 0 },
|
||||
{ category: 'backup', total: 3, ok: 3, warning: 0, critical: 0 },
|
||||
{ category: 'storage', total: 3, ok: 3, warning: 0, critical: 0 },
|
||||
],
|
||||
stats: {
|
||||
total_ok: 26,
|
||||
total_warning: 2,
|
||||
total_critical: 2
|
||||
total_ok: 30,
|
||||
total_warning: 1,
|
||||
total_critical: 1
|
||||
},
|
||||
items: mockItems,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
GitBranch,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// --- Tipos ---
|
||||
|
||||
interface N8nLastExecution {
|
||||
status: 'success' | 'error' | 'running' | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
duration_ms: number | null
|
||||
}
|
||||
|
||||
interface N8nWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
last_execution: N8nLastExecution | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface N8nDashboard {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
failed_24h: number
|
||||
workflows: N8nWorkflow[]
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
// --- Animações ---
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
// --- Utilitários ---
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const min = Math.floor(ms / 60000)
|
||||
const sec = Math.round((ms % 60000) / 1000)
|
||||
return `${min}m ${sec}s`
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleString('pt-PT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatLastUpdated(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return `Actualizado: ${d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`
|
||||
}
|
||||
|
||||
// --- Sub-componentes ---
|
||||
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
colorClass,
|
||||
highlight,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
icon: React.ElementType
|
||||
colorClass: string
|
||||
highlight?: boolean
|
||||
}) => (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={`glass-card p-6 ${highlight && value > 0 ? 'border border-red-500/40' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-zinc-400">{label}</span>
|
||||
<div className={`w-10 h-10 rounded-xl ${colorClass} flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${highlight && value > 0 ? 'text-red-400' : 'text-white'}`}>
|
||||
{value}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
function StatusBadge({ status }: { status: N8nLastExecution['status'] }) {
|
||||
if (status === 'success')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 text-xs font-medium">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Sucesso
|
||||
</span>
|
||||
)
|
||||
if (status === 'error')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/15 text-red-400 text-xs font-medium">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
Erro
|
||||
</span>
|
||||
)
|
||||
if (status === 'running')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400 text-xs font-medium">
|
||||
<Clock className="w-3.5 h-3.5 animate-spin" />
|
||||
A correr
|
||||
</span>
|
||||
)
|
||||
return <span className="text-zinc-600 text-xs">—</span>
|
||||
}
|
||||
|
||||
function ActiveBadge({ active }: { active: boolean }) {
|
||||
return active ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 text-xs font-medium">
|
||||
<Play className="w-3 h-3" />
|
||||
Activo
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-700/50 text-zinc-500 text-xs font-medium">
|
||||
<Pause className="w-3 h-3" />
|
||||
Inactivo
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Página principal ---
|
||||
|
||||
export default function N8nMonitor() {
|
||||
const [data, setData] = useState<N8nDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [showOnlyActive, setShowOnlyActive] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch('/api/n8n')
|
||||
if (!response.ok) {
|
||||
const json = await response.json().catch(() => ({}))
|
||||
throw new Error(json.error || `Erro ${response.status}`)
|
||||
}
|
||||
const json: N8nDashboard = await response.json()
|
||||
setData(json)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao carregar dados do n8n')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// --- Loading ---
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<GitBranch className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A carregar workflows n8n...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Erro ---
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card p-8 text-center"
|
||||
>
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-white font-semibold mb-2">Erro ao carregar dados</p>
|
||||
<p className="text-zinc-400 text-sm mb-6">{error}</p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
className="px-5 py-2.5 rounded-xl bg-brand-500/20 hover:bg-brand-500/30 border border-brand-500/30 text-brand-300 text-sm font-medium transition-all"
|
||||
>
|
||||
Tentar novamente
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const visibleWorkflows = showOnlyActive
|
||||
? data.workflows.filter((w) => w.active)
|
||||
: data.workflows
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">Automações n8n</h2>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{data.last_updated ? formatLastUpdated(data.last_updated) : 'Workflows operacionais'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Toggle activos/todos */}
|
||||
<button
|
||||
onClick={() => setShowOnlyActive((v) => !v)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||
showOnlyActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-300'
|
||||
: 'bg-white/5 border-white/10 text-zinc-400 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{showOnlyActive ? 'Apenas activos' : 'Todos'}
|
||||
</button>
|
||||
{/* Refresh */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||
{/* Alerta falhas 24h */}
|
||||
{data.failed_24h > 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="mb-6 flex items-center gap-3 px-5 py-4 rounded-2xl bg-red-500/10 border border-red-500/30"
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-300">
|
||||
<span className="font-semibold">{data.failed_24h} execução{data.failed_24h !== 1 ? 'ões' : ''} falharam</span>{' '}
|
||||
nas últimas 24 horas. Verifique os workflows em erro.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
|
||||
<StatCard
|
||||
label="Total de Workflows"
|
||||
value={data.total}
|
||||
icon={GitBranch}
|
||||
colorClass="bg-violet-500/20"
|
||||
/>
|
||||
<StatCard
|
||||
label="Activos"
|
||||
value={data.active}
|
||||
icon={Play}
|
||||
colorClass="bg-emerald-500/20"
|
||||
/>
|
||||
<StatCard
|
||||
label="Falhas (24h)"
|
||||
value={data.failed_24h}
|
||||
icon={AlertTriangle}
|
||||
colorClass={data.failed_24h > 0 ? 'bg-red-500/20' : 'bg-zinc-700/40'}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabela de Workflows */}
|
||||
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wide flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-brand-400" />
|
||||
Workflows
|
||||
</h3>
|
||||
<span className="text-xs text-zinc-500">{visibleWorkflows.length} workflow{visibleWorkflows.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{visibleWorkflows.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Pause className="w-8 h-8 text-zinc-600 mx-auto mb-3" />
|
||||
<p className="text-zinc-500 text-sm">Nenhum workflow encontrado</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Nome
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Estado
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Último Run
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Resultado
|
||||
</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Duração
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{visibleWorkflows.map((wf) => (
|
||||
<tr
|
||||
key={wf.id}
|
||||
className="hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
{/* Nome */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GitBranch className="w-4 h-4 text-zinc-600 flex-shrink-0" />
|
||||
<span className="text-sm text-white font-medium truncate max-w-[220px]">
|
||||
{wf.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Activo */}
|
||||
<td className="px-4 py-4">
|
||||
<ActiveBadge active={wf.active} />
|
||||
</td>
|
||||
{/* Data do último run */}
|
||||
<td className="px-4 py-4">
|
||||
<span className="text-sm text-zinc-400 tabular-nums">
|
||||
{formatDate(wf.last_execution?.started_at ?? null)}
|
||||
</span>
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="px-4 py-4">
|
||||
<StatusBadge status={wf.last_execution?.status ?? null} />
|
||||
</td>
|
||||
{/* Duração */}
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="text-sm text-zinc-500 tabular-nums font-mono">
|
||||
{formatDuration(wf.last_execution?.duration_ms ?? null)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Página Operações — Tickets e Cobertura de PROCs
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Ticket,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
FileText,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TicketsByDept {
|
||||
dept: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface CoverageItem {
|
||||
dept: string
|
||||
procs: number
|
||||
total_expected: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
interface OperationsData {
|
||||
tickets: {
|
||||
open: number
|
||||
high_priority: number
|
||||
avg_response_hours: number
|
||||
by_department: TicketsByDept[]
|
||||
}
|
||||
procedures: {
|
||||
total: number
|
||||
departments: number
|
||||
coverage: CoverageItem[]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animações
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-componentes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const StatCard = ({
|
||||
label, value, icon: Icon, color, sub,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
icon: React.ElementType
|
||||
color: string
|
||||
sub?: string
|
||||
}) => (
|
||||
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-zinc-400">{label}</span>
|
||||
<div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-white">{value}</div>
|
||||
{sub && <div className="text-xs text-zinc-500 mt-1">{sub}</div>}
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean
|
||||
payload?: { color: string; name: string; value: number }[]
|
||||
label?: string
|
||||
}) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-white/10 rounded-xl px-4 py-3 shadow-xl">
|
||||
<p className="text-sm font-medium text-white mb-1">{label}</p>
|
||||
{payload.map((p, i) => (
|
||||
<p key={i} className="text-xs" style={{ color: p.color }}>
|
||||
{p.name}: {p.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Barra de progresso para cobertura de PROCs
|
||||
const ProgressBar = ({ pct }: { pct: number }) => {
|
||||
const color =
|
||||
pct >= 80 ? 'bg-emerald-500' :
|
||||
pct >= 50 ? 'bg-amber-500' :
|
||||
'bg-red-500'
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-500 ${color}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Página principal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Operations() {
|
||||
const [data, setData] = useState<OperationsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const response = await fetch('/api/operations')
|
||||
if (!response.ok) throw new Error('Resposta inválida')
|
||||
const json: OperationsData = await response.json()
|
||||
setData(json)
|
||||
} catch (err) {
|
||||
console.error('[Operations] Erro ao carregar dados:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
// Estado de carregamento
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<Ticket className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||
<p className="text-zinc-400">A carregar dados de operações...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Cor do card de alta prioridade: vermelho se existirem tickets críticos
|
||||
const priorityColor = data.tickets.high_priority > 0
|
||||
? 'bg-red-500/20'
|
||||
: 'bg-zinc-700/40'
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||
{/* Cabeçalho */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">Operações</h2>
|
||||
<p className="text-xs text-zinc-500">Tickets de Suporte e Cobertura de Procedimentos</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={fetchData}
|
||||
disabled={refreshing}
|
||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||
{/* Cards de resumo */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
|
||||
<StatCard
|
||||
label="Tickets Abertos"
|
||||
value={String(data.tickets.open)}
|
||||
icon={Ticket}
|
||||
color="bg-blue-500/20"
|
||||
sub="Status: Open, In Progress, Answered"
|
||||
/>
|
||||
<StatCard
|
||||
label="Alta Prioridade"
|
||||
value={String(data.tickets.high_priority)}
|
||||
icon={AlertTriangle}
|
||||
color={priorityColor}
|
||||
sub={data.tickets.high_priority > 0 ? 'Requerem atenção imediata' : 'Sem tickets críticos'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Tempo Médio Resposta"
|
||||
value={`${data.tickets.avg_response_hours}h`}
|
||||
icon={Clock}
|
||||
color="bg-amber-500/20"
|
||||
sub="Média últimos 90 dias"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de barras horizontal + Resumo de PROCs */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-8">
|
||||
{/* Tickets por departamento (barra horizontal) */}
|
||||
<motion.div variants={itemVariants} className="glass-card p-6 lg:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-brand-400" />
|
||||
Tickets por Departamento
|
||||
</h3>
|
||||
<div className="h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data.tickets.by_department}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: '#71717a', fontSize: 12 }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="dept"
|
||||
tick={{ fill: '#a1a1aa', fontSize: 11 }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
width={140}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" name="Tickets" fill="#10b981" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Resumo de PROCs */}
|
||||
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-5 uppercase tracking-wide flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-brand-400" />
|
||||
Procedimentos
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span className="text-sm text-zinc-400">Total de PROCs</span>
|
||||
<span className="text-xl font-bold text-white">{data.procedures.total}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span className="text-sm text-zinc-400">Departamentos cobertos</span>
|
||||
<span className="text-xl font-bold text-white">{data.procedures.departments}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-zinc-400">Cobertura global</span>
|
||||
<span className="text-xl font-bold text-emerald-400">
|
||||
{Math.round(
|
||||
(data.procedures.coverage.reduce((s, d) => s + d.procs, 0) /
|
||||
data.procedures.coverage.reduce((s, d) => s + d.total_expected, 0)) * 100
|
||||
)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Tabela de cobertura por departamento */}
|
||||
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-brand-400" />
|
||||
Cobertura de PROCs por Departamento
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 pr-4 text-zinc-400 font-medium">Departamento</th>
|
||||
<th className="text-center py-2 px-4 text-zinc-400 font-medium">PROCs</th>
|
||||
<th className="text-center py-2 px-4 text-zinc-400 font-medium">Esperados</th>
|
||||
<th className="text-left py-2 pl-4 text-zinc-400 font-medium w-48">Cobertura</th>
|
||||
<th className="text-right py-2 pl-4 text-zinc-400 font-medium">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.procedures.coverage.map((item, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-white/5 hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="py-3 pr-4 text-white font-medium">{item.dept}</td>
|
||||
<td className="py-3 px-4 text-center text-zinc-300 tabular-nums">{item.procs}</td>
|
||||
<td className="py-3 px-4 text-center text-zinc-500 tabular-nums">{item.total_expected}</td>
|
||||
<td className="py-3 pl-4 w-48">
|
||||
<ProgressBar pct={item.pct} />
|
||||
</td>
|
||||
<td className="py-3 pl-4 text-right tabular-nums font-semibold"
|
||||
style={{
|
||||
color: item.pct >= 80 ? '#34d399' : item.pct >= 50 ? '#fbbf24' : '#f87171',
|
||||
}}
|
||||
>
|
||||
{item.pct}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Página Paperclip — Painel de agentes, routines e issues
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Bot,
|
||||
Users,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PaperclipAgent {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
status: 'active' | 'idle' | 'error' | 'archived'
|
||||
last_heartbeat: string | null
|
||||
last_run: string | null
|
||||
total_runs: number
|
||||
}
|
||||
|
||||
interface PaperclipRoutine {
|
||||
id: string
|
||||
name: string
|
||||
cron: string
|
||||
active: boolean
|
||||
last_run: string | null
|
||||
last_status: 'success' | 'error' | null
|
||||
next_run: string | null
|
||||
}
|
||||
|
||||
interface PaperclipDashboard {
|
||||
agents: {
|
||||
total: number
|
||||
active: number
|
||||
idle: number
|
||||
error: number
|
||||
list: PaperclipAgent[]
|
||||
}
|
||||
routines: {
|
||||
total: number
|
||||
active: number
|
||||
list: PaperclipRoutine[]
|
||||
}
|
||||
issues: {
|
||||
open: number
|
||||
in_progress: number
|
||||
closed_7d: number
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animation variants (consistente com Monitor.tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring' as const, stiffness: 300, damping: 30 },
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ordem de prioridade de roles para agrupamento hierárquico.
|
||||
* Roles desconhecidos ficam no fim.
|
||||
*/
|
||||
const ROLE_ORDER: Record<string, number> = {
|
||||
Board: 0,
|
||||
CEO: 1,
|
||||
'C-Level': 2,
|
||||
COO: 2,
|
||||
CTO: 2,
|
||||
CFO: 2,
|
||||
CMO: 2,
|
||||
Director: 3,
|
||||
Specialist: 4,
|
||||
}
|
||||
|
||||
function roleOrder(role: string): number {
|
||||
return ROLE_ORDER[role] ?? 99
|
||||
}
|
||||
|
||||
/** Agrupa a lista de agentes por role, ordenando roles hierarchicamente. */
|
||||
function groupAgentsByRole(agents: PaperclipAgent[]): Record<string, PaperclipAgent[]> {
|
||||
const groups: Record<string, PaperclipAgent[]> = {}
|
||||
for (const agent of agents) {
|
||||
if (!groups[agent.role]) groups[agent.role] = []
|
||||
groups[agent.role].push(agent)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** Ordena as keys de um objecto de grupos pelo ranking hierárquico. */
|
||||
function sortedRoleKeys(groups: Record<string, PaperclipAgent[]>): string[] {
|
||||
return Object.keys(groups).sort((a, b) => roleOrder(a) - roleOrder(b))
|
||||
}
|
||||
|
||||
/** Formata uma string ISO ou null para data/hora legível PT. */
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('pt-PT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dot colorido de estado (verde/amarelo/vermelho). */
|
||||
const StatusDot = ({ status }: { status: string }) => {
|
||||
const colour =
|
||||
status === 'active'
|
||||
? 'bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.5)]'
|
||||
: status === 'idle'
|
||||
? 'bg-amber-400 shadow-[0_0_10px_rgba(245,158,11,0.5)]'
|
||||
: status === 'error'
|
||||
? 'bg-red-400 shadow-[0_0_10px_rgba(239,68,68,0.5)] animate-pulse'
|
||||
: 'bg-zinc-600'
|
||||
return <span className={`inline-block w-2 h-2 rounded-full ${colour}`} />
|
||||
}
|
||||
|
||||
/** Badge de role do agente. */
|
||||
const RoleBadge = ({ role }: { role: string }) => {
|
||||
const styles: Record<string, string> = {
|
||||
Board: 'bg-violet-500/20 text-violet-300 border border-violet-500/30',
|
||||
CEO: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30',
|
||||
'C-Level': 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
|
||||
COO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
|
||||
CTO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
|
||||
CFO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
|
||||
CMO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30',
|
||||
Director: 'bg-sky-500/20 text-sky-300 border border-sky-500/30',
|
||||
Specialist: 'bg-teal-500/20 text-teal-300 border border-teal-500/30',
|
||||
}
|
||||
const cls = styles[role] ?? 'bg-zinc-700/40 text-zinc-400 border border-zinc-600/30'
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide ${cls}`}>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Card de estatística no topo. */
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
accent,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
icon: React.ElementType
|
||||
accent: string
|
||||
}) => (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="glass-card p-5 flex items-center gap-4"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${accent}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
/** Card individual de agente. */
|
||||
const AgentCard = ({ agent }: { agent: PaperclipAgent }) => {
|
||||
const statusLabel: Record<string, string> = {
|
||||
active: 'Activo',
|
||||
idle: 'Inactivo',
|
||||
error: 'Erro',
|
||||
archived: 'Arquivado',
|
||||
}
|
||||
const statusColour: Record<string, string> = {
|
||||
active: 'text-emerald-400',
|
||||
idle: 'text-amber-400',
|
||||
error: 'text-red-400',
|
||||
archived: 'text-zinc-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="glass-card p-4 flex flex-col gap-3 hover:border-cyan-500/20 transition-colors"
|
||||
>
|
||||
{/* Cabeçalho: ícone + nome + status dot */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500/20 to-blue-600/20 border border-cyan-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white leading-tight">{agent.name}</span>
|
||||
</div>
|
||||
<StatusDot status={agent.status} />
|
||||
</div>
|
||||
|
||||
{/* Role badge + estado */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<RoleBadge role={agent.role} />
|
||||
<span className={`text-xs font-medium ${statusColour[agent.status] ?? 'text-zinc-400'}`}>
|
||||
{statusLabel[agent.status] ?? agent.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Último heartbeat */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<Clock className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{agent.last_heartbeat ? formatDate(agent.last_heartbeat) : 'Sem heartbeat'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Secção de agentes agrupados por role. */
|
||||
const AgentsSection = ({ agents }: { agents: PaperclipAgent[] }) => {
|
||||
const groups = groupAgentsByRole(agents)
|
||||
const roleKeys = sortedRoleKeys(groups)
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<motion.div variants={itemVariants} className="glass-card p-8 text-center">
|
||||
<Bot className="w-8 h-8 text-zinc-600 mx-auto mb-2" />
|
||||
<p className="text-zinc-500 text-sm">Sem agentes disponíveis</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{roleKeys.map(role => (
|
||||
<motion.div key={role} variants={itemVariants}>
|
||||
{/* Separador de grupo */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Users className="w-4 h-4 text-cyan-500" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-cyan-400">{role}</h3>
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
<span className="text-xs text-zinc-600">{groups[role].length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-6">
|
||||
{groups[role].map(agent => (
|
||||
<AgentCard key={agent.id || agent.name} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tabela de routines. */
|
||||
const RoutinesTable = ({ routines }: { routines: PaperclipRoutine[] }) => {
|
||||
if (routines.length === 0) {
|
||||
return (
|
||||
<motion.div variants={itemVariants} className="glass-card p-8 text-center">
|
||||
<Calendar className="w-8 h-8 text-zinc-600 mx-auto mb-2" />
|
||||
<p className="text-zinc-500 text-sm">Sem routines disponíveis</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-white/5 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">Routines</h3>
|
||||
<span className="ml-auto text-xs text-zinc-500">{routines.length} routines</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-zinc-500 uppercase tracking-wide border-b border-white/5">
|
||||
<th className="px-5 py-3 text-left">Nome</th>
|
||||
<th className="px-5 py-3 text-left">Cron</th>
|
||||
<th className="px-5 py-3 text-center">Activa</th>
|
||||
<th className="px-5 py-3 text-left">Último run</th>
|
||||
<th className="px-5 py-3 text-center">Estado</th>
|
||||
<th className="px-5 py-3 text-left">Próximo run</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routines.map((routine, idx) => (
|
||||
<tr
|
||||
key={routine.id || routine.name}
|
||||
className={`border-b border-white/5 hover:bg-white/[0.02] transition-colors ${
|
||||
idx % 2 === 0 ? '' : 'bg-white/[0.01]'
|
||||
}`}
|
||||
>
|
||||
<td className="px-5 py-3 text-white font-medium">{routine.name}</td>
|
||||
<td className="px-5 py-3">
|
||||
<code className="text-xs bg-white/5 px-2 py-0.5 rounded text-cyan-300">
|
||||
{routine.cron}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-center">
|
||||
{routine.active ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-xs font-medium">
|
||||
<CheckCircle2 className="w-3 h-3" /> Sim
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-700/40 text-zinc-500 text-xs font-medium">
|
||||
Não
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-zinc-400 text-xs whitespace-nowrap">
|
||||
{formatDate(routine.last_run)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-center">
|
||||
{routine.last_status === 'success' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 mx-auto" />
|
||||
) : routine.last_status === 'error' ? (
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 mx-auto" />
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-zinc-400 text-xs whitespace-nowrap">
|
||||
{formatDate(routine.next_run)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Contador de issues (open / in-progress / closed). */
|
||||
const IssuesBar = ({ issues }: { issues: PaperclipDashboard['issues'] }) => (
|
||||
<motion.div variants={itemVariants} className="glass-card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wide">Issues</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-red-400">{issues.open}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Abertas</p>
|
||||
</div>
|
||||
<div className="text-center border-x border-white/5">
|
||||
<p className="text-2xl font-bold text-amber-400">{issues.in_progress}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Em progresso</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-400">{issues.closed_7d}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Fechadas (7d)</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Página principal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Paperclip() {
|
||||
const [data, setData] = useState<PaperclipDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/paperclip')
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.message ?? `HTTP ${res.status}`)
|
||||
}
|
||||
const json: PaperclipDashboard = await res.json()
|
||||
setData(json)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 p-6 space-y-6">
|
||||
{/* Cabeçalho */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<motion.div variants={itemVariants} className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center shadow-lg shadow-cyan-500/30">
|
||||
<Bot className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">Paperclip</h1>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Orquestrador autónomo — agentes, routines e issues
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants} className="flex items-center gap-3">
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-zinc-600">
|
||||
Actualizado às {lastUpdated.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm text-zinc-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Estado de erro */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-3 px-5 py-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Erro ao carregar dados: {error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Estado de carregamento inicial */}
|
||||
{loading && !data && (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 sm:grid-cols-4 gap-4"
|
||||
>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="glass-card p-5 h-20 animate-pulse bg-white/[0.03]" />
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Conteúdo principal */}
|
||||
{data && (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Agentes activos"
|
||||
value={data.agents.active}
|
||||
icon={Zap}
|
||||
accent="bg-emerald-500/20 text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Agentes idle"
|
||||
value={data.agents.idle}
|
||||
icon={Bot}
|
||||
accent="bg-amber-500/20 text-amber-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Agentes com erro"
|
||||
value={data.agents.error}
|
||||
icon={AlertTriangle}
|
||||
accent="bg-red-500/20 text-red-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Routines activas"
|
||||
value={data.routines.active}
|
||||
icon={Calendar}
|
||||
accent="bg-cyan-500/20 text-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issues */}
|
||||
<IssuesBar issues={data.issues} />
|
||||
|
||||
{/* Agentes agrupados por role */}
|
||||
<div>
|
||||
<motion.div variants={itemVariants} className="flex items-center gap-2 mb-4">
|
||||
<Users className="w-4 h-4 text-cyan-400" />
|
||||
<h2 className="text-sm font-semibold text-white uppercase tracking-wide">
|
||||
Agentes
|
||||
</h2>
|
||||
<span className="text-xs text-zinc-500 ml-1">({data.agents.total} total)</span>
|
||||
</motion.div>
|
||||
<AgentsSection agents={data.agents.list} />
|
||||
</div>
|
||||
|
||||
{/* Routines */}
|
||||
<RoutinesTable routines={data.routines.list} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { getSession } from '../lib/api/sessions'
|
||||
import { EventBlock } from '../components/sessions/EventBlock'
|
||||
import type { SessionMeta, SessionEvent } from '../../api/types/session'
|
||||
|
||||
type FilterMode = 'all' | 'no-system' | 'tools-only' | 'prompts-only'
|
||||
|
||||
export default function SessionDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [meta, setMeta] = useState<SessionMeta | null>(null)
|
||||
const [events, setEvents] = useState<SessionEvent[]>([])
|
||||
const [mode, setMode] = useState<FilterMode>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getSession(id)
|
||||
.then((r) => {
|
||||
setMeta(r.meta)
|
||||
setEvents(r.events)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const visible = events.filter((e) => {
|
||||
if (mode === 'no-system') return e.type !== 'system'
|
||||
if (mode === 'tools-only') return e.tool_name !== null || e.tool_result !== null
|
||||
if (mode === 'prompts-only') return e.type === 'user' && e.tool_result === null
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) return <div className="p-6 text-slate-400">A carregar…</div>
|
||||
if (error) return <div className="p-6 text-red-300">{error}</div>
|
||||
if (!meta) return <div className="p-6 text-slate-400">Sessão não encontrada.</div>
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Link to="/sessions" className="text-sm text-slate-400 hover:underline">
|
||||
← Voltar à lista
|
||||
</Link>
|
||||
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-xl font-semibold text-white">{meta.first_prompt?.slice(0, 120) ?? meta.session_id}</h1>
|
||||
<div className="text-sm text-slate-400 flex flex-wrap gap-4">
|
||||
<span>{new Date(meta.started_at).toLocaleString('pt-PT')}</span>
|
||||
<span>Projecto: {meta.project_slug}</span>
|
||||
<span>Duração: {meta.duration_sec ?? 0}s</span>
|
||||
<span>Eventos: {meta.event_count}</span>
|
||||
<span>Tool calls: {meta.tool_calls}</span>
|
||||
<span>Skills: {meta.skills_invoked.join(', ') || '—'}</span>
|
||||
<span>Resultado: {meta.outcome}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">JSONL: {meta.jsonl_path}</div>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
{(['all', 'no-system', 'tools-only', 'prompts-only'] as FilterMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-1 rounded ${mode === m ? 'bg-indigo-500/30 text-indigo-200' : 'bg-white/5 text-slate-400'}`}
|
||||
>
|
||||
{m === 'all' ? 'Tudo' : m === 'no-system' ? 'Esconder system' : m === 'tools-only' ? 'Só tools' : 'Só prompts'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
A mostrar {visible.length} de {events.length} eventos.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{visible.map((e) => (
|
||||
<EventBlock key={e.index} event={e} defaultCollapsed={e.type === 'system' || (e.tool_result !== null && e.tool_result !== undefined)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { listSessions, type ListResponse } from '../lib/api/sessions'
|
||||
import { SessionRow } from '../components/sessions/SessionRow'
|
||||
import { FilterBar, type Filters } from '../components/sessions/FilterBar'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function Sessions() {
|
||||
const [filters, setFilters] = useState<Filters>({ days: 7, project: '', tool: '', skill: '', q: '' })
|
||||
const [data, setData] = useState<ListResponse | null>(null)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listSessions({
|
||||
days: filters.days,
|
||||
project: filters.project || undefined,
|
||||
tool: filters.tool || undefined,
|
||||
skill: filters.skill || undefined,
|
||||
q: filters.q || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setData(r)
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) setError(e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [filters, offset])
|
||||
|
||||
const { projects, tools, skills } = useMemo(() => {
|
||||
const p = new Set<string>()
|
||||
const t = new Set<string>()
|
||||
const s = new Set<string>()
|
||||
data?.items.forEach((it) => {
|
||||
p.add(it.project_slug)
|
||||
it.tools_used.forEach((x) => t.add(x))
|
||||
it.skills_invoked.forEach((x) => s.add(x))
|
||||
})
|
||||
return { projects: [...p].sort(), tools: [...t].sort(), skills: [...s].sort() }
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-white">Espelho — Sessões Claude</h1>
|
||||
<p className="text-sm text-slate-400">Replay de sessões para observar comportamento real.</p>
|
||||
</header>
|
||||
|
||||
<FilterBar
|
||||
initial={filters}
|
||||
projects={projects}
|
||||
tools={tools}
|
||||
skills={skills}
|
||||
onChange={(f) => {
|
||||
setFilters(f)
|
||||
setOffset(0)
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <div className="p-3 bg-red-500/10 border border-red-500/30 rounded text-red-300 text-sm">{error}</div>}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-white/10">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/5 text-xs uppercase text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Início</th>
|
||||
<th className="px-3 py-2 text-left">Projecto</th>
|
||||
<th className="px-3 py-2 text-left">Prompt</th>
|
||||
<th className="px-3 py-2 text-left">Duração</th>
|
||||
<th className="px-3 py-2 text-right">Eventos</th>
|
||||
<th className="px-3 py-2 text-right">Tools</th>
|
||||
<th className="px-3 py-2 text-left">Skills</th>
|
||||
<th className="px-3 py-2 text-center">OK</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-500">
|
||||
A carregar…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-500">
|
||||
Sem sessões para estes filtros.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.items.map((s) => <SessionRow key={s.session_id} session={s} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>
|
||||
{data ? `${offset + 1}–${Math.min(offset + PAGE_SIZE, data.total)} de ${data.total}` : ''}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
className="px-3 py-1 bg-white/5 rounded disabled:opacity-30"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
disabled={!data || offset + PAGE_SIZE >= data.total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
className="px-3 py-1 bg-white/5 rounded disabled:opacity-30"
|
||||
>
|
||||
Seguinte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Observabilidade (Espelho) — indexer incremental de sessões Claude
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/media/ealmeida/Dados/Dev/DashDescomplicar
|
||||
ExecStart=/home/ealmeida/.nvm/versions/node/v22.22.2/bin/npx tsx api/scripts/sessions-indexer.ts --watch
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10s
|
||||
StandardOutput=append:/home/ealmeida/.claude-work/observabilidade-indexer.log
|
||||
StandardError=append:/home/ealmeida/.claude-work/observabilidade-indexer.log
|
||||
Environment="OBSERVABILIDADE_DB=/home/ealmeida/.claude-work/sessions.db"
|
||||
Environment="PATH=/home/ealmeida/.nvm/versions/node/v22.22.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,2 @@
|
||||
MCP_GATEWAY_TOKEN=coloca-token-aqui
|
||||
MCP_GATEWAY_URL=https://gateway.descomplicar.pt/v1/desk-crm/mcp
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Observabilidade — detector semanal de padrões
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/media/ealmeida/Dados/Dev/DashDescomplicar
|
||||
ExecStart=/home/ealmeida/.nvm/versions/node/v22.22.2/bin/npx tsx api/scripts/sessions-patterns.ts --publish
|
||||
Environment="OBSERVABILIDADE_DB=/home/ealmeida/.claude-work/sessions.db"
|
||||
Environment="PATH=/home/ealmeida/.nvm/versions/node/v22.22.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
EnvironmentFile=/home/ealmeida/.claude-work/observabilidade-patterns.env
|
||||
StandardOutput=append:/home/ealmeida/.claude-work/observabilidade-patterns.log
|
||||
StandardError=append:/home/ealmeida/.claude-work/observabilidade-patterns.log
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Observabilidade — detector semanal
|
||||
|
||||
[Timer]
|
||||
OnCalendar=Sun 23:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Observabilidade — import diário de worklogs Desk (#31/#32/#33)
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/media/ealmeida/Dados/Dev/DashDescomplicar
|
||||
ExecStart=/home/ealmeida/.nvm/versions/node/v22.22.2/bin/npx tsx api/scripts/sessions-worklog-import.ts --discussion all --since-days 7
|
||||
Environment="OBSERVABILIDADE_DB=/home/ealmeida/.claude-work/sessions.db"
|
||||
Environment="PATH=/home/ealmeida/.nvm/versions/node/v22.22.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
EnvironmentFile=/home/ealmeida/.claude-work/observabilidade-patterns.env
|
||||
StandardOutput=append:/home/ealmeida/.claude-work/observabilidade-worklog-import.log
|
||||
StandardError=append:/home/ealmeida/.claude-work/observabilidade-worklog-import.log
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Observabilidade — import diário de worklogs Desk
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
OnCalendar=*-*-* 03:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Reference in New Issue
Block a user