feat(events): timeline d'événements machine (tâche 5 backlog)

- listMachineEvents (machine_events, 30 derniers, desc) + route GET /machines/:id/events
- api machineEvents ; section repliable « Timeline » dans le panneau détail
  (badge sévérité + horodatage), exploite les events déjà enregistrés par recordEvent

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:36:06 +02:00
parent a93a43e1c8
commit fa73ab07b0
4 changed files with 49 additions and 0 deletions
@@ -7,6 +7,7 @@ import {
type DockerSettingsView,
type DockerStackRow,
type ImportantMessageView,
type MachineEventView,
type MachineHardwareView,
type ProbeResultView,
type ProfileManifestView,
@@ -835,6 +836,26 @@ function fmtBytes(b: number | null): string {
return `${b} o`;
}
function TimelineSection({ machineId }: { machineId: string }) {
const [events, setEvents] = useState<MachineEventView[] | null>(null);
useEffect(() => {
void api.machineEvents(machineId).then(setEvents).catch(() => setEvents([]));
}, [machineId]);
if (events === null) return <div className="machine-section-body"><span className="machine-placeholder">Chargement</span></div>;
if (events.length === 0) return <div className="machine-section-body"><span className="machine-placeholder">Aucun événement.</span></div>;
return (
<div className="machine-section-body">
{events.map((e) => (
<div key={e.id} className="docker-service">
<DockerBadge status={e.severity === "error" ? "error" : e.severity === "warning" ? "updates_available" : "candidate"} />
<span className="docker-service-name">{e.message ?? e.eventType}</span>
<span className="docker-service-diff mono">{new Date(e.createdAt).toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}</span>
</div>
))}
</div>
);
}
function MessagesCard({ machineId }: { machineId: string }) {
const [msgs, setMsgs] = useState<ImportantMessageView[]>([]);
const load = () => { void api.machineMessages(machineId).then(setMsgs).catch(() => setMsgs([])); };
@@ -1047,6 +1068,7 @@ export function MachineDetailPanel({
const [dockerOpen, setDockerOpen] = useState(true);
const [postOpen, setPostOpen] = useState(true);
const [hwOpen, setHwOpen] = useState(true);
const [tlOpen, setTlOpen] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const isError = machine.status === "error" || machine.status === "unknown";
@@ -1099,6 +1121,8 @@ export function MachineDetailPanel({
{postOpen && <PostInstallSection machine={machine} onSelect={onSelect} />}
<SectionToggle icon="cpu" title="Hardware" open={hwOpen} onToggle={() => setHwOpen((v) => !v)} />
{hwOpen && <HardwareSection machineId={machine.id} />}
<SectionToggle icon="clock" title="Timeline" open={tlOpen} onToggle={() => setTlOpen((v) => !v)} />
{tlOpen && <TimelineSection machineId={machine.id} />}
</div>
{configOpen && (
+9
View File
@@ -68,6 +68,7 @@ export const api = {
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
machineMessages: (id: string) => req<ImportantMessageView[]>(`/machines/${id}/messages`),
machineEvents: (id: string) => req<MachineEventView[]>(`/machines/${id}/events`),
ackMessage: (id: string, msgId: string) => req<{ ok: boolean }>(`/machines/${id}/messages/${msgId}/ack`, { method: "POST" }),
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
@@ -195,6 +196,14 @@ export interface UpdateMachineBody {
aptProxyUrl?: string | null;
}
export interface MachineEventView {
id: string;
eventType: string;
severity: "info" | "warning" | "error";
createdAt: string;
message: string | null;
}
export interface ImportantMessageView {
id: string;
source: string;
+4
View File
@@ -10,6 +10,7 @@ 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";
import { listMachineEvents } from "../services/machineState.js";
export const machinesRoutes = new Hono();
@@ -78,6 +79,9 @@ machinesRoutes.post("/:id/apt-repositories", async (c) => {
}
});
// Timeline d'événements machine.
machinesRoutes.get("/:id/events", (c) => c.json(listMachineEvents(c.req.param("id"))));
// 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) => {
+12
View File
@@ -1,8 +1,20 @@
// server/services/machineState.ts
import { randomUUID } from "node:crypto";
import { desc, eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import type { UpdateSnapshot } from "@shared/types.js";
/** Derniers événements d'une machine (timeline), du plus récent au plus ancien. */
export function listMachineEvents(machineId: string, limit = 30) {
return db
.select()
.from(schema.machineEvents)
.where(eq(schema.machineEvents.machineId, machineId))
.orderBy(desc(schema.machineEvents.createdAt))
.limit(limit)
.all();
}
export interface AptDerivedState {
status: string;
aptStatus: string;