3 Commits

Author SHA1 Message Date
ealmeida 94db202de9 fix(monitoring): SSH ao EasyPanel em vez de API inexistente
- server-metrics: substituir CWP (só aceita ed25519) por Easy server
  (aceita password auth na porta 22)
- monitoring-collector: remover chamadas a monitor.getSystemStats e
  monitor.getDockerTaskStats (endpoint não existe nesta versão EasyPanel);
  métricas CPU/RAM via SSH e containers via docker service ls sobre SSH

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:03:44 +01:00
ealmeida 3c85d03e70 fix: excluir interrupções longas da taxa de erro de skills
Sessões com outcome=interrupted e ≥10 eventos são redirects naturais
do utilizador, não falhas da skill. O detector contava todas as
interrupções como falhas, gerando falsos positivos para skills
conversacionais como superpowers:brainstorming.

Fix: só contar como falha erros reais (outcome=error) ou interrupções
precoces (<10 eventos).

Resolve ticket #10407.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:15:09 +01:00
5 changed files with 79 additions and 92 deletions
+44 -62
View File
@@ -19,14 +19,6 @@ interface CheckResult {
error?: string error?: string
} }
/**
* EasyPanel API config.
* Accessible from Docker Swarm via service name 'easypanel'.
* Token read from EASYPANEL_API_TOKEN env var.
*/
const EASYPANEL_API_URL = process.env.EASYPANEL_API_URL || 'http://easypanel:3000/api/trpc'
const EASYPANEL_API_TOKEN = process.env.EASYPANEL_API_TOKEN || ''
/** /**
* Services to monitor via HTTP health check. * Services to monitor via HTTP health check.
* Each entry maps to a record in tbl_eal_monitoring (category='service'). * Each entry maps to a record in tbl_eal_monitoring (category='service').
@@ -163,71 +155,57 @@ export async function checkStaleness(): Promise<number> {
} }
/** /**
* Call EasyPanel tRPC API endpoint. * Collect EasyPanel server metrics + container stats via SSH.
* Returns parsed JSON or null on failure. * A API tRPC do EasyPanel não expõe endpoint monitor.* nesta versão.
*/ * SSH com password ao Easy server (5.9.90.70) funciona a partir do container.
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> { export async function collectEasyPanelMetrics(): Promise<boolean> {
const stats = await callEasyPanelAPI('monitor.getSystemStats') const { collectSSHMetrics } = await import('./server-metrics.js')
if (!stats) return false const result = await collectSSHMetrics()
return result.success > 0
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. * Collect Docker Swarm service status via SSH to EasyPanel server.
* Updates the 'container' category in monitoring DB. * Usa `docker service ls` para obter replicas actual vs desired.
*/ */
export async function collectEasyPanelContainers(): Promise<boolean> { export async function collectEasyPanelContainers(): Promise<boolean> {
const tasks = await callEasyPanelAPI('monitor.getDockerTaskStats') const easyHost = process.env.EASY_HOST || '5.9.90.70'
if (!tasks) return false const easyUser = process.env.EASY_USER || 'root'
const easyPass = process.env.EASY_PASS || ''
if (!easyPass) return false
try {
const { Client } = await import('ssh2')
const output = await new Promise<string>((resolve, reject) => {
const conn = new Client()
let data = ''
const timer = setTimeout(() => { conn.end(); reject(new Error('timeout')) }, 20000)
conn.on('ready', () => {
conn.exec("docker service ls --format '{{.Name}} {{.Replicas}}'", (err, stream) => {
if (err) { clearTimeout(timer); conn.end(); reject(err); return }
stream.on('data', (chunk: Buffer) => { data += chunk.toString() })
stream.on('close', () => { clearTimeout(timer); conn.end(); resolve(data) })
stream.stderr.on('data', () => {})
})
})
conn.on('error', (err) => { clearTimeout(timer); reject(err) })
conn.connect({ host: easyHost, port: 22, username: easyUser, password: easyPass, readyTimeout: 15000 })
})
let total = 0, up = 0, down = 0 let total = 0, up = 0, down = 0
const unhealthy: string[] = [] const unhealthy: string[] = []
for (const [name, info] of Object.entries(tasks) as [string, { actual: number; desired: number }][]) { for (const line of output.trim().split('\n')) {
if (!line.trim()) continue
const parts = line.trim().split(/\s+/)
const name = parts[0] || ''
const replicas = parts[1] || '0/0'
const [actual, desired] = replicas.split('/').map(Number)
total++ total++
if (info.actual >= info.desired) { if (actual >= desired && desired > 0) {
up++ up++
} else { } else {
down++ down++
@@ -243,6 +221,10 @@ export async function collectEasyPanelContainers(): Promise<boolean> {
console.log(`[EASYPANEL] Containers: ${up}/${total} running${down > 0 ? `, ${down} down: ${unhealthy.join(', ')}` : ''}`) console.log(`[EASYPANEL] Containers: ${up}/${total} running${down > 0 ? `, ${down} down: ${unhealthy.join(', ')}` : ''}`)
return true return true
} catch (err: unknown) {
console.error('[EASYPANEL] Container collection failed:', err instanceof Error ? err.message : err)
return false
}
} }
/** /**
@@ -264,7 +246,7 @@ export async function collectMonitoringData(): Promise<void> {
const gotStats = await collectEasyPanelMetrics() const gotStats = await collectEasyPanelMetrics()
const gotContainers = await collectEasyPanelContainers() const gotContainers = await collectEasyPanelContainers()
if (!gotStats && !gotContainers) { if (!gotStats && !gotContainers) {
console.warn('[COLLECTOR] EasyPanel API unavailable (check EASYPANEL_API_TOKEN)') console.warn('[COLLECTOR] EasyPanel metrics unavailable (check EASY_HOST/EASY_USER/EASY_PASS)')
} }
} catch (err: unknown) { } catch (err: unknown) {
console.error('[COLLECTOR] EasyPanel collection failed:', err instanceof Error ? err.message : err) console.error('[COLLECTOR] EasyPanel collection failed:', err instanceof Error ? err.message : err)
+9 -9
View File
@@ -15,17 +15,17 @@ interface SSHServer {
pass: string pass: string
} }
// EasyPanel metrics: collected via API in monitoring-collector.ts // CWP Server: só aceita autenticação por chave ed25519 (não por password) — não acessível a partir do container
// Gateway metrics: not needed (just Nginx proxy, covered by HTTP health check) // EasyPanel Server: aceita password auth na porta 22 — usado para métricas CPU/RAM/disk
// Only CWP Server remains on SSH (password auth) // Gateway: apenas proxy Nginx, coberto pelo health check HTTP
const SSH_SERVERS: SSHServer[] = [ const SSH_SERVERS: SSHServer[] = [
{ {
name: 'server', name: 'easy',
monitorName: 'CWP Server', monitorName: 'EasyPanel',
host: process.env.SERVER_HOST || '5.9.90.105', host: process.env.EASY_HOST || '5.9.90.70',
port: parseInt(process.env.SERVER_PORT || '9443'), port: 22,
user: process.env.SERVER_USER || 'root', user: process.env.EASY_USER || 'root',
pass: process.env.SERVER_PASS || '' pass: process.env.EASY_PASS || ''
} }
] ]
+5 -1
View File
@@ -92,7 +92,11 @@ export function detectSkillsHighErrorRate(ctx: DetectCtx): Pattern[] {
for (const sk of skills) { for (const sk of skills) {
const entry = bySkill.get(sk) ?? { total: 0, fail: 0, ids: [] } const entry = bySkill.get(sk) ?? { total: 0, fail: 0, ids: [] }
entry.total++ entry.total++
if (r.outcome === 'error' || r.outcome === 'interrupted') { // Interrupções em sessões longas (≥10 eventos) são redirects naturais do utilizador,
// não falhas da skill. Só contar erros reais ou interrupções muito precoces.
const isRealFailure = r.outcome === 'error' ||
(r.outcome === 'interrupted' && (r.event_count ?? 0) < 10)
if (isRealFailure) {
entry.fail++ entry.fail++
if (entry.ids.length < 5) entry.ids.push(r.session_id) if (entry.ids.length < 5) entry.ids.push(r.session_id)
} }
+3 -3
View File
@@ -7967,9 +7967,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+2 -1
View File
@@ -5,8 +5,9 @@ export const oidcConfig = {
client_id: 'OKRSM2FZeSxJDhoV9e17dGRU1L1NEE1JBdnPVWTO', client_id: 'OKRSM2FZeSxJDhoV9e17dGRU1L1NEE1JBdnPVWTO',
redirect_uri: window.location.origin + '/callback', redirect_uri: window.location.origin + '/callback',
post_logout_redirect_uri: window.location.origin, post_logout_redirect_uri: window.location.origin,
scope: 'openid profile email', scope: 'openid profile email offline_access',
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
onSigninCallback: () => { onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
}, },