feat(workbench): expand SCADA monitoring and investigation presentation

This commit is contained in:
2026-11-07 19:50:14 +08:00
parent 0801e161ec
commit ba71e2d529
133 changed files with 6754 additions and 1438 deletions
@@ -153,7 +153,7 @@ describe("live investigation presentation following", () => {
test.channel.publish({ type: "events", events: [event(12, openChart), event(20, openChart), event(21, closeChart)] });
oldDisplay.resolve();
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["highlight_features", "close_chart"]));
expect(test.clear.mock.calls.length).toBeGreaterThan(clearCount);
expect(test.clear.mock.calls.length).toBe(clearCount);
expect(execute.mock.calls[1]?.[1].aborted).toBe(false);
} finally { oldDisplay.resolve(); test.follower.stop(); }
});
@@ -199,3 +199,51 @@ it("reads saved history even before the live event stream opens", async () => {
expect(test.execute).not.toHaveBeenCalled();
} finally { test.follower.stop(); }
});
it("keeps successful results across disconnect and failed reconnect, clearing only for a new identity", async () => {
let visible = "saved chart";
const clear = vi.fn(() => { visible = ""; });
const fetchCursor = vi.fn(async () => cursor(10));
const test = setup({ clear, fetchCursor });
test.follower.start(); test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
visible = "saved chart";
clear.mockClear();
test.channel.publish({ type: "disconnect" });
expect(visible).toBe("saved chart");
expect(clear).not.toHaveBeenCalled();
fetchCursor.mockRejectedValueOnce(new Error("offline"));
test.channel.publish({ type: "open" });
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("error", "offline"));
expect(visible).toBe("saved chart");
fetchCursor.mockResolvedValueOnce(cursor(20, 2));
test.channel.publish({ type: "open" });
await vi.waitFor(() => expect(clear).toHaveBeenCalledTimes(1));
expect(visible).toBe("");
} finally { test.follower.stop(); }
});
it("clears a changed generation before hydrating its saved results and ignores a stale connection", async () => {
const stale = deferred<InvestigationPresentationCursor>();
const order: string[] = [];
const fetchCursor = vi.fn(async () => cursor(10))
.mockResolvedValueOnce(cursor(10))
.mockImplementationOnce(() => stale.promise)
.mockResolvedValueOnce(cursor(30, 2));
const test = setup({ fetchCursor, clear: () => order.push("clear"), loadHistory: async (next) => { order.push(`history:${next.execution_generation}`); } });
test.follower.start();
try {
await vi.waitFor(() => expect(order).toContain("history:1"));
order.length = 0;
test.channel.publish({ type: "open" });
test.channel.publish({ type: "disconnect" });
test.channel.publish({ type: "open" });
await vi.waitFor(() => expect(order).toContain("history:2"));
expect(order).toEqual(["clear", "history:2"]);
stale.resolve(cursor(20));
await Promise.resolve();
expect(order).toEqual(["clear", "history:2"]);
} finally { stale.resolve(cursor(20)); test.follower.stop(); }
});
@@ -156,12 +156,16 @@ export class InvestigationPresentationFollower {
const cursor = await this.options.fetchCursor(controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
if (cursor.task_id !== this.options.taskId) throw new Error("调查游标与所选任务不一致");
if (this.identity && cursor.execution_generation < this.identity.execution_generation)
throw new Error("调查游标已过期,等待重新同步");
// Clear a changed identity before loading its replacement, never after hydration.
// Leave the baseline unbound until history is ready so live events stay buffered.
this.updateIdentity(cursor);
await this.options.loadHistory?.(cursor, controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
this.baseline = cursor.event_cursor;
this.lastSeen = cursor.event_cursor;
this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor);
await this.options.loadHistory?.(cursor, controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
this.updateIdentity(cursor);
this.options.onStatus(
this.streamConnected && this.options.follow !== false ? "live" : "history"
);
@@ -180,12 +184,13 @@ export class InvestigationPresentationFollower {
this.binding = null;
this.work.abort();
this.work = new AbortController();
this.identity = null;
// Keep the last verified identity and successful view through transport failures.
// The owning hook clears task/project changes; updateIdentity handles new runs.
this.baseline = null;
this.lastSeen = 0;
this.lookupKey = null;
this.pending = [];
this.options.clear();
this.options.stopPulse();
this.options.onStatus("disconnected");
}
@@ -0,0 +1,43 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { useDismissedWorkbenchAlerts } from "./use-dismissed-workbench-alerts";
import type { WorkbenchAlert } from "../types";
const alert: WorkbenchAlert = { id: "alert-1", title: "压力异常", description: "等待核查", time: "08:00" };
beforeEach(() => localStorage.clear());
it("keeps removed alerts hidden after refresh while new notifications still appear", () => {
const { result, rerender, unmount } = renderHook(({ alerts }) => useDismissedWorkbenchAlerts(alerts, "project-1", "operator"),
{ initialProps: { alerts: [alert] } });
act(() => { expect(result.current.dismissAlert(alert)).toBe(true); });
expect(result.current.alerts).toEqual([]);
rerender({ alerts: [{ ...alert }, { ...alert, id: "alert-2" }] });
expect(result.current.alerts.map((item) => item.id)).toEqual(["alert-2"]);
unmount();
const restored = renderHook(() => useDismissedWorkbenchAlerts([alert], "project-1", "operator"));
expect(restored.result.current.alerts).toEqual([]);
});
it("isolates users and projects and shows a new notification version", () => {
const item = { ...alert, notificationVersion: 1 };
const { result, rerender } = renderHook(({ project, user, version }) =>
useDismissedWorkbenchAlerts([{ ...item, notificationVersion: version }], project, user),
{ initialProps: { project: "project-1", user: "operator", version: 1 } });
act(() => { result.current.dismissAlert(item); });
rerender({ project: "project-2", user: "operator", version: 1 });
expect(result.current.alerts).toHaveLength(1);
rerender({ project: "project-1", user: "viewer", version: 1 });
expect(result.current.alerts).toHaveLength(1);
rerender({ project: "project-1", user: "operator", version: 1 });
expect(result.current.alerts).toEqual([]);
rerender({ project: "project-1", user: "operator", version: 2 });
expect(result.current.alerts).toHaveLength(1);
});
it("preserves the row when local storage cannot save the removal", () => {
const { result } = renderHook(() => useDismissedWorkbenchAlerts([alert], "project-1", "operator"));
const spy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage unavailable"); });
act(() => { expect(result.current.dismissAlert(alert)).toBe(false); });
expect(result.current.alerts).toEqual([alert]);
spy.mockRestore();
});
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
import type { WorkbenchAlert } from "../types";
function readDismissed(key: string): string[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
}
function alertKey(alert: WorkbenchAlert): string {
return JSON.stringify([alert.id, alert.notificationVersion ?? null]);
}
export function useDismissedWorkbenchAlerts(
alerts: WorkbenchAlert[], projectId: string | null, username: string
) {
const storageKey = `tjwater.dismissed-alerts@1:${JSON.stringify([projectId, username])}`;
const [stored, setStored] = useState(() => ({ key: storageKey, ids: readDismissed(storageKey) }));
// Switch scopes synchronously so another user's or project's rows never flash hidden.
if (stored.key !== storageKey) {
setStored({ key: storageKey, ids: readDismissed(storageKey) });
}
useEffect(() => {
const sync = (event: StorageEvent) => {
if (event.storageArea === localStorage && (event.key === storageKey || event.key === null)) {
setStored({ key: storageKey, ids: readDismissed(storageKey) });
}
};
window.addEventListener("storage", sync);
return () => window.removeEventListener("storage", sync);
}, [storageKey]);
const dismissAlert = useCallback((alert: WorkbenchAlert) => {
if (!projectId) return false;
const ids = [...new Set([...readDismissed(storageKey), alertKey(alert)])];
try {
localStorage.setItem(storageKey, JSON.stringify(ids));
} catch {
return false;
}
setStored({ key: storageKey, ids });
return true;
}, [projectId, storageKey]);
const dismissed = new Set(stored.key === storageKey ? stored.ids : readDismissed(storageKey));
return { alerts: alerts.filter((alert) => !dismissed.has(alertKey(alert))), dismissAlert };
}
@@ -0,0 +1,97 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { WorkbenchMapController } from "../map/workbench-map-controller";
import type { WorkbenchSyncState } from "./use-workbench-sync";
import { useInvestigationPresentation } from "./use-investigation-presentation";
const sync = { subscribePresentationEvents: vi.fn(() => () => undefined) } as unknown as WorkbenchSyncState;
const mapRef: RefObject<MapLibreMap | null> = { current: null };
const controller = { queryPresentationFeatures: vi.fn() } as unknown as WorkbenchMapController;
const padding = () => ({ top: 0, right: 0, bottom: 0, left: 0 });
const onClearLayers = vi.fn();
const options = { scope: "preview" as const, enabled: false, task: null, projectId: "one", sync, mapRef, mapReady: false, controller, getPadding: padding, onClearLayers };
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("restores the last successful result when an unfinished display is aborted", async () => {
const { result } = renderHook(() => useInvestigationPresentation(options));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "saved" }], commands: [] }));
const abort = new AbortController();
let pending!: Promise<unknown>;
act(() => {
pending = result.current.executeCommand({ action: "open_chart", chart_id: "pending", evidence_refs: ["test"], chart: {
title: "Unfinished", chart_type: "line", x_data: ["10:00"], series: [{ name: "pressure", data: [20] }]
} }, abort.signal, "preview").catch(() => undefined);
});
await waitFor(() => expect(result.current.charts).toHaveLength(1));
await act(async () => { abort.abort(); await pending; });
expect(result.current.charts).toEqual([]);
expect(result.current.records).toEqual([{ value: "saved" }]);
expect(result.current.history.entries.at(-1)?.status).toBe("cancelled");
});
it("retries a cancelled live event in the same history row", async () => {
const { result } = renderHook(() => useInvestigationPresentation(options));
const command = { action: "open_chart" as const, chart_id: "retry", evidence_refs: ["test"], chart: {
title: "Recoverable", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "pressure", data: [20] }],
} };
const event = { task_id: "50000000-0000-4000-8000-000000000001", run_id: "55000000-0000-4000-8000-000000000001",
execution_generation: 1, event_seq: 1, command };
const abort = new AbortController();
let pending!: Promise<unknown>;
act(() => { pending = result.current.executeCommand(command, abort.signal, "live", event).catch(() => undefined); });
await waitFor(() => expect(result.current.charts).toHaveLength(1));
await act(async () => { abort.abort(); await pending; });
const id = result.current.history.entries[0].id;
act(() => result.current.history.onSelect(id));
await act(() => result.current.executeCommand(command, new AbortController().signal, "live", event));
expect(result.current.history.entries).toHaveLength(1);
expect(result.current.history.entries[0]).toMatchObject({ id, status: "completed" });
act(() => result.current.history.onSelect(null));
expect(result.current.charts[0].chart.title).toBe("Recoverable");
});
it("does not restore an old partial display after the project changes", async () => {
const { result, rerender } = renderHook(({ projectId }) => useInvestigationPresentation({ ...options, projectId }), { initialProps: { projectId: "one" } });
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "old project" }], commands: [] }));
const abort = new AbortController();
let pending!: Promise<unknown>;
act(() => {
pending = result.current.executeCommand({ action: "open_chart", chart_id: "pending", evidence_refs: ["test"], chart: {
title: "Unfinished", chart_type: "line", x_data: ["10:00"], series: [{ name: "pressure", data: [20] }]
} }, abort.signal, "preview").catch(() => undefined);
});
await waitFor(() => expect(result.current.charts).toHaveLength(1));
rerender({ projectId: "two" });
await act(async () => { abort.abort(); await pending; });
expect(result.current.charts).toEqual([]);
expect(result.current.records).toEqual([]);
expect(result.current.history.entries).toEqual([]);
});
it("advances saved snapshots on its timer, ignores new results and stops when the user clears overlays", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useInvestigationPresentation(options));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 1 }], commands: [] }));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 2 }], commands: [] }));
act(() => result.current.history.playback.onToggle());
expect(result.current.records).toEqual([{ value: 1 }]);
expect(result.current.history.playback.playing).toBe(true);
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 3 }], commands: [] }));
expect(result.current.records).toEqual([{ value: 1 }]);
act(() => vi.advanceTimersByTime(1500));
expect(result.current.records).toEqual([{ value: 2 }]);
act(() => vi.advanceTimersByTime(1500));
expect(result.current.history.playback.playing).toBe(false);
expect(result.current.records).toEqual([{ value: 2 }]);
act(() => result.current.history.playback.onToggle());
expect(result.current.history.playback.playing).toBe(true);
act(() => result.current.clearMapOverlays());
expect(result.current.history.playback.playing).toBe(false);
expect(result.current.records).toEqual([{ value: 3 }]);
act(() => vi.advanceTimersByTime(3000));
expect(result.current.records).toEqual([{ value: 3 }]);
});
@@ -127,6 +127,7 @@ export function useInvestigationPresentation(options: Options) {
"connecting" | "live" | "history" | "disconnected" | "error"
>("disconnected");
const [error, setError] = useState<string | null>(null);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
const activityIdRef = useRef(0);
const previewRef = useRef<AbortController | null>(null);
@@ -171,7 +172,7 @@ export function useInvestigationPresentation(options: Options) {
[publish, source]
);
const selectHistory = useCallback(
(id: number | null) => {
(id: number | null, pin = false) => {
const store = storeRef.current;
if (store.selectedId === null)
store.update({
@@ -179,7 +180,7 @@ export function useInvestigationPresentation(options: Options) {
camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
});
if (!store.select(id === store.currentEntryId ? null : id)) return;
if (!store.select(!pin && id === store.currentEntryId ? null : id)) return;
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
@@ -228,8 +229,9 @@ export function useInvestigationPresentation(options: Options) {
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
signal.throwIfAborted();
const store = storeRef.current;
const previousView = store.current;
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
if (eventKey && store.entries.some((entry) => entry.key === eventKey && entry.status === "completed"))
return { recorded: true, duplicate: true };
update((view) => ({
...view,
@@ -252,6 +254,7 @@ export function useInvestigationPresentation(options: Options) {
valuesRef.current.task?.title ?? null,
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
);
const pendingEntry = store.entries.find((entry) => entry.id === historyId);
const id = ++activityIdRef.current;
setActivity((current) => [
...current,
@@ -259,6 +262,7 @@ export function useInvestigationPresentation(options: Options) {
]);
let actionSnapshot = store.current;
const apply = (updater: (view: PresentationView) => PresentationView) => {
signal.throwIfAborted();
update(updater);
actionSnapshot = store.current;
};
@@ -285,7 +289,7 @@ export function useInvestigationPresentation(options: Options) {
})
);
signal.throwIfAborted();
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
@@ -300,13 +304,22 @@ export function useInvestigationPresentation(options: Options) {
viewing_history: true
};
} catch (cause) {
// An unfinished display may already have applied a chart or geometry before
// waiting for its frame. Roll back only its own view, never a newer scope.
if (signal.aborted && store.current === actionSnapshot) {
store.update(previousView);
actionSnapshot = previousView;
const map = valuesRef.current.mapRef.current;
if (map && store.selectedId === null && valuesRef.current.mapReady)
renderPresentationSnapshot(scene, map, previousView);
}
const message = signal.aborted
? "展示已取消"
: cause instanceof Error
? cause.message
: "展示失败";
if (historyId !== null)
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot, pendingEntry);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
@@ -339,6 +352,8 @@ export function useInvestigationPresentation(options: Options) {
loadHistory: async (cursor, signal) => {
const task = valuesRef.current.task;
if (!task || task.task_id !== taskId) return;
if (task.execution_generation !== cursor.execution_generation)
throw new Error("调查展示代次正在同步,请稍后重试");
const events = await fetchOperationalTaskPresentationHistory(
taskId,
cursor.event_cursor,
@@ -365,6 +380,9 @@ export function useInvestigationPresentation(options: Options) {
})
});
signal.throwIfAborted();
if (valuesRef.current.task?.task_id !== task.task_id ||
valuesRef.current.task.execution_generation !== task.execution_generation)
throw new Error("调查任务已切换,忽略旧展示结果");
storeRef.current.update(next);
publish();
const map = valuesRef.current.mapRef.current;
@@ -514,25 +532,26 @@ export function useInvestigationPresentation(options: Options) {
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
const map = valuesRef.current.mapRef.current;
if (!map) return "";
const task = valuesRef.current.task;
const progress =
task?.latest_progress?.execution_generation === task?.execution_generation
? task?.latest_progress
: null;
const target = progress?.progress.diagram?.node_refs.flatMap(
(node) => node.map_targets ?? []
)[0];
return createInvestigationDevCase(
caseId,
map.getCenter().toArray() as [number, number],
target
);
return createInvestigationDevCase(caseId);
}, []);
const taskEntries =
!options.scope || options.scope === "investigation"
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
: storeRef.current.entries;
const playing = storeRef.current.playing;
const selectedHistoryId = storeRef.current.selectedId;
useEffect(() => {
if (!playing) return;
const timer = window.setTimeout(() => {
const store = storeRef.current;
store.advancePlayback();
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
}, 1500 / playbackSpeed);
return () => window.clearTimeout(timer);
}, [playing, selectedHistoryId, playbackSpeed, publish, scene]);
const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null;
return {
@@ -552,7 +571,27 @@ export function useInvestigationPresentation(options: Options) {
currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries,
selectedId: storeRef.current.selectedId,
onSelect: selectHistory
onSelect: selectHistory,
playback: {
playing,
speed: playbackSpeed,
onToggle: () => {
const store = storeRef.current;
if (store.playing) store.pausePlayback();
else {
if (store.selectedId === null)
store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera });
store.startPlayback(taskEntries);
}
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
},
onSpeedChange: (speed: number) => {
if ([0.5, 1, 2].includes(speed)) setPlaybackSpeed(speed);
},
onSeek: (id: number) => selectHistory(id, true)
}
},
executeCommand,
runPreview,
@@ -0,0 +1,97 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import type { WorkbenchAlert } from "../types";
import { useScadaAlertNotices } from "./use-scada-alert-notices";
vi.mock("@/features/map/core", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
const alert: WorkbenchAlert = {
id: "incident-1", title: "SCADA 指标异常", description: "已启动调查,详细诊断见调查报告。",
time: "08:00", severity: "warning", conditionId: "operational-task:task-1",
source: { kind: "scada_replay", replayId: "replay-1", ruleId: "scada_diagnosis", batchSequence: 2 }
};
afterEach(() => { cleanup(); sessionStorage.clear(); vi.clearAllMocks(); });
it("restores old notifications quietly and announces each newly persisted SCADA incident once", () => {
const onOpen = vi.fn();
const { result, rerender } = renderHook(({ alerts }: { alerts: WorkbenchAlert[] | null }) =>
useScadaAlertNotices("project-1", alerts, onOpen), { initialProps: { alerts: null as WorkbenchAlert[] | null } });
rerender({ alerts: [alert] });
expect(showMapNotice).not.toHaveBeenCalled();
expect(result.current.atmosphere?.alert).toEqual(alert);
const next = { ...alert, id: "incident-2", conditionId: "operational-task:task-2" };
rerender({ alerts: [alert, next] });
rerender({ alerts: [alert, { ...next }] });
expect(showMapNotice).toHaveBeenCalledTimes(1);
expect(result.current.atmosphere).toMatchObject({ preview: false, count: 2 });
act(() => result.current.dismissAtmosphere(result.current.atmosphere!.id));
rerender({ alerts: [alert, { ...next }] });
expect(result.current.atmosphere).toBeNull();
expect(showMapNotice).toHaveBeenCalledWith(expect.objectContaining({
title: alert.title, tone: "warning", position: "top-center", actionLabel: "查看调查", duration: Infinity
}));
vi.mocked(showMapNotice).mock.calls[0][0].onAction?.();
expect(onOpen).toHaveBeenCalledWith(next);
expect(dismissMapNotice).not.toHaveBeenCalled();
});
it("retains linked notices after recovery and never reannounces the same incident", () => {
const { result, rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [alert] });
expect(result.current.atmosphere?.alert).toEqual(alert);
rerender({ alerts: [] });
expect(result.current.atmosphere).toBeNull();
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender({ alerts: [alert] });
expect(showMapNotice).toHaveBeenCalledTimes(1);
});
it("clears recovered data-quality notices without task links", () => {
const { rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [{ ...alert, conditionId: undefined }] });
rerender({ alerts: [] });
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-1:incident-1");
});
it("clears popups when leaving a project and keeps a different project's initial alerts quiet", () => {
const { result, rerender, unmount } = renderHook(({ project, alerts }) =>
useScadaAlertNotices(project, alerts, vi.fn()),
{ initialProps: { project: "project-1", alerts: [] as WorkbenchAlert[] } });
rerender({ project: "project-1", alerts: [alert] });
rerender({ project: "project-2", alerts: [alert] });
expect(result.current.atmosphere?.projectId).toBe("project-2");
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-1:incident-1");
expect(showMapNotice).toHaveBeenCalledTimes(1);
rerender({ project: "project-2", alerts: [alert, { ...alert, id: "incident-2", severity: "critical" }] });
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error" }));
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-2:incident-2");
});
it("ignores other sources and leaves data quality notices without a fabricated investigation link", () => {
const { result, rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [{ ...alert, source: undefined }] });
expect(showMapNotice).not.toHaveBeenCalled();
rerender({ alerts: [{ ...alert, conditionId: undefined }] });
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ actionLabel: undefined, onAction: undefined }));
expect(result.current.atmosphere).toBeNull();
rerender({ alerts: [{ ...alert, id: "quality-only", source: { ...alert.source!, kind: "scada_replay", replayId: "replay-1", ruleId: "quality-P1", batchSequence: 2 } }] });
expect(result.current.atmosphere).toBeNull();
});
it("previews locally and an older dismissal cannot clear a newer real incident", () => {
const { result, rerender } = renderHook(({ project, alerts }) => useScadaAlertNotices(project, alerts, vi.fn()),
{ initialProps: { project: "project-1", alerts: [] as WorkbenchAlert[] } });
act(() => result.current.previewAtmosphere());
const preview = result.current.atmosphere!;
expect(preview).toMatchObject({ preview: true, alert: null, projectId: "project-1" });
expect(showMapNotice).not.toHaveBeenCalled();
rerender({ project: "project-1", alerts: [alert] });
act(() => result.current.dismissAtmosphere(preview.id));
expect(result.current.atmosphere?.alert).toEqual(alert);
rerender({ project: "project-2", alerts: [] });
expect(result.current.atmosphere).toBeNull();
});
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import type { WorkbenchAlert } from "../types";
import { useScadaEventAtmosphere, type ScadaEventTask } from "./use-scada-event-atmosphere";
export type { ScadaAlertAtmosphereState } from "./use-scada-event-atmosphere";
/** Announce newly persisted alerts, including while the peripheral panel is closed. */
export function useScadaAlertNotices(
projectId: string | null,
alerts: WorkbenchAlert[] | null,
onOpen: (alert: WorkbenchAlert) => void,
tasks: readonly ScadaEventTask[] | null = null
) {
const atmosphere = useScadaEventAtmosphere(projectId, alerts, tasks);
const openRef = useRef(onOpen);
openRef.current = onOpen;
const state = useRef<{ initialized: boolean; seen: Set<string>; visible: Set<string>; linked: Set<string> }>({
initialized: false, seen: new Set(), visible: new Set(), linked: new Set()
});
useEffect(() => {
const current = { initialized: false, seen: new Set<string>(), visible: new Set<string>(), linked: new Set<string>() };
state.current = current;
return () => { current.visible.forEach((id) => dismissMapNotice(id)); };
}, [projectId]);
useEffect(() => {
if (!alerts) return;
const current = state.current;
const incoming = alerts.filter((alert) => alert.source && "kind" in alert.source
&& alert.source.kind === "scada_replay");
const noticeId = (id: string) => `scada-alert:${projectId}:${id}`;
const activeIds = new Set(incoming.map((alert) => noticeId(alert.id)));
for (const id of current.visible) {
if (!activeIds.has(id) && !current.linked.has(id)) {
dismissMapNotice(id);
current.visible.delete(id);
}
}
for (const alert of incoming) {
if (current.seen.has(alert.id)) continue;
current.seen.add(alert.id);
// Opening a project restores its notification center without replaying old popups.
if (!current.initialized) continue;
const id = noticeId(alert.id);
current.visible.add(id);
if (alert.conditionId) current.linked.add(id);
showMapNotice({
id,
title: alert.title,
message: alert.description,
tone: alert.severity === "critical" ? "error" : "warning",
position: "top-center",
duration: alert.conditionId ? Infinity : 10_000,
actionLabel: alert.conditionId ? "查看调查" : undefined,
onAction: alert.conditionId ? () => openRef.current(alert) : undefined
});
}
current.initialized = true;
}, [alerts, projectId]);
return atmosphere;
}
@@ -0,0 +1,63 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { useScadaEventAtmosphere, type ScadaEventTask } from "./use-scada-event-atmosphere";
import type { WorkbenchAlert } from "../types";
const task: ScadaEventTask = {
task_id: "task-1", project_id: "project-1", task_type: "event_investigation", workflow: "scada_anomaly",
lifecycle_status: "open", title: "监测事件", created_at: "2026-07-16T00:00:00Z",
source: { kind: "external_system", external_id: "scada:1", external_revision: 1, event_kind: "scada_anomaly",
replay_context: { replay_id: "replay-1", knowledge_cutoff: "2026-07-16T00:00:00Z", batch_sequence: 1 } }
};
const alert: WorkbenchAlert = { id: "incident-1", conditionId: "operational-task:task-1", title: "SCADA 指标异常",
time: "08:00", description: "调查已启动", source: { kind: "scada_replay", replayId: "replay-1", ruleId: "scada_diagnosis", batchSequence: 1 } };
afterEach(() => { cleanup(); sessionStorage.clear(); });
it("retains an event after sensor-alert recovery, human waiting and execution failure, until event completion", () => {
const { result, rerender } = renderHook(({ alerts, tasks }) => useScadaEventAtmosphere("project-1", alerts, tasks),
{ initialProps: { alerts: [alert], tasks: [task] } });
expect(result.current.atmosphere?.count).toBe(1);
rerender({ alerts: [], tasks: [{ ...task, lifecycle_status: "waiting_human" }] });
expect(result.current.atmosphere?.alert?.conditionId).toBe(alert.conditionId);
rerender({ alerts: [], tasks: [{ ...task, lifecycle_status: "failed" }] });
expect(result.current.atmosphere).not.toBeNull();
rerender({ alerts: [alert], tasks: [{ ...task, lifecycle_status: "completed" }] });
expect(result.current.atmosphere).toBeNull();
});
it("restores unfinished events on reload and remembers a manual dismissal for that event", () => {
const first = renderHook(() => useScadaEventAtmosphere("project-1", null, [task]));
expect(first.result.current.atmosphere).not.toBeNull();
act(() => first.result.current.dismissAtmosphere(first.result.current.atmosphere!.id));
expect(first.result.current.atmosphere).toBeNull();
first.unmount();
const next = renderHook(() => useScadaEventAtmosphere("project-1", [alert], [task]));
expect(next.result.current.atmosphere).toBeNull();
});
it("keeps other events visible when one ends and shows a new event after the earlier group was dismissed", () => {
const second = { ...task, task_id: "task-2" };
const { result, rerender } = renderHook(({ tasks }) => useScadaEventAtmosphere("project-1", [], tasks),
{ initialProps: { tasks: [task, second] } });
expect(result.current.atmosphere?.count).toBe(2);
rerender({ tasks: [{ ...task, lifecycle_status: "cancelled" }, second] });
expect(result.current.atmosphere?.count).toBe(1);
act(() => result.current.dismissAtmosphere(result.current.atmosphere!.id));
rerender({ tasks: [second, { ...task, task_id: "task-3" }] });
expect(result.current.atmosphere?.count).toBe(1);
expect(result.current.atmosphere?.alert?.conditionId).toBe("operational-task:task-3");
});
it("isolates project state and lets Dev end only the local preview", () => {
const { result, rerender } = renderHook(({ project, tasks }) => useScadaEventAtmosphere(project, [], tasks),
{ initialProps: { project: "project-1", tasks: [] as ScadaEventTask[] } });
act(() => result.current.previewAtmosphere());
expect(result.current.atmosphere?.preview).toBe(true);
act(() => result.current.endAtmospherePreview());
expect(result.current.atmosphere).toBeNull();
rerender({ project: "project-1", tasks: [task] });
act(() => result.current.endAtmospherePreview());
expect(result.current.atmosphere?.preview).toBe(false);
rerender({ project: "project-2", tasks: [task] });
expect(result.current.atmosphere).toBeNull();
});
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { OperationalTaskSummary } from "../api/operational-task-client";
import type { WorkbenchAlert } from "../types";
export type ScadaEventTask = Pick<OperationalTaskSummary,
"task_id" | "project_id" | "task_type" | "workflow" | "lifecycle_status" | "source" | "title" | "created_at">;
export type ScadaAlertAtmosphereState = {
id: string;
projectId: string | null;
alert: WorkbenchAlert | null;
preview: boolean;
count?: number;
};
const storageKey = (project: string | null) => `tjwater.scada-visual-dismissed@1:${project}`;
function readDismissed(project: string | null): string[] {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(storageKey(project)) ?? "[]");
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
} catch { return []; }
}
/** Visual state follows event completion, independently of sensor recovery or run outcomes. */
export function useScadaEventAtmosphere(projectId: string | null, alerts: WorkbenchAlert[] | null,
tasks: readonly ScadaEventTask[] | null) {
const storedDismissed = useMemo(() => readDismissed(projectId), [projectId]);
const [dismissed, setDismissed] = useState<{ project: string | null; ids: string[] } | null>(null);
const muted = dismissed?.project === projectId ? dismissed.ids : storedDismissed;
const [preview, setPreview] = useState<ScadaAlertAtmosphereState | null>(null);
useEffect(() => setPreview(null), [projectId]);
const candidates = new Map<string, WorkbenchAlert>();
const currentTasks = tasks?.filter((task) => task.project_id === projectId) ?? [];
const ended = new Set(currentTasks.filter((task) => ["completed", "cancelled"].includes(task.lifecycle_status))
.map((task) => `operational-task:${task.task_id}`));
for (const alert of alerts ?? []) {
if (alert.source && "kind" in alert.source && alert.source.kind === "scada_replay"
&& alert.source.ruleId === "scada_diagnosis" && alert.conditionId && !ended.has(alert.conditionId)) {
candidates.set(alert.conditionId, alert);
}
}
for (const task of currentTasks) {
const id = `operational-task:${task.task_id}`;
if (task.task_type !== "event_investigation" || !task.source.replay_context
|| !["scada_anomaly", "burst_report"].includes(task.workflow) || ended.has(id) || candidates.has(id)) continue;
candidates.set(id, { id, conditionId: id, title: task.title,
description: "监测事件持续处理中", occurredAt: task.created_at, time: "" });
}
const ids = [...candidates.keys()].filter((id) => !muted.includes(id)).sort();
const realKey = ids.join("|");
useEffect(() => { if (realKey) setPreview(null); }, [realKey]);
const atmosphere: ScadaAlertAtmosphereState | null = ids.length ? {
id: realKey, projectId, alert: candidates.get(ids[0])!, preview: false, count: ids.length
} : preview?.projectId === projectId ? preview : null;
const previewAtmosphere = useCallback(() => {
if (realKey) return;
setPreview({ id: `preview:${crypto.randomUUID()}`, projectId, alert: null, preview: true });
}, [projectId, realKey]);
const endAtmospherePreview = useCallback(() => setPreview(null), []);
const dismissAtmosphere = (id: string) => {
if (id !== atmosphere?.id) return;
setPreview(null);
if (atmosphere.preview) return;
const next = [...new Set([...muted, ...ids])];
setDismissed({ project: projectId, ids: next });
try { sessionStorage.setItem(storageKey(projectId), JSON.stringify(next)); } catch { /* Retain the current page preference. */ }
};
return { atmosphere, previewAtmosphere, endAtmospherePreview, dismissAtmosphere };
}