diff --git a/client/src/features/machines/MachineTile.tsx b/client/src/features/machines/MachineTile.tsx index d44f675..601f48a 100644 --- a/client/src/features/machines/MachineTile.tsx +++ b/client/src/features/machines/MachineTile.tsx @@ -6,6 +6,7 @@ import { api, type DockerSettingsView, type DockerStackRow, + type ImportantMessageView, type MachineHardwareView, type ProbeResultView, type ProfileManifestView, @@ -834,6 +835,27 @@ function fmtBytes(b: number | null): string { return `${b} o`; } +function MessagesCard({ machineId }: { machineId: string }) { + const [msgs, setMsgs] = useState([]); + const load = () => { void api.machineMessages(machineId).then(setMsgs).catch(() => setMsgs([])); }; + useEffect(load, [machineId]); + if (msgs.length === 0) return null; + return ( +
+ Messages importants ({msgs.length}) + {msgs.slice(0, 8).map((m) => ( +
+ + {m.message} + +
+ ))} + {msgs.length > 8 && +{msgs.length - 8} autres…} +
+ ); +} + function HardwareSection({ machineId }: { machineId: string }) { const [hw, setHw] = useState(null); const [metrics, setMetrics] = useState(null); @@ -1049,6 +1071,8 @@ export function MachineDetailPanel({ setConfigOpen(true)} /> + +
System info diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 6cc17e8..24ebd16 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -67,6 +67,8 @@ export const api = { req(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }), probe: (id: string) => req(`/machines/${id}/probe`, { method: "POST" }), machineHardware: (id: string) => req(`/machines/${id}/hardware`), + machineMessages: (id: string) => req(`/machines/${id}/messages`), + ackMessage: (id: string, msgId: string) => req<{ ok: boolean }>(`/machines/${id}/messages/${msgId}/ack`, { method: "POST" }), latestMetrics: (id: string) => req(`/machines/${id}/metrics`), collectMetrics: (id: string) => req(`/machines/${id}/metrics/collect`, { method: "POST" }), analyzeRepositories: (id: string) => req(`/machines/${id}/apt-repositories`, { method: "POST" }), @@ -193,6 +195,16 @@ export interface UpdateMachineBody { aptProxyUrl?: string | null; } +export interface ImportantMessageView { + id: string; + source: string; + category: string; + severity: "error" | "warning" | "info"; + message: string; + packageName: string | null; + lastSeenAt: string; +} + export interface MachineHardwareView { osFamily: string; osVersion: string | null; diff --git a/server/routes/machines.ts b/server/routes/machines.ts index 4644d9e..8f897d2 100644 --- a/server/routes/machines.ts +++ b/server/routes/machines.ts @@ -9,6 +9,7 @@ import { refreshMachine, getLatestSnapshot } from "../services/refresh.js"; import { runProbe } from "../services/machineProbe.js"; import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js"; import { analyzeMachineRepositories } from "../services/aptRepositories.js"; +import { listImportantMessages, acknowledgeMessage } from "../services/importantMessages.js"; export const machinesRoutes = new Hono(); @@ -77,6 +78,13 @@ machinesRoutes.post("/:id/apt-repositories", async (c) => { } }); +// Messages importants (warnings/erreurs/évolutions) extraits des sorties. +machinesRoutes.get("/:id/messages", (c) => c.json(listImportantMessages(c.req.param("id")))); +machinesRoutes.post("/:id/messages/:msgId/ack", (c) => { + acknowledgeMessage(c.req.param("msgId")); + return c.json({ ok: true }); +}); + machinesRoutes.get("/:id/hardware", (c) => { try { return c.json(getMachineHardware(c.req.param("id"))); diff --git a/server/services/importantMessages.test.ts b/server/services/importantMessages.test.ts new file mode 100644 index 0000000..0205a7d --- /dev/null +++ b/server/services/importantMessages.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { extractImportantMessages } from "./importantMessages.js"; + +describe("extractImportantMessages", () => { + it("classe les erreurs APT (E:, dpkg) en error", () => { + const raw = [ + "E: Unable to locate package toto", + "dpkg: error processing package nginx (--configure):", + "Inst libc6 [2.36] (2.37 Debian:13)", + ].join("\n"); + const msgs = extractImportantMessages(raw, "apt"); + expect(msgs.filter((m) => m.severity === "error").length).toBe(2); + expect(msgs.every((m) => m.category === "error")).toBe(true); + }); + + it("classe W: et erreurs GPG en warning", () => { + const raw = [ + "W: GPG error: http://deb.debian.org ... NO_PUBKEY 1234ABCD", + "W: Target Packages is configured multiple times", + ].join("\n"); + const msgs = extractImportantMessages(raw, "apt"); + expect(msgs.length).toBe(2); + expect(msgs.every((m) => m.severity === "warning")).toBe(true); + }); + + it("détecte les annonces de dépréciation / changement majeur", () => { + const raw = "Note: package foo is deprecated and will be removed in the next release"; + const msgs = extractImportantMessages(raw, "apt"); + expect(msgs.some((m) => m.category === "future_major_change")).toBe(true); + }); + + it("nettoie les secrets éventuels dans les URLs", () => { + const raw = "E: Failed to fetch https://user:pass@repo.example/x"; + const msgs = extractImportantMessages(raw, "apt"); + expect(msgs[0]!.message).not.toContain("user:pass"); + }); + + it("ignore les lignes normales", () => { + const raw = "Reading package lists...\nBuilding dependency tree..."; + expect(extractImportantMessages(raw, "apt")).toHaveLength(0); + }); +}); diff --git a/server/services/importantMessages.ts b/server/services/importantMessages.ts new file mode 100644 index 0000000..c7cdbc3 --- /dev/null +++ b/server/services/importantMessages.ts @@ -0,0 +1,120 @@ +// 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(); +} diff --git a/server/services/refresh.ts b/server/services/refresh.ts index 0624725..a4320a8 100644 --- a/server/services/refresh.ts +++ b/server/services/refresh.ts @@ -9,6 +9,7 @@ import { runScriptSudo } from "../ssh/client.js"; import { buildAptSnapshotDetail } from "./aptParse.js"; import { outputHub } from "../ws/outputHub.js"; import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js"; +import { extractImportantMessages, recordImportantMessages } from "./importantMessages.js"; import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js"; /** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */ @@ -82,6 +83,7 @@ export async function refreshMachine(machineId: string): Promise db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run(); upsertMachineState(machineId, deriveAptState(snapshot)); + recordImportantMessages(machineId, extractImportantMessages(raw, "apt"), { snapshotId }); recordEvent({ machineId, eventType: "apt_refresh",