// server/services/importantMessages.ts import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { db, schema } from "../db/client.js"; export type MessageSource = "apt" | "docker" | "post_install" | "ssh" | "system"; export type MessageCategory = "error" | "warning" | "future_major_change" | "security"; export interface ExtractedMessage { source: MessageSource; category: MessageCategory; severity: "error" | "warning" | "info"; message: string; packageName?: string | null; } /** Masque les identifiants éventuels dans une ligne (URLs user:pass@, tokens). */ function clean(line: string): string { return line .replace(/https?:\/\/[^/@\s]+:[^/@\s]+@/gi, "https://@") .replace(/\b(token|bearer|password|secret|key)=\S+/gi, "$1=") .trim(); } const DEPRECATION = /\b(deprecat|will be removed|no longer supported|end of life|end-of-life|\bEOL\b|obsolete)\b/i; const GPG = /\b(GPG error|NO_PUBKEY|KEYEXPIRED|EXPKEYSIG|not signed)\b/i; /** Extrait les messages importants (erreurs/warnings/évolutions) d'une sortie brute. */ export function extractImportantMessages(raw: string, source: MessageSource): ExtractedMessage[] { const out: ExtractedMessage[] = []; const seen = new Set(); for (const line of raw.split("\n")) { const t = line.trim(); if (!t) continue; let category: MessageCategory | null = null; let severity: ExtractedMessage["severity"] = "warning"; if (/^E:/.test(t) || /dpkg:\s*error/i.test(t) || /unmet dependencies|unable to correct problems/i.test(t)) { category = "error"; severity = "error"; } else if (DEPRECATION.test(t)) { category = "future_major_change"; severity = "warning"; } else if (/^W:/.test(t) || GPG.test(t)) { category = "warning"; severity = "warning"; } else if (source === "docker" && /\b(error|unauthorized|denied|failed)\b/i.test(t)) { category = "error"; severity = "error"; } else { continue; } const message = clean(t); if (!message || seen.has(message)) continue; seen.add(message); out.push({ source, category, severity, message }); } return out; } /** Persiste les messages (dédup par machine+source+message non acquitté → maj lastSeen). */ export function recordImportantMessages( machineId: string, messages: ExtractedMessage[], refs: { snapshotId?: string | null; executionId?: string | null } = {}, ): void { const now = new Date().toISOString(); for (const m of messages) { const existing = db .select() .from(schema.importantMessages) .where( and( eq(schema.importantMessages.machineId, machineId), eq(schema.importantMessages.source, m.source), eq(schema.importantMessages.message, m.message), eq(schema.importantMessages.acknowledged, 0), ), ) .get(); if (existing) { db.update(schema.importantMessages).set({ lastSeenAt: now }).where(eq(schema.importantMessages.id, existing.id)).run(); } else { db.insert(schema.importantMessages).values({ id: randomUUID(), machineId, source: m.source, category: m.category, severity: m.severity, packageName: m.packageName ?? null, message: m.message, snapshotId: refs.snapshotId ?? null, executionId: refs.executionId ?? null, firstSeenAt: now, lastSeenAt: now, acknowledged: 0, }).run(); } } } export function listImportantMessages(machineId: string, includeAck = false) { const rows = db .select() .from(schema.importantMessages) .where(eq(schema.importantMessages.machineId, machineId)) .all(); return rows .filter((r) => includeAck || !r.acknowledged) .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)); } export function acknowledgeMessage(id: string, by = "ui") { db.update(schema.importantMessages) .set({ acknowledged: 1, acknowledgedAt: new Date().toISOString(), acknowledgedBy: by }) .where(eq(schema.importantMessages.id, id)) .run(); }