feat(messages): extraction des messages importants APT (tâche 5 backlog)
- 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>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
api,
|
api,
|
||||||
type DockerSettingsView,
|
type DockerSettingsView,
|
||||||
type DockerStackRow,
|
type DockerStackRow,
|
||||||
|
type ImportantMessageView,
|
||||||
type MachineHardwareView,
|
type MachineHardwareView,
|
||||||
type ProbeResultView,
|
type ProbeResultView,
|
||||||
type ProfileManifestView,
|
type ProfileManifestView,
|
||||||
@@ -834,6 +835,27 @@ function fmtBytes(b: number | null): string {
|
|||||||
return `${b} o`;
|
return `${b} o`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MessagesCard({ machineId }: { machineId: string }) {
|
||||||
|
const [msgs, setMsgs] = useState<ImportantMessageView[]>([]);
|
||||||
|
const load = () => { void api.machineMessages(machineId).then(setMsgs).catch(() => setMsgs([])); };
|
||||||
|
useEffect(load, [machineId]);
|
||||||
|
if (msgs.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="machine-detail-card">
|
||||||
|
<span className="label">Messages importants ({msgs.length})</span>
|
||||||
|
{msgs.slice(0, 8).map((m) => (
|
||||||
|
<div key={m.id} className="docker-service">
|
||||||
|
<DockerBadge status={m.severity === "error" ? "error" : m.severity === "warning" ? "updates_available" : "candidate"} />
|
||||||
|
<span className="docker-service-name mono" title={m.message}>{m.message}</span>
|
||||||
|
<button className="interactive su-viewtoggle-btn" style={{ padding: "2px 7px", fontSize: 11 }}
|
||||||
|
onClick={() => api.ackMessage(machineId, m.id).then(load)}>ack</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{msgs.length > 8 && <span className="machine-placeholder">+{msgs.length - 8} autres…</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HardwareSection({ machineId }: { machineId: string }) {
|
function HardwareSection({ machineId }: { machineId: string }) {
|
||||||
const [hw, setHw] = useState<MachineHardwareView | null>(null);
|
const [hw, setHw] = useState<MachineHardwareView | null>(null);
|
||||||
const [metrics, setMetrics] = useState<MachineMetricsSimple | null>(null);
|
const [metrics, setMetrics] = useState<MachineMetricsSimple | null>(null);
|
||||||
@@ -1049,6 +1071,8 @@ export function MachineDetailPanel({
|
|||||||
<IconButton icon="cog" label="Profil & proxy (sonde)" active={false} danger={false} primary={false} onClick={() => setConfigOpen(true)} />
|
<IconButton icon="cog" label="Profil & proxy (sonde)" active={false} danger={false} primary={false} onClick={() => setConfigOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MessagesCard machineId={machine.id} />
|
||||||
|
|
||||||
<div className="machine-detail-cards">
|
<div className="machine-detail-cards">
|
||||||
<div className="machine-detail-card">
|
<div className="machine-detail-card">
|
||||||
<span className="label">System info</span>
|
<span className="label">System info</span>
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const api = {
|
|||||||
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
|
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
|
||||||
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
|
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
|
||||||
|
machineMessages: (id: string) => req<ImportantMessageView[]>(`/machines/${id}/messages`),
|
||||||
|
ackMessage: (id: string, msgId: string) => req<{ ok: boolean }>(`/machines/${id}/messages/${msgId}/ack`, { method: "POST" }),
|
||||||
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
|
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
|
||||||
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
|
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
|
||||||
analyzeRepositories: (id: string) => req<AptRepositoriesAnalysis>(`/machines/${id}/apt-repositories`, { method: "POST" }),
|
analyzeRepositories: (id: string) => req<AptRepositoriesAnalysis>(`/machines/${id}/apt-repositories`, { method: "POST" }),
|
||||||
@@ -193,6 +195,16 @@ export interface UpdateMachineBody {
|
|||||||
aptProxyUrl?: string | null;
|
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 {
|
export interface MachineHardwareView {
|
||||||
osFamily: string;
|
osFamily: string;
|
||||||
osVersion: string | null;
|
osVersion: string | null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
|
|||||||
import { runProbe } from "../services/machineProbe.js";
|
import { runProbe } from "../services/machineProbe.js";
|
||||||
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
|
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
|
||||||
import { analyzeMachineRepositories } from "../services/aptRepositories.js";
|
import { analyzeMachineRepositories } from "../services/aptRepositories.js";
|
||||||
|
import { listImportantMessages, acknowledgeMessage } from "../services/importantMessages.js";
|
||||||
|
|
||||||
export const machinesRoutes = new Hono();
|
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) => {
|
machinesRoutes.get("/:id/hardware", (c) => {
|
||||||
try {
|
try {
|
||||||
return c.json(getMachineHardware(c.req.param("id")));
|
return c.json(getMachineHardware(c.req.param("id")));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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://<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();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { runScriptSudo } from "../ssh/client.js";
|
|||||||
import { buildAptSnapshotDetail } from "./aptParse.js";
|
import { buildAptSnapshotDetail } from "./aptParse.js";
|
||||||
import { outputHub } from "../ws/outputHub.js";
|
import { outputHub } from "../ws/outputHub.js";
|
||||||
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
|
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
|
||||||
|
import { extractImportantMessages, recordImportantMessages } from "./importantMessages.js";
|
||||||
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
|
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
|
||||||
|
|
||||||
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
|
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
|
||||||
@@ -82,6 +83,7 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
|
|||||||
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
|
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
|
||||||
|
|
||||||
upsertMachineState(machineId, deriveAptState(snapshot));
|
upsertMachineState(machineId, deriveAptState(snapshot));
|
||||||
|
recordImportantMessages(machineId, extractImportantMessages(raw, "apt"), { snapshotId });
|
||||||
recordEvent({
|
recordEvent({
|
||||||
machineId,
|
machineId,
|
||||||
eventType: "apt_refresh",
|
eventType: "apt_refresh",
|
||||||
|
|||||||
Reference in New Issue
Block a user