feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchElementGisProperties,
fetchSourceGisFeatureProperties,
type GisAttributeGroup
} from "../api/gis-properties-client";
import { isSourceGisSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
type GisPropertiesState = {
status: "idle" | "loading" | "ready" | "error";
groups: GisAttributeGroup[];
};
const INITIAL_STATE: GisPropertiesState = { status: "idle", groups: [] };
export function useElementGisProperties(
feature: DetailFeature | null,
projectId: string | null,
getAccessToken?: AccessTokenProvider
) {
const [state, setState] = useState<GisPropertiesState>(INITIAL_STATE);
useEffect(() => {
if (!feature) {
setState(INITIAL_STATE);
return;
}
if (feature.layer === "water_quality_monitoring_points") {
setState({ status: "ready", groups: [] });
return;
}
const controller = new AbortController();
setState({ status: "loading", groups: [] });
const sourceLayerKey = feature.properties.source_layer_key;
const rawSourceFid = feature.properties.source_fid;
const sourceFid = typeof rawSourceFid === "number"
? rawSourceFid
: Number(rawSourceFid);
const request = isSourceGisSourceId(feature.layer)
&& typeof sourceLayerKey === "string"
&& Number.isSafeInteger(sourceFid)
&& sourceFid >= 0
? fetchSourceGisFeatureProperties(
sourceLayerKey,
sourceFid,
projectId,
getAccessToken,
controller.signal
)
: fetchElementGisProperties(
feature.layer,
feature.id,
projectId,
getAccessToken,
controller.signal
);
void request.then((groups) => {
if (!controller.signal.aborted) setState({ status: "ready", groups });
}).catch(() => {
if (!controller.signal.aborted) setState({ status: "error", groups: [] });
});
return () => controller.abort();
}, [feature, getAccessToken, projectId]);
return state;
}
@@ -33,14 +33,13 @@ describe("map feature interaction state", () => {
});
it("uses transparent hit layers for pointer interactions", () => {
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual([
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual(expect.arrayContaining([
"pipes-hit",
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
"scada-hit"
]);
"scada-pressure-hit",
"scada-flow-hit",
"water_quality_monitoring_points-hit",
"fx_20200423_pipes-hit"
]));
});
});
@@ -7,6 +7,7 @@ import type { PersonaState } from "@/shared/ai-elements/persona";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import {
FrontendActionExecutor,
agentBootstrapKey,
@@ -62,7 +63,18 @@ export function useWorkbenchAgent({
const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
const projectIdPromiseRef = useRef<Promise<string> | null>(null);
const getProjectId = useCallback(() => {
projectIdPromiseRef.current ??= findOwnedWorkbenchProject(getAccessToken).then(
({ project }) => project.project_id,
(error) => {
projectIdPromiseRef.current = null;
throw error;
}
);
return projectIdPromiseRef.current;
}, [getAccessToken]);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId }));
const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null);
@@ -82,6 +94,7 @@ export function useWorkbenchAgent({
const [resumedStreaming, setResumedStreaming] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [promptDraft, setPromptDraft] = useState("");
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
@@ -605,6 +618,7 @@ export function useWorkbenchAgent({
setPermissionOverrides({});
setQuestionOverrides({});
setStreamRenderState({});
setPromptDraft("");
chat.setMessages([]);
},
[chat, clearSessionId]
@@ -821,7 +835,9 @@ export function useWorkbenchAgent({
statusLabel,
streamRenderState,
selectedModel,
promptDraft,
setApprovalMode,
setPromptDraft,
setSelectedModel,
stopPrompt,
streaming,
+144 -28
View File
@@ -1,6 +1,6 @@
import "maplibre-gl/dist/maplibre-gl.css";
import type { FeatureCollection, Point } from "geojson";
import type { FeatureCollection, Geometry, Point } from "geojson";
import maplibregl, {
type GeoJSONSource,
type Map as MapLibreMap,
@@ -10,14 +10,14 @@ import maplibregl, {
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
import { fetchScadaSnapshot, type ScadaDevice } from "../api/scada-client";
import type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
simulationAnnotationLayers,
simulationSources
} from "../map/annotation-layers";
import { fitNetworkBounds } from "../map/camera";
import { fitNetworkBounds, type NetworkViewParams } from "../map/camera";
import {
filterWaterNetworkLayersBySourceIds,
waterNetworkBusinessLayers,
@@ -66,6 +66,9 @@ type UseWorkbenchMapOptions = {
onClearSelection: () => void;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null;
getAccessToken?: AccessTokenProvider;
};
@@ -75,7 +78,9 @@ type UseWorkbenchMapResult = {
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
availableSourceIds: WaterNetworkSourceId[];
sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>;
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
scadaObservedAt: string | null;
refreshGeoServerLayers: () => boolean;
fitNetworkBounds: () => void;
};
@@ -105,10 +110,16 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source",
[SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source"
};
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
const SCADA_REFRESH_INTERVAL_MS = 15_000;
const EMPTY_SIMULATION_COLLECTION: FeatureCollection = {
type: "FeatureCollection",
features: []
};
export function useWorkbenchMap({
containerRef,
@@ -117,16 +128,30 @@ export function useWorkbenchMap({
onClearSelection,
onSelectFeature,
selectedFeature,
simulationOverlay,
allowDemoSimulation,
networkView,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const simulationOverlayRef = useRef(simulationOverlay);
const allowDemoSimulationRef = useRef(allowDemoSimulation);
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
const fittedNetworkViewRef = useRef<string | null>(null);
simulationOverlayRef.current = simulationOverlay;
allowDemoSimulationRef.current = allowDemoSimulation;
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
const [scadaObservedAt, setScadaObservedAt] = useState<string | null>(null);
const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState<
GeoServerWaterNetworkSourceId[]
>(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]);
const [sourceFeatureCounts, setSourceFeatureCounts] = useState<
Partial<Record<WaterNetworkSourceId, number>>
>({});
const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0);
const geoServerLayerProbeInFlightRef = useRef(false);
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
@@ -172,6 +197,9 @@ export function useWorkbenchMap({
? current
: availability.availableSourceIds
);
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts);
if (availability.failedSourceIds.length > 0) {
updateSourceStatus(
@@ -224,12 +252,8 @@ export function useWorkbenchMap({
setMapError(null);
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const scadaAbortController = new AbortController();
let scadaRefreshTimeoutId: number | undefined;
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const scadaLoadPromise = fetchScadaDeviceCollection(
getAccessToken,
scadaAbortController.signal
);
scadaLoadPromiseRef.current = scadaLoadPromise;
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
@@ -254,6 +278,12 @@ export function useWorkbenchMap({
});
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(
map,
simulationOverlayRef.current,
allowDemoSimulationRef.current
);
await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers =
@@ -305,27 +335,47 @@ export function useWorkbenchMap({
: "SCADA 图标不可用,已使用圆点图层降级显示。"
);
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
fitNetworkBounds(map, networkViewRef.current);
fittedNetworkViewRef.current = networkViewKey(networkViewRef.current);
});
void scadaLoadPromise.then((collection) => {
scadaCollectionRef.current = collection;
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
"online",
`已通过API加载 ${collection.features.length} 个SCADA设备。`
const refreshScada = async () => {
const snapshotPromise = fetchScadaSnapshot(
getAccessToken,
scadaAbortController.signal
);
}).catch((error) => {
if (scadaAbortController.signal.aborted) return;
updateSourceStatus(
setSourceStatuses,
"scada-api",
"offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
});
scadaLoadPromiseRef.current = snapshotPromise
.then((snapshot) => snapshot.collection)
.catch(() => scadaCollectionRef.current);
try {
const snapshot = await snapshotPromise;
if (scadaAbortController.signal.aborted) return;
scadaCollectionRef.current = snapshot.collection;
setScadaObservedAt(snapshot.observedAt);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(snapshot.collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
snapshot.telemetryStatus === "ready" ? "online" : "degraded",
getScadaStatusMessage(snapshot)
);
} catch (error) {
if (scadaAbortController.signal.aborted) return;
setScadaObservedAt(null);
updateSourceStatus(
setSourceStatuses,
"scada-api",
scadaCollectionRef.current.features.length > 0 ? "degraded" : "offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
} finally {
if (!scadaAbortController.signal.aborted) {
scadaRefreshTimeoutId = window.setTimeout(refreshScada, SCADA_REFRESH_INTERVAL_MS);
}
}
};
void refreshScada();
let resizeFrame: number | null = null;
const resizeMap = () => map.resize();
@@ -375,6 +425,9 @@ export function useWorkbenchMap({
}
resizeObserver.disconnect();
scadaAbortController.abort();
if (scadaRefreshTimeoutId !== undefined) {
window.clearTimeout(scadaRefreshTimeoutId);
}
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
@@ -387,6 +440,15 @@ export function useWorkbenchMap({
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !networkView) return;
const key = networkViewKey(networkView);
if (fittedNetworkViewRef.current === key) return;
fitNetworkBounds(map, networkView);
fittedNetworkViewRef.current = key;
}, [mapReady, networkView]);
useMapInteractions({
availableSourceIds: availableGeoServerSourceIds,
mapRef,
@@ -401,10 +463,16 @@ export function useWorkbenchMap({
visible: impactVisible
});
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation);
}, [allowDemoSimulation, mapReady, simulationOverlay]);
function fitToNetworkBounds() {
const map = mapRef.current;
if (map) {
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
fitNetworkBounds(map, networkViewRef.current);
}
}
@@ -418,12 +486,60 @@ export function useWorkbenchMap({
sourceId === "scada" ||
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
),
sourceFeatureCounts,
getScadaFeatures,
scadaObservedAt,
refreshGeoServerLayers,
fitNetworkBounds: fitToNetworkBounds
};
}
function areFeatureCountsEqual(
current: Partial<Record<WaterNetworkSourceId, number>>,
next: Partial<Record<WaterNetworkSourceId, number>>
) {
const currentEntries = Object.entries(current);
const nextEntries = Object.entries(next);
return currentEntries.length === nextEntries.length
&& nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count);
}
function networkViewKey(view: NetworkViewParams) {
return view.bbox3857.join(",");
}
function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSnapshot>>) {
const deviceSummary = `已通过API加载 ${snapshot.collection.features.length} 个SCADA设备`;
if (snapshot.telemetryStatus === "ready") {
return `${deviceSummary},其中 ${snapshot.observedDeviceCount} 个带有最近15分钟监测值。`;
}
if (snapshot.telemetryStatus === "unavailable") {
return `${deviceSummary};实时监测值暂不可用,保留设备元数据。`;
}
return `${deviceSummary};最近15分钟没有监测值,保留设备元数据。`;
}
function syncSimulationSourceData(
map: MapLibreMap,
backendOverlay: FeatureCollection<Geometry> | null,
allowDemoSimulation: boolean
) {
const useBackendOverlay = Boolean(backendOverlay?.features.length);
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION
);
(map.getSource(SIMULATION_SOURCE_IDS.impactArea) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.impactArea.data
);
(map.getSource(SIMULATION_SOURCE_IDS.annotations) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.annotations.data
);
}
function syncDataDependentGeoServerLayers(
map: MapLibreMap,
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { WorkbenchProjectPresentation } from "../api/project-client";
import { resolveWorkbenchUser } from "./use-workbench-project-presentation";
const fallbackUser = {
name: "调度员",
role: "供水运行调度中心"
};
function presentation(
overrides: Partial<WorkbenchProjectPresentation> = {}
): WorkbenchProjectPresentation {
return {
projectId: "project-1",
projectName: "北区供水项目",
username: "north.operator",
projectRole: "member",
systemRole: "user",
isSystemAdmin: false,
networkView: null,
metadataAvailable: true,
accessContextAvailable: true,
...overrides
};
}
describe("workbench project presentation", () => {
it("uses backend identity and a readable project role", () => {
expect(resolveWorkbenchUser(fallbackUser, presentation())).toEqual({
name: "north.operator",
role: "北区供水项目 · 项目成员"
});
});
it("retains the existing user when project context is unavailable", () => {
expect(resolveWorkbenchUser(fallbackUser, null)).toBe(fallbackUser);
});
it("can enrich the authenticated fallback name with project metadata", () => {
expect(resolveWorkbenchUser(fallbackUser, presentation({
username: null,
projectRole: "viewer"
}))).toEqual({
name: "调度员",
role: "北区供水项目 · 项目访客"
});
});
});
@@ -0,0 +1,65 @@
import { useEffect, useMemo, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchWorkbenchProjectPresentation,
type WorkbenchProjectPresentation
} from "../api/project-client";
import type { WorkbenchUser } from "../types";
const PROJECT_ROLE_LABELS: Record<string, string> = {
owner: "项目负责人",
member: "项目成员",
viewer: "项目访客"
};
export function useWorkbenchProjectPresentation(
fallbackUser: WorkbenchUser,
getAccessToken?: AccessTokenProvider
) {
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(null);
useEffect(() => {
const controller = new AbortController();
void fetchWorkbenchProjectPresentation(getAccessToken, controller.signal)
.then((value) => {
if (!controller.signal.aborted) setPresentation(value);
})
.catch(() => {
if (!controller.signal.aborted) setPresentation(null);
});
return () => controller.abort();
}, [getAccessToken]);
const user = useMemo(
() => resolveWorkbenchUser(fallbackUser, presentation),
[fallbackUser, presentation]
);
return {
user,
projectId: presentation?.projectId ?? null,
networkView: presentation?.networkView ?? null,
dataSource: presentation?.metadataAvailable || presentation?.accessContextAvailable
? "backend" as const
: "fallback" as const
};
}
export function resolveWorkbenchUser(
fallbackUser: WorkbenchUser,
presentation: WorkbenchProjectPresentation | null
): WorkbenchUser {
if (!presentation) return fallbackUser;
const role = presentation.projectRole
? PROJECT_ROLE_LABELS[presentation.projectRole] ?? `项目角色 ${presentation.projectRole}`
: null;
const roleParts = [
presentation.projectName,
role,
presentation.isSystemAdmin ? "系统管理员" : null
].filter((part): part is string => Boolean(part));
return {
name: presentation.username ?? fallbackUser.name,
role: roleParts.length > 0 ? roleParts.join(" · ") : fallbackUser.role
};
}
@@ -42,9 +42,6 @@ export function useWorkbenchResponsiveLayout({
const handleViewportChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleViewportChange();
@@ -58,6 +55,17 @@ export function useWorkbenchResponsiveLayout({
};
}, []);
useEffect(() => {
if (!isLargeScreen || mobileSheet === null) {
return;
}
if (mobileSheet === "agent") {
expandAgentPanel();
}
setMobileSheet(null);
}, [expandAgentPanel, isLargeScreen, mobileSheet]);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
useEffect(() => {
@@ -1,15 +1,375 @@
import { describe, expect, it } from "vitest";
import {
formatHeaderDataTime,
getMsUntilNextHeaderDataTimeTick
getMsUntilNextHeaderDataTimeTick,
resolveWorkbenchOperationalDate,
resolveWorkbenchRuntimeData
} from "./use-workbench-runtime-data";
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
const SEPTEMBER_17 = new Date("2026-09-17T02:30:00Z");
const SEPTEMBER_18 = new Date("2026-09-18T02:30:00Z");
const mockCondition: ScheduledConditionRecord = {
id: "mock-condition",
kind: "condition",
taskId: "scada-diagnosis",
sessionId: "scheduled-condition-mock",
title: "SCADA 数据诊断",
summary: "mock",
status: "error",
riskLevel: "critical",
scheduledAt: "2026-09-17T10:00:00+08:00",
updatedAt: Date.parse("2026-09-17T10:00:00+08:00")
};
const backendCondition: ScheduledConditionRecord = {
...mockCondition,
id: "schedule-run-1",
taskId: "backend-schedule",
sessionId: "scheduled-condition-schedule-run-1",
title: "演示 合成低压调查",
modelName: "TJWater scheduler"
};
const mockWorkOrder: ScheduledWorkOrderItem = {
id: "mock-work-order",
kind: "work_order",
code: "WO-MOCK",
scheduledAt: "2026-09-17T11:00:00+08:00",
title: "演示工单",
summary: "mock",
status: "pending",
riskLevel: "normal",
updatedAt: Date.parse("2026-09-17T10:00:00+08:00"),
detail: "mock",
durationMinutes: 30,
source: "演示",
dispatcher: "调度中心",
priority: "normal",
subtasks: [{
id: "mock-subtask-1",
title: "现场复核",
summary: "复核设备状态并回传。",
status: "pending",
scheduledAt: "2026-09-17T11:00:00+08:00",
location: "V-1",
assignee: "现场班组",
durationMinutes: 30,
replyWindowMinutes: 15,
stages: [{
id: "dispatch",
sequence: 1,
title: "派发",
status: "current",
owner: "调度中心",
timeLabel: "10:45",
description: "派发现场复核任务。"
}],
replyRequirements: ["回传设备状态"]
}]
};
describe("workbench runtime data", () => {
it("snaps the header time to the current five-minute interval", () => {
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31))).toBe("2026-07-22 15:40");
});
it("preserves the backend observation minute", () => {
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31), false)).toBe("2026-07-22 15:43");
});
it("schedules the next header refresh on the next interval boundary", () => {
expect(getMsUntilNextHeaderDataTimeTick(new Date(2026, 6, 22, 15, 43, 30))).toBe(90_000);
});
it("keeps mock conditions and shows no work orders when backend storage is not configured", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
availability: "not_configured",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "not_configured",
alertAvailability: "not_configured",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.workbenchAlerts).toHaveLength(1);
expect(resolved.scenarioDataSource).toBe("mock");
expect(resolved.artifactDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("none");
});
it("keeps mock mode separate from the selected backend history day", () => {
const backendAlert = {
id: "scheduled-condition-alert-schedule-run-1",
title: "演示 测试待复核",
description: "运行失败",
time: "10:25"
};
const backendSnapshot = {
availability: "ready" as const,
source: "scheduled_tasks" as const,
scenarioAvailability: "not_configured" as const,
artifactAvailability: "not_configured" as const,
scheduleAvailability: "not_configured" as const,
workOrderAvailability: "not_configured" as const,
alertAvailability: "not_configured" as const,
generatedAt: "2026-09-17T02:30:00Z",
conditions: [backendCondition],
alerts: [backendAlert],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
};
const resolved = resolveWorkbenchRuntimeData([mockCondition], backendSnapshot, {
operationalDate: SEPTEMBER_17,
runtimeMode: "history"
});
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.workbenchAlerts).not.toEqual([backendAlert]);
expect(resolved.workbenchAlerts[0]?.id).toBe("scheduled-condition-alert-mock-condition");
expect(resolved.alertDataSource).toBe("mock");
expect(resolved.scenarioDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("none");
const mockResolved = resolveWorkbenchRuntimeData([mockCondition], backendSnapshot, {
operationalDate: SEPTEMBER_17,
runtimeMode: "demo"
});
expect(mockResolved.runtimeDataSource).toBe("mock");
expect(mockResolved.scheduledConditions).toEqual([mockCondition]);
expect(mockResolved.workOrderDataSource).toBe("none");
});
it("anchors history mode to the latest day that contains backend work orders", () => {
const olderWorkOrder = {
...mockWorkOrder,
id: "older-backend-work-order",
scheduledAt: "2026-08-03T10:05:00+08:00"
};
const latestWorkOrder = {
...mockWorkOrder,
id: "latest-backend-work-order",
scheduledAt: "2026-08-17T17:40:00+08:00"
};
const futureWorkOrder = {
...mockWorkOrder,
id: "future-backend-work-order",
scheduledAt: "2026-09-20T10:00:00+08:00"
};
const now = new Date("2026-09-18T02:30:00Z");
const operationalDate = resolveWorkbenchOperationalDate({
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "ready",
artifactAvailability: "ready",
scheduleAvailability: "ready",
workOrderAvailability: "ready",
alertAvailability: "ready",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [backendCondition],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: [olderWorkOrder, latestWorkOrder, futureWorkOrder]
}, { runtimeMode: "history", now });
expect(operationalDate.toISOString()).toBe("2026-08-17T09:40:00.000Z");
expect(resolveWorkbenchOperationalDate({
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "ready",
artifactAvailability: "ready",
scheduleAvailability: "ready",
workOrderAvailability: "ready",
alertAvailability: "ready",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: [olderWorkOrder, latestWorkOrder, futureWorkOrder]
}, { runtimeMode: "demo", now }).toISOString()).toBe("2026-08-17T09:40:00.000Z");
});
it("keeps current-day mock conditions when backend schedules are configured", () => {
const currentMockCondition = {
...mockCondition,
id: "mock-current-day",
scheduledAt: "2026-09-18T10:00:00+08:00"
};
const resolved = resolveWorkbenchRuntimeData([currentMockCondition], {
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "ready",
artifactAvailability: "ready",
scheduleAvailability: "ready",
workOrderAvailability: "not_configured",
alertAvailability: "not_configured",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [backendCondition],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_18 });
expect(resolved.scheduledConditions).toEqual([currentMockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
});
it("ignores current-day backend conditions and keeps demo conditions", () => {
const currentMockCondition = {
...mockCondition,
id: "mock-current-day",
scheduledAt: "2026-09-18T10:00:00+08:00"
};
const currentBackendCondition = {
...backendCondition,
id: "backend-current-day",
scheduledAt: "2026-09-18T10:05:00+08:00"
};
const resolved = resolveWorkbenchRuntimeData([currentMockCondition], {
availability: "ready",
source: "scheduled_tasks",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "not_configured",
alertAvailability: "not_configured",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [currentBackendCondition],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_18 });
expect(resolved.scheduledConditions).toEqual([currentMockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
});
it("treats an empty backend day as authoritative when backend mode is explicit", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
availability: "ready",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "ready",
alertAvailability: "not_configured",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("backend");
});
it("uses a provisioned alert list while other sections keep their mock fallback", () => {
const backendAlert = {
id: "backend-alert-alarm-1",
title: "泵站通信告警",
description: "来自正式告警同步适配器。",
severity: "warning" as const,
time: "10:29"
};
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
availability: "ready",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "not_configured",
alertAvailability: "ready",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [backendAlert],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
});
it("does not invent demo alerts when the backend alert partition is empty", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
availability: "ready",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "ready",
workOrderAvailability: "not_configured",
alertAvailability: "ready",
generatedAt: "2026-09-17T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: []
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.alertDataSource).toBe("backend");
expect(resolved.workbenchAlerts).toEqual([]);
});
it("uses valid backend records retained by a degraded partition", () => {
const backendWorkOrder = { ...mockWorkOrder, id: "backend-work-order", code: "WO-BACKEND" };
const backendAlert = {
id: "backend-alert-1",
title: "后端任务告警",
description: "合法记录与另一条坏记录同时存在。",
severity: "warning" as const,
time: "09-18 10:29"
};
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
availability: "degraded",
source: "none",
scenarioAvailability: "not_configured",
artifactAvailability: "not_configured",
scheduleAvailability: "not_configured",
workOrderAvailability: "degraded",
alertAvailability: "degraded",
generatedAt: "2026-09-18T02:30:00Z",
conditions: [],
alerts: [backendAlert],
scenarios: [],
latestAnalysisArtifact: null,
simulationOverlay: null,
workOrders: [backendWorkOrder]
}, { operationalDate: SEPTEMBER_17 });
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
expect(resolved.alertDataSource).toBe("backend");
expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
expect(resolved.workOrderDataSource).toBe("backend");
});
});
@@ -1,23 +1,60 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import {
fetchWorkbenchRuntimeSnapshot,
type WorkbenchRuntimeSnapshot
} from "../api/workbench-runtime-client";
import { fetchRealtimePipeFlows } from "../api/realtime-flow-client";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "../data/scheduled-conditions";
import { WORKBENCH_SCENARIOS } from "../data/workbench-session";
import type { ScheduledConditionItem } from "../types";
import { createScheduledConditionAlerts } from "../utils/scheduled-condition-alerts";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
const BACKEND_RUNTIME_REFRESH_INTERVAL_MS = 15_000;
export function formatHeaderDataTime(date: Date) {
const snappedMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
export function resolveWorkbenchOperationalDate(
backendSnapshot: WorkbenchRuntimeSnapshot | null,
options: {
now?: Date;
runtimeMode?: "auto" | "demo" | "history" | "backend";
} = {}
) {
const now = options.now ?? new Date();
if (
!["demo", "history"].includes(options.runtimeMode ?? "auto")
|| !backendSnapshot
) return now;
const timestamps = backendSnapshot.workOrders
.map((workOrder) => Date.parse(workOrder.scheduledAt))
.filter(Number.isFinite);
const historicalTimestamps = timestamps.filter((timestamp) => timestamp <= now.getTime());
const latestTimestamp = historicalTimestamps.length > 0
? Math.max(...historicalTimestamps)
: timestamps.length > 0
? Math.min(...timestamps)
: null;
return latestTimestamp === null ? now : new Date(latestTimestamp);
}
export function formatHeaderDataTime(date: Date, snapToStep = true) {
const minutes = snapToStep
? Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES
: date.getMinutes();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(snappedMinutes).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
return `${year}-${month}-${day} ${hours}:${formattedMinutes}`;
}
export function getMsUntilNextHeaderDataTimeTick(date: Date) {
@@ -30,9 +67,74 @@ export function getMsUntilNextHeaderDataTimeTick(date: Date) {
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
}
export function useWorkbenchRuntimeData() {
export function resolveWorkbenchRuntimeData(
mockConditions: ScheduledConditionItem[],
backendSnapshot: WorkbenchRuntimeSnapshot | null,
options: {
operationalDate?: Date;
runtimeMode?: "auto" | "demo" | "history" | "backend";
} = {}
) {
const operationalDate = options.operationalDate ?? new Date();
const fallbackConditions = mockConditions.filter(
(condition) => condition.kind === "condition"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
);
const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter(
(workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate)
);
const backendWorkOrdersConfigured = Boolean(
backendSnapshot && backendSnapshot.workOrderAvailability !== "not_configured"
);
const resolvedWorkOrders = backendWorkOrdersConfigured ? backendWorkOrders : [];
const backendAlertsConfigured = Boolean(
backendSnapshot
&& backendSnapshot.alertAvailability !== "not_configured"
);
const useBackendScenarios = Boolean(
backendSnapshot
&& backendSnapshot.scenarioAvailability !== "not_configured"
&& backendSnapshot.scenarios.length > 0
);
const useBackendArtifact = Boolean(
backendSnapshot
&& backendSnapshot.artifactAvailability !== "not_configured"
&& backendSnapshot.latestAnalysisArtifact
);
return {
scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
workbenchAlerts: backendAlertsConfigured
? backendSnapshot!.alerts
: createScheduledConditionAlerts(fallbackConditions),
alertDataSource: backendAlertsConfigured ? "backend" as const : "mock" as const,
runtimeDataSource: "mock" as const,
scenarios: useBackendScenarios ? backendSnapshot!.scenarios : WORKBENCH_SCENARIOS,
scenarioDataSource: useBackendScenarios ? "analysis_runs" as const : "mock" as const,
latestAnalysisArtifact: useBackendArtifact
? backendSnapshot!.latestAnalysisArtifact
: null,
simulationOverlay: useBackendArtifact ? backendSnapshot!.simulationOverlay : null,
artifactDataSource: useBackendArtifact ? "analysis_runs" as const : "mock" as const,
workOrderDataSource: backendWorkOrdersConfigured ? "backend" as const : "none" as const
};
}
function isSameLocalOperationalDate(value: string, operationalDate: Date) {
const candidate = new Date(value);
if (Number.isNaN(candidate.getTime()) || Number.isNaN(operationalDate.getTime())) return false;
return candidate.getFullYear() === operationalDate.getFullYear()
&& candidate.getMonth() === operationalDate.getMonth()
&& candidate.getDate() === operationalDate.getDate();
}
export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
const [backendSnapshot, setBackendSnapshot] = useState<WorkbenchRuntimeSnapshot | null>(null);
const [realtimePipeFlows, setRealtimePipeFlows] = useState<Awaited<
ReturnType<typeof fetchRealtimePipeFlows>
> | null>(null);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
useEffect(() => {
@@ -50,7 +152,10 @@ export function useWorkbenchRuntimeData() {
useEffect(() => {
const refreshScheduledConditions = () => {
setScheduledConditions(createOperationalScheduledConditions(new Date()));
const operationalDate = resolveWorkbenchOperationalDate(backendSnapshot, {
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
});
setMockConditions(createOperationalScheduledConditions(operationalDate));
setScheduledConditionsLoading(false);
};
@@ -61,11 +166,114 @@ export function useWorkbenchRuntimeData() {
);
return () => window.clearInterval(intervalId);
}, []);
}, [backendSnapshot]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshBackendSnapshot = async () => {
try {
const snapshot = await fetchWorkbenchRuntimeSnapshot(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setBackendSnapshot(snapshot);
}
} catch {
if (!controller.signal.aborted) setBackendSnapshot(null);
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshBackendSnapshot,
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
);
}
}
};
void refreshBackendSnapshot();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshRealtimePipeFlows = async () => {
try {
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
}
} catch {
if (!controller.signal.aborted) setRealtimePipeFlows(null);
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshRealtimePipeFlows,
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
);
}
}
};
void refreshRealtimePipeFlows();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
const operationalDate = resolveWorkbenchOperationalDate(backendSnapshot, {
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
});
const resolvedRuntimeData = resolveWorkbenchRuntimeData(mockConditions, backendSnapshot, {
operationalDate,
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
});
const backendDataTime = realtimePipeFlows?.observedAt;
const backendRuntimeDataTime = backendSnapshot?.generatedAt;
const demoMode = env.TJWATER_WORKBENCH_RUNTIME_MODE === "demo";
const historyMode = env.TJWATER_WORKBENCH_RUNTIME_MODE === "history";
const backendRuntimeActive = resolvedRuntimeData.runtimeDataSource !== "mock";
const backendWorkOrdersActive = resolvedRuntimeData.workOrderDataSource === "backend";
const backendHistoryActive = historyMode && (backendRuntimeActive || backendWorkOrdersActive);
return {
headerDataTime,
scheduledConditions,
scheduledConditionsLoading
headerDataTime: backendHistoryActive
? `历史 ${formatHeaderDataTime(operationalDate, false)}`
: demoMode || historyMode
? `演示 ${formatHeaderDataTime(operationalDate, false)}`
: backendDataTime
? formatHeaderDataTime(new Date(backendDataTime), false)
: backendRuntimeActive && backendRuntimeDataTime
? formatHeaderDataTime(new Date(backendRuntimeDataTime), false)
: headerDataTime === INITIAL_HEADER_DATA_TIME
? headerDataTime
: `演示 ${headerDataTime}`,
headerDataSource: backendHistoryActive
? "history" as const
: demoMode || historyMode
? "demo" as const
: backendDataTime
? "realtime" as const
: backendRuntimeActive
? "backend" as const
: "mock" as const,
operationalDate: formatLocalDateKey(operationalDate),
operationalCursorTime: operationalDate.toISOString(),
scheduledConditionsLoading,
realtimePipeFlows,
flowDataSource: realtimePipeFlows ? "realtime" as const : "mock" as const,
...resolvedRuntimeData
};
}
function formatLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
@@ -0,0 +1,66 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchScenarioAnalysis } from "./use-workbench-scenario-analysis";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
function analysis(runId: string) {
return {
run_id: runId,
name: "历史爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:20:00Z",
started_at: "2026-09-17T02:10:00Z",
results: [],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "历史运行几何" },
geometry: { type: "Point", coordinates: [121.4, 30.8] }
}]
}
};
}
describe("selected workbench scenario analysis", () => {
afterEach(() => vi.unstubAllGlobals());
it("loads geometry for the selected backend run", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(analysis(runId)), { status: 200 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("backend"));
expect(result.current.projection?.runId).toBe(runId);
expect(result.current.projection?.simulationOverlay?.features[0]?.properties).toEqual({
label: "历史运行几何"
});
unmount();
});
it("falls back independently when the selected run cannot be read", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "unavailable" }), { status: 503 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("fallback"));
expect(result.current.projection).toBeNull();
unmount();
});
});
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchWorkbenchAnalysisRun,
type WorkbenchAnalysisProjection
} from "../api/workbench-runtime-client";
const SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS = 15_000;
type ScenarioAnalysisState = {
runId: string;
projection: WorkbenchAnalysisProjection | null;
failed: boolean;
};
export type WorkbenchScenarioAnalysis = {
projection: WorkbenchAnalysisProjection | null;
status: "disabled" | "loading" | "backend" | "fallback";
};
export function useWorkbenchScenarioAnalysis(
runId: string | null,
getAccessToken?: AccessTokenProvider
): WorkbenchScenarioAnalysis {
const [state, setState] = useState<ScenarioAnalysisState | null>(null);
useEffect(() => {
if (!runId) {
setState(null);
return;
}
const controller = new AbortController();
let timeoutId: number | undefined;
setState((current) => current?.runId === runId
? current
: { runId, projection: null, failed: false });
const refresh = async () => {
try {
const projection = await fetchWorkbenchAnalysisRun(
runId,
getAccessToken,
controller.signal
);
if (!controller.signal.aborted) {
setState({ runId, projection, failed: false });
}
} catch {
if (!controller.signal.aborted) {
setState({ runId, projection: null, failed: true });
}
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(refresh, SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS);
}
}
};
void refresh();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken, runId]);
if (!runId) return { projection: null, status: "disabled" };
if (!state || state.runId !== runId) return { projection: null, status: "loading" };
if (state.failed) return { projection: null, status: "fallback" };
if (state.projection) return { projection: state.projection, status: "backend" };
return { projection: null, status: "loading" };
}