feat: container monitoring frontend UI (PR-2)
- Custom ToastContext + ToastProvider + ToastContainer (~170 lines, no deps) - useEvents() SSE hook with exponential backoff reconnect - EventProvider context for app-wide SSE stream sharing - Event-to-toast bridge with severity mapping and deduplication - Real-time status badge updates replacing 30s polling - EventSource auth probe (401/429 detection via fetch) - 14 frontend tests (useEvents + toast-rules) Quality gates: vitest 14 passed, tsc clean, eslint clean
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { handleEventToast, clearToastDedup } from "./toast-rules";
|
||||
import type { InstanceEventPayload } from "../types/events";
|
||||
|
||||
const mockToastInfo = vi.fn();
|
||||
const mockToastSuccess = vi.fn();
|
||||
const mockToastWarning = vi.fn();
|
||||
const mockToastError = vi.fn();
|
||||
|
||||
vi.mock("../state/toast", () => ({
|
||||
toast: {
|
||||
info: (...args: unknown[]) => mockToastInfo(...args),
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
warning: (...args: unknown[]) => mockToastWarning(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("toast-rules", () => {
|
||||
beforeEach(() => {
|
||||
clearToastDedup();
|
||||
mockToastInfo.mockClear();
|
||||
mockToastSuccess.mockClear();
|
||||
mockToastWarning.mockClear();
|
||||
mockToastError.mockClear();
|
||||
});
|
||||
|
||||
it("maps instance.started to info toast", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.started",
|
||||
instance_id: "inst-1",
|
||||
status: "starting",
|
||||
message: "Container starting...",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastInfo).toHaveBeenCalledWith("Container starting...", {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps health_changed to running to success toast", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.health_changed",
|
||||
instance_id: "inst-1",
|
||||
status: "running",
|
||||
message: "Container is running",
|
||||
metadata: { previous_status: "starting" },
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("Container running", {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps health_changed to unhealthy to warning toast", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.health_changed",
|
||||
instance_id: "inst-1",
|
||||
status: "unhealthy",
|
||||
message: "Container is unhealthy",
|
||||
metadata: { previous_status: "running" },
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastWarning).toHaveBeenCalledWith("Container unhealthy", {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps instance.error to error toast with exit code", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.error",
|
||||
instance_id: "inst-1",
|
||||
status: "error",
|
||||
message: "Container crashed",
|
||||
metadata: { exit_code: 137 },
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"Container crashed (exit code: 137)",
|
||||
{ duration: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("maps instance.error to error toast without exit code", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.error",
|
||||
instance_id: "inst-1",
|
||||
status: "error",
|
||||
message: "Build failed",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
expect(mockToastError).toHaveBeenCalledWith("Build failed", {
|
||||
duration: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates within one second", () => {
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.started",
|
||||
instance_id: "inst-1",
|
||||
status: "starting",
|
||||
message: "Container starting...",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
handleEventToast(event);
|
||||
expect(mockToastInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows duplicate after one second", () => {
|
||||
vi.useFakeTimers();
|
||||
const event: InstanceEventPayload = {
|
||||
event: "instance.started",
|
||||
instance_id: "inst-1",
|
||||
status: "starting",
|
||||
message: "Container starting...",
|
||||
metadata: {},
|
||||
timestamp: "2026-05-28T12:00:00Z",
|
||||
correlation_id: "corr-1",
|
||||
};
|
||||
|
||||
handleEventToast(event);
|
||||
vi.advanceTimersByTime(1100);
|
||||
handleEventToast(event);
|
||||
expect(mockToastInfo).toHaveBeenCalledTimes(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user