a93a43e1c8
- extractImportantMessages (TDD) : E:/dpkg error → error, W:/GPG → warning, déprecations/EOL → future_major_change ; nettoyage des secrets dans les URLs - recordImportantMessages : dédup par (machine, source, message) non acquitté → maj lastSeenAt, sinon insert (firstSeen/lastSeen) dans important_messages - branché dans refreshMachine (sortie APT) avec snapshotId - routes GET /machines/:id/messages + POST .../:msgId/ack - UI : carte « Messages importants » (badge sévérité + ack) dans le panneau détail tsc 0 · 118 tests · build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
4.0 KiB
TypeScript
121 lines
4.0 KiB
TypeScript
// 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://<redacted>@")
|
|
.replace(/\b(token|bearer|password|secret|key)=\S+/gi, "$1=<redacted>")
|
|
.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<string>();
|
|
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();
|
|
}
|