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:
2026-06-06 19:30:07 +02:00
parent ff9cfaa9e1
commit a93a43e1c8
6 changed files with 208 additions and 0 deletions
@@ -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<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 }) {
const [hw, setHw] = useState<MachineHardwareView | 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)} />
</div>
<MessagesCard machineId={machine.id} />
<div className="machine-detail-cards">
<div className="machine-detail-card">
<span className="label">System info</span>