447 lines
22 KiB
TypeScript
447 lines
22 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Activity,
|
|
ChevronDown,
|
|
RotateCcw,
|
|
Square,
|
|
Pause,
|
|
Play
|
|
} from "lucide-react";
|
|
|
|
import { Button } from "@/shared/ui/button";
|
|
import { DateTimeField } from "@/shared/ui/date-time-field";
|
|
import type { LatestScadaReadings } from "../api/scada-client";
|
|
import type { WorkbenchAccess } from "../api/request-context";
|
|
import type { ScadaReplay, ScadaReplayRule } from "../api/scada-replay-client";
|
|
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
|
import { snapTimelineTime } from "./scada-replay-time";
|
|
import { ScadaTimeTuner } from "./scada-time-tuner";
|
|
import { ScadaMonitoringRules } from "./scada-monitoring-rules";
|
|
import { useScadaReplay } from "./use-scada-replay";
|
|
import { useScadaPlayback } from "./use-scada-playback";
|
|
import { ScadaMonitoringNotice } from "./scada-monitoring-notice";
|
|
import { ScadaMonitoringStatus } from "./scada-monitoring-status";
|
|
|
|
const ACTIVE = new Set(["ready", "paused"]);
|
|
const TIMELINE_STEP_MS = 5 * 60_000;
|
|
const floorToTimelineStep = (time: number) => Math.floor(time / TIMELINE_STEP_MS) * TIMELINE_STEP_MS;
|
|
const CASE3_START = Date.parse("2026-07-16T07:30:00+08:00");
|
|
const CASE3_END = Date.parse("2026-07-16T08:30:00+08:00");
|
|
const STATUS: Record<ScadaReplay["status"], string> = {
|
|
ready: "待监测",
|
|
paused: "等待下一步",
|
|
completed: "区间已完成",
|
|
stopped: "本轮已停止",
|
|
failed: "处理失败"
|
|
};
|
|
|
|
export function ScadaReplaySection({
|
|
active,
|
|
catalog,
|
|
getAccessToken,
|
|
onOpenInvestigation
|
|
}: {
|
|
active: boolean;
|
|
catalog: LatestScadaReadings;
|
|
getAccessToken?: WorkbenchAccess;
|
|
onOpenInvestigation?: (taskId: string) => void;
|
|
}) {
|
|
const backend = useScadaReplay(active, getAccessToken);
|
|
const { replay, capabilities, busy } = backend;
|
|
const [start, setStart] = useState(() => localTime(CASE3_START));
|
|
const [end, setEnd] = useState(() => localTime(CASE3_END));
|
|
const [threshold, setThreshold] = useState("30");
|
|
const [qualityMetric, setQualityMetric] = useState("");
|
|
const [qualityLower, setQualityLower] = useState("");
|
|
const [qualityUpper, setQualityUpper] = useState("");
|
|
const [selectedTypes, setSelectedTypes] = useState(() => new Set(
|
|
catalog.items.map((item) => item.device_type)
|
|
));
|
|
const [view, setView] = useState<number | null>(null);
|
|
const restoredId = useRef<string | undefined>(undefined);
|
|
const announced = useRef(new Set<string>());
|
|
const [animationId, setAnimationId] = useState<string | null>(null);
|
|
const [monitoringSelection, setMonitoringSelection] = useState(false);
|
|
const locked = Boolean(replay && ACTIVE.has(replay.status));
|
|
const rulesFrozen = Boolean(replay && ["ready", "paused", "failed"].includes(replay.status));
|
|
const monitoringConfig = rulesFrozen ? replay?.monitoring_config : capabilities?.monitoring_config;
|
|
const obsoleteCalibration = Boolean(rulesFrozen && replay?.monitoring_config?.decision_policy === "weighted"
|
|
&& (replay.processed_count > 0 || replay.monitoring_calibration)
|
|
&& (replay.monitoring_calibration?.version !== "scada-monitor-calibration@2"
|
|
|| replay.monitoring_calibration?.diagnostic_policy !== "normal-phase-envelope@1"));
|
|
const interval = locked && replay
|
|
? replay.monitoring_interval_seconds
|
|
: capabilities?.monitoring_interval_seconds;
|
|
const startMs = locked && replay ? Date.parse(replay.start_time) : Date.parse(start);
|
|
const endMs = locked && replay ? Date.parse(replay.end_time) : Date.parse(end);
|
|
const matchingReplay = replay && Date.parse(replay.start_time) === startMs && Date.parse(replay.end_time) === endMs
|
|
? replay : null;
|
|
const case3Range = startMs === CASE3_START && endMs === CASE3_END;
|
|
const now = matchingReplay ? Date.parse(matchingReplay.virtual_now) : startMs;
|
|
const processedDisplayTime = replay?.processed_until
|
|
? floorToTimelineStep(Date.parse(replay.processed_until))
|
|
: null;
|
|
const selectedDevices = catalog.items.filter((item) => selectedTypes.has(item.device_type));
|
|
const rules = useMemo(() => {
|
|
const groups = new Map<string, typeof catalog.items>();
|
|
for (const item of catalog.items) {
|
|
if (!selectedTypes.has(item.device_type)) continue;
|
|
const key = `${item.device_type}/${item.measurement_unit}/${item.transmission_frequency}`;
|
|
groups.set(key, [...groups.get(key) ?? [], item]);
|
|
}
|
|
const result: ScadaReplayRule[] = [];
|
|
for (const devices of groups.values()) {
|
|
const device = devices[0];
|
|
if (!device.measurement_unit) continue;
|
|
if (["pressure", "pipe_flow", "flow"].includes(device.device_type) && Number(threshold) > 0) {
|
|
result.push({
|
|
rule_id: `change_${result.length}`,
|
|
device_ids: devices.map((item) => item.device_id),
|
|
kind: "relative_change",
|
|
direction: device.device_type === "pressure" ? "decrease" : "both",
|
|
threshold: Number(threshold),
|
|
unit: device.measurement_unit,
|
|
minimum_samples: 3,
|
|
window_samples: 4,
|
|
consecutive_samples: 1,
|
|
recovery_samples: 2,
|
|
minimum_devices: 1
|
|
});
|
|
}
|
|
if (device.device_type === "quality" && qualityMetric.trim()
|
|
&& (qualityLower.trim() || qualityUpper.trim())) {
|
|
result.push({
|
|
rule_id: `quality_${result.length}`,
|
|
device_ids: devices.map((item) => item.device_id),
|
|
kind: "bounds",
|
|
unit: device.measurement_unit,
|
|
metric_name: qualityMetric.trim(),
|
|
lower: qualityLower.trim() ? Number(qualityLower) : null,
|
|
upper: qualityUpper.trim() ? Number(qualityUpper) : null,
|
|
consecutive_samples: 1,
|
|
recovery_samples: 2,
|
|
minimum_devices: 1
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}, [catalog, selectedTypes, threshold, qualityMetric, qualityLower, qualityUpper]);
|
|
const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs
|
|
&& endMs - startMs <= 31 * 24 * 60 * 60_000
|
|
&& selectedDevices.length > 0 && selectedDevices.length <= 500
|
|
&& Boolean(interval) && rules.length > 0
|
|
&& rules.every((rule) => [rule.threshold, rule.lower, rule.upper].every(
|
|
(number) => number == null || Number.isFinite(number)
|
|
) && (rule.lower == null || rule.upper == null || rule.lower < rule.upper));
|
|
|
|
useEffect(() => {
|
|
if (!replay || restoredId.current === replay.replay_id) return;
|
|
restoredId.current = replay.replay_id;
|
|
if (!["ready", "paused", "failed"].includes(replay.status)) return;
|
|
setView(null);
|
|
setStart(localTime(Date.parse(replay.start_time)));
|
|
setEnd(localTime(Date.parse(replay.end_time)));
|
|
setSelectedTypes(new Set(replay.devices.map((device) => device.device_type)));
|
|
const relative = replay.rules.find((rule) => rule.kind === "relative_change");
|
|
if (relative?.threshold != null) setThreshold(String(relative.threshold));
|
|
const water = replay.rules.find((rule) => rule.kind === "bounds" && rule.metric_name);
|
|
setQualityMetric(water?.metric_name ?? "");
|
|
setQualityLower(water?.lower == null ? "" : String(water.lower));
|
|
setQualityUpper(water?.upper == null ? "" : String(water.upper));
|
|
}, [replay]);
|
|
|
|
const announceMonitoringResult = (next: ScadaReplay | null) => {
|
|
const result = next?.last_monitoring;
|
|
if (result && (result.task_id || ["attention", "suspected_burst"].includes(result.status))
|
|
&& !["ongoing", "resolved"].includes(result.incident?.action ?? "")) {
|
|
const id = result.incident?.incident_id ?? result.task_id ?? result.monitoring_id;
|
|
if (!announced.current.has(id)) {
|
|
announced.current.add(id);
|
|
setAnimationId(id);
|
|
}
|
|
}
|
|
};
|
|
const advance = async (): Promise<ScadaReplay | null> => {
|
|
const prior = matchingReplay?.last_monitoring;
|
|
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
|
|
let next: ScadaReplay | null;
|
|
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
|
|
next = await backend.command("step");
|
|
} else {
|
|
if (!valid) return null;
|
|
next = await backend.change(async () => {
|
|
const created = await backend.client.create({
|
|
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
|
|
device_ids: selectedDevices.map((item) => item.device_id), rules
|
|
});
|
|
return backend.client.command(created, "step");
|
|
});
|
|
}
|
|
announceMonitoringResult(next);
|
|
return next;
|
|
};
|
|
const canMonitor = Boolean(interval && capabilities?.available && capabilities.can_write && (locked || valid)
|
|
&& !backend.error && !obsoleteCalibration
|
|
&& (!replay || ["ready", "paused", "completed", "stopped"].includes(replay.status)));
|
|
const playback = useScadaPlayback({
|
|
active, enabled: canMonitor, busy: busy || Boolean(replay?.step_pending),
|
|
start: startMs, end: endMs, now, intervalSeconds: interval,
|
|
restart: Boolean(replay && ["completed", "stopped"].includes(replay.status)), advance
|
|
});
|
|
const displayTime = view === null && playback.started ? playback.time
|
|
: snapTimelineTime(view ?? now, startMs, endMs, TIMELINE_STEP_MS);
|
|
|
|
const future = Boolean(
|
|
replay && view !== null && (!replay.processed_until || displayTime > Date.parse(replay.processed_until))
|
|
);
|
|
const selectionNeedsNewRound = Boolean(matchingReplay && (displayTime < now
|
|
|| ["stopped", "failed"].includes(matchingReplay.status) || obsoleteCalibration));
|
|
const monitorSelection = async () => {
|
|
playback.pause();
|
|
const target = new Date(displayTime).toISOString();
|
|
const prior = matchingReplay?.last_monitoring;
|
|
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
|
|
setMonitoringSelection(true);
|
|
try {
|
|
const next = await backend.change(async () => {
|
|
let current = matchingReplay;
|
|
if (!current || selectionNeedsNewRound) {
|
|
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
|
|
await backend.client.command(replay, "stop");
|
|
}
|
|
current = await backend.client.create({
|
|
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
|
|
device_ids: selectedDevices.map((item) => item.device_id), rules
|
|
});
|
|
}
|
|
return backend.client.monitorAt(current, target);
|
|
});
|
|
announceMonitoringResult(next);
|
|
} finally {
|
|
setMonitoringSelection(false);
|
|
}
|
|
};
|
|
|
|
const selectCase3 = () => {
|
|
playback.pause();
|
|
const apply = () => {
|
|
setStart(localTime(CASE3_START));
|
|
setEnd(localTime(CASE3_END));
|
|
setView(null);
|
|
setSelectedTypes(new Set(catalog.items.map((item) => item.device_type)));
|
|
};
|
|
if (rulesFrozen && replay) {
|
|
return backend.change(async () => {
|
|
const stopped = await backend.client.command(replay, "stop");
|
|
apply();
|
|
return stopped;
|
|
});
|
|
}
|
|
apply();
|
|
};
|
|
|
|
const restarting = playback.newRound;
|
|
const requiresRestart = Boolean(matchingReplay && ["completed", "stopped"].includes(matchingReplay.status));
|
|
const restart = async () => {
|
|
playback.pause();
|
|
const next = await backend.change(async () => {
|
|
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
|
|
await backend.client.command(replay, "stop");
|
|
}
|
|
return backend.client.create({
|
|
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
|
|
device_ids: selectedDevices.map((item) => item.device_id), rules
|
|
});
|
|
});
|
|
if (next) {
|
|
setView(null);
|
|
setAnimationId(null);
|
|
playback.reset();
|
|
}
|
|
};
|
|
const result = restarting ? null : matchingReplay?.last_monitoring;
|
|
const nextTime = interval ? Math.min(endMs,
|
|
startMs + (Math.floor(((restarting ? startMs : now) - startMs) / (interval * 1000)) + 1) * interval * 1000
|
|
) : null;
|
|
const stepDisabled = busy || replay?.step_pending || !canMonitor || requiresRestart;
|
|
const restartDisabled = busy || replay?.step_pending || !capabilities?.available || !capabilities.can_write
|
|
|| !valid || (!matchingReplay && !playback.started);
|
|
const selectionDisabled = busy || replay?.step_pending || playback.playing
|
|
|| !capabilities?.available || !capabilities.can_write || !valid;
|
|
|
|
return (
|
|
<WorkbenchSection
|
|
title="SCADA 数据诊断"
|
|
description="自动播放并按周期监测,发现异常后启动调查。"
|
|
icon={<Activity size={15} />}
|
|
tone="teal"
|
|
bodyClassName="space-y-3"
|
|
>
|
|
{capabilities && !capabilities.available ? (
|
|
<WorkbenchNotice tone="warning" title="后台回放尚未配置">
|
|
{capabilities.reason}
|
|
</WorkbenchNotice>
|
|
) : null}
|
|
{backend.error || matchingReplay?.error ? (
|
|
<WorkbenchNotice role="alert" tone="danger" title="本步处理未完成">
|
|
{backend.error ?? matchingReplay?.error}
|
|
</WorkbenchNotice>
|
|
) : null}
|
|
{obsoleteCalibration ? (
|
|
<WorkbenchNotice tone="warning" title="本轮使用旧版诊断">
|
|
旧版判断已停用,请重新开始本轮,以使用历史同期正常范围。
|
|
</WorkbenchNotice>
|
|
) : null}
|
|
{capabilities && !interval ? (
|
|
<WorkbenchNotice tone="warning" title="监测频率未配置">
|
|
{locked ? "本轮未固定监测频率,请停止后重新建立演示。" : "请先在项目配置中设置监测频率。"}
|
|
</WorkbenchNotice>
|
|
) : null}
|
|
{capabilities && interval && !capabilities.burst_monitoring_ready ? (
|
|
<WorkbenchNotice tone="neutral" title="爆管组合监测尚未就绪">
|
|
{capabilities.burst_monitoring_reason};仍可演示读取、清洗和基础规则检测。
|
|
</WorkbenchNotice>
|
|
) : null}
|
|
|
|
<ScadaMonitoringStatus
|
|
status={replay?.step_pending || busy ? "正在处理监测数据" : playback.playing ? "自动播放中" : matchingReplay?.status === "completed" && !restarting ? "播放完成" : playback.started ? "播放已暂停" : matchingReplay ? STATUS[matchingReplay.status] : "尚未开始"}
|
|
playing={playback.playing}
|
|
processing={busy || Boolean(replay?.step_pending)}
|
|
intervalSeconds={interval}
|
|
count={restarting ? 0 : matchingReplay?.monitoring_count ?? 0}
|
|
progress={Number.isFinite(startMs) && endMs > startMs
|
|
? Math.max(0, Math.min(1, ((playback.started ? playback.time : now) - startMs) / (endMs - startMs))) : 0}
|
|
/>
|
|
|
|
<ScadaMonitoringNotice result={result} animationId={animationId}
|
|
processing={busy || Boolean(replay?.step_pending)}
|
|
onOpenInvestigation={onOpenInvestigation ? (taskId) => { playback.pause(); onOpenInvestigation(taskId); } : undefined} />
|
|
|
|
<section aria-label="SCADA 回放控制" className="overflow-hidden rounded-xl border border-border bg-background">
|
|
<div className="space-y-3 p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[11px] leading-5 text-muted-foreground">
|
|
<span>Case3 · 事件 07/16 08:00 · 北京时间</span>
|
|
{case3Range ? <span>事件前后各 30 分钟</span> : <Button size="sm" variant="ghost"
|
|
disabled={busy || replay?.step_pending || !capabilities?.can_write}
|
|
onClick={() => void selectCase3()}>切换到 Case3</Button>}
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<DateTimeField
|
|
label="演示开始时间"
|
|
value={locked && replay ? localTime(startMs) : start}
|
|
onChange={(value) => { playback.pause(); setStart(value); }}
|
|
minuteStep={5}
|
|
disabled={locked || busy || playback.playing}
|
|
contentClassName="workbench-shadcn-surface"
|
|
/>
|
|
<DateTimeField
|
|
label="演示结束时间"
|
|
value={locked && replay ? localTime(endMs) : end}
|
|
onChange={(value) => { playback.pause(); setEnd(value); }}
|
|
minuteStep={5}
|
|
disabled={locked || busy || playback.playing}
|
|
contentClassName="workbench-shadcn-surface"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 border-t border-border p-3">
|
|
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? (
|
|
<ScadaTimeTuner
|
|
start={startMs}
|
|
end={endMs}
|
|
value={Math.max(startMs, Math.min(endMs, displayTime))}
|
|
step={TIMELINE_STEP_MS}
|
|
following={playback.started && view === null}
|
|
browsing={view !== null}
|
|
onInteract={playback.pause}
|
|
onChange={(value) => { playback.pause(); setView(value); }}
|
|
/>
|
|
) : null}
|
|
<div className="flex min-h-9 flex-wrap items-center justify-between gap-x-2 gap-y-1 text-[11px] leading-5 text-muted-foreground">
|
|
<span>{view !== null
|
|
? future ? "该时间尚未完成监测" : "已定位到历史监测时间"
|
|
: "拨动刻度只定位时间,不会自动触发监测"}</span>
|
|
<Button size="sm" variant="ghost" disabled={displayTime === startMs}
|
|
onClick={() => { playback.pause(); setView(startMs); }}>回到开始时间</Button>
|
|
</div>
|
|
<div role="group" aria-label="SCADA 播放控制" className="mx-auto grid w-full max-w-sm grid-cols-3 gap-2">
|
|
<Button size="sm" className="h-10 gap-1.5 px-2" disabled={stepDisabled || playback.playing}
|
|
onClick={() => { setView(null); playback.play(); }}><Play size={14} />播放</Button>
|
|
<Button size="sm" variant="outline" className="h-10 gap-1.5 px-2" disabled={!playback.playing}
|
|
onClick={playback.pause}><Pause size={14} />暂停</Button>
|
|
<Button size="sm" variant="ghost" className="h-10 px-2" disabled={restartDisabled}
|
|
onClick={() => void restart()}>重新开始</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 border-t border-border bg-muted/30 p-3">
|
|
{selectionNeedsNewRound ? <p className="text-[11px] leading-5 text-muted-foreground">
|
|
监测此时刻将建立新一轮回放,原有调查记录保留。
|
|
</p> : null}
|
|
<div role="group" aria-label="SCADA 单步监测" className="grid grid-cols-2 gap-2">
|
|
<Button size="sm" variant="outline" className="h-10 px-2" disabled={selectionDisabled}
|
|
onClick={() => void monitorSelection()}>
|
|
{monitoringSelection ? "正在监测" : "监测选定时刻"}
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="h-10 px-2" disabled={stepDisabled || playback.playing}
|
|
onClick={() => { playback.pause(); setView(null); void advance(); }}>
|
|
{!monitoringSelection && (replay?.step_pending || busy) ? "正在监测本步" : locked ? "下一步并监测" : "单步监测"}
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
|
<div className="space-y-0.5 text-[11px] leading-5 text-muted-foreground tabular-nums">
|
|
<p role="status" aria-label="SCADA 监测进度">
|
|
已处理至 {matchingReplay && !restarting && processedDisplayTime !== null
|
|
? stamp(new Date(processedDisplayTime).toISOString()) : "未开始"}
|
|
</p>
|
|
{nextTime !== null ? <p>{matchingReplay?.status === "completed" && !restarting
|
|
? "本轮监测已完成" : `下次监测 ${clock(new Date(nextTime).toISOString())}`}</p> : null}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{replay && ["ready", "paused", "failed"].includes(replay.status) ? <Button
|
|
size="sm" variant="ghost" className="h-10 px-2" disabled={busy || !capabilities?.can_write}
|
|
onClick={() => { playback.pause(); void backend.command("stop"); }}><Square size={13} />停止本轮</Button> : null}
|
|
{replay?.status === "failed" ? <Button size="sm" variant="outline" className="h-10 px-2"
|
|
disabled={busy || !capabilities?.can_write} onClick={() => { playback.pause(); void backend.command("retry"); }}>
|
|
<RotateCcw size={13} />重试本步</Button> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<details className="border-t border-border pt-3">
|
|
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 text-xs font-medium text-foreground">
|
|
<ChevronDown size={13} />检测规则
|
|
<span className="font-normal text-muted-foreground">
|
|
{monitoringConfig ? "任一指标异常即调查" : `${selectedDevices.length} 个测点 · ${rules.length} 项基础规则`}
|
|
</span>
|
|
</summary>
|
|
<div className="mt-3 space-y-3">
|
|
{monitoringConfig ? <ScadaMonitoringRules
|
|
config={monitoringConfig}
|
|
calibration={rulesFrozen ? replay?.monitoring_calibration : null}
|
|
frozen={rulesFrozen}
|
|
/> : <p className="text-xs text-muted-foreground">任一已配置指标异常即启动调查。项目暂未返回组合监测配置。</p>}
|
|
|
|
</div>
|
|
</details>
|
|
</WorkbenchSection>
|
|
);
|
|
}
|
|
|
|
function stamp(value: string) {
|
|
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
|
|
}
|
|
|
|
function localTime(time: number) {
|
|
const date = new Date(time);
|
|
return new Date(time - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
|
}
|
|
|
|
function clock(value?: string | null) {
|
|
return value
|
|
? new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })
|
|
: "未开始";
|
|
}
|