feat(scada): add manual monitoring timeline
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
RotateCcw,
|
||||
SkipForward,
|
||||
Square,
|
||||
TriangleAlert
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
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 { formatMonitoringInterval, snapTimelineTime } from "./scada-replay-time";
|
||||
import { ScadaTimeTuner } from "./scada-time-tuner";
|
||||
import { useScadaReplay } from "./use-scada-replay";
|
||||
|
||||
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 ceilToTimelineStep = (time: number) => Math.ceil(time / TIMELINE_STEP_MS) * TIMELINE_STEP_MS;
|
||||
const STATUS: Record<ScadaReplay["status"], string> = {
|
||||
ready: "待监测",
|
||||
paused: "等待下一步",
|
||||
completed: "区间已完成",
|
||||
stopped: "本轮已停止",
|
||||
failed: "处理失败"
|
||||
};
|
||||
const MONITORING_STATUS = {
|
||||
normal: {
|
||||
title: "本周期监测正常",
|
||||
icon: CheckCircle2,
|
||||
className: "bg-emerald-50/80 text-emerald-950 ring-emerald-600/20",
|
||||
iconClassName: "text-emerald-700"
|
||||
},
|
||||
attention: {
|
||||
title: "本周期需要关注",
|
||||
icon: TriangleAlert,
|
||||
className: "bg-amber-50/90 text-amber-950 ring-amber-600/20",
|
||||
iconClassName: "text-amber-700"
|
||||
},
|
||||
insufficient_data: {
|
||||
title: "本周期数据不足",
|
||||
icon: CircleHelp,
|
||||
className: "bg-[var(--surface-control)] text-slate-800 ring-[var(--surface-border)]",
|
||||
iconClassName: "text-slate-500"
|
||||
},
|
||||
suspected_burst: {
|
||||
title: "本周期疑似爆管",
|
||||
icon: TriangleAlert,
|
||||
className: "bg-rose-50/90 text-rose-950 ring-rose-600/20",
|
||||
iconClassName: "text-rose-700"
|
||||
}
|
||||
};
|
||||
|
||||
export function ScadaReplaySection({
|
||||
active,
|
||||
catalog,
|
||||
getAccessToken
|
||||
}: {
|
||||
active: boolean;
|
||||
catalog: LatestScadaReadings;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
}) {
|
||||
const backend = useScadaReplay(active, getAccessToken);
|
||||
const { replay, capabilities, busy } = backend;
|
||||
const [start, setStart] = useState(() => localTime(
|
||||
floorToTimelineStep(Date.parse(catalog.latest_observed_at ?? new Date().toISOString()) - 24 * 60 * 60_000)
|
||||
));
|
||||
const [end, setEnd] = useState(() => localTime(
|
||||
ceilToTimelineStep(Date.parse(catalog.latest_observed_at ?? new Date().toISOString()))
|
||||
));
|
||||
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 locked = Boolean(replay && ACTIVE.has(replay.status));
|
||||
const interval = locked && replay
|
||||
? replay.monitoring_interval_seconds
|
||||
: capabilities?.monitoring_interval_seconds;
|
||||
const intervalLabel = interval ? formatMonitoringInterval(interval) : null;
|
||||
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 now = replay ? Date.parse(replay.virtual_now) : startMs;
|
||||
const displayTime = snapTimelineTime(view ?? now, startMs, endMs, TIMELINE_STEP_MS);
|
||||
const processedDisplayTime = replay?.processed_until
|
||||
? floorToTimelineStep(Date.parse(replay.processed_until))
|
||||
: null;
|
||||
const future = Boolean(
|
||||
replay && view !== null && (!replay.processed_until || displayTime > Date.parse(replay.processed_until))
|
||||
);
|
||||
const types = [...new Set(catalog.items.map((item) => item.device_type))];
|
||||
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;
|
||||
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 advance = () => {
|
||||
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
|
||||
return backend.command("step");
|
||||
}
|
||||
if (!valid) return;
|
||||
return 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");
|
||||
});
|
||||
};
|
||||
|
||||
const monitoring = replay?.last_monitoring
|
||||
? MONITORING_STATUS[replay.last_monitoring.status]
|
||||
: null;
|
||||
const MonitoringIcon = monitoring?.icon;
|
||||
const stepDisabled = busy || replay?.step_pending || !interval
|
||||
|| !capabilities?.available || !capabilities.can_write
|
||||
|| (!locked && !valid)
|
||||
|| Boolean(replay && !["ready", "paused", "completed", "stopped"].includes(replay.status));
|
||||
|
||||
return (
|
||||
<WorkbenchSection
|
||||
title="SCADA 监测演示"
|
||||
description="时间轴按 5 分钟定位,监测按钮按项目周期触发异常分析。"
|
||||
icon={<Activity size={15} />}
|
||||
tone="teal"
|
||||
bodyClassName="space-y-4"
|
||||
>
|
||||
{capabilities && !capabilities.available ? (
|
||||
<WorkbenchNotice tone="warning" title="后台回放尚未配置">
|
||||
{capabilities.reason}
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
{backend.error || replay?.error ? (
|
||||
<WorkbenchNotice role="alert" tone="danger" title="本步处理未完成">
|
||||
{backend.error ?? replay?.error}
|
||||
</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}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<DateTimeField
|
||||
label="演示开始时间"
|
||||
value={locked && replay ? localTime(startMs) : start}
|
||||
onChange={setStart}
|
||||
minuteStep={5}
|
||||
disabled={locked || busy}
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
/>
|
||||
<DateTimeField
|
||||
label="演示结束时间"
|
||||
value={locked && replay ? localTime(endMs) : end}
|
||||
onChange={setEnd}
|
||||
minuteStep={5}
|
||||
disabled={locked || busy}
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-label="SCADA 演示状态"
|
||||
className="grid grid-cols-3 divide-x divide-[var(--surface-divider)] overflow-hidden rounded-lg bg-[var(--surface-control)] ring-1 ring-inset ring-[var(--surface-divider)]"
|
||||
>
|
||||
<Readout label="监测步长" value={intervalLabel ?? "未配置"} />
|
||||
<Readout label="当前状态" value={replay ? STATUS[replay.status] : "尚未开始"} />
|
||||
<Readout label="已监测周期" value={String(replay?.monitoring_count ?? 0)} />
|
||||
</div>
|
||||
|
||||
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? (
|
||||
<div className="py-1">
|
||||
<ScadaTimeTuner
|
||||
start={startMs}
|
||||
end={endMs}
|
||||
value={Math.max(startMs, Math.min(endMs, displayTime))}
|
||||
step={TIMELINE_STEP_MS}
|
||||
following={false}
|
||||
browsing={view !== null}
|
||||
onChange={setView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-10 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] leading-5 text-muted-foreground">
|
||||
<span>
|
||||
{view !== null
|
||||
? future ? "该时间尚未完成监测" : "已定位到历史监测时间"
|
||||
: "拨动刻度只定位时间,不会自动触发监测"}
|
||||
</span>
|
||||
{view !== null ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => setView(null)}>回到监测时间</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10"
|
||||
disabled={stepDisabled}
|
||||
onClick={() => void advance()}
|
||||
>
|
||||
<SkipForward size={14} />
|
||||
{replay?.step_pending
|
||||
? "正在监测本步"
|
||||
: locked ? "下一步并监测" : replay ? "开始新一轮监测" : "开始并监测"}
|
||||
</Button>
|
||||
{replay && ["ready", "paused", "failed"].includes(replay.status) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-10"
|
||||
disabled={busy || !capabilities?.can_write}
|
||||
onClick={() => void backend.command("stop")}
|
||||
>
|
||||
<Square size={13} />停止本轮
|
||||
</Button>
|
||||
) : null}
|
||||
{replay?.status === "failed" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-10"
|
||||
disabled={busy || !capabilities?.can_write}
|
||||
onClick={() => void backend.command("retry")}
|
||||
>
|
||||
<RotateCcw size={13} />重试本步
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{replay ? (
|
||||
<p role="status" aria-label="SCADA 监测进度" className="text-[11px] leading-5 text-muted-foreground tabular-nums">
|
||||
当前时间 {clock(new Date(displayTime).toISOString())} · 已处理至 {processedDisplayTime === null ? "未开始" : clock(new Date(processedDisplayTime).toISOString())}
|
||||
{replay.anomaly_count ? ` · 累计检出 ${replay.anomaly_count} 个异常事件` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{replay?.last_monitoring && monitoring && MonitoringIcon ? (
|
||||
<section
|
||||
aria-label="本步监测结果"
|
||||
role="status"
|
||||
className={`rounded-lg px-3.5 py-3 ring-1 ring-inset ${monitoring.className}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MonitoringIcon className={`mt-0.5 shrink-0 ${monitoring.iconClassName}`} size={17} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<h4 className="text-xs font-semibold">{monitoring.title}</h4>
|
||||
<time className="text-[11px] tabular-nums" dateTime={replay.last_monitoring.period_end}>
|
||||
{clock(replay.last_monitoring.period_end)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5">{replay.last_monitoring.detail}</p>
|
||||
{replay.last_monitoring.task_id ? (
|
||||
<p className="mt-1 text-[11px] font-medium">已进入爆管调查与定位流程</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<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">
|
||||
{selectedDevices.length} 个测点 · {rules.length} 项规则
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{locked && replay ? (
|
||||
<ul className="space-y-1 text-xs text-muted-foreground" aria-label="本轮固定规则">
|
||||
{replay.rules.map((rule) => (
|
||||
<li key={rule.rule_id}>
|
||||
{rule.metric_name ?? typeLabel(replay.devices.find((device) => rule.device_ids.includes(device.device_id))?.device_type ?? rule.rule_id)}
|
||||
{` · ${rule.device_ids.length} 个测点 · `}
|
||||
{rule.kind === "bounds"
|
||||
? `${rule.lower ?? "无下限"} ~ ${rule.upper ?? "无上限"} ${rule.unit}`
|
||||
: `变化阈值 ${rule.threshold}%`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{types.map((type) => (
|
||||
<label key={type} className="flex min-h-10 items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTypes.has(type)}
|
||||
disabled={locked || busy}
|
||||
onChange={(event) => setSelectedTypes((current) => {
|
||||
const next = new Set(current);
|
||||
if (event.target.checked) next.add(type);
|
||||
else next.delete(type);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
{typeLabel(type)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-xs text-muted-foreground">
|
||||
压力下降/流量变化阈值(%)
|
||||
<Input
|
||||
aria-label="压力流量变化阈值"
|
||||
className="mt-1 h-10 bg-card"
|
||||
type="number"
|
||||
min="0.1"
|
||||
value={threshold}
|
||||
disabled={locked || busy}
|
||||
onChange={(event) => setThreshold(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{types.includes("quality") ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-xs text-muted-foreground sm:col-span-2">
|
||||
水质指标名称
|
||||
<Input
|
||||
aria-label="水质指标名称"
|
||||
className="mt-1 h-10 bg-card"
|
||||
value={qualityMetric}
|
||||
onChange={(event) => setQualityMetric(event.target.value)}
|
||||
disabled={locked || busy}
|
||||
placeholder="例如:化学示踪物浓度"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground">
|
||||
水质下限(设备单位)
|
||||
<Input aria-label="水质下限" className="mt-1 h-10 bg-card" type="number" step="any" value={qualityLower} onChange={(event) => setQualityLower(event.target.value)} disabled={locked || busy} />
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground">
|
||||
水质上限(设备单位)
|
||||
<Input aria-label="水质上限" className="mt-1 h-10 bg-card" type="number" step="any" value={qualityUpper} onChange={(event) => setQualityUpper(event.target.value)} disabled={locked || busy} />
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
原始值先存入 TSDB,再完成质量校验与清洗;异常检测使用清洗结果,同时保留原始值供追溯。
|
||||
</p>
|
||||
{!valid && !locked ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
请选择不超过 31 天的有效区间、最多 500 个设备,并配置至少一项有效规则。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</WorkbenchSection>
|
||||
);
|
||||
}
|
||||
|
||||
function Readout({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 px-2.5 py-2.5 sm:px-3">
|
||||
<p className="truncate text-[10px] leading-4 text-muted-foreground">{label}</p>
|
||||
<p className="mt-0.5 truncate text-xs font-semibold text-foreground tabular-nums">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 })
|
||||
: "未开始";
|
||||
}
|
||||
|
||||
function typeLabel(value: string) {
|
||||
return ({ pressure: "压力", pipe_flow: "流量", flow: "流量", quality: "水质" } as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
Reference in New Issue
Block a user