feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user