Compare commits
4 Commits
c794e1b6d6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94db202de9 | |||
| a594df1c7c | |||
| 3c85d03e70 | |||
| 3887547f1c |
@@ -24,6 +24,7 @@
|
|||||||
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
|
* 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)
|
* 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 { openSessionsDb, type PatternRecord, type SessionsDb } from '../services/sessions/db.js'
|
||||||
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +35,42 @@ import {
|
|||||||
type Pattern,
|
type Pattern,
|
||||||
} from '../services/sessions/patterns.js'
|
} 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 {
|
interface Args {
|
||||||
week?: string
|
week?: string
|
||||||
publish: boolean
|
publish: boolean
|
||||||
@@ -344,6 +381,8 @@ async function main(): Promise<void> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[patterns] falha ao criar ticket para ${rec.pattern_key}:`, (e as Error).message)
|
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) {
|
} catch (e) {
|
||||||
@@ -360,6 +399,7 @@ async function main(): Promise<void> {
|
|||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
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) Ticket seria criado: ${rec.title} — ${rec.consecutive_weeks} sem.`)
|
||||||
|
console.error(`[patterns] (dry-run) CARL staging seria proposto: pattern-${rec.pattern_key}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-3
@@ -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
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user