Compare commits

...

29 Commits

Author SHA1 Message Date
caoqianming 45b2317ee8 feat(docs): add README.md button opening shared doc drawer
Reuses the existing API.md drawer for README; switching between
docs reloads content and updates the drawer title. Backend serves
README.md via /api/docs/readme-md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:27:51 +08:00
caoqianming c2cac19f7e fix(points): ensure equipment map is loaded before rendering point list
loadPoints() relies on state.equipmentMap to display bound equipment names.
Running it in parallel with loadEquipments() caused a race condition where
points rendered before the map was populated, showing all as "Unbound".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:21:57 +08:00
caoqianming c1c70ed7f6 refactor(web): remove topbar equipment filter 2026-03-26 15:33:19 +08:00
caoqianming 656a2a6b36 feat: api文档更新 2026-03-26 13:55:35 +08:00
caoqianming 68b4eec610 fix(logs): follow rotated log files in stream 2026-03-26 13:41:36 +08:00
caoqianming 9f833f3a5e fix(control): refresh unit mappings on config changes 2026-03-26 13:30:14 +08:00
caoqianming dbfa673468 fix(control): validate unit timing configuration 2026-03-26 13:19:10 +08:00
caoqianming 86e651d9ca refactor(sort): order units and equipment by code 2026-03-26 12:57:01 +08:00
caoqianming ad4b0c0680 docs(api): update control section for fault-guard changes
- start-auto now requires fault_locked=false and manual_ack_required=false
- batch-start-auto now also skips units with manual_ack_required
- add manual_ack_required to pre-flight check list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:12:00 +08:00
caoqianming ce8383e815 feat(ops): replace signal dots with pills, gate Start/Stop on REM/FLT
Signal indicators are now wider pill badges (40×20px rect) with the role
label (REM/RUN/FLT) embedded inside, replacing the 10px dot+label rows.

Equipment Start/Stop buttons are disabled when:
- auto control is active
- REM = 0 (device in local mode, not accepting remote commands)
- FLT = 1 (fault active)

Button state reacts in real time to WS signal updates via a per-equipment
syncBtns closure registered in state.opsUnitSyncFns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:09:23 +08:00
caoqianming 00c16ae3d7 fix(unit): block auto control start when fault is active or unacknowledged
Prevent starting unit auto control while fault_locked or manual_ack_required,
enforcing that faults must be manually acknowledged before resuming automation.
Also disable the Start Auto button in the frontend with descriptive tooltips.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:54:10 +08:00
caoqianming f37924ae36 feat(events): color-code event level badges by severity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:44:12 +08:00
caoqianming 8e52a327f5 fix(events): load system events on page refresh in ops view
Events were only loaded when switching to config view, but the
system events panel lives in ops view, leaving it empty on refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:41:27 +08:00
caoqianming 4338895e0a refactor(ops): use units-embedded equipments, lazy-load config data
Ops view now reads equipment+role_points from state.units (returned by
unit list API) instead of state.equipments, eliminating the loadEquipments()
call on bootstrap.

Config data (sources, equipments, events, points) is deferred until the
user first switches to config view. On WS reconnect, loadEquipments is
only refreshed when config view is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:33:19 +08:00
caoqianming 0545388b85 feat(unit): embed equipments with role_points in unit list and get responses
Unit list and single-unit endpoints now include per-unit equipment list
with signal-role points and monitor data, consistent with unit detail.
Uses batch queries to avoid N+1 DB calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:26:45 +08:00
caoqianming 5a481a5eb3 refactor(simulate): consolidate all simulation code into simulate.rs
Moved simulate_run_feedback from command.rs into simulate.rs where it
reuses patch_signal. command.rs now only contains real PLC command logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:17:44 +08:00
caoqianming 532eeaba42 feat(simulate): chaos task for rem/flt signal testing
When SIMULATE_PLC=true, a background task randomly disrupts rem or flt
signals on equipment (rem=false for 5-15s, flt=true for 3-10s) to
exercise fault detection, comm lock, and recovery logic in the engine.
Uses XorShift64 PRNG with no extra dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:11:05 +08:00
caoqianming 9d787e452b feat(events): use Chinese messages with entity names
Event messages are now stored and displayed in Chinese. Names/codes are
resolved via lightweight DB lookups in persist_event_if_needed (entities
still exist at processing time). SourceDelete passes the name explicitly
since the source is deleted before the async event is processed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:53:26 +08:00
caoqianming 0b7f2401bd feat(ws): refresh units and equipments on WebSocket reconnect
After a disconnect/reconnect, re-fetch units (runtimes) and equipments
(monitor data) so the UI reflects current server state without requiring
a page reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:37:00 +08:00
caoqianming e304fd342d feat(ops): embed role_points in equipment list, remove unit detail API calls
Equipment list response now includes signal-role points with monitor data,
so the ops view can render signal dots directly from state.equipments
without fetching /api/unit/:id/detail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:34:59 +08:00
caoqianming 0e8d194a70 fix(web): remove duplicate loadAllEquipmentCards call in startOps
startOps() was calling loadAllEquipmentCards() redundantly — the
units-loaded event listener already calls it after loadUnits()
completes. This caused two parallel requests to /api/unit/:id/detail
for every unit on page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:23:09 +08:00
caoqianming 08add0d087 refactor(api): embed runtime in unit list/get/detail responses
Remove the standalone GET /api/unit/runtimes endpoint in favour of
embedding runtime directly in existing responses:
- GET /api/unit          → each item now includes `runtime` field
- GET /api/unit/:id      → returns UnitWithRuntime
- GET /api/unit/:id/detail → UnitDetail now includes `runtime`

runtime is null when the engine has not yet initialised the unit.
Frontend loadUnits() reads the embedded runtime field to populate
state.runtimes — one request instead of two.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:18:14 +08:00
caoqianming 42cdbbc0cc fix(web): fetch all unit runtimes on page load
Root cause: state.runtimes was empty after refresh because the engine
only pushes UnitRuntimeChanged on state transitions — if the engine
is mid-wait-phase, no push occurs and badges show OFFLINE.

Fix: add GET /api/unit/runtimes batch endpoint (returns all known
runtimes as { unit_id: UnitRuntime }) and call it in parallel with
the unit list fetch inside loadUnits(), so runtime badges are correct
immediately after page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:11:47 +08:00
caoqianming b3f92867bc fix(engine): fix supervisor restart, deduplicate helpers, fix notify race
- engine.rs: replace HashSet<Uuid> with HashMap<Uuid, JoinHandle> in
  supervise(); use is_finished() to detect exited tasks so units that
  are disabled then re-enabled get a new task on next 10s scan
- control/mod.rs: extract shared monitor_value_as_bool (using the more
  complete validator version that includes "yes"); remove duplicate
  copies from engine.rs and validator.rs
- runtime.rs: fix get_or_create_notify TOCTOU by using entry API
  instead of read-drop-write pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:08:25 +08:00
caoqianming 4ce91adf60 refactor(web): remove dead code and translate plan docs to Chinese
- ops.js: remove unused `formatValue` import
- logs.js: remove `export` from internal-only `appendLog`
- state.js: fix stale comment ({ valueEl, qualityEl } → { dotEl })
- docs: rewrite both plan docs in Chinese; update dual-view-web plan to
  reflect actual implementation (sigDotClass dots, loadAllEquipmentCards,
  syncEquipmentButtonsForUnit, batch auto buttons in startOps)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:57:29 +08:00
caoqianming dd0e782450 fix(engine): push WS immediately on notify wake-up
When auto_enabled or fault_locked changes externally, the engine task
wakes via notify but previously only pushed WS on the next state
transition (potentially seconds later). Now push the fresh runtime
immediately in the notify.notified() arm so the frontend reflects
the change without delay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:44:46 +08:00
caoqianming 8c1b7b636d refactor(engine): replace 500ms ticker with per-unit event-driven tasks
- Engine now spawns one async task per enabled unit (supervised every 10s)
- wait_phase uses sleep_until + select! for precise timing; 500ms fault-tick
  runs inside each phase so fault/comm is still checked promptly
- WS UnitRuntimeChanged pushed only on state transitions, not every tick
- ControlRuntimeStore gains notify_unit/get_or_create_notify for instant
  wake-up when handlers change auto_enabled or fault_locked
- UnitRuntime: remove last_tick_at, current_run/stop/distributor_elapsed_sec;
  add display_acc_sec (snapshot at transition, avoids mid-cycle jitter)
- accumulated_run_sec now increments by exact run_time_sec*1000 per cycle
- unit.state_changed events no longer written to DB (too frequent)
- Frontend: show display_acc_sec instead of accumulated_run_sec
- styles: event-card flex-shrink:0 fixes text overlap under flex column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:33:00 +08:00
caoqianming da03441c11 refactor(events): display each event on a single line
Flatten two-row card (meta + message) into one flex row:
badge | time | event_type | message (ellipsis overflow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 16:40:43 +08:00
caoqianming a8d36578fa feat: ws backoff, signal dots, dom cap, unwrap fix, batch size limit
- logs.js: WS reconnect exponential backoff 1s→2s→4s…30s
- ops.js: replace badge+text signal display with red/green/yellow dots
  (sig-on=green, sig-fault=red, sig-warn=yellow, gray=off)
- events.js: cap live-prepended event cards at 100 DOM nodes
- source.rs: fix attach_children unwrap() → Option<TreeNode>/filter_map
- point.rs: add max=500 validation to all batch Vec<Uuid> fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 16:37:14 +08:00
30 changed files with 2588 additions and 1361 deletions

734
API.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,272 @@
# 控制引擎实现计划
> **适用于代理执行:** 必须使用 superpowers:subagent-driven-development推荐或 superpowers:executing-plans 逐任务执行。步骤使用复选框(`- [ ]`)语法跟踪进度。
**目标:** 实现投煤器 / 布料机单元的自动控制引擎,包括状态机、故障/通信保护、运行时 API 及前端控制面板。
**架构:** 引擎为每个已启用的单元各启动一个异步任务由10秒扫描器监督。每个任务通过 `tokio::time::sleep_until` 控制阶段计时,通过 `tokio::sync::Notify` 在外部状态变化时(自动启停、故障确认)立即唤醒。状态保存在 `ControlRuntimeStore`(内存中,不持久化)。前端通过 `WsMessage::UnitRuntimeChanged` 实时接收更新——**仅在状态转换时推送**,不做周期性推送。
**技术栈:** Rust/Axum 后端、sqlx/PostgreSQL、tokio 异步、Vanilla JS ES 模块前端。
---
## 文件清单
| 文件 | 操作 | 职责 |
|------|------|------|
| `src/control/runtime.rs` | ✅ 已完成 | `UnitRuntime` 结构体 + `ControlRuntimeStore`(含 `Notify` |
| `src/control/command.rs` | ✅ 已完成 | 共享 `send_pulse_command()``simulate_run_feedback()` |
| `src/control/engine.rs` | ✅ 已完成 | 监督器 + 单元异步任务 + `wait_phase` |
| `src/control/validator.rs` | ✅ 已完成 | 故障/通信锁定时阻断手动指令 |
| `src/control/mod.rs` | ✅ 已完成 | 导出 `command`、`engine`、`runtime`、`validator` |
| `src/event.rs` | ✅ 已完成 | 7个 `AppEvent` 变体;`UnitStateChanged` 触发但**不持久化到数据库** |
| `src/websocket.rs` | ✅ 已完成 | `WsMessage::UnitRuntimeChanged` |
| `src/service/control.rs` | ✅ 已完成 | `get_all_enabled_units`、`get_equipment_by_unit_id` |
| `src/handler/control.rs` | ✅ 已完成 | `start_auto`、`stop_auto`、`batch_start_auto`、`batch_stop_auto`、`ack_fault`、`get_unit_runtime`;每次状态变更后调用 `notify_unit` |
| `src/main.rs` | ✅ 已完成 | 上述端点的路由注册 |
| `web/js/state.js` | ✅ 已完成 | `runtimes: new Map()` |
| `web/js/units.js` | ✅ 已完成 | 运行时状态徽章、Auto Start/Stop、Ack Fault显示 `display_acc_sec` |
| `web/js/ops.js` | ✅ 已完成 | 运维面板单元卡片显示运行时徽章与 `display_acc_sec` |
| `web/js/app.js` | ✅ 已完成 | 处理 `UnitRuntimeChanged` WS 消息 |
| `web/styles.css` | ✅ 已完成 | `.event-card { flex-shrink: 0 }` 防止 flex 列表中文字重叠 |
---
## UnitRuntime 结构体(当前)
```rust
// src/control/runtime.rs
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UnitRuntime {
pub unit_id: Uuid,
pub state: UnitRuntimeState,
pub auto_enabled: bool,
pub accumulated_run_sec: i64, // 内部累加器(毫秒),不直接用于显示
pub display_acc_sec: i64, // 状态转换时的快照,前端展示用此字段
pub fault_locked: bool,
pub flt_active: bool,
pub comm_locked: bool,
pub manual_ack_required: bool,
}
// 注意elapsed 字段current_run_elapsed_sec、current_stop_elapsed_sec、
// distributor_run_elapsed_sec、last_tick_at已在事件驱动重构中移除。
// 计时完全由单元任务内部的 tokio::time::sleep_until 管理,请勿重新添加。
```
`ControlRuntimeStore` 额外包含:
```rust
notifiers: Arc<RwLock<HashMap<Uuid, Arc<Notify>>>>,
// 方法:
pub async fn get_or_create_notify(&self, unit_id: Uuid) -> Arc<Notify>
pub async fn notify_unit(&self, unit_id: Uuid) // 每次状态变更后调用
```
---
## 引擎架构事件驱动2026-03-26
```
start()
└─ supervise() —— 10秒间隔为每个启用单元启动 unit_task
unit_task(unit_id)
├─ load_equipment_maps —— 任务启动时加载一次(缓存至任务生命周期结束)
├─ fault_tick —— 500ms 间隔,在 wait_phase 内部使用
└─ loop:
├─ 重新加载单元配置(检查是否仍启用)
├─ check_fault_comm → 有变化则推送 WS
├─ 若 !auto || fault || comm → select!(fault_tick | notify)continue
└─ 按状态分支:
Stopped → wait_phase(stop_time_sec) → 启动给煤机 → 状态=Running → 推送 WS
Running → wait_phase(run_time_sec) → 停止给煤机 → acc += run_time_sec
→ 若 acc >= acc_time_sec启动布料机状态=DistributorRunning
→ 否则:状态=Stopped → 推送 WS
DistributorRunning → wait_phase(bl_time_sec) → 停止布料机 → acc=0 → 状态=Stopped → 推送 WS
FaultLocked|CommLocked → select!(fault_tick | notify)
wait_phase(secs):
deadline = now + secs
loop:
select! { sleep_until(deadline) => 返回 true阶段正常完成
fault_tick.tick() => 重新检查故障/通信;若中断返回 false
notify.notified() => 重新检查故障/通信;若中断返回 false }
```
**关键不变量:**
- `accumulated_run_sec` 每个完成周期**精确**增加 `run_time_sec * 1000`(无 delta 漂移)。
- `display_acc_sec` 仅在 Running→Stopped 或 Running→DistributorRunning 转换时从 `accumulated_run_sec` 复制快照,前端始终读取 `display_acc_sec`
- WS **仅在状态变化时**推送,无周期性推送。
- `unit.state_changed` 事件仅用于日志记录,**不写入**数据库事件表(频率过高)。
---
## 任务一:扩展 UnitRuntime ✅ 已完成
**文件:** `src/control/runtime.rs`
字段如上方"UnitRuntime 结构体"所示。`ControlRuntimeStore` 包含 `notifiers` 映射,提供 `get_or_create_notify``notify_unit` 方法。
---
## 任务二:创建共享脉冲指令辅助函数 ✅ 已完成
**文件:** `src/control/command.rs`、`src/control/mod.rs`、`src/handler/control.rs`
`send_pulse_command(connection_manager, point_id, value_type, pulse_ms)` 写入高→延迟→低电平序列。
`simulate_run_feedback(state, eq_id, running)` 在模拟模式下写入虚拟运行反馈值(用于无真实 OPC UA 设备时的调试)。
---
## 任务三:在 validator.rs 添加运行时状态检查 ✅ 已完成
**文件:** `src/control/validator.rs`
`validate_manual_control` 的现有 REM/FLT/quality 检查之后添加:
```rust
if let Some(unit_id) = equipment.unit_id {
if let Some(runtime) = state.control_runtime.get(unit_id).await {
if runtime.auto_enabled {
return Err(ApiErr::Forbidden("自动控制已激活,请先停止自动控制", ...));
}
if runtime.comm_locked {
return Err(ApiErr::Forbidden("单元通信已锁定", ...));
}
if runtime.fault_locked {
return Err(ApiErr::Forbidden("单元处于故障锁定状态", ...));
}
}
}
```
---
## 任务四:扩展 AppEvent 业务事件 ✅ 已完成
**文件:** `src/event.rs`
新增 7 个变体:
```rust
AutoControlStarted { unit_id: Uuid },
AutoControlStopped { unit_id: Uuid },
FaultLocked { unit_id: Uuid, equipment_id: Uuid },
FaultAcked { unit_id: Uuid },
CommLocked { unit_id: Uuid },
CommRecovered { unit_id: Uuid },
UnitStateChanged { unit_id: Uuid, from_state: String, to_state: String },
```
**`persist_event_if_needed` 映射:**
| 变体 | 写库? | event_type |
|------|--------|-----------|
| `AutoControlStarted` | ✅ | `unit.auto_control_started` |
| `AutoControlStopped` | ✅ | `unit.auto_control_stopped` |
| `FaultLocked` | ✅ | `unit.fault_locked`level: error|
| `FaultAcked` | ✅ | `unit.fault_acked` |
| `CommLocked` | ✅ | `unit.comm_locked`level: warn|
| `CommRecovered` | ✅ | `unit.comm_recovered` |
| `UnitStateChanged` | ❌ | —(频率过高,每周期触发)|
---
## 任务五:添加 WsMessage::UnitRuntimeChanged ✅ 已完成
**文件:** `src/websocket.rs`
```rust
UnitRuntimeChanged(crate::control::runtime::UnitRuntime),
```
---
## 任务六:添加 service 辅助函数 ✅ 已完成
**文件:** `src/service/control.rs`
```rust
pub async fn get_all_enabled_units(pool: &PgPool) -> Result<Vec<ControlUnit>, sqlx::Error>
pub async fn get_equipment_by_unit_id(pool: &PgPool, unit_id: Uuid) -> Result<Vec<Equipment>, sqlx::Error>
```
---
## 任务七:实现 control/engine.rs ✅ 已完成事件驱动2026-03-26
**文件:** `src/control/engine.rs`
完整设计参见上方"引擎架构"章节。
**后续修改的关键规则:**
- 除状态转换或故障/通信变化外,不得推送 `WsMessage::UnitRuntimeChanged`
- `wait_phase` 必须使用 `sleep_until(deadline)` 而非 `sleep(duration)`——deadline 在阶段开始时固定,故障 tick 重检不会重置计时器。
- 在 `wait_phase` 内处理 `notify.notified()` 时,必须从 store 重新读取运行时handler 可能已修改 `auto_enabled`)。
- 设备映射在每次任务调用时加载一次若设备配置变更监督器将在下次扫描≤10秒时重启该任务。
---
## 任务八:新增 API 端点 ✅ 已完成
**文件:** `src/handler/control.rs`、`src/main.rs`
| 方法 | 路径 | Handler |
|------|------|---------|
| POST | `/api/unit/:id/start-auto` | `start_auto_unit` |
| POST | `/api/unit/:id/stop-auto` | `stop_auto_unit` |
| POST | `/api/unit/:id/ack-fault` | `ack_fault_unit` |
| POST | `/api/unit/batch-start-auto` | `batch_start_auto` |
| POST | `/api/unit/batch-stop-auto` | `batch_stop_auto` |
| GET | `/api/unit/:id/runtime` | `get_unit_runtime` |
**Notify 规约:** 每个修改 `auto_enabled``fault_locked` 的 handler在 upsert 运行时后**必须**调用 `state.control_runtime.notify_unit(unit_id).await`,以立即唤醒休眠中的单元任务。
```rust
// 每个 auto/fault handler 必须遵循此模式:
state.control_runtime.upsert(runtime).await;
state.control_runtime.notify_unit(unit_id).await; // ← 不可省略
let _ = state.event_manager.send(AppEvent::...);
```
---
## 任务九:前端运行时集成 ✅ 已完成
**文件:** `web/js/state.js`、`web/js/units.js`、`web/js/ops.js`、`web/js/app.js`
**app.js 中的 WS 处理器:**
```js
case "UnitRuntimeChanged":
state.runtimes.set(payload.data.unit_id, payload.data);
renderUnits(); // 重新渲染单元卡片(更新徽章和按钮)
renderOpsUnits(); // 更新运维视图单元列表
syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled);
break;
```
**显示规则:** 前端始终使用 `runtime.display_acc_sec` 显示累计时间,不使用 `runtime.accumulated_run_sec`
```js
// ✅ 正确
`Acc ${Math.floor(runtime.display_acc_sec / 1000)}s`
// ❌ 错误——会显示周期中途的抖动值
`Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s`
```
**事件列表 CSS** `.event-card` 必须设置 `flex-shrink: 0`(在 `web/styles.css` 中),防止 flex 列高度压缩导致文字重叠。
---
## 任务十:将引擎接入 AppState ✅ 已完成
**文件:** `src/main.rs`
```rust
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
// ... 构建 AppState ...
control::engine::start(state.clone(), control_runtime);
```

View File

@ -0,0 +1,358 @@
# 双视图 Web UI 实现计划
> **适用于代理执行:** 必须使用 superpowers:subagent-driven-development推荐或 superpowers:executing-plans 逐任务执行。步骤使用复选框(`- [ ]`)语法跟踪进度。
**目标:** 在顶部添加 **运维视图****配置视图** 两个标签页切换。运维视图以设备为核心,展示实时信号点状态(彩色信号点)及底部系统事件面板;配置视图在原有布局基础上,将底部中间面板替换为实时 SSE 日志流。
**架构:** `<main>` 元素通过 CSS 类名(`grid-ops` / `grid-config`)控制面板显示。新建 `ops.js` 模块负责运维视图:加载所有单元的设备详情并渲染设备卡片,每张卡片包含 REM/RUN/FLT 三个信号点(彩色小圆点),卡片中的 DOM 元素注册到 `state.opsPointEls``Map<point_id, { dotEl }>`WebSocket 处理器通过 `sigDotClass()` 实时更新信号点颜色。SSE 日志流(`/api/logs/stream`)仅在配置视图中启动,切换标签时启停。
**技术栈:** Vanilla JS ES 模块、CSS Grid、SSE`EventSource`)、现有 WebSocket 基础设施、`/api/unit/{id}/detail` 端点。
---
## 当前布局(参考)
```
grid3列 × 2行
左上 → equipment-panel.html 第1列第1行
右上 → points-panel.html 第2-3列第1行
左下 → source-panel.html 第1列第2行—— 单元 + 数据源
中下 → logs-panel.html 第2列第2行—— 系统事件
右下 → chart-panel.html 第3列第2行
```
## 目标布局
```
grid-config与原布局一致
左上 → equipment-panel 第1列第1行
右上 → points-panel 第2-3列第1行
左下 → source-panel 第1列第2行
中下 → log-stream-panel【新建】 第2列第2行—— SSE 日志
右下 → chart-panel 第3列第2行
grid-ops新布局
上方 → ops-panel【新建】 第1-2列第1行—— 单元侧栏 + 设备卡片
下方 → logs-panel 第1-2列第2行—— 系统事件(全宽)
```
## 文件清单
| 文件 | 操作 | 用途 |
|---|---|---|
| `web/html/topbar.html` | 修改 | 添加 `#tabOps` / `#tabConfig` 标签按钮及批量自动按钮 |
| `web/html/ops-panel.html` | **新建** | 运维视图:`#opsUnitList` 侧栏 + `#opsEquipmentArea` 设备卡片区 |
| `web/html/log-stream-panel.html` | **新建** | 配置视图底部中间SSE 日志流(`#logView`|
| `web/index.html` | 修改 | 引入新 partial、更新版本号 |
| `web/js/ops.js` | **新建** | 加载设备详情、渲染设备卡片、`sigDotClass()`、`syncEquipmentButtonsForUnit()` |
| `web/js/state.js` | 修改 | 添加 `activeView`、`opsPointEls`、`logSource`、`selectedOpsUnitId` |
| `web/js/dom.js` | 修改 | 添加引用:`tabOps`、`tabConfig`、`batchStartAutoBtn`、`batchStopAutoBtn`、`opsUnitList`、`opsEquipmentArea`、`logView` |
| `web/js/logs.js` | 修改 | 添加 `startLogs` / `stopLogs`;在 WS 处理器中更新 `opsPointEls` 信号点 |
| `web/js/app.js` | 修改 | 标签切换逻辑、监听 `units-loaded` 事件、启动 ops 视图 |
| `web/styles.css` | 修改 | 标签样式、`grid-ops`、`grid-config`、设备卡片与信号点样式 |
---
## 任务一:标签脚手架 + CSS 布局切换 ✅ 已完成
**涉及文件:**
- 修改:`web/html/topbar.html`
- 修改:`web/index.html`
- 修改:`web/js/state.js`
- 修改:`web/js/dom.js`
- 修改:`web/js/app.js`
- 修改:`web/styles.css`
- [x] **步骤 1在顶栏添加标签按钮**
`web/html/topbar.html` 中添加 `.tab-bar`(含 `#tabOps` / `#tabConfig`)及批量自动控制按钮(`#batchStartAutoBtn` / `#batchStopAutoBtn`)。
- [x] **步骤 2`web/styles.css` 添加标签与网格 CSS**
添加 `.tab-bar`、`.tab-btn`、`.tab-btn.active` 样式;将原有 `.grid` 替换为 `.grid-ops``.grid-config`,分别定义列、行及面板 `grid-column/row` 赋值。
- [x] **步骤 3`web/js/state.js` 添加新字段**
```js
activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { dotEl }
logSource: null,
selectedOpsUnitId: null,
```
- [x] **步骤 4`web/js/dom.js` 添加 DOM 引用**
```js
tabOps: byId("tabOps"),
tabConfig: byId("tabConfig"),
batchStartAutoBtn: byId("batchStartAutoBtn"),
batchStopAutoBtn: byId("batchStopAutoBtn"),
opsUnitList: byId("opsUnitList"),
opsEquipmentArea: byId("opsEquipmentArea"),
logView: byId("logView"),
```
- [x] **步骤 5`web/js/app.js` 中添加 `switchView` 函数并绑定事件**
```js
function switchView(view) {
state.activeView = view;
const main = document.querySelector("main");
main.className = view === "ops" ? "grid-ops" : "grid-config";
dom.tabOps.classList.toggle("active", view === "ops");
dom.tabConfig.classList.toggle("active", view === "config");
// 显示/隐藏配置视图专属面板top-left/top-right/bottom-left/bottom-right/bottom-mid
// 显示/隐藏运维视图专属面板ops-main/ops-bottom
if (view === "config") startLogs(); else stopLogs();
}
```
`bindEvents` 中添加:
```js
dom.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabConfig.addEventListener("click", () => switchView("config"));
```
`bootstrap` 中调用:
```js
switchView("ops"); // 默认进入运维视图
```
- [x] **步骤 6更新 `web/index.html`**
`<main class="grid-ops">` 中引入所有 partial含新建的 ops-panel.html、log-stream-panel.html并更新 CSS/JS 版本号。
---
## 任务二:运维面板 HTML + CSS 骨架 ✅ 已完成
**涉及文件:**
- 新建:`web/html/ops-panel.html`
- 修改:`web/styles.css`
- [x] **步骤 1新建 `web/html/ops-panel.html`**
```html
<section class="panel ops-main">
<div class="ops-layout">
<aside class="ops-unit-sidebar">
<div class="panel-head"><h2>控制单元</h2></div>
<div class="list ops-unit-list" id="opsUnitList"></div>
</aside>
<div class="ops-equipment-area" id="opsEquipmentArea">
<div class="muted ops-placeholder">← 选择控制单元</div>
</div>
</div>
</section>
```
`web/html/logs-panel.html` 增加 `ops-bottom` class使其在运维视图中作为底部全宽面板。
- [x] **步骤 2`web/styles.css` 添加运维视图 CSS**
`.ops-layout`flex 横向)、`.ops-unit-sidebar`(固定宽度)、`.ops-unit-list`(可滚动)、`.ops-equipment-area`flex-wrap 卡片区)。
设备卡片相关类:`.ops-eq-card`、`.ops-eq-card-head`、`.ops-signal-rows`、`.ops-signal-row`、`.ops-signal-label`、`.ops-eq-card-actions`。
单元列表项相关类:`.ops-unit-item`、`.ops-unit-item-name`、`.ops-unit-item-meta`、`.ops-unit-item-actions`。
信号点相关类:`.sig-dot`(灰色默认)、`.sig-dot.sig-on`(绿色)、`.sig-dot.sig-fault`(红色)、`.sig-dot.sig-warn`(黄色)。
---
## 任务三ops.js —— 单元列表 + 设备卡片渲染 ✅ 已完成
**涉及文件:**
- 新建:`web/js/ops.js`
- 修改:`web/js/app.js`、`web/js/units.js`
### 实际实现说明
运维视图在**初始加载时一次性加载所有单元的所有设备卡片**`loadAllEquipmentCards`),而非等待点击单元后再加载。点击某个单元会过滤只展示该单元的设备;再次点击同一单元则取消过滤并恢复全部展示。
信号点使用**彩色小圆点**`sig-dot` 类)而非文字值+质量徽章。
#### 核心函数
**`sigDotClass(role, quality, valueText) → string`**(导出)
根据信号质量与值计算 CSS 类名:
- `quality !== "good"``"sig-dot sig-warn"`(黄色)
- 值为 `"1"` / `"true"` / `"on"`
- `role === "flt"``"sig-dot sig-fault"`(红色)
- 其他 → `"sig-dot sig-on"`(绿色)
- 其他 → `"sig-dot"`(灰色)
**`renderOpsUnits()`**(导出)
遍历 `state.units`,为每个单元渲染列表项,包含:
- 运行状态徽章(`runtimeBadge`)、启用/禁用徽章、累计时间
- "Start Auto" / "Stop Auto" 按钮(调用 `/api/control/unit/:id/start-auto``stop-auto`
- 若 `runtime.manual_ack_required` 为真,显示 "Ack Fault" 按钮
**`loadAllEquipmentCards()`**(导出)
并发请求所有单元的 `/api/unit/{id}/detail`,将全部设备合并后调用 `renderOpsEquipments()`
**`selectOpsUnit(unitId)`**(私有)
切换 `state.selectedOpsUnitId`。若取消选择,调用 `loadAllEquipmentCards()` 恢复全部展示;若选中某单元,加载该单元详情并渲染其设备。
**`renderOpsEquipments(equipments)`**(私有)
为每台设备渲染一张卡片,包含:
- 卡片头:设备编码 + 类型徽章
- 信号行REM / RUN / FLT 三个角色,每行一个 `<span class="sig-dot ...">` 元素(`data-ops-dot` + `data-ops-role` 属性)
- 控制按钮(仅 `coal_feeder` / `distributor`Start / Stop`auto_enabled` 时禁用
- 注册 DOM 元素:`state.opsPointEls.set(pointId, { dotEl })`
- 若缓存中有 `point.point_monitor`,立即根据缓存值初始化信号点颜色
**`startOps()`**(导出)
```js
export function startOps() {
renderOpsUnits();
loadAllEquipmentCards();
dom.batchStartAutoBtn?.addEventListener("click", () => {
apiFetch("/api/control/unit/batch-start-auto", { method: "POST" }).then(() => loadUnits()).catch(() => {});
});
dom.batchStopAutoBtn?.addEventListener("click", () => {
apiFetch("/api/control/unit/batch-stop-auto", { method: "POST" }).then(() => loadUnits()).catch(() => {});
});
}
```
**`syncEquipmentButtonsForUnit(unitId, autoEnabled)`**(导出)
WS 收到 `UnitRuntimeChanged` 时调用,同步设备卡片中 Start/Stop 按钮的 `disabled` 状态(避免重新渲染整个卡片区):
```js
export function syncEquipmentButtonsForUnit(unitId, autoEnabled) {
dom.opsEquipmentArea
.querySelectorAll(`.ops-eq-card-actions[data-unit-id="${unitId}"]`)
.forEach((actions) => {
actions.querySelectorAll("button").forEach((btn) => {
btn.disabled = autoEnabled;
btn.title = autoEnabled ? "自动控制运行中,请先停止自动" : "";
});
});
}
```
#### app.js 接入
```js
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
// bootstrap 中:
await withStatus(loadUnits());
startOps();
// 事件监听:
document.addEventListener("equipments-updated", () => {
renderUnits();
renderOpsUnits();
});
document.addEventListener("units-loaded", () => {
renderOpsUnits();
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
});
```
---
## 任务四:运维卡片信号点实时更新 ✅ 已完成
**涉及文件:**
- 修改:`web/js/logs.js`
`startPointSocket` 的 WebSocket `PointNewValue` 分支中,添加运维视图信号点更新逻辑:
```js
// 运维视图信号点
const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) {
const { dotEl } = opsEntry;
const role = dotEl.dataset.opsRole;
import("./ops.js").then(({ sigDotClass }) => {
dotEl.className = sigDotClass(role, data.quality, data.value_text);
});
}
```
`UnitRuntimeChanged` 分支同步更新运维单元列表和设备按钮状态:
```js
if (payload.type === "UnitRuntimeChanged") {
const runtime = payload.data;
state.runtimes.set(runtime.unit_id, runtime);
renderUnits();
import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => {
renderOpsUnits();
syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled);
});
return;
}
```
> 注意:使用动态 `import("./ops.js")` 避免循环依赖(`ops.js` → `logs.js``ops.js`)。
---
## 任务五:配置视图的日志流面板 ✅ 已完成
**涉及文件:**
- 新建:`web/html/log-stream-panel.html`
- 修改:`web/js/logs.js`
- 修改:`web/js/dom.js`
- [x] **步骤 1新建 `web/html/log-stream-panel.html`**
```html
<section class="panel bottom-mid">
<div class="panel-head"><h2>实时日志</h2></div>
<div class="log" id="logView"></div>
</section>
```
- [x] **步骤 2`web/js/logs.js` 中实现 `startLogs` / `stopLogs`**
```js
export function startLogs() {
if (state.logSource) return;
state.logSource = new EventSource("/api/logs/stream");
state.logSource.addEventListener("log", (event) => {
const data = JSON.parse(event.data);
(data.lines || []).forEach(appendLog);
});
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
}
export function stopLogs() {
if (state.logSource) {
state.logSource.close();
state.logSource = null;
}
}
```
`startLogs()` 是幂等的(有 `if (state.logSource) return` 守卫),可安全重复调用。
---
## 任务六:收尾、清理与样式完善 ✅ 已完成
- [x] 补充日志面板 CSS`.log`、`.log-line`、`.level-info/warn/error`
- [x] `web/js/units.js``loadUnits()` 末尾派发 `units-loaded` 事件
- [x] 更新 `web/index.html` 版本号
- [x] 最终验证标签切换、信号点实时更新、Start/Stop 控制按钮、SSE 日志流
---
## 实现者注意事项
- `state.opsPointEls` 在每次重新渲染设备卡片时清空重建,不存在陈旧引用。
- `syncEquipmentButtonsForUnit` 仅更新按钮的 `disabled` 状态,避免每次运行时更新都重渲染整个卡片区。
- 运维视图默认展示**所有单元的所有设备**,点击单元后过滤;取消选择后恢复全部展示。
- 设备卡片头部 `data-unit-id` 属性供 `syncEquipmentButtonsForUnit` 精确定位按钮。
- 后端 `/api/unit/{id}/detail` 响应中 `point.point_monitor` 字段包含最新缓存值,可用于初始渲染信号点颜色,无需等待 WebSocket 推送。

View File

@ -1,7 +1,6 @@
use crate::{ use crate::{
connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem}, connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem},
telemetry::ValueType, telemetry::ValueType,
AppState,
}; };
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
@ -43,143 +42,6 @@ pub async fn send_pulse_command(
Ok(()) Ok(())
} }
/// Simulate RUN signal feedback after a command when SIMULATE_PLC=true.
///
/// Strategy:
/// 1. Try writing the desired value to the RUN point via the normal OPC UA write path.
/// If the proxy accepts the write, `write_point_values_batch` will emit a local
/// `PointNewValue` event that updates the cache and WebSocket automatically.
/// 2. If the write is rejected (proxy has no write target or returns an error),
/// fall back to directly patching the local monitor cache and broadcasting over WS.
pub async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, run_on: bool) {
let role_points =
match crate::service::get_equipment_role_points(&state.pool, equipment_id).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("simulate_run_feedback: db error: {}", e);
return;
}
};
let run_point = match role_points.iter().find(|p| p.signal_role == "run") {
Some(p) => p.clone(),
None => return,
};
// Determine the write value based on the current known value_type for the point.
let write_json = {
let guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
match guard
.get(&run_point.point_id)
.and_then(|m| m.value_type.as_ref())
{
Some(crate::telemetry::ValueType::Int) | Some(crate::telemetry::ValueType::UInt) => {
serde_json::json!(if run_on { 1 } else { 0 })
}
_ => serde_json::json!(run_on),
}
};
// Try writing to the proxy server first.
let write_ok = match state
.connection_manager
.write_point_values_batch(crate::connection::BatchSetPointValueReq {
items: vec![crate::connection::SetPointValueReqItem {
point_id: run_point.point_id,
value: write_json,
}],
})
.await
{
Ok(res) => res.success,
Err(e) => {
tracing::debug!("simulate_run_feedback: write attempt failed: {}", e);
false
}
};
if write_ok {
// write_point_values_batch already emitted PointNewValue; nothing more to do.
tracing::info!(
"simulate_run_feedback: wrote run={} for equipment={} via OPC UA",
run_on,
equipment_id
);
return;
}
// Fallback: patch the local cache and push over WebSocket.
tracing::debug!(
"simulate_run_feedback: OPC UA write rejected, falling back to cache patch for equipment={}",
equipment_id
);
let (value, value_type, value_text) = {
let guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
match guard
.get(&run_point.point_id)
.and_then(|m| m.value_type.as_ref())
{
Some(crate::telemetry::ValueType::Int) => (
crate::telemetry::DataValue::Int(if run_on { 1 } else { 0 }),
Some(crate::telemetry::ValueType::Int),
Some(if run_on { "1" } else { "0" }.to_string()),
),
Some(crate::telemetry::ValueType::UInt) => (
crate::telemetry::DataValue::UInt(if run_on { 1 } else { 0 }),
Some(crate::telemetry::ValueType::UInt),
Some(if run_on { "1" } else { "0" }.to_string()),
),
_ => (
crate::telemetry::DataValue::Bool(run_on),
Some(crate::telemetry::ValueType::Bool),
Some(run_on.to_string()),
),
}
};
let monitor = crate::telemetry::PointMonitorInfo {
protocol: "simulation".to_string(),
source_id: uuid::Uuid::nil(),
point_id: run_point.point_id,
client_handle: 0,
scan_mode: crate::model::ScanMode::Poll,
timestamp: Some(chrono::Utc::now()),
quality: crate::telemetry::PointQuality::Good,
value: Some(value),
value_type,
value_text,
old_value: None,
old_timestamp: None,
value_changed: true,
};
if let Err(e) = state
.connection_manager
.update_point_monitor_data(monitor.clone())
.await
{
tracing::warn!("simulate_run_feedback: cache update failed: {}", e);
return;
}
let _ = state
.ws_manager
.send_to_public(crate::websocket::WsMessage::PointNewValue(monitor))
.await;
tracing::info!(
"simulate_run_feedback: cache-patched run={} for equipment={}",
run_on,
equipment_id
);
}
fn pulse_value(high: bool, value_type: Option<&ValueType>) -> serde_json::Value { fn pulse_value(high: bool, value_type: Option<&ValueType>) -> serde_json::Value {
match value_type { match value_type {
Some(ValueType::Bool) => serde_json::Value::Bool(high), Some(ValueType::Bool) => serde_json::Value::Bool(high),

View File

@ -1,7 +1,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc; use tokio::sync::Notify;
use tokio::time::Duration;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@ -11,383 +12,416 @@ use crate::{
}, },
event::AppEvent, event::AppEvent,
service::EquipmentRolePoint, service::EquipmentRolePoint,
telemetry::{DataValue, PointMonitorInfo, PointQuality}, telemetry::{PointMonitorInfo, PointQuality},
websocket::WsMessage, websocket::WsMessage,
AppState, AppState,
}; };
/// Start the engine: a supervisor spawns one async task per enabled unit.
pub fn start(state: AppState, runtime_store: Arc<ControlRuntimeStore>) { pub fn start(state: AppState, runtime_store: Arc<ControlRuntimeStore>) {
tokio::spawn(async move { tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(500)); supervise(state, runtime_store).await;
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
tick_all_units(&state, &runtime_store).await;
}
}); });
} }
async fn tick_all_units(state: &AppState, store: &ControlRuntimeStore) { /// Supervisor: scans for enabled units every 10 s and ensures each has a running task.
let units = match crate::service::get_all_enabled_units(&state.pool).await { /// Uses JoinHandle to detect exited tasks so disabled-then-re-enabled units are restarted.
Ok(u) => u, async fn supervise(state: AppState, store: Arc<ControlRuntimeStore>) {
Err(e) => { let mut tasks: HashMap<Uuid, tokio::task::JoinHandle<()>> = HashMap::new();
tracing::error!("Engine: failed to load units: {}", e); let mut interval = tokio::time::interval(Duration::from_secs(10));
return; interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
match crate::service::get_all_enabled_units(&state.pool).await {
Ok(units) => {
for unit in units {
let needs_spawn = tasks
.get(&unit.id)
.map_or(true, |h| h.is_finished());
if needs_spawn {
let s = state.clone();
let st = store.clone();
let handle = tokio::spawn(async move { unit_task(s, st, unit.id).await; });
tasks.insert(unit.id, handle);
}
}
}
Err(e) => tracing::error!("Engine supervisor: failed to load units: {}", e),
} }
};
for unit in units {
tick_unit(state, store, &unit).await;
} }
} }
async fn tick_unit( // ── Per-unit task ─────────────────────────────────────────────────────────────
async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uuid) {
let notify = store.get_or_create_notify(unit_id).await;
// Fault/comm check ticker — still need periodic polling of point monitor data.
let mut fault_tick = tokio::time::interval(Duration::from_millis(500));
fault_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
// Reload unit config on each iteration to detect disable/delete.
let unit = match crate::service::get_unit_by_id(&state.pool, unit_id).await {
Ok(Some(u)) if u.enabled => u,
Ok(_) => {
tracing::info!("Engine: unit {} disabled or deleted, task exiting", unit_id);
return;
}
Err(e) => {
tracing::error!("Engine: unit {} config reload failed: {}", unit_id, e);
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
};
// ── Fault / comm check ────────────────────────────────────────────────
let (kind_roles, kind_eq_ids, all_roles) = match load_equipment_maps(&state, unit_id).await {
Ok(maps) => maps,
Err(e) => {
tracing::error!("Engine: unit {} equipment load failed: {}", unit_id, e);
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
};
let mut runtime = store.get_or_init(unit_id).await;
if check_fault_comm(&state, &mut runtime, &unit, &all_roles).await {
store.upsert(runtime.clone()).await;
push_ws(&state, &runtime).await;
}
// ── Wait when not active ──────────────────────────────────────────────
if !runtime.auto_enabled || runtime.fault_locked || runtime.comm_locked || runtime.manual_ack_required {
tokio::select! {
_ = fault_tick.tick() => {}
_ = notify.notified() => {
// Push fresh runtime immediately so the frontend reflects the change
// (e.g. auto_enabled toggled) without waiting for the next state transition.
let runtime = store.get_or_init(unit_id).await;
push_ws(&state, &runtime).await;
}
}
continue;
}
// ── State machine step ────────────────────────────────────────────────
match runtime.state {
UnitRuntimeState::Stopped => {
// Wait stop_time_sec (0 = skip wait, start immediately).
if !wait_phase(&state, &store, &unit, &all_roles, &notify, &mut fault_tick).await {
continue;
}
// Send feeder start command.
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "start_cmd", &monitor));
drop(monitor);
if let Some((pid, vt)) = cmd {
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
tracing::warn!("Engine: start feeder failed for unit {}: {}", unit_id, e);
continue;
}
if state.config.simulate_plc {
if let Some(eq_id) = kind_eq_ids.get("coal_feeder").copied() {
crate::control::simulate::simulate_run_feedback(&state, eq_id, true).await;
}
}
}
let mut runtime = store.get_or_init(unit_id).await;
runtime.state = UnitRuntimeState::Running;
store.upsert(runtime.clone()).await;
push_ws(&state, &runtime).await;
}
UnitRuntimeState::Running => {
// Wait run_time_sec. run_time_sec == 0 means run without a time limit
// (relies on acc_time_sec to eventually stop). Treat as a very long phase.
let secs = if unit.run_time_sec > 0 { unit.run_time_sec } else { i32::MAX };
let unit_for_wait = crate::model::ControlUnit { run_time_sec: secs, ..unit.clone() };
if !wait_phase(&state, &store, &unit_for_wait, &all_roles, &notify, &mut fault_tick).await {
continue;
}
// Stop feeder.
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
drop(monitor);
if let Some((pid, vt)) = cmd {
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
tracing::warn!("Engine: stop feeder failed for unit {}: {}", unit_id, e);
continue;
}
if state.config.simulate_plc {
if let Some(eq_id) = kind_eq_ids.get("coal_feeder").copied() {
crate::control::simulate::simulate_run_feedback(&state, eq_id, false).await;
}
}
}
let mut runtime = store.get_or_init(unit_id).await;
runtime.accumulated_run_sec += secs as i64 * 1000;
runtime.display_acc_sec = runtime.accumulated_run_sec;
if unit.acc_time_sec > 0 && runtime.accumulated_run_sec >= unit.acc_time_sec as i64 * 1000 {
// Accumulated threshold reached — start distributor.
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
let dist_cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "start_cmd", &monitor));
drop(monitor);
if let Some((pid, vt)) = dist_cmd {
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
tracing::warn!("Engine: start distributor failed for unit {}: {}", unit_id, e);
} else if state.config.simulate_plc {
if let Some(eq_id) = kind_eq_ids.get("distributor").copied() {
crate::control::simulate::simulate_run_feedback(&state, eq_id, true).await;
}
}
}
runtime.state = UnitRuntimeState::DistributorRunning;
} else {
runtime.state = UnitRuntimeState::Stopped;
}
store.upsert(runtime.clone()).await;
push_ws(&state, &runtime).await;
}
UnitRuntimeState::DistributorRunning => {
// Wait bl_time_sec then stop distributor.
if !wait_phase(&state, &store, &unit, &all_roles, &notify, &mut fault_tick).await {
continue;
}
let monitor = state.connection_manager.get_point_monitor_data_read_guard().await;
let cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
drop(monitor);
if let Some((pid, vt)) = cmd {
if let Err(e) = send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await {
tracing::warn!("Engine: stop distributor failed for unit {}: {}", unit_id, e);
continue;
}
if state.config.simulate_plc {
if let Some(eq_id) = kind_eq_ids.get("distributor").copied() {
crate::control::simulate::simulate_run_feedback(&state, eq_id, false).await;
}
}
}
let mut runtime = store.get_or_init(unit_id).await;
runtime.accumulated_run_sec = 0;
runtime.display_acc_sec = 0;
runtime.state = UnitRuntimeState::Stopped;
store.upsert(runtime.clone()).await;
push_ws(&state, &runtime).await;
}
UnitRuntimeState::FaultLocked | UnitRuntimeState::CommLocked => {
tokio::select! {
_ = fault_tick.tick() => {}
_ = notify.notified() => {}
}
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Sleep for the duration appropriate to the *current* state, interrupting every
/// 500 ms to re-check fault/comm. Returns `true` when the full time elapsed,
/// `false` if the phase was interrupted (auto disabled, fault, or comm lock).
async fn wait_phase(
state: &AppState, state: &AppState,
store: &ControlRuntimeStore, store: &ControlRuntimeStore,
unit: &crate::model::ControlUnit, unit: &crate::model::ControlUnit,
) { all_roles: &[(Uuid, HashMap<String, EquipmentRolePoint>)],
let mut runtime = store.get_or_init(unit.id).await; notify: &Arc<Notify>,
fault_tick: &mut tokio::time::Interval,
// ── Load equipment role-point maps by kind ─────────────── ) -> bool {
let equipment_list = match crate::service::get_equipment_by_unit_id(&state.pool, unit.id).await { let secs = match store.get_or_init(unit.id).await.state {
Ok(e) => e, UnitRuntimeState::Stopped => unit.stop_time_sec,
Err(e) => { UnitRuntimeState::Running => unit.run_time_sec,
tracing::error!( UnitRuntimeState::DistributorRunning => unit.bl_time_sec,
"Engine: equipment load failed for unit {}: {}", _ => return false,
unit.id,
e
);
return;
}
}; };
if secs <= 0 {
// kind -> role -> EquipmentRolePoint (first equipment per kind wins) return true;
let mut kind_roles: HashMap<String, HashMap<String, EquipmentRolePoint>> = HashMap::new(); }
// kind -> equipment id (first equipment per kind) let deadline = tokio::time::Instant::now() + Duration::from_secs(secs as u64);
let mut kind_eq_ids: HashMap<String, Uuid> = HashMap::new(); loop {
// all role maps for fault/comm scanning across all equipment let completed = tokio::select! {
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = Vec::new(); _ = tokio::time::sleep_until(deadline) => true,
_ = fault_tick.tick() => false,
for equip in &equipment_list { _ = notify.notified() => false,
match crate::service::get_equipment_role_points(&state.pool, equip.id).await { };
Ok(role_points) => { if completed {
let role_map: HashMap<String, EquipmentRolePoint> = role_points return true;
.into_iter() }
.map(|rp| (rp.signal_role.clone(), rp)) // Re-check fault/comm mid-phase.
.collect(); let mut runtime = store.get_or_init(unit.id).await;
if check_fault_comm(state, &mut runtime, unit, all_roles).await {
if let Some(kind) = &equip.kind { store.upsert(runtime.clone()).await;
if kind_roles.contains_key(kind.as_str()) { push_ws(state, &runtime).await;
tracing::warn!( }
"Engine: unit {} has multiple {} equipment; using first", if !runtime.auto_enabled || runtime.fault_locked || runtime.comm_locked || runtime.manual_ack_required {
unit.id, return false;
kind
);
} else {
kind_roles.insert(kind.clone(), role_map.clone());
kind_eq_ids.insert(kind.clone(), equip.id);
}
}
all_roles.push((equip.id, role_map));
}
Err(e) => {
tracing::warn!(
"Engine: role points load failed for equipment {}: {}",
equip.id,
e
);
}
} }
} }
}
let monitor_guard = state async fn push_ws(state: &AppState, runtime: &UnitRuntime) {
.connection_manager
.get_point_monitor_data_read_guard()
.await;
// ── Communication check ──────────────────────────────────
let any_bad_quality = all_roles.iter().flat_map(|(_, r)| r.values()).any(|rp| {
monitor_guard
.get(&rp.point_id)
.map(|m| m.quality != PointQuality::Good)
.unwrap_or(false)
});
let prev_comm = runtime.comm_locked;
runtime.comm_locked = any_bad_quality;
if !prev_comm && runtime.comm_locked {
let _ = state
.event_manager
.send(AppEvent::CommLocked { unit_id: unit.id });
} else if prev_comm && !runtime.comm_locked {
let _ = state
.event_manager
.send(AppEvent::CommRecovered { unit_id: unit.id });
}
// ── Fault check ──────────────────────────────────────────
let any_flt = all_roles.iter().any(|(_, roles)| {
roles
.get("flt")
.and_then(|rp| monitor_guard.get(&rp.point_id))
.map(|m| monitor_value_as_bool(m))
.unwrap_or(false)
});
let prev_flt = runtime.flt_active;
runtime.flt_active = any_flt;
if any_flt && !runtime.fault_locked {
// Find which equipment triggered the fault
let flt_eq_id = all_roles
.iter()
.find(|(_, roles)| {
roles
.get("flt")
.and_then(|rp| monitor_guard.get(&rp.point_id))
.map(|m| monitor_value_as_bool(m))
.unwrap_or(false)
})
.map(|(eq_id, _)| *eq_id)
.unwrap_or(Uuid::nil());
runtime.fault_locked = true;
let _ = state.event_manager.send(AppEvent::FaultLocked {
unit_id: unit.id,
equipment_id: flt_eq_id,
});
if runtime.auto_enabled {
runtime.auto_enabled = false;
let _ = state
.event_manager
.send(AppEvent::AutoControlStopped { unit_id: unit.id });
}
}
// FLT just cleared → require manual ack if unit is configured that way
if prev_flt && !any_flt && runtime.fault_locked {
if unit.require_manual_ack_after_fault {
runtime.manual_ack_required = true;
} else {
// Auto-clear fault lock
runtime.fault_locked = false;
}
}
drop(monitor_guard);
// ── State machine tick ───────────────────────────────────
if runtime.auto_enabled && !runtime.fault_locked && !runtime.comm_locked {
let now = Utc::now();
// Accumulate in milliseconds to avoid sub-second truncation
let delta_ms = runtime
.last_tick_at
.map(|t| (now - t).num_milliseconds().max(0))
.unwrap_or(0);
let prev_state = runtime.state.clone();
tick_state_machine(state, &mut runtime, unit, &kind_roles, &kind_eq_ids, delta_ms).await;
if runtime.state != prev_state {
let _ = state.event_manager.send(AppEvent::UnitStateChanged {
unit_id: unit.id,
from_state: format!("{:?}", prev_state),
to_state: format!("{:?}", runtime.state),
});
}
}
runtime.last_tick_at = Some(Utc::now());
store.upsert(runtime.clone()).await;
if let Err(e) = state if let Err(e) = state
.ws_manager .ws_manager
.send_to_public(WsMessage::UnitRuntimeChanged(runtime)) .send_to_public(WsMessage::UnitRuntimeChanged(runtime.clone()))
.await .await
{ {
tracing::debug!("Engine: WS push skipped (no subscribers): {}", e); tracing::debug!("Engine: WS push skipped (no subscribers): {}", e);
} }
} }
/// Drive one state-machine tick for a unit. /// Check fault and comm status, mutate runtime, fire events.
/// All elapsed counters accumulate in **milliseconds**; comparisons use `*_time_sec * 1000`. /// Returns `true` if any field changed.
async fn tick_state_machine( async fn check_fault_comm(
state: &AppState, state: &AppState,
runtime: &mut UnitRuntime, runtime: &mut UnitRuntime,
unit: &crate::model::ControlUnit, unit: &crate::model::ControlUnit,
kind_roles: &HashMap<String, HashMap<String, EquipmentRolePoint>>, all_roles: &[(Uuid, HashMap<String, EquipmentRolePoint>)],
kind_eq_ids: &HashMap<String, Uuid>, ) -> bool {
delta_ms: i64, let monitor = state
) { .connection_manager
let feeder_roles = kind_roles.get("coal_feeder"); .get_point_monitor_data_read_guard()
let dist_roles = kind_roles.get("distributor"); .await;
let feeder_eq_id = kind_eq_ids.get("coal_feeder").copied();
let dist_eq_id = kind_eq_ids.get("distributor").copied();
match runtime.state { let any_bad = all_roles.iter().flat_map(|(_, r)| r.values()).any(|rp| {
UnitRuntimeState::Stopped => { monitor
// stop_time_sec == 0 means start immediately (no wait) .get(&rp.point_id)
if unit.stop_time_sec > 0 { .map(|m| m.quality != PointQuality::Good)
runtime.current_stop_elapsed_sec += delta_ms; // field holds ms .unwrap_or(false)
if runtime.current_stop_elapsed_sec < unit.stop_time_sec as i64 * 1000 { });
return;
}
}
let monitor = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
feeder_roles.and_then(|r| find_cmd(r, "start_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: auto start coal_feeder failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = feeder_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, true).await;
}
}
runtime.state = UnitRuntimeState::Running;
runtime.current_stop_elapsed_sec = 0;
runtime.current_run_elapsed_sec = 0;
}
}
UnitRuntimeState::Running => { let any_flt = all_roles.iter().any(|(_, roles)| {
runtime.current_run_elapsed_sec += delta_ms; roles
runtime.accumulated_run_sec += delta_ms; .get("flt")
.and_then(|rp| monitor.get(&rp.point_id))
.map(|m| super::monitor_value_as_bool(m))
.unwrap_or(false)
});
// Check RunTime first — stop feeder before considering distributor trigger let flt_eq_id = if any_flt && !runtime.fault_locked {
if unit.run_time_sec > 0 all_roles
&& runtime.current_run_elapsed_sec >= unit.run_time_sec as i64 * 1000 .iter()
{ .find(|(_, roles)| {
let monitor = state roles
.connection_manager .get("flt")
.get_point_monitor_data_read_guard() .and_then(|rp| monitor.get(&rp.point_id))
.await; .map(|m| super::monitor_value_as_bool(m))
if let Some((pid, vt)) = .unwrap_or(false)
feeder_roles.and_then(|r| find_cmd(r, "stop_cmd", &monitor)) })
{ .map(|(eq_id, _)| *eq_id)
drop(monitor); } else {
if let Err(e) = None
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await };
{
tracing::warn!("Engine: auto stop coal_feeder failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = feeder_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, false)
.await;
}
}
runtime.state = UnitRuntimeState::Stopped;
runtime.current_run_elapsed_sec = 0;
runtime.current_stop_elapsed_sec = 0;
}
return;
}
// Check AccTime — stop feeder then trigger distributor drop(monitor);
if unit.acc_time_sec > 0
&& runtime.accumulated_run_sec >= unit.acc_time_sec as i64 * 1000
{
let monitor = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
feeder_roles.and_then(|r| find_cmd(r, "stop_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: stop coal_feeder before distributor failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = feeder_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, false)
.await;
}
}
}
runtime.state = UnitRuntimeState::DistributorRunning;
runtime.distributor_run_elapsed_sec = 0;
}
}
UnitRuntimeState::DistributorRunning => { let prev_comm = runtime.comm_locked;
// First tick in this state (distributor_run_elapsed_sec == 0): send start pulse then return. let prev_flt = runtime.flt_active;
// Time advance happens on subsequent ticks. let prev_fault_locked = runtime.fault_locked;
if runtime.distributor_run_elapsed_sec == 0 { let prev_auto = runtime.auto_enabled;
let monitor = state let prev_ack = runtime.manual_ack_required;
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
dist_roles.and_then(|r| find_cmd(r, "start_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: auto start distributor failed: {}", e);
} else if state.config.simulate_plc {
if let Some(eq_id) = dist_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, true)
.await;
}
}
}
// Mark as "started" by advancing to 1ms so this branch won't re-fire
runtime.distributor_run_elapsed_sec = 1;
return;
}
runtime.distributor_run_elapsed_sec += delta_ms; runtime.comm_locked = any_bad;
runtime.flt_active = any_flt;
if unit.bl_time_sec > 0 if !prev_comm && runtime.comm_locked {
&& runtime.distributor_run_elapsed_sec >= unit.bl_time_sec as i64 * 1000 let _ = state.event_manager.send(AppEvent::CommLocked { unit_id: unit.id });
{ } else if prev_comm && !runtime.comm_locked {
let monitor = state let _ = state.event_manager.send(AppEvent::CommRecovered { unit_id: unit.id });
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
dist_roles.and_then(|r| find_cmd(r, "stop_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: auto stop distributor failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = dist_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, false)
.await;
}
}
}
runtime.accumulated_run_sec = 0;
runtime.distributor_run_elapsed_sec = 0;
runtime.state = UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
}
}
UnitRuntimeState::FaultLocked | UnitRuntimeState::CommLocked => {}
} }
if let Some(eq_id) = flt_eq_id {
runtime.fault_locked = true;
let _ = state.event_manager.send(AppEvent::FaultLocked { unit_id: unit.id, equipment_id: eq_id });
if runtime.auto_enabled {
runtime.auto_enabled = false;
let _ = state.event_manager.send(AppEvent::AutoControlStopped { unit_id: unit.id });
}
}
if prev_flt && !any_flt && runtime.fault_locked {
if unit.require_manual_ack_after_fault {
runtime.manual_ack_required = true;
} else {
runtime.fault_locked = false;
}
}
runtime.comm_locked != prev_comm
|| runtime.flt_active != prev_flt
|| runtime.fault_locked != prev_fault_locked
|| runtime.auto_enabled != prev_auto
|| runtime.manual_ack_required != prev_ack
} }
/// Find a command point by role in a single equipment's role map. type EquipMaps = (
/// Returns `None` if REM==0 or FLT==1 or quality is bad. HashMap<String, HashMap<String, EquipmentRolePoint>>,
HashMap<String, Uuid>,
Vec<(Uuid, HashMap<String, EquipmentRolePoint>)>,
);
async fn load_equipment_maps(state: &AppState, unit_id: Uuid) -> Result<EquipMaps, sqlx::Error> {
let equipment_list = crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?;
let equipment_ids: Vec<Uuid> = equipment_list.iter().map(|equip| equip.id).collect();
let role_point_rows =
crate::service::get_signal_role_points_batch(&state.pool, &equipment_ids).await?;
let mut role_points_by_equipment: HashMap<Uuid, Vec<EquipmentRolePoint>> = HashMap::new();
for row in role_point_rows {
role_points_by_equipment
.entry(row.equipment_id)
.or_default()
.push(EquipmentRolePoint {
point_id: row.point_id,
signal_role: row.signal_role,
});
}
Ok(build_equipment_maps(
unit_id,
&equipment_list,
role_points_by_equipment,
))
}
fn build_equipment_maps(
unit_id: Uuid,
equipment_list: &[crate::model::Equipment],
mut role_points_by_equipment: HashMap<Uuid, Vec<EquipmentRolePoint>>,
) -> EquipMaps {
let mut kind_roles: HashMap<String, HashMap<String, EquipmentRolePoint>> = HashMap::new();
let mut kind_eq_ids: HashMap<String, Uuid> = HashMap::new();
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = Vec::new();
for equip in equipment_list {
let role_map: HashMap<String, EquipmentRolePoint> = role_points_by_equipment
.remove(&equip.id)
.unwrap_or_default()
.into_iter()
.map(|rp| (rp.signal_role.clone(), rp))
.collect();
if let Some(kind) = &equip.kind {
if !kind_roles.contains_key(kind.as_str()) {
kind_roles.insert(kind.clone(), role_map.clone());
kind_eq_ids.insert(kind.clone(), equip.id);
} else {
tracing::warn!(
"Engine: unit {} has multiple {} equipment; using first",
unit_id, kind
);
}
}
all_roles.push((equip.id, role_map));
}
(kind_roles, kind_eq_ids, all_roles)
}
/// Find a command point by role. Returns `None` if REM==0, FLT==1, or quality is bad.
fn find_cmd( fn find_cmd(
roles: &HashMap<String, EquipmentRolePoint>, roles: &HashMap<String, EquipmentRolePoint>,
role: &str, role: &str,
@ -398,13 +432,13 @@ fn find_cmd(
let rem_ok = roles let rem_ok = roles
.get("rem") .get("rem")
.and_then(|rp| monitor.get(&rp.point_id)) .and_then(|rp| monitor.get(&rp.point_id))
.map(|m| monitor_value_as_bool(m) && m.quality == PointQuality::Good) .map(|m| super::monitor_value_as_bool(m) && m.quality == PointQuality::Good)
.unwrap_or(true); .unwrap_or(true);
let flt_ok = roles let flt_ok = roles
.get("flt") .get("flt")
.and_then(|rp| monitor.get(&rp.point_id)) .and_then(|rp| monitor.get(&rp.point_id))
.map(|m| !monitor_value_as_bool(m) && m.quality == PointQuality::Good) .map(|m| !super::monitor_value_as_bool(m) && m.quality == PointQuality::Good)
.unwrap_or(true); .unwrap_or(true);
if rem_ok && flt_ok { if rem_ok && flt_ok {
@ -417,15 +451,64 @@ fn find_cmd(
} }
} }
fn monitor_value_as_bool(monitor: &PointMonitorInfo) -> bool { #[cfg(test)]
match monitor.value.as_ref() { mod tests {
Some(DataValue::Bool(v)) => *v, use super::build_equipment_maps;
Some(DataValue::Int(v)) => *v != 0, use crate::model::Equipment;
Some(DataValue::UInt(v)) => *v != 0, use crate::service::EquipmentRolePoint;
Some(DataValue::Float(v)) => *v != 0.0, use chrono::Utc;
Some(DataValue::Text(v)) => { use std::collections::HashMap;
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on") use uuid::Uuid;
fn equipment(id: Uuid, unit_id: Uuid, kind: &str) -> Equipment {
Equipment {
id,
unit_id: Some(unit_id),
code: format!("EQ-{id}"),
name: format!("Equipment-{id}"),
kind: Some(kind.to_string()),
description: None,
created_at: Utc::now(),
updated_at: Utc::now(),
} }
_ => false, }
#[test]
fn build_equipment_maps_reflects_latest_role_bindings() {
let unit_id = Uuid::new_v4();
let equipment_id = Uuid::new_v4();
let first_start_point = Uuid::new_v4();
let second_start_point = Uuid::new_v4();
let equipment_list = vec![equipment(equipment_id, unit_id, "coal_feeder")];
let mut first_roles = HashMap::new();
first_roles.insert(
equipment_id,
vec![EquipmentRolePoint {
point_id: first_start_point,
signal_role: "start_cmd".to_string(),
}],
);
let (first_kind_roles, _, _) = build_equipment_maps(unit_id, &equipment_list, first_roles);
let mut second_roles = HashMap::new();
second_roles.insert(
equipment_id,
vec![EquipmentRolePoint {
point_id: second_start_point,
signal_role: "start_cmd".to_string(),
}],
);
let (second_kind_roles, _, _) =
build_equipment_maps(unit_id, &equipment_list, second_roles);
assert_eq!(
first_kind_roles["coal_feeder"]["start_cmd"].point_id,
first_start_point
);
assert_eq!(
second_kind_roles["coal_feeder"]["start_cmd"].point_id,
second_start_point
);
} }
} }

View File

@ -1,4 +1,20 @@
pub mod command; pub mod command;
pub mod engine; pub mod engine;
pub mod runtime; pub mod runtime;
pub mod simulate;
pub mod validator; pub mod validator;
use crate::telemetry::{DataValue, PointMonitorInfo};
pub(crate) fn monitor_value_as_bool(monitor: &PointMonitorInfo) -> bool {
match monitor.value.as_ref() {
Some(DataValue::Bool(v)) => *v,
Some(DataValue::Int(v)) => *v != 0,
Some(DataValue::UInt(v)) => *v != 0,
Some(DataValue::Float(v)) => *v != 0.0,
Some(DataValue::Text(v)) => {
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on" | "yes")
}
_ => false,
}
}

View File

@ -1,7 +1,6 @@
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc}; use tokio::sync::{Notify, RwLock};
use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
@ -20,14 +19,12 @@ pub struct UnitRuntime {
pub state: UnitRuntimeState, pub state: UnitRuntimeState,
pub auto_enabled: bool, pub auto_enabled: bool,
pub accumulated_run_sec: i64, pub accumulated_run_sec: i64,
pub current_run_elapsed_sec: i64, /// Snapshot updated only on state transitions; used for display to avoid mid-tick jitter.
pub current_stop_elapsed_sec: i64, pub display_acc_sec: i64,
pub distributor_run_elapsed_sec: i64,
pub fault_locked: bool, pub fault_locked: bool,
pub flt_active: bool, pub flt_active: bool,
pub comm_locked: bool, pub comm_locked: bool,
pub manual_ack_required: bool, pub manual_ack_required: bool,
pub last_tick_at: Option<DateTime<Utc>>,
} }
impl UnitRuntime { impl UnitRuntime {
@ -37,14 +34,11 @@ impl UnitRuntime {
state: UnitRuntimeState::Stopped, state: UnitRuntimeState::Stopped,
auto_enabled: false, auto_enabled: false,
accumulated_run_sec: 0, accumulated_run_sec: 0,
current_run_elapsed_sec: 0, display_acc_sec: 0,
current_stop_elapsed_sec: 0,
distributor_run_elapsed_sec: 0,
fault_locked: false, fault_locked: false,
flt_active: false, flt_active: false,
comm_locked: false, comm_locked: false,
manual_ack_required: false, manual_ack_required: false,
last_tick_at: None,
} }
} }
} }
@ -52,6 +46,7 @@ impl UnitRuntime {
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct ControlRuntimeStore { pub struct ControlRuntimeStore {
inner: Arc<RwLock<HashMap<Uuid, UnitRuntime>>>, inner: Arc<RwLock<HashMap<Uuid, UnitRuntime>>>,
notifiers: Arc<RwLock<HashMap<Uuid, Arc<Notify>>>>,
} }
impl ControlRuntimeStore { impl ControlRuntimeStore {
@ -76,4 +71,24 @@ impl ControlRuntimeStore {
pub async fn upsert(&self, runtime: UnitRuntime) { pub async fn upsert(&self, runtime: UnitRuntime) {
self.inner.write().await.insert(runtime.unit_id, runtime); self.inner.write().await.insert(runtime.unit_id, runtime);
} }
pub async fn get_or_create_notify(&self, unit_id: Uuid) -> Arc<Notify> {
self.notifiers
.write()
.await
.entry(unit_id)
.or_insert_with(|| Arc::new(Notify::new()))
.clone()
}
pub async fn get_all(&self) -> HashMap<Uuid, UnitRuntime> {
self.inner.read().await.clone()
}
/// Wake the engine task for a unit (e.g., when auto_enabled or fault_locked changes).
pub async fn notify_unit(&self, unit_id: Uuid) {
if let Some(n) = self.notifiers.read().await.get(&unit_id) {
n.notify_one();
}
}
} }

213
src/control/simulate.rs Normal file
View File

@ -0,0 +1,213 @@
use tokio::time::Duration;
use uuid::Uuid;
use crate::{
connection::{BatchSetPointValueReq, SetPointValueReqItem},
telemetry::{DataValue, PointMonitorInfo, PointQuality, ValueType},
websocket::WsMessage,
AppState,
};
/// Start the chaos simulation task (only when SIMULATE_PLC=true).
/// Randomly disrupts `rem` or `flt` signals on equipment to exercise the control engine.
pub fn start(state: AppState) {
tokio::spawn(async move {
run(state).await;
});
}
async fn run(state: AppState) {
let mut rng = seed_rng();
loop {
// Wait a random 1560 s between events.
let wait_secs = 15 + xorshift(&mut rng) % 46;
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
// Pick a random enabled unit.
let units = match crate::service::get_all_enabled_units(&state.pool).await {
Ok(u) if !u.is_empty() => u,
_ => continue,
};
let unit = &units[xorshift(&mut rng) as usize % units.len()];
// Only target units with auto control running — otherwise the event is uninteresting.
let runtime = state.control_runtime.get(unit.id).await;
if runtime.map_or(true, |r| !r.auto_enabled) {
continue;
}
// Pick a random equipment in that unit.
let equipments =
match crate::service::get_equipment_by_unit_id(&state.pool, unit.id).await {
Ok(e) if !e.is_empty() => e,
_ => continue,
};
let eq = &equipments[xorshift(&mut rng) as usize % equipments.len()];
// Find which of rem / flt this equipment has.
let role_points =
match crate::service::get_equipment_role_points(&state.pool, eq.id).await {
Ok(rp) if !rp.is_empty() => rp,
_ => continue,
};
let candidates: Vec<&str> = ["flt", "rem"]
.iter()
.filter(|&&r| role_points.iter().any(|p| p.signal_role == r))
.copied()
.collect();
if candidates.is_empty() {
continue;
}
let target_role = candidates[xorshift(&mut rng) as usize % candidates.len()];
let target_point = role_points
.iter()
.find(|p| p.signal_role == target_role)
.unwrap();
// rem=false → not in remote mode (blocks commands)
// flt=true → fault signal active (triggers fault lock)
let trigger_value = target_role == "flt";
// Hold duration: 515 s for rem, 310 s for flt.
let hold_secs = if target_role == "flt" {
3 + xorshift(&mut rng) % 8
} else {
5 + xorshift(&mut rng) % 11
};
tracing::info!(
"[chaos] unit={} eq={} role={} → {} (hold {}s)",
unit.code,
eq.code,
target_role,
if trigger_value { "FAULT" } else { "REM OFF" },
hold_secs
);
patch_signal(&state, target_point.point_id, trigger_value).await;
tokio::time::sleep(Duration::from_secs(hold_secs)).await;
patch_signal(&state, target_point.point_id, !trigger_value).await;
tracing::info!(
"[chaos] unit={} eq={} role={} → RESTORED",
unit.code,
eq.code,
target_role
);
}
}
/// Simulate RUN signal feedback for an equipment after a manual start/stop command.
/// Called by the engine and control handler when SIMULATE_PLC=true.
pub async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, run_on: bool) {
let role_points =
match crate::service::get_equipment_role_points(&state.pool, equipment_id).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("simulate_run_feedback: db error: {}", e);
return;
}
};
let run_point = match role_points.iter().find(|p| p.signal_role == "run") {
Some(p) => p.clone(),
None => return,
};
patch_signal(state, run_point.point_id, run_on).await;
}
/// Patch a signal point value: try OPC UA write first, fall back to cache patch + WS push.
pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
let write_json = serde_json::json!(if value_on { 1 } else { 0 });
let write_ok = match state
.connection_manager
.write_point_values_batch(BatchSetPointValueReq {
items: vec![SetPointValueReqItem {
point_id,
value: write_json,
}],
})
.await
{
Ok(res) => res.success,
Err(_) => false,
};
if write_ok {
return;
}
// Fallback: patch the monitor cache directly and broadcast over WS.
let (value, value_type, value_text) = {
let guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
match guard.get(&point_id).and_then(|m| m.value_type.as_ref()) {
Some(ValueType::Int) => (
DataValue::Int(if value_on { 1 } else { 0 }),
Some(ValueType::Int),
Some(if value_on { "1" } else { "0" }.to_string()),
),
Some(ValueType::UInt) => (
DataValue::UInt(if value_on { 1 } else { 0 }),
Some(ValueType::UInt),
Some(if value_on { "1" } else { "0" }.to_string()),
),
_ => (
DataValue::Bool(value_on),
Some(ValueType::Bool),
Some(value_on.to_string()),
),
}
};
let monitor = PointMonitorInfo {
protocol: "simulation".to_string(),
source_id: Uuid::nil(),
point_id,
client_handle: 0,
scan_mode: crate::model::ScanMode::Poll,
timestamp: Some(chrono::Utc::now()),
quality: PointQuality::Good,
value: Some(value),
value_type,
value_text,
old_value: None,
old_timestamp: None,
value_changed: true,
};
if let Err(e) = state
.connection_manager
.update_point_monitor_data(monitor.clone())
.await
{
tracing::warn!("[chaos] cache update failed for {}: {}", point_id, e);
return;
}
let _ = state
.ws_manager
.send_to_public(WsMessage::PointNewValue(monitor))
.await;
}
// ── Minimal XorShift64 PRNG (no external crate needed) ────────────────────────
fn seed_rng() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64 ^ d.as_secs().wrapping_mul(0x9e37_79b9_7f4a_7c15))
.unwrap_or(0xdeadbeef)
}
fn xorshift(s: &mut u64) -> u64 {
*s ^= *s << 13;
*s ^= *s >> 7;
*s ^= *s << 17;
*s
}

View File

@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::{ use crate::{
service::EquipmentRolePoint, service::EquipmentRolePoint,
telemetry::{DataValue, PointMonitorInfo, PointQuality, ValueType}, telemetry::{PointMonitorInfo, PointQuality, ValueType},
util::response::ApiErr, util::response::ApiErr,
AppState, AppState,
}; };
@ -95,7 +95,7 @@ pub async fn validate_manual_control(
let rem_monitor = monitor_guard let rem_monitor = monitor_guard
.get(&rem_point.point_id) .get(&rem_point.point_id)
.ok_or_else(|| missing_monitor_err("REM", equipment_id))?; .ok_or_else(|| missing_monitor_err("REM", equipment_id))?;
if !monitor_value_as_bool(rem_monitor) { if !super::monitor_value_as_bool(rem_monitor) {
return Err(ApiErr::Forbidden( return Err(ApiErr::Forbidden(
"Remote control not allowed, REM is not enabled".to_string(), "Remote control not allowed, REM is not enabled".to_string(),
Some(json!({ "equipment_id": equipment_id })), Some(json!({ "equipment_id": equipment_id })),
@ -107,7 +107,7 @@ pub async fn validate_manual_control(
let flt_monitor = monitor_guard let flt_monitor = monitor_guard
.get(&flt_point.point_id) .get(&flt_point.point_id)
.ok_or_else(|| missing_monitor_err("FLT", equipment_id))?; .ok_or_else(|| missing_monitor_err("FLT", equipment_id))?;
if monitor_value_as_bool(flt_monitor) { if super::monitor_value_as_bool(flt_monitor) {
return Err(ApiErr::Forbidden( return Err(ApiErr::Forbidden(
"Equipment fault is active, command denied".to_string(), "Equipment fault is active, command denied".to_string(),
Some(json!({ "equipment_id": equipment_id })), Some(json!({ "equipment_id": equipment_id })),
@ -199,16 +199,3 @@ fn missing_monitor_err(role: &str, equipment_id: Uuid) -> ApiErr {
) )
} }
fn monitor_value_as_bool(monitor: &PointMonitorInfo) -> bool {
match monitor.value.as_ref() {
Some(DataValue::Bool(value)) => *value,
Some(DataValue::Int(value)) => *value != 0,
Some(DataValue::UInt(value)) => *value != 0,
Some(DataValue::Float(value)) => *value != 0.0,
Some(DataValue::Text(value)) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "on" | "yes"
),
_ => false,
}
}

View File

@ -15,6 +15,7 @@ pub enum AppEvent {
}, },
SourceDelete { SourceDelete {
source_id: Uuid, source_id: Uuid,
source_name: String,
}, },
PointCreateBatch { PointCreateBatch {
source_id: Uuid, source_id: Uuid,
@ -159,7 +160,7 @@ async fn handle_control_event(
tracing::error!("Failed to reconnect source {}: {}", source_id, e); tracing::error!("Failed to reconnect source {}: {}", source_id, e);
} }
} }
AppEvent::SourceDelete { source_id } => { AppEvent::SourceDelete { source_id, .. } => {
tracing::info!("Processing SourceDelete event for {}", source_id); tracing::info!("Processing SourceDelete event for {}", source_id);
if let Err(e) = connection_manager.disconnect(source_id).await { if let Err(e) = connection_manager.disconnect(source_id).await {
tracing::error!("Failed to disconnect from source {}: {}", source_id, e); tracing::error!("Failed to disconnect from source {}: {}", source_id, e);
@ -253,133 +254,166 @@ async fn handle_control_event(
} }
} }
async fn fetch_source_name(pool: &sqlx::PgPool, id: Uuid) -> String {
sqlx::query_scalar::<_, String>("SELECT name FROM source WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.ok()
.flatten()
.unwrap_or_else(|| id.to_string())
}
async fn fetch_unit_code(pool: &sqlx::PgPool, id: Uuid) -> String {
sqlx::query_scalar::<_, String>("SELECT code FROM unit WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.ok()
.flatten()
.unwrap_or_else(|| id.to_string())
}
async fn fetch_equipment_code(pool: &sqlx::PgPool, id: Uuid) -> String {
sqlx::query_scalar::<_, String>("SELECT code FROM equipment WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.ok()
.flatten()
.unwrap_or_else(|| id.to_string())
}
async fn persist_event_if_needed( async fn persist_event_if_needed(
event: &AppEvent, event: &AppEvent,
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>, ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
) { ) {
let record = match event { let record = match event {
AppEvent::SourceCreate { source_id } => Some(( AppEvent::SourceCreate { source_id } => {
"source.created", let name = fetch_source_name(pool, *source_id).await;
"info", Some((
None, "source.created", "info",
None, None, None, Some(*source_id),
Some(*source_id), format!("数据源【{}】已创建", name),
format!("Source {} created", source_id), serde_json::json!({ "source_id": source_id }),
))
}
AppEvent::SourceUpdate { source_id } => {
let name = fetch_source_name(pool, *source_id).await;
Some((
"source.updated", "info",
None, None, Some(*source_id),
format!("数据源【{}】已更新", name),
serde_json::json!({ "source_id": source_id }),
))
}
AppEvent::SourceDelete { source_id, source_name } => Some((
"source.deleted", "warn",
None, None, None,
format!("数据源【{}】已删除", source_name),
serde_json::json!({ "source_id": source_id }), serde_json::json!({ "source_id": source_id }),
)), )),
AppEvent::SourceUpdate { source_id } => Some(( AppEvent::PointCreateBatch { source_id, point_ids } => {
"source.updated", let name = fetch_source_name(pool, *source_id).await;
"info", Some((
None, "point.batch_created", "info",
None, None, None, Some(*source_id),
Some(*source_id), format!("批量创建 {} 个测点(数据源:{}", point_ids.len(), name),
format!("Source {} updated", source_id), serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
serde_json::json!({ "source_id": source_id }), ))
)), }
AppEvent::SourceDelete { source_id } => Some(( AppEvent::PointDeleteBatch { source_id, point_ids } => {
"source.deleted", let name = fetch_source_name(pool, *source_id).await;
"warn", Some((
None, "point.batch_deleted", "warn",
None, None, None, Some(*source_id),
None, format!("批量删除 {} 个测点(数据源:{}", point_ids.len(), name),
format!("Source {} deleted", source_id), serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
serde_json::json!({ "source_id": source_id }), ))
)), }
AppEvent::PointCreateBatch { source_id, point_ids } => Some(( AppEvent::EquipmentStartCommandSent { equipment_id, unit_id, point_id } => {
"point.batch_created", let code = fetch_equipment_code(pool, *equipment_id).await;
"info", Some((
None, "equipment.start_command_sent", "info",
None, *unit_id, Some(*equipment_id), None,
Some(*source_id), format!("已发送启动指令(设备:{}", code),
format!("{} points created for source {}", point_ids.len(), source_id), serde_json::json!({
serde_json::json!({ "source_id": source_id, "point_ids": point_ids }), "equipment_id": equipment_id,
)), "unit_id": unit_id,
AppEvent::PointDeleteBatch { source_id, point_ids } => Some(( "point_id": point_id
"point.batch_deleted", }),
"warn", ))
None, }
None, AppEvent::EquipmentStopCommandSent { equipment_id, unit_id, point_id } => {
Some(*source_id), let code = fetch_equipment_code(pool, *equipment_id).await;
format!("{} points deleted for source {}", point_ids.len(), source_id), Some((
serde_json::json!({ "source_id": source_id, "point_ids": point_ids }), "equipment.stop_command_sent", "info",
)), *unit_id, Some(*equipment_id), None,
AppEvent::EquipmentStartCommandSent { format!("已发送停止指令(设备:{}", code),
equipment_id, serde_json::json!({
unit_id, "equipment_id": equipment_id,
point_id, "unit_id": unit_id,
} => Some(( "point_id": point_id
"equipment.start_command_sent", }),
"info", ))
*unit_id, }
Some(*equipment_id), AppEvent::AutoControlStarted { unit_id } => {
None, let code = fetch_unit_code(pool, *unit_id).await;
format!("Start command sent to equipment {}", equipment_id), Some((
serde_json::json!({ "unit.auto_control_started", "info",
"equipment_id": equipment_id, Some(*unit_id), None, None,
"unit_id": unit_id, format!("已启动自动控制(单元:{}", code),
"point_id": point_id serde_json::json!({ "unit_id": unit_id }),
}), ))
)), }
AppEvent::EquipmentStopCommandSent { AppEvent::AutoControlStopped { unit_id } => {
equipment_id, let code = fetch_unit_code(pool, *unit_id).await;
unit_id, Some((
point_id, "unit.auto_control_stopped", "info",
} => Some(( Some(*unit_id), None, None,
"equipment.stop_command_sent", format!("已停止自动控制(单元:{}", code),
"info", serde_json::json!({ "unit_id": unit_id }),
*unit_id, ))
Some(*equipment_id), }
None, AppEvent::FaultLocked { unit_id, equipment_id } => {
format!("Stop command sent to equipment {}", equipment_id), let unit_code = fetch_unit_code(pool, *unit_id).await;
serde_json::json!({ let eq_code = fetch_equipment_code(pool, *equipment_id).await;
"equipment_id": equipment_id, Some((
"unit_id": unit_id, "unit.fault_locked", "error",
"point_id": point_id Some(*unit_id), Some(*equipment_id), None,
}), format!("单元【{}】发生故障锁定,触发设备:{}", unit_code, eq_code),
)), serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }),
AppEvent::AutoControlStarted { unit_id } => Some(( ))
"unit.auto_control_started", "info", }
Some(*unit_id), None, None, AppEvent::FaultAcked { unit_id } => {
format!("Auto control started for unit {}", unit_id), let code = fetch_unit_code(pool, *unit_id).await;
serde_json::json!({ "unit_id": unit_id }), Some((
)), "unit.fault_acked", "info",
AppEvent::AutoControlStopped { unit_id } => Some(( Some(*unit_id), None, None,
"unit.auto_control_stopped", "info", format!("单元【{}】故障已人工确认", code),
Some(*unit_id), None, None, serde_json::json!({ "unit_id": unit_id }),
format!("Auto control stopped for unit {}", unit_id), ))
serde_json::json!({ "unit_id": unit_id }), }
)), AppEvent::CommLocked { unit_id } => {
AppEvent::FaultLocked { unit_id, equipment_id } => Some(( let code = fetch_unit_code(pool, *unit_id).await;
"unit.fault_locked", "error", Some((
Some(*unit_id), Some(*equipment_id), None, "unit.comm_locked", "warn",
format!("Unit {} fault locked by equipment {}", unit_id, equipment_id), Some(*unit_id), None, None,
serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }), format!("单元【{}】通讯中断", code),
)), serde_json::json!({ "unit_id": unit_id }),
AppEvent::FaultAcked { unit_id } => Some(( ))
"unit.fault_acked", "info", }
Some(*unit_id), None, None, AppEvent::CommRecovered { unit_id } => {
format!("Unit {} fault acknowledged", unit_id), let code = fetch_unit_code(pool, *unit_id).await;
serde_json::json!({ "unit_id": unit_id }), Some((
)), "unit.comm_recovered", "info",
AppEvent::CommLocked { unit_id } => Some(( Some(*unit_id), None, None,
"unit.comm_locked", "warn", format!("单元【{}】通讯恢复", code),
Some(*unit_id), None, None, serde_json::json!({ "unit_id": unit_id }),
format!("Unit {} communication locked", unit_id), ))
serde_json::json!({ "unit_id": unit_id }), }
)), AppEvent::UnitStateChanged { .. } => None,
AppEvent::CommRecovered { unit_id } => Some((
"unit.comm_recovered", "info",
Some(*unit_id), None, None,
format!("Unit {} communication recovered", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::UnitStateChanged { unit_id, from_state, to_state } => Some((
"unit.state_changed", "info",
Some(*unit_id), None, None,
format!("Unit {} state: {}{}", unit_id, from_state, to_state),
serde_json::json!({ "unit_id": unit_id, "from": from_state, "to": to_state }),
)),
AppEvent::PointNewValue(_) => None, AppEvent::PointNewValue(_) => None,
}; };

View File

@ -18,6 +18,27 @@ use crate::{
AppState, AppState,
}; };
fn validate_unit_timing_order(
run_time_sec: i32,
acc_time_sec: i32,
) -> Result<(), ApiErr> {
if acc_time_sec <= run_time_sec {
return Err(ApiErr::BadRequest(
"acc_time_sec must be greater than run_time_sec".to_string(),
Some(json!({
"run_time_sec": ["must be less than acc_time_sec"],
"acc_time_sec": ["must be greater than run_time_sec"]
})),
));
}
Ok(())
}
fn auto_control_start_blocked(runtime: &crate::control::runtime::UnitRuntime) -> bool {
runtime.fault_locked || runtime.comm_locked || runtime.manual_ack_required
}
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate)]
pub struct GetUnitListQuery { pub struct GetUnitListQuery {
#[validate(length(min = 1, max = 100))] #[validate(length(min = 1, max = 100))]
@ -26,6 +47,21 @@ pub struct GetUnitListQuery {
pub pagination: PaginationParams, pub pagination: PaginationParams,
} }
#[derive(serde::Serialize)]
pub struct UnitEquipmentItem {
#[serde(flatten)]
pub equipment: crate::model::Equipment,
pub role_points: Vec<crate::handler::equipment::SignalRolePoint>,
}
#[derive(serde::Serialize)]
pub struct UnitWithRuntime {
#[serde(flatten)]
pub unit: crate::model::ControlUnit,
pub runtime: Option<crate::control::runtime::UnitRuntime>,
pub equipments: Vec<UnitEquipmentItem>,
}
pub async fn get_unit_list( pub async fn get_unit_list(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<GetUnitListQuery>, Query(query): Query<GetUnitListQuery>,
@ -33,7 +69,7 @@ pub async fn get_unit_list(
query.validate()?; query.validate()?;
let total = crate::service::get_units_count(&state.pool, query.keyword.as_deref()).await?; let total = crate::service::get_units_count(&state.pool, query.keyword.as_deref()).await?;
let data = crate::service::get_units_paginated( let units = crate::service::get_units_paginated(
&state.pool, &state.pool,
query.keyword.as_deref(), query.keyword.as_deref(),
query.pagination.page_size, query.pagination.page_size,
@ -41,6 +77,58 @@ pub async fn get_unit_list(
) )
.await?; .await?;
let all_runtimes = state.control_runtime.get_all().await;
let unit_ids: Vec<Uuid> = units.iter().map(|u| u.id).collect();
let all_equipments =
crate::service::get_equipment_by_unit_ids(&state.pool, &unit_ids).await?;
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
let role_point_rows =
crate::service::get_signal_role_points_batch(&state.pool, &eq_ids).await?;
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
let mut role_points_map: std::collections::HashMap<
Uuid,
Vec<crate::handler::equipment::SignalRolePoint>,
> = std::collections::HashMap::new();
for rp in role_point_rows {
role_points_map
.entry(rp.equipment_id)
.or_default()
.push(crate::handler::equipment::SignalRolePoint {
point_id: rp.point_id,
signal_role: rp.signal_role,
point_monitor: monitor_guard.get(&rp.point_id).cloned(),
});
}
drop(monitor_guard);
let mut equipments_by_unit: std::collections::HashMap<Uuid, Vec<UnitEquipmentItem>> =
std::collections::HashMap::new();
for eq in all_equipments {
let role_points = role_points_map.remove(&eq.id).unwrap_or_default();
if let Some(unit_id) = eq.unit_id {
equipments_by_unit
.entry(unit_id)
.or_default()
.push(UnitEquipmentItem { equipment: eq, role_points });
}
}
let data = units
.into_iter()
.map(|unit| {
let runtime = all_runtimes.get(&unit.id).cloned();
let equipments = equipments_by_unit.remove(&unit.id).unwrap_or_default();
UnitWithRuntime { unit, runtime, equipments }
})
.collect::<Vec<_>>();
Ok(Json(PaginatedResponse::new( Ok(Json(PaginatedResponse::new(
data, data,
total, total,
@ -82,7 +170,7 @@ async fn send_equipment_command(
.map_err(|e| ApiErr::Internal(e, None))?; .map_err(|e| ApiErr::Internal(e, None))?;
if state.config.simulate_plc { if state.config.simulate_plc {
crate::control::command::simulate_run_feedback( crate::control::simulate::simulate_run_feedback(
&state, &state,
equipment_id, equipment_id,
matches!(action, ControlAction::Start), matches!(action, ControlAction::Start),
@ -118,10 +206,45 @@ pub async fn get_unit(
State(state): State<AppState>, State(state): State<AppState>,
Path(unit_id): Path<Uuid>, Path(unit_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> { ) -> Result<impl IntoResponse, ApiErr> {
match crate::service::get_unit_by_id(&state.pool, unit_id).await? { let unit = crate::service::get_unit_by_id(&state.pool, unit_id)
Some(unit) => Ok(Json(unit)), .await?
None => Err(ApiErr::NotFound("Unit not found".to_string(), None)), .ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let runtime = state.control_runtime.get(unit_id).await;
let all_equipments =
crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?;
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
let role_point_rows =
crate::service::get_signal_role_points_batch(&state.pool, &eq_ids).await?;
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
let mut role_points_map: std::collections::HashMap<
Uuid,
Vec<crate::handler::equipment::SignalRolePoint>,
> = std::collections::HashMap::new();
for rp in role_point_rows {
role_points_map
.entry(rp.equipment_id)
.or_default()
.push(crate::handler::equipment::SignalRolePoint {
point_id: rp.point_id,
signal_role: rp.signal_role,
point_monitor: monitor_guard.get(&rp.point_id).cloned(),
});
} }
drop(monitor_guard);
let equipments = all_equipments
.into_iter()
.map(|eq| {
let role_points = role_points_map.remove(&eq.id).unwrap_or_default();
UnitEquipmentItem { equipment: eq, role_points }
})
.collect();
Ok(Json(UnitWithRuntime { unit, runtime, equipments }))
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@ -142,6 +265,7 @@ pub struct EquipmentDetail {
pub struct UnitDetail { pub struct UnitDetail {
#[serde(flatten)] #[serde(flatten)]
pub unit: crate::model::ControlUnit, pub unit: crate::model::ControlUnit,
pub runtime: Option<crate::control::runtime::UnitRuntime>,
pub equipments: Vec<EquipmentDetail>, pub equipments: Vec<EquipmentDetail>,
} }
@ -153,6 +277,8 @@ pub async fn get_unit_detail(
.await? .await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?; .ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let runtime = state.control_runtime.get(unit_id).await;
let equipments = crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?; let equipments = crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?;
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect(); let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?; let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?;
@ -177,7 +303,7 @@ pub async fn get_unit_detail(
}) })
.collect(); .collect();
Ok(Json(UnitDetail { unit, equipments })) Ok(Json(UnitDetail { unit, runtime, equipments }))
} }
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate)]
@ -188,13 +314,13 @@ pub struct CreateUnitReq {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub enabled: Option<bool>, pub enabled: Option<bool>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub run_time_sec: Option<i32>, pub run_time_sec: Option<i32>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub stop_time_sec: Option<i32>, pub stop_time_sec: Option<i32>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub acc_time_sec: Option<i32>, pub acc_time_sec: Option<i32>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub bl_time_sec: Option<i32>, pub bl_time_sec: Option<i32>,
pub require_manual_ack_after_fault: Option<bool>, pub require_manual_ack_after_fault: Option<bool>,
} }
@ -205,6 +331,33 @@ pub async fn create_unit(
) -> Result<impl IntoResponse, ApiErr> { ) -> Result<impl IntoResponse, ApiErr> {
payload.validate()?; payload.validate()?;
let run_time_sec = payload.run_time_sec.ok_or_else(|| {
ApiErr::BadRequest(
"run_time_sec is required".to_string(),
Some(json!({ "run_time_sec": ["is required"] })),
)
})?;
let stop_time_sec = payload.stop_time_sec.ok_or_else(|| {
ApiErr::BadRequest(
"stop_time_sec is required".to_string(),
Some(json!({ "stop_time_sec": ["is required"] })),
)
})?;
let acc_time_sec = payload.acc_time_sec.ok_or_else(|| {
ApiErr::BadRequest(
"acc_time_sec is required".to_string(),
Some(json!({ "acc_time_sec": ["is required"] })),
)
})?;
let bl_time_sec = payload.bl_time_sec.ok_or_else(|| {
ApiErr::BadRequest(
"bl_time_sec is required".to_string(),
Some(json!({ "bl_time_sec": ["is required"] })),
)
})?;
validate_unit_timing_order(run_time_sec, acc_time_sec)?;
if crate::service::get_unit_by_code(&state.pool, &payload.code) if crate::service::get_unit_by_code(&state.pool, &payload.code)
.await? .await?
.is_some() .is_some()
@ -222,10 +375,10 @@ pub async fn create_unit(
name: &payload.name, name: &payload.name,
description: payload.description.as_deref(), description: payload.description.as_deref(),
enabled: payload.enabled.unwrap_or(true), enabled: payload.enabled.unwrap_or(true),
run_time_sec: payload.run_time_sec.unwrap_or(0), run_time_sec,
stop_time_sec: payload.stop_time_sec.unwrap_or(0), stop_time_sec,
acc_time_sec: payload.acc_time_sec.unwrap_or(0), acc_time_sec,
bl_time_sec: payload.bl_time_sec.unwrap_or(0), bl_time_sec,
require_manual_ack_after_fault: payload require_manual_ack_after_fault: payload
.require_manual_ack_after_fault .require_manual_ack_after_fault
.unwrap_or(true), .unwrap_or(true),
@ -250,13 +403,13 @@ pub struct UpdateUnitReq {
pub name: Option<String>, pub name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub enabled: Option<bool>, pub enabled: Option<bool>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub run_time_sec: Option<i32>, pub run_time_sec: Option<i32>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub stop_time_sec: Option<i32>, pub stop_time_sec: Option<i32>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub acc_time_sec: Option<i32>, pub acc_time_sec: Option<i32>,
#[validate(range(min = 0))] #[validate(range(min = 1, message = "must be greater than 0"))]
pub bl_time_sec: Option<i32>, pub bl_time_sec: Option<i32>,
pub require_manual_ack_after_fault: Option<bool>, pub require_manual_ack_after_fault: Option<bool>,
} }
@ -268,12 +421,14 @@ pub async fn update_unit(
) -> Result<impl IntoResponse, ApiErr> { ) -> Result<impl IntoResponse, ApiErr> {
payload.validate()?; payload.validate()?;
if crate::service::get_unit_by_id(&state.pool, unit_id) let existing_unit = crate::service::get_unit_by_id(&state.pool, unit_id)
.await? .await?
.is_none() .ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
{
return Err(ApiErr::NotFound("Unit not found".to_string(), None)); validate_unit_timing_order(
} payload.run_time_sec.unwrap_or(existing_unit.run_time_sec),
payload.acc_time_sec.unwrap_or(existing_unit.acc_time_sec),
)?;
if let Some(code) = payload.code.as_deref() { if let Some(code) = payload.code.as_deref() {
let duplicate = crate::service::get_unit_by_code(&state.pool, code).await?; let duplicate = crate::service::get_unit_by_code(&state.pool, code).await?;
@ -383,10 +538,20 @@ pub async fn start_auto_unit(
} }
let mut runtime = state.control_runtime.get_or_init(unit_id).await; let mut runtime = state.control_runtime.get_or_init(unit_id).await;
if auto_control_start_blocked(&runtime) {
let message = if runtime.fault_locked {
"Unit is fault locked, cannot start auto control"
} else if runtime.comm_locked {
"Unit communication is locked, cannot start auto control"
} else {
"Fault acknowledgement required before starting auto control"
};
return Err(ApiErr::BadRequest(message.to_string(), None));
}
runtime.auto_enabled = true; runtime.auto_enabled = true;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped; runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
state.control_runtime.upsert(runtime).await; state.control_runtime.upsert(runtime).await;
state.control_runtime.notify_unit(unit_id).await;
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStarted { unit_id }); let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStarted { unit_id });
@ -404,6 +569,7 @@ pub async fn stop_auto_unit(
let mut runtime = state.control_runtime.get_or_init(unit_id).await; let mut runtime = state.control_runtime.get_or_init(unit_id).await;
runtime.auto_enabled = false; runtime.auto_enabled = false;
state.control_runtime.upsert(runtime).await; state.control_runtime.upsert(runtime).await;
state.control_runtime.notify_unit(unit_id).await;
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStopped { unit_id }); let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStopped { unit_id });
@ -423,14 +589,14 @@ pub async fn batch_start_auto(
skipped.push(unit.id); skipped.push(unit.id);
continue; continue;
} }
if runtime.fault_locked || runtime.comm_locked { if auto_control_start_blocked(&runtime) {
skipped.push(unit.id); skipped.push(unit.id);
continue; continue;
} }
runtime.auto_enabled = true; runtime.auto_enabled = true;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped; runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
state.control_runtime.upsert(runtime).await; state.control_runtime.upsert(runtime).await;
state.control_runtime.notify_unit(unit.id).await;
let _ = state let _ = state
.event_manager .event_manager
.send(crate::event::AppEvent::AutoControlStarted { unit_id: unit.id }); .send(crate::event::AppEvent::AutoControlStarted { unit_id: unit.id });
@ -453,6 +619,7 @@ pub async fn batch_stop_auto(
} }
runtime.auto_enabled = false; runtime.auto_enabled = false;
state.control_runtime.upsert(runtime).await; state.control_runtime.upsert(runtime).await;
state.control_runtime.notify_unit(unit.id).await;
let _ = state let _ = state
.event_manager .event_manager
.send(crate::event::AppEvent::AutoControlStopped { unit_id: unit.id }); .send(crate::event::AppEvent::AutoControlStopped { unit_id: unit.id });
@ -489,6 +656,7 @@ pub async fn ack_fault_unit(
runtime.manual_ack_required = false; runtime.manual_ack_required = false;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped; runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
state.control_runtime.upsert(runtime).await; state.control_runtime.upsert(runtime).await;
state.control_runtime.notify_unit(unit_id).await;
let _ = state.event_manager.send(crate::event::AppEvent::FaultAcked { unit_id }); let _ = state.event_manager.send(crate::event::AppEvent::FaultAcked { unit_id });
@ -506,3 +674,74 @@ pub async fn get_unit_runtime(
let runtime = state.control_runtime.get_or_init(unit_id).await; let runtime = state.control_runtime.get_or_init(unit_id).await;
Ok(Json(runtime)) Ok(Json(runtime))
} }
#[cfg(test)]
mod tests {
use super::{
auto_control_start_blocked, validate_unit_timing_order, CreateUnitReq, UpdateUnitReq,
};
use crate::control::runtime::{UnitRuntime, UnitRuntimeState};
use uuid::Uuid;
use validator::Validate;
#[test]
fn create_unit_req_rejects_zero_second_fields() {
let payload = CreateUnitReq {
code: "U-01".to_string(),
name: "Unit 01".to_string(),
description: None,
enabled: Some(true),
run_time_sec: Some(0),
stop_time_sec: Some(10),
acc_time_sec: Some(20),
bl_time_sec: Some(5),
require_manual_ack_after_fault: Some(true),
};
assert!(payload.validate().is_err());
}
#[test]
fn create_unit_req_rejects_acc_time_not_greater_than_run_time() {
assert!(validate_unit_timing_order(10, 10).is_err());
}
#[test]
fn update_unit_req_rejects_zero_second_fields() {
let payload = UpdateUnitReq {
code: None,
name: None,
description: None,
enabled: None,
run_time_sec: None,
stop_time_sec: Some(0),
acc_time_sec: Some(20),
bl_time_sec: Some(5),
require_manual_ack_after_fault: None,
};
assert!(payload.validate().is_err());
}
#[test]
fn update_unit_req_rejects_acc_time_not_greater_than_run_time_when_both_present() {
assert!(validate_unit_timing_order(20, 15).is_err());
}
#[test]
fn auto_control_start_is_blocked_by_comm_lock() {
let runtime = UnitRuntime {
unit_id: Uuid::new_v4(),
state: UnitRuntimeState::Stopped,
auto_enabled: false,
accumulated_run_sec: 0,
display_acc_sec: 0,
fault_locked: false,
flt_active: false,
comm_locked: true,
manual_ack_required: false,
};
assert!(auto_control_start_blocked(&runtime));
}
}

View File

@ -21,3 +21,20 @@ pub async fn get_api_md() -> Result<impl IntoResponse, ApiErr> {
Ok((StatusCode::OK, headers, content)) Ok((StatusCode::OK, headers, content))
} }
pub async fn get_readme_md() -> Result<impl IntoResponse, ApiErr> {
let content = tokio::fs::read_to_string("README.md")
.await
.map_err(|err| {
tracing::error!("Failed to read README.md: {}", err);
ApiErr::NotFound("README.md not found".to_string(), None)
})?;
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/markdown; charset=utf-8"),
);
Ok((StatusCode::OK, headers, content))
}

View File

@ -14,6 +14,18 @@ use crate::util::{
}; };
use crate::AppState; use crate::AppState;
async fn notify_units(
state: &AppState,
unit_ids: impl IntoIterator<Item = Uuid>,
) {
let mut seen = std::collections::HashSet::new();
for unit_id in unit_ids {
if seen.insert(unit_id) {
state.control_runtime.notify_unit(unit_id).await;
}
}
}
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct GetEquipmentListQuery { pub struct GetEquipmentListQuery {
#[validate(length(min = 1, max = 100))] #[validate(length(min = 1, max = 100))]
@ -22,11 +34,19 @@ pub struct GetEquipmentListQuery {
pub pagination: PaginationParams, pub pagination: PaginationParams,
} }
#[derive(Serialize)]
pub struct SignalRolePoint {
pub point_id: uuid::Uuid,
pub signal_role: String,
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct EquipmentListItem { pub struct EquipmentListItem {
#[serde(flatten)] #[serde(flatten)]
pub equipment: crate::model::Equipment, pub equipment: crate::model::Equipment,
pub point_count: i64, pub point_count: i64,
pub role_points: Vec<SignalRolePoint>,
} }
pub async fn get_equipment_list( pub async fn get_equipment_list(
@ -36,7 +56,7 @@ pub async fn get_equipment_list(
query.validate()?; query.validate()?;
let total = crate::service::get_equipment_count(&state.pool, query.keyword.as_deref()).await?; let total = crate::service::get_equipment_count(&state.pool, query.keyword.as_deref()).await?;
let data = crate::service::get_equipment_paginated( let items = crate::service::get_equipment_paginated(
&state.pool, &state.pool,
query.keyword.as_deref(), query.keyword.as_deref(),
query.pagination.page_size, query.pagination.page_size,
@ -44,6 +64,38 @@ pub async fn get_equipment_list(
) )
.await?; .await?;
let equipment_ids: Vec<uuid::Uuid> = items.iter().map(|item| item.equipment.id).collect();
let role_point_rows =
crate::service::get_signal_role_points_batch(&state.pool, &equipment_ids).await?;
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
let mut role_points_map: std::collections::HashMap<uuid::Uuid, Vec<SignalRolePoint>> =
std::collections::HashMap::new();
for rp in role_point_rows {
role_points_map
.entry(rp.equipment_id)
.or_default()
.push(SignalRolePoint {
point_id: rp.point_id,
signal_role: rp.signal_role,
point_monitor: monitor_guard.get(&rp.point_id).cloned(),
});
}
let data = items
.into_iter()
.map(|item| EquipmentListItem {
role_points: role_points_map
.remove(&item.equipment.id)
.unwrap_or_default(),
..item
})
.collect::<Vec<_>>();
Ok(Json(PaginatedResponse::new( Ok(Json(PaginatedResponse::new(
data, data,
total, total,
@ -136,6 +188,10 @@ pub async fn create_equipment(
) )
.await?; .await?;
if let Some(unit_id) = payload.unit_id {
notify_units(&state, [unit_id]).await;
}
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(serde_json::json!({ Json(serde_json::json!({
@ -162,9 +218,11 @@ pub async fn update_equipment(
} }
let exists = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?; let exists = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
if exists.is_none() { let existing_equipment = if let Some(equipment) = exists {
equipment
} else {
return Err(ApiErr::NotFound("Equipment not found".to_string(), None)); return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
} };
if let Some(Some(unit_id)) = payload.unit_id { if let Some(Some(unit_id)) = payload.unit_id {
let unit_exists = crate::service::get_unit_by_id(&state.pool, unit_id).await?; let unit_exists = crate::service::get_unit_by_id(&state.pool, unit_id).await?;
@ -197,6 +255,19 @@ pub async fn update_equipment(
) )
.await?; .await?;
let mut unit_ids = Vec::new();
if let Some(unit_id) = existing_equipment.unit_id {
unit_ids.push(unit_id);
}
let next_unit_id = match payload.unit_id {
Some(next) => next,
None => existing_equipment.unit_id,
};
if let Some(unit_id) = next_unit_id {
unit_ids.push(unit_id);
}
notify_units(&state, unit_ids).await;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"ok_msg": "Equipment updated successfully" "ok_msg": "Equipment updated successfully"
}))) })))
@ -222,6 +293,9 @@ pub async fn batch_set_equipment_unit(
} }
} }
let before_unit_ids =
crate::service::get_unit_ids_by_equipment_ids(&state.pool, &payload.equipment_ids).await?;
let updated_count = crate::service::batch_set_equipment_unit( let updated_count = crate::service::batch_set_equipment_unit(
&state.pool, &state.pool,
&payload.equipment_ids, &payload.equipment_ids,
@ -229,6 +303,12 @@ pub async fn batch_set_equipment_unit(
) )
.await?; .await?;
let mut unit_ids = before_unit_ids;
if let Some(unit_id) = payload.unit_id {
unit_ids.push(unit_id);
}
notify_units(&state, unit_ids).await;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"ok_msg": "Equipment unit updated successfully", "ok_msg": "Equipment unit updated successfully",
"updated_count": updated_count "updated_count": updated_count
@ -239,10 +319,13 @@ pub async fn delete_equipment(
State(state): State<AppState>, State(state): State<AppState>,
Path(equipment_id): Path<Uuid>, Path(equipment_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> { ) -> Result<impl IntoResponse, ApiErr> {
let unit_ids = crate::service::get_unit_ids_by_equipment_ids(&state.pool, &[equipment_id]).await?;
let deleted = crate::service::delete_equipment(&state.pool, equipment_id).await?; let deleted = crate::service::delete_equipment(&state.pool, equipment_id).await?;
if !deleted { if !deleted {
return Err(ApiErr::NotFound("Equipment not found".to_string(), None)); return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
} }
notify_units(&state, unit_ids).await;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@ -46,6 +46,13 @@ pub struct LogChunkResponse {
pub reset: bool, pub reset: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct StreamFileState {
path: PathBuf,
file_name: String,
cursor: u64,
}
pub async fn get_logs(Query(query): Query<LogQuery>) -> Result<impl IntoResponse, ApiErr> { pub async fn get_logs(Query(query): Query<LogQuery>) -> Result<impl IntoResponse, ApiErr> {
let path = resolve_log_file(query.file.as_deref()).await?; let path = resolve_log_file(query.file.as_deref()).await?;
let file_name = file_name_of(&path); let file_name = file_name_of(&path);
@ -74,17 +81,44 @@ pub async fn stream_logs(Query(query): Query<LogQuery>) -> Result<impl IntoRespo
.max_bytes .max_bytes
.unwrap_or(STREAM_MAX_BYTES) .unwrap_or(STREAM_MAX_BYTES)
.clamp(1, MAX_MAX_BYTES); .clamp(1, MAX_MAX_BYTES);
let follow_latest = query.file.is_none();
let start_cursor = query.cursor.unwrap_or(file_len(&path).await?); let start_cursor = query.cursor.unwrap_or(file_len(&path).await?);
let event_stream = stream! { let event_stream = stream! {
let mut ticker = interval(Duration::from_millis(800)); let mut ticker = interval(Duration::from_millis(800));
let mut cursor = start_cursor; let mut stream_file = StreamFileState {
path,
file_name,
cursor: start_cursor,
};
loop { loop {
ticker.tick().await; ticker.tick().await;
match read_since(&path, &file_name, cursor, max_bytes).await { let switched = if follow_latest {
match latest_log_file(Path::new(LOG_DIR)).await {
Ok(latest_path) => {
let latest = StreamFileState {
file_name: file_name_of(&latest_path),
path: latest_path,
cursor: 0,
};
let (next, switched) = advance_stream_file(&stream_file, &latest);
stream_file = next;
switched
}
Err(_) => false,
}
} else {
false
};
match read_since(&stream_file.path, &stream_file.file_name, stream_file.cursor, max_bytes).await {
Ok(chunk) => { Ok(chunk) => {
cursor = chunk.cursor; stream_file.cursor = chunk.cursor;
let chunk = LogChunkResponse {
reset: chunk.reset || switched,
..chunk
};
if chunk.reset || !chunk.lines.is_empty() { if chunk.reset || !chunk.lines.is_empty() {
match Event::default().event("log").json_data(&chunk) { match Event::default().event("log").json_data(&chunk) {
Ok(event) => yield Ok::<Event, Infallible>(event), Ok(event) => yield Ok::<Event, Infallible>(event),
@ -267,9 +301,54 @@ fn file_name_of(path: &Path) -> String {
.to_string() .to_string()
} }
fn advance_stream_file(
current: &StreamFileState,
latest: &StreamFileState,
) -> (StreamFileState, bool) {
if current.path == latest.path {
return (current.clone(), false);
}
(
StreamFileState {
path: latest.path.clone(),
file_name: latest.file_name.clone(),
cursor: 0,
},
true,
)
}
fn map_open_err(err: std::io::Error) -> ApiErr { fn map_open_err(err: std::io::Error) -> ApiErr {
match err.kind() { match err.kind() {
std::io::ErrorKind::NotFound => ApiErr::NotFound("log file not found".to_string(), None), std::io::ErrorKind::NotFound => ApiErr::NotFound("log file not found".to_string(), None),
_ => ApiErr::Internal("failed to access log file".to_string(), None), _ => ApiErr::Internal("failed to access log file".to_string(), None),
} }
} }
#[cfg(test)]
mod tests {
use super::{advance_stream_file, StreamFileState};
use std::path::PathBuf;
#[test]
fn advance_stream_file_switches_to_latest_file_and_resets_cursor() {
let current = StreamFileState {
path: PathBuf::from("logs/app.log"),
file_name: "app.log".to_string(),
cursor: 128,
};
let latest = StreamFileState {
path: PathBuf::from("logs/app.log.1"),
file_name: "app.log.1".to_string(),
cursor: 42,
};
let (next, switched) = advance_stream_file(&current, &latest);
assert!(switched);
assert_eq!(next.path, latest.path);
assert_eq!(next.file_name, latest.file_name);
assert_eq!(next.cursor, 0);
}
}

View File

@ -21,6 +21,18 @@ use crate::{
AppState, AppState,
}; };
async fn notify_units(
state: &AppState,
unit_ids: impl IntoIterator<Item = Uuid>,
) {
let mut seen = std::collections::HashSet::new();
for unit_id in unit_ids {
if seen.insert(unit_id) {
state.control_runtime.notify_unit(unit_id).await;
}
}
}
/// List all points. /// List all points.
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct GetPointListQuery { pub struct GetPointListQuery {
@ -161,12 +173,14 @@ pub struct UpdatePointReq {
/// Request payload for batch setting point tags. /// Request payload for batch setting point tags.
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct BatchSetPointTagsReq { pub struct BatchSetPointTagsReq {
#[validate(length(min = 1, max = 500))]
pub point_ids: Vec<Uuid>, pub point_ids: Vec<Uuid>,
pub tag_id: Option<Uuid>, pub tag_id: Option<Uuid>,
} }
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct BatchSetPointEquipmentReq { pub struct BatchSetPointEquipmentReq {
#[validate(length(min = 1, max = 500))]
pub point_ids: Vec<Uuid>, pub point_ids: Vec<Uuid>,
pub equipment_id: Option<Uuid>, pub equipment_id: Option<Uuid>,
pub signal_role: Option<String>, pub signal_role: Option<String>,
@ -225,6 +239,7 @@ pub async fn update_point(
if existing_point.is_none() { if existing_point.is_none() {
return Err(ApiErr::NotFound("Point not found".to_string(), None)); return Err(ApiErr::NotFound("Point not found".to_string(), None));
} }
let before_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("UPDATE point SET "); let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("UPDATE point SET ");
let mut wrote_field = false; let mut wrote_field = false;
@ -280,6 +295,9 @@ pub async fn update_point(
qb.push(" WHERE id = ").push_bind(point_id); qb.push(" WHERE id = ").push_bind(point_id);
qb.build().execute(pool).await?; qb.build().execute(pool).await?;
let after_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
notify_units(&state, before_unit_ids.into_iter().chain(after_unit_ids)).await;
Ok(Json( Ok(Json(
serde_json::json!({"ok_msg": "Point updated successfully"}), serde_json::json!({"ok_msg": "Point updated successfully"}),
)) ))
@ -380,6 +398,8 @@ pub async fn batch_set_point_equipment(
return Err(ApiErr::NotFound("No valid points found".to_string(), None)); return Err(ApiErr::NotFound("No valid points found".to_string(), None));
} }
let before_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &existing_points).await?;
let result = sqlx::query( let result = sqlx::query(
r#" r#"
UPDATE point UPDATE point
@ -395,6 +415,9 @@ pub async fn batch_set_point_equipment(
.execute(pool) .execute(pool)
.await?; .await?;
let after_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &existing_points).await?;
notify_units(&state, before_unit_ids.into_iter().chain(after_unit_ids)).await;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"ok_msg": "Point equipment updated successfully", "ok_msg": "Point equipment updated successfully",
"updated_count": result.rows_affected() "updated_count": result.rows_affected()
@ -407,6 +430,7 @@ pub async fn delete_point(
Path(point_id): Path<Uuid>, Path(point_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> { ) -> Result<impl IntoResponse, ApiErr> {
let pool = &state.pool; let pool = &state.pool;
let affected_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
let source_id = { let source_id = {
let grouped = crate::service::get_points_grouped_by_source(pool, &[point_id]).await?; let grouped = crate::service::get_points_grouped_by_source(pool, &[point_id]).await?;
@ -440,6 +464,8 @@ pub async fn delete_point(
} }
} }
notify_units(&state, affected_unit_ids).await;
Ok(Json( Ok(Json(
serde_json::json!({"ok_msg": "Point deleted successfully"}), serde_json::json!({"ok_msg": "Point deleted successfully"}),
)) ))
@ -448,6 +474,7 @@ pub async fn delete_point(
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
/// Request payload for batch point creation from node ids. /// Request payload for batch point creation from node ids.
pub struct BatchCreatePointsReq { pub struct BatchCreatePointsReq {
#[validate(length(min = 1, max = 500))]
pub node_ids: Vec<Uuid>, pub node_ids: Vec<Uuid>,
} }
@ -563,6 +590,7 @@ pub async fn batch_create_points(
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
/// Request payload for batch point deletion. /// Request payload for batch point deletion.
pub struct BatchDeletePointsReq { pub struct BatchDeletePointsReq {
#[validate(length(min = 1, max = 500))]
pub point_ids: Vec<Uuid>, pub point_ids: Vec<Uuid>,
} }
@ -590,6 +618,7 @@ pub async fn batch_delete_points(
let point_ids = payload.point_ids; let point_ids = payload.point_ids;
let grouped = crate::service::get_points_grouped_by_source(pool, &point_ids).await?; let grouped = crate::service::get_points_grouped_by_source(pool, &point_ids).await?;
let affected_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &point_ids).await?;
let existing_point_ids: Vec<Uuid> = grouped let existing_point_ids: Vec<Uuid> = grouped
.values() .values()
.flat_map(|points| points.iter().map(|p| p.point_id)) .flat_map(|points| points.iter().map(|p| p.point_id))
@ -617,6 +646,8 @@ pub async fn batch_delete_points(
} }
} }
notify_units(&state, affected_unit_ids).await;
Ok(Json(BatchDeletePointsRes { Ok(Json(BatchDeletePointsRes {
deleted_count: result.rows_affected(), deleted_count: result.rows_affected(),
})) }))

View File

@ -171,23 +171,24 @@ fn build_node_tree(nodes: Vec<Node>) -> Vec<TreeNode> {
id: Uuid, id: Uuid,
node_map: &mut HashMap<Uuid, TreeNode>, node_map: &mut HashMap<Uuid, TreeNode>,
children_map: &HashMap<Uuid, Vec<Uuid>>, children_map: &HashMap<Uuid, Vec<Uuid>>,
) -> TreeNode { ) -> Option<TreeNode> {
let mut node = node_map.remove(&id).unwrap(); let mut node = node_map.remove(&id)?;
if let Some(child_ids) = children_map.get(&id) { if let Some(child_ids) = children_map.get(&id) {
for &cid in child_ids { for &cid in child_ids {
let child = attach_children(cid, node_map, children_map); if let Some(child) = attach_children(cid, node_map, children_map) {
node.children.push(child); node.children.push(child);
}
} }
} }
node Some(node)
} }
// ③ 生成最终树 // ③ 生成最终树
roots roots
.into_iter() .into_iter()
.map(|rid| attach_children(rid, &mut node_map, &children_map)) .filter_map(|rid| attach_children(rid, &mut node_map, &children_map))
.collect() .collect()
} }
@ -311,19 +312,19 @@ pub async fn delete_source(
) -> Result<impl IntoResponse, ApiErr> { ) -> Result<impl IntoResponse, ApiErr> {
let pool = &state.pool; let pool = &state.pool;
// 删除source let source_name = sqlx::query_scalar::<_, String>("SELECT name FROM source WHERE id = $1")
let result = sqlx::query("DELETE FROM source WHERE id = $1") .bind(source_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiErr::NotFound(format!("Source with id {} not found", source_id), None))?;
sqlx::query("DELETE FROM source WHERE id = $1")
.bind(source_id) .bind(source_id)
.execute(pool) .execute(pool)
.await?; .await?;
// 检查是否删除了记录
if result.rows_affected() == 0 {
return Err(ApiErr::NotFound(format!("Source with id {} not found", source_id), None));
}
// 触发 SourceDelete 事件 // 触发 SourceDelete 事件
let _ = state.event_manager.send(crate::event::AppEvent::SourceDelete { source_id }); let _ = state.event_manager.send(crate::event::AppEvent::SourceDelete { source_id, source_name });
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@ -104,6 +104,9 @@ async fn main() {
control_runtime: control_runtime.clone(), control_runtime: control_runtime.clone(),
}; };
control::engine::start(state.clone(), control_runtime); control::engine::start(state.clone(), control_runtime);
if config.simulate_plc {
control::simulate::start(state.clone());
}
let app = build_router(state.clone()); let app = build_router(state.clone());
let addr = format!("{}:{}", config.server_host, config.server_port); let addr = format!("{}:{}", config.server_host, config.server_port);
tracing::info!("Starting server at http://{}", addr); tracing::info!("Starting server at http://{}", addr);
@ -277,7 +280,8 @@ fn build_router(state: AppState) -> Router {
) )
.route("/api/logs", get(handler::log::get_logs)) .route("/api/logs", get(handler::log::get_logs))
.route("/api/logs/stream", get(handler::log::stream_logs)) .route("/api/logs/stream", get(handler::log::stream_logs))
.route("/api/docs/api-md", get(handler::doc::get_api_md)); .route("/api/docs/api-md", get(handler::doc::get_api_md))
.route("/api/docs/readme-md", get(handler::doc::get_readme_md));
Router::new() Router::new()
.merge(all_route) .merge(all_route)

View File

@ -2,6 +2,14 @@ use crate::model::{ControlUnit, EventRecord};
use sqlx::{PgPool, QueryBuilder, Row}; use sqlx::{PgPool, QueryBuilder, Row};
use uuid::Uuid; use uuid::Uuid;
fn unit_order_clause() -> &'static str {
"code"
}
fn equipment_order_clause_with_unit() -> &'static str {
"unit_id, code"
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EquipmentRolePoint { pub struct EquipmentRolePoint {
pub point_id: Uuid, pub point_id: Uuid,
@ -35,31 +43,36 @@ pub async fn get_units_paginated(
page_size: i32, page_size: i32,
offset: u32, offset: u32,
) -> Result<Vec<ControlUnit>, sqlx::Error> { ) -> Result<Vec<ControlUnit>, sqlx::Error> {
let unit_order = unit_order_clause();
match keyword { match keyword {
Some(keyword) => { Some(keyword) => {
let like = format!("%{}%", keyword); let like = format!("%{}%", keyword);
if page_size == -1 { if page_size == -1 {
sqlx::query_as::<_, ControlUnit>( let sql = format!(
r#" r#"
SELECT * SELECT *
FROM unit FROM unit
WHERE code ILIKE $1 OR name ILIKE $1 WHERE code ILIKE $1 OR name ILIKE $1
ORDER BY created_at ORDER BY {}
"#, "#,
) unit_order
);
sqlx::query_as::<_, ControlUnit>(&sql)
.bind(like) .bind(like)
.fetch_all(pool) .fetch_all(pool)
.await .await
} else { } else {
sqlx::query_as::<_, ControlUnit>( let sql = format!(
r#" r#"
SELECT * SELECT *
FROM unit FROM unit
WHERE code ILIKE $1 OR name ILIKE $1 WHERE code ILIKE $1 OR name ILIKE $1
ORDER BY created_at ORDER BY {}
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
"#, "#,
) unit_order
);
sqlx::query_as::<_, ControlUnit>(&sql)
.bind(like) .bind(like)
.bind(page_size as i64) .bind(page_size as i64)
.bind(offset as i64) .bind(offset as i64)
@ -69,18 +82,21 @@ pub async fn get_units_paginated(
} }
None => { None => {
if page_size == -1 { if page_size == -1 {
sqlx::query_as::<_, ControlUnit>(r#"SELECT * FROM unit ORDER BY created_at"#) let sql = format!("SELECT * FROM unit ORDER BY {}", unit_order);
sqlx::query_as::<_, ControlUnit>(&sql)
.fetch_all(pool) .fetch_all(pool)
.await .await
} else { } else {
sqlx::query_as::<_, ControlUnit>( let sql = format!(
r#" r#"
SELECT * SELECT *
FROM unit FROM unit
ORDER BY created_at ORDER BY {}
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
"#, "#,
) unit_order
);
sqlx::query_as::<_, ControlUnit>(&sql)
.bind(page_size as i64) .bind(page_size as i64)
.bind(offset as i64) .bind(offset as i64)
.fetch_all(pool) .fetch_all(pool)
@ -309,9 +325,28 @@ pub async fn get_events_paginated(
} }
pub async fn get_all_enabled_units(pool: &PgPool) -> Result<Vec<ControlUnit>, sqlx::Error> { pub async fn get_all_enabled_units(pool: &PgPool) -> Result<Vec<ControlUnit>, sqlx::Error> {
sqlx::query_as::<_, ControlUnit>( let sql = format!(
r#"SELECT * FROM unit WHERE enabled = TRUE ORDER BY created_at"#, "SELECT * FROM unit WHERE enabled = TRUE ORDER BY {}",
) unit_order_clause()
);
sqlx::query_as::<_, ControlUnit>(&sql)
.fetch_all(pool)
.await
}
pub async fn get_equipment_by_unit_ids(
pool: &PgPool,
unit_ids: &[Uuid],
) -> Result<Vec<crate::model::Equipment>, sqlx::Error> {
if unit_ids.is_empty() {
return Ok(vec![]);
}
let sql = format!(
"SELECT * FROM equipment WHERE unit_id = ANY($1) ORDER BY {}",
equipment_order_clause_with_unit()
);
sqlx::query_as::<_, crate::model::Equipment>(&sql)
.bind(unit_ids)
.fetch_all(pool) .fetch_all(pool)
.await .await
} }
@ -320,9 +355,11 @@ pub async fn get_equipment_by_unit_id(
pool: &PgPool, pool: &PgPool,
unit_id: Uuid, unit_id: Uuid,
) -> Result<Vec<crate::model::Equipment>, sqlx::Error> { ) -> Result<Vec<crate::model::Equipment>, sqlx::Error> {
sqlx::query_as::<_, crate::model::Equipment>( let sql = format!(
r#"SELECT * FROM equipment WHERE unit_id = $1 ORDER BY created_at"#, "SELECT * FROM equipment WHERE unit_id = $1 ORDER BY {}",
) unit_order_clause()
);
sqlx::query_as::<_, crate::model::Equipment>(&sql)
.bind(unit_id) .bind(unit_id)
.fetch_all(pool) .fetch_all(pool)
.await .await
@ -343,6 +380,105 @@ pub async fn get_points_by_equipment_ids(
.await .await
} }
pub async fn get_unit_ids_by_equipment_ids(
pool: &PgPool,
equipment_ids: &[Uuid],
) -> Result<Vec<Uuid>, sqlx::Error> {
if equipment_ids.is_empty() {
return Ok(vec![]);
}
let rows = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT DISTINCT unit_id
FROM equipment
WHERE id = ANY($1)
AND unit_id IS NOT NULL
"#,
)
.bind(equipment_ids)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn get_unit_ids_by_point_ids(
pool: &PgPool,
point_ids: &[Uuid],
) -> Result<Vec<Uuid>, sqlx::Error> {
if point_ids.is_empty() {
return Ok(vec![]);
}
let rows = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT DISTINCT e.unit_id
FROM point p
INNER JOIN equipment e ON e.id = p.equipment_id
WHERE p.id = ANY($1)
AND e.unit_id IS NOT NULL
"#,
)
.bind(point_ids)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub struct EquipmentSignalRole {
pub equipment_id: Uuid,
pub point_id: Uuid,
pub signal_role: String,
}
/// Batch fetch all signal-role points for multiple equipment IDs in one query.
pub async fn get_signal_role_points_batch(
pool: &PgPool,
equipment_ids: &[Uuid],
) -> Result<Vec<EquipmentSignalRole>, sqlx::Error> {
if equipment_ids.is_empty() {
return Ok(vec![]);
}
let rows = sqlx::query(
r#"
SELECT p.equipment_id, p.id AS point_id, p.signal_role
FROM point p
WHERE p.equipment_id = ANY($1)
AND p.signal_role IS NOT NULL
ORDER BY p.equipment_id, p.created_at
"#,
)
.bind(equipment_ids)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| EquipmentSignalRole {
equipment_id: row.get("equipment_id"),
point_id: row.get("point_id"),
signal_role: row.get("signal_role"),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::{equipment_order_clause_with_unit, unit_order_clause};
#[test]
fn unit_ordering_defaults_to_code() {
assert_eq!(unit_order_clause(), "code");
}
#[test]
fn unit_equipment_ordering_uses_code_within_unit() {
assert_eq!(equipment_order_clause_with_unit(), "unit_id, code");
}
}
pub async fn get_equipment_role_points( pub async fn get_equipment_role_points(
pool: &PgPool, pool: &PgPool,
equipment_id: Uuid, equipment_id: Uuid,

View File

@ -5,6 +5,10 @@ use crate::{
use sqlx::{query_as, PgPool, Row}; use sqlx::{query_as, PgPool, Row};
use uuid::Uuid; use uuid::Uuid;
fn equipment_order_clause() -> &'static str {
"e.code"
}
pub async fn get_points_by_equipment_id( pub async fn get_points_by_equipment_id(
pool: &PgPool, pool: &PgPool,
equipment_id: uuid::Uuid, equipment_id: uuid::Uuid,
@ -49,11 +53,12 @@ pub async fn get_equipment_paginated(
page_size: i32, page_size: i32,
offset: u32, offset: u32,
) -> Result<Vec<EquipmentListItem>, sqlx::Error> { ) -> Result<Vec<EquipmentListItem>, sqlx::Error> {
let equipment_order = equipment_order_clause();
let rows = match keyword { let rows = match keyword {
Some(keyword) => { Some(keyword) => {
let like = format!("%{}%", keyword); let like = format!("%{}%", keyword);
if page_size == -1 { if page_size == -1 {
sqlx::query( let sql = format!(
r#" r#"
SELECT SELECT
e.*, e.*,
@ -62,14 +67,16 @@ pub async fn get_equipment_paginated(
LEFT JOIN point p ON p.equipment_id = e.id LEFT JOIN point p ON p.equipment_id = e.id
WHERE e.code ILIKE $1 OR e.name ILIKE $1 WHERE e.code ILIKE $1 OR e.name ILIKE $1
GROUP BY e.id GROUP BY e.id
ORDER BY e.created_at ORDER BY {}
"#, "#,
) equipment_order
);
sqlx::query(&sql)
.bind(like) .bind(like)
.fetch_all(pool) .fetch_all(pool)
.await? .await?
} else { } else {
sqlx::query( let sql = format!(
r#" r#"
SELECT SELECT
e.*, e.*,
@ -78,10 +85,12 @@ pub async fn get_equipment_paginated(
LEFT JOIN point p ON p.equipment_id = e.id LEFT JOIN point p ON p.equipment_id = e.id
WHERE e.code ILIKE $1 OR e.name ILIKE $1 WHERE e.code ILIKE $1 OR e.name ILIKE $1
GROUP BY e.id GROUP BY e.id
ORDER BY e.created_at ORDER BY {}
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
"#, "#,
) equipment_order
);
sqlx::query(&sql)
.bind(like) .bind(like)
.bind(page_size as i64) .bind(page_size as i64)
.bind(offset as i64) .bind(offset as i64)
@ -91,7 +100,7 @@ pub async fn get_equipment_paginated(
} }
None => { None => {
if page_size == -1 { if page_size == -1 {
sqlx::query( let sql = format!(
r#" r#"
SELECT SELECT
e.*, e.*,
@ -99,13 +108,15 @@ pub async fn get_equipment_paginated(
FROM equipment e FROM equipment e
LEFT JOIN point p ON p.equipment_id = e.id LEFT JOIN point p ON p.equipment_id = e.id
GROUP BY e.id GROUP BY e.id
ORDER BY e.created_at ORDER BY {}
"#, "#,
) equipment_order
);
sqlx::query(&sql)
.fetch_all(pool) .fetch_all(pool)
.await? .await?
} else { } else {
sqlx::query( let sql = format!(
r#" r#"
SELECT SELECT
e.*, e.*,
@ -113,10 +124,12 @@ pub async fn get_equipment_paginated(
FROM equipment e FROM equipment e
LEFT JOIN point p ON p.equipment_id = e.id LEFT JOIN point p ON p.equipment_id = e.id
GROUP BY e.id GROUP BY e.id
ORDER BY e.created_at ORDER BY {}
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
"#, "#,
) equipment_order
);
sqlx::query(&sql)
.bind(page_size as i64) .bind(page_size as i64)
.bind(offset as i64) .bind(offset as i64)
.fetch_all(pool) .fetch_all(pool)
@ -139,6 +152,7 @@ pub async fn get_equipment_paginated(
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
}, },
point_count: row.get::<i64, _>("point_count"), point_count: row.get::<i64, _>("point_count"),
role_points: vec![],
}) })
.collect()) .collect())
} }
@ -283,3 +297,13 @@ pub async fn batch_set_equipment_unit(
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
#[cfg(test)]
mod tests {
use super::equipment_order_clause;
#[test]
fn equipment_ordering_defaults_to_code() {
assert_eq!(equipment_order_clause(), "e.code");
}
}

View File

@ -5,7 +5,7 @@
<button type="button" class="tab-btn" id="tabConfig">配置</button> <button type="button" class="tab-btn" id="tabConfig">配置</button>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button> <button type="button" class="secondary" id="openReadmeDoc">README.md</button>
<button type="button" class="secondary" id="openApiDoc">API.md</button> <button type="button" class="secondary" id="openApiDoc">API.md</button>
<div class="status" id="statusText"> <div class="status" id="statusText">
<span class="ws-dot" id="wsDot"></span> <span class="ws-dot" id="wsDot"></span>

View File

@ -1,11 +1,10 @@
import { withStatus } from "./api.js"; import { withStatus } from "./api.js";
import { openChart, renderChart } from "./chart.js"; import { openChart, renderChart } from "./chart.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js"; import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./docs.js";
import { loadEvents } from "./events.js"; import { loadEvents } from "./events.js";
import { import {
applyBatchEquipmentUnit, applyBatchEquipmentUnit,
clearEquipmentFilter,
clearPointBinding, clearPointBinding,
clearSelectedEquipments, clearSelectedEquipments,
closeEquipmentModal, closeEquipmentModal,
@ -35,6 +34,8 @@ import { state } from "./state.js";
import { loadSources, saveSource } from "./sources.js"; import { loadSources, saveSource } from "./sources.js";
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js"; import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
let _configLoaded = false;
function switchView(view) { function switchView(view) {
state.activeView = view; state.activeView = view;
const main = document.querySelector("main"); const main = document.querySelector("main");
@ -60,6 +61,13 @@ function switchView(view) {
if (view === "config") { if (view === "config") {
startLogs(); startLogs();
if (!_configLoaded) {
_configLoaded = true;
withStatus((async () => {
await Promise.all([loadSources(), loadEquipments(), loadEvents()]);
await loadPoints();
})());
}
} else { } else {
stopLogs(); stopLogs();
} }
@ -82,7 +90,6 @@ function bindEvents() {
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments())); dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal); dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal);
dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal); dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal);
dom.clearEquipmentFilterBtn.addEventListener("click", () => withStatus(clearEquipmentFilter()));
dom.applyEquipmentUnitBtn.addEventListener("click", () => withStatus(applyBatchEquipmentUnit())); dom.applyEquipmentUnitBtn.addEventListener("click", () => withStatus(applyBatchEquipmentUnit()));
dom.clearEquipmentSelectionBtn.addEventListener("click", clearSelectedEquipments); dom.clearEquipmentSelectionBtn.addEventListener("click", clearSelectedEquipments);
@ -123,6 +130,7 @@ function bindEvents() {
}); });
}); });
dom.openReadmeDocBtn.addEventListener("click", () => withStatus(openReadmeDrawer()));
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer())); dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer); dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents())); dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));
@ -161,7 +169,8 @@ function bindEvents() {
document.addEventListener("equipments-updated", () => { document.addEventListener("equipments-updated", () => {
renderUnits(); renderUnits();
renderOpsUnits(); // Re-fetch units so embedded equipment data stays in sync with config changes.
loadUnits().catch(() => {});
}); });
document.addEventListener("units-loaded", () => { document.addEventListener("units-loaded", () => {
@ -179,12 +188,8 @@ async function bootstrap() {
renderChart(); renderChart();
startPointSocket(); startPointSocket();
await withStatus(loadUnits()); await withStatus(Promise.all([loadUnits(), loadEvents()]));
startOps(); startOps();
await withStatus(loadSources());
await withStatus(loadEquipments());
await withStatus(loadEvents());
await withStatus(loadPoints());
} }
bootstrap(); bootstrap();

View File

@ -82,11 +82,11 @@ function parseMarkdown(text) {
return { html: blocks.join(""), headings }; return { html: blocks.join(""), headings };
} }
export async function loadApiDoc() { async function loadDoc(url, emptyMessage) {
const text = await apiFetch("/api/docs/api-md"); const text = await apiFetch(url);
const { html, headings } = parseMarkdown(text || ""); const { html, headings } = parseMarkdown(text || "");
dom.apiDocContent.innerHTML = html || "<p>API.md 为空</p>"; dom.apiDocContent.innerHTML = html || `<p>${emptyMessage}</p>`;
dom.apiDocToc.innerHTML = headings.length dom.apiDocToc.innerHTML = headings.length
? headings ? headings
.map( .map(
@ -110,14 +110,25 @@ export async function loadApiDoc() {
} }
}); });
}); });
state.apiDocLoaded = true;
} }
export async function openApiDocDrawer() { export async function openApiDocDrawer() {
const title = dom.apiDocDrawer.querySelector("h3");
if (title) title.textContent = "API.md";
dom.apiDocDrawer.classList.remove("hidden"); dom.apiDocDrawer.classList.remove("hidden");
if (!state.apiDocLoaded) { if (state.docDrawerSource !== "api") {
await loadApiDoc(); state.docDrawerSource = "api";
await loadDoc("/api/docs/api-md", "API.md 为空");
}
}
export async function openReadmeDrawer() {
const title = dom.apiDocDrawer.querySelector("h3");
if (title) title.textContent = "README.md";
dom.apiDocDrawer.classList.remove("hidden");
if (state.docDrawerSource !== "readme") {
state.docDrawerSource = "readme";
await loadDoc("/api/docs/readme-md", "README.md 为空");
} }
} }

View File

@ -20,7 +20,6 @@ export const dom = {
selectedCount: byId("selectedCount"), selectedCount: byId("selectedCount"),
selectedPointCount: byId("selectedPointCount"), selectedPointCount: byId("selectedPointCount"),
pointFilterSummary: byId("pointFilterSummary"), pointFilterSummary: byId("pointFilterSummary"),
clearEquipmentFilterBtn: byId("clearEquipmentFilter"),
pointSourceSelect: byId("pointSourceSelect"), pointSourceSelect: byId("pointSourceSelect"),
pointSourceNodeCount: byId("pointSourceNodeCount"), pointSourceNodeCount: byId("pointSourceNodeCount"),
openPointModalBtn: byId("openPointModal"), openPointModalBtn: byId("openPointModal"),
@ -82,6 +81,7 @@ export const dom = {
batchBindingSignalRole: byId("batchBindingSignalRole"), batchBindingSignalRole: byId("batchBindingSignalRole"),
apiDocToc: byId("apiDocToc"), apiDocToc: byId("apiDocToc"),
apiDocContent: byId("apiDocContent"), apiDocContent: byId("apiDocContent"),
openReadmeDocBtn: byId("openReadmeDoc"),
openApiDocBtn: byId("openApiDoc"), openApiDocBtn: byId("openApiDoc"),
closeApiDocBtn: byId("closeApiDoc"), closeApiDocBtn: byId("closeApiDoc"),
refreshChartBtn: byId("refreshChart"), refreshChartBtn: byId("refreshChart"),

View File

@ -146,13 +146,6 @@ export function renderEquipments() {
dom.equipmentList.innerHTML = ""; dom.equipmentList.innerHTML = "";
updateSelectedEquipmentSummary(); updateSelectedEquipmentSummary();
const activeEquipment = state.selectedEquipmentId
? state.equipmentMap.get(state.selectedEquipmentId) || null
: null;
dom.clearEquipmentFilterBtn.textContent = activeEquipment
? `设备筛选 ${activeEquipment.name}`
: "设备筛选 全部";
const items = filteredEquipments(); const items = filteredEquipments();
if (!items.length) { if (!items.length) {
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>'; dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>';

View File

@ -14,8 +14,9 @@ function formatTime(value) {
function makeCard(item) { function makeCard(item) {
const row = document.createElement("div"); const row = document.createElement("div");
const level = (item.level || "info").toLowerCase();
row.className = "event-card"; row.className = "event-card";
row.innerHTML = `<div class="event-meta"><span class="badge">${(item.level || "info").toUpperCase()}</span><span class="muted event-time">${formatTime(item.created_at)}</span><strong class="event-type">${item.event_type}</strong></div><div class="event-message">${item.message}</div>`; row.innerHTML = `<span class="badge event-badge level-${level}">${level.toUpperCase()}</span><span class="muted event-time">${formatTime(item.created_at)}</span><span class="event-type">${item.event_type}</span><span class="event-message">${item.message}</span>`;
return row; return row;
} }
@ -71,6 +72,10 @@ export function prependEvent(item) {
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild); dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild);
// Keep DOM bounded to prevent unbounded growth
const cards = dom.eventList.querySelectorAll(".event-card");
if (cards.length > 100) cards[cards.length - 1].remove();
} }
dom.eventList.addEventListener("scroll", () => { dom.eventList.addEventListener("scroll", () => {

View File

@ -3,7 +3,8 @@ import { dom } from "./dom.js";
import { prependEvent } from "./events.js"; import { prependEvent } from "./events.js";
import { formatValue } from "./points.js"; import { formatValue } from "./points.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { renderUnits } from "./units.js"; import { loadUnits, renderUnits } from "./units.js";
import { loadEquipments } from "./equipment.js";
import { showToast } from "./api.js"; import { showToast } from "./api.js";
function escapeHtml(text) { function escapeHtml(text) {
@ -16,7 +17,7 @@ function parseLogLine(line) {
try { return JSON.parse(trimmed); } catch { return null; } try { return JSON.parse(trimmed); } catch { return null; }
} }
export function appendLog(line) { function appendLog(line) {
if (!dom.logView) return; if (!dom.logView) return;
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10; const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
const div = document.createElement("div"); const div = document.createElement("div");
@ -39,11 +40,26 @@ export function appendLog(line) {
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight; if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
} }
function appendLogDivider(text) {
if (!dom.logView) return;
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
const div = document.createElement("div");
div.className = "log-line muted";
div.textContent = text;
dom.logView.appendChild(div);
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
}
export function startLogs() { export function startLogs() {
if (state.logSource) return; if (state.logSource) return;
let currentLogFile = null;
state.logSource = new EventSource("/api/logs/stream"); state.logSource = new EventSource("/api/logs/stream");
state.logSource.addEventListener("log", (event) => { state.logSource.addEventListener("log", (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.reset && data.file && data.file !== currentLogFile) {
appendLogDivider(`[log switched to ${data.file}]`);
}
currentLogFile = data.file || currentLogFile;
(data.lines || []).forEach(appendLog); (data.lines || []).forEach(appendLog);
}); });
state.logSource.addEventListener("error", () => appendLog("[log stream error]")); state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
@ -79,12 +95,23 @@ function setWsStatus(connected) {
} }
} }
let _reconnectDelay = 1000;
let _connectedOnce = false;
export function startPointSocket() { export function startPointSocket() {
const protocol = location.protocol === "https:" ? "wss" : "ws"; const protocol = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`); const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
state.pointSocket = ws; state.pointSocket = ws;
ws.onopen = () => setWsStatus(true); ws.onopen = () => {
setWsStatus(true);
_reconnectDelay = 1000;
if (_connectedOnce) {
loadUnits().catch(() => {});
if (state.activeView === "config") loadEquipments().catch(() => {});
}
_connectedOnce = true;
};
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
@ -101,12 +128,16 @@ export function startPointSocket() {
entry.time.textContent = data.timestamp || "--"; entry.time.textContent = data.timestamp || "--";
} }
// ops view signal cell // ops view signal pill
const opsEntry = state.opsPointEls.get(data.point_id); const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) { if (opsEntry) {
opsEntry.valueEl.textContent = formatValue(data); const { pillEl, syncBtns } = opsEntry;
opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; state.opsSignalCache.set(data.point_id, { quality: data.quality, value_text: data.value_text });
opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase(); const role = pillEl.dataset.opsRole;
import("./ops.js").then(({ sigPillClass }) => {
pillEl.className = sigPillClass(role, data.quality, data.value_text);
syncBtns?.();
});
} }
if (state.chartPointId === data.point_id) { if (state.chartPointId === data.point_id) {
@ -126,7 +157,7 @@ export function startPointSocket() {
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js) // lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => { import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => {
renderOpsUnits(); renderOpsUnits();
syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled); syncEquipmentButtonsForUnit(runtime.unit_id);
}); });
return; return;
} }
@ -137,7 +168,8 @@ export function startPointSocket() {
ws.onclose = () => { ws.onclose = () => {
setWsStatus(false); setWsStatus(false);
window.setTimeout(startPointSocket, 2000); window.setTimeout(startPointSocket, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
}; };
ws.onerror = () => setWsStatus(false); ws.onerror = () => setWsStatus(false);

View File

@ -1,12 +1,24 @@
import { apiFetch } from "./api.js"; import { apiFetch } from "./api.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { formatValue } from "./points.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { loadUnits } from "./units.js"; import { loadUnits } from "./units.js";
const SIGNAL_ROLES = ["rem", "run", "flt"]; const SIGNAL_ROLES = ["rem", "run", "flt"];
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" }; const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
function isSignalOn(quality, valueText) {
if (!quality || quality.toLowerCase() !== "good") return false;
const v = String(valueText ?? "").trim().toLowerCase();
return v === "1" || v === "true" || v === "on";
}
export function sigPillClass(role, quality, valueText) {
if (!quality || quality.toLowerCase() !== "good") return "sig-pill sig-warn";
const on = isSignalOn(quality, valueText);
if (!on) return "sig-pill";
return role === "flt" ? "sig-pill sig-fault" : "sig-pill sig-on";
}
function runtimeBadge(runtime) { function runtimeBadge(runtime) {
if (!runtime) return '<span class="badge offline">OFFLINE</span>'; if (!runtime) return '<span class="badge offline">OFFLINE</span>';
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>'; if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
@ -34,7 +46,7 @@ export function renderOpsUnits() {
<div class="ops-unit-item-meta"> <div class="ops-unit-item-meta">
${runtimeBadge(runtime)} ${runtimeBadge(runtime)}
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span> <span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
${runtime ? `<span class="muted">Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s</span>` : ""} ${runtime ? `<span class="muted">Acc ${Math.floor(runtime.display_acc_sec / 1000)}s</span>` : ""}
</div> </div>
<div class="ops-unit-item-actions"></div> <div class="ops-unit-item-actions"></div>
`; `;
@ -43,10 +55,14 @@ export function renderOpsUnits() {
const actions = item.querySelector(".ops-unit-item-actions"); const actions = item.querySelector(".ops-unit-item-actions");
const isAutoOn = runtime?.auto_enabled; const isAutoOn = runtime?.auto_enabled;
const startBlocked = !isAutoOn && (runtime?.fault_locked || runtime?.manual_ack_required);
const autoBtn = document.createElement("button"); const autoBtn = document.createElement("button");
autoBtn.className = isAutoOn ? "danger" : "secondary"; autoBtn.className = isAutoOn ? "danger" : "secondary";
autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto"; autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto";
autoBtn.title = isAutoOn ? "停止自动控制" : "启动自动控制"; autoBtn.disabled = startBlocked;
autoBtn.title = startBlocked
? (runtime?.fault_locked ? "设备故障中,无法启动自动控制" : "需人工确认故障后才可启动自动控制")
: (isAutoOn ? "停止自动控制" : "启动自动控制");
autoBtn.addEventListener("click", (e) => { autoBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
apiFetch(`/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`, { method: "POST" }) apiFetch(`/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`, { method: "POST" })
@ -71,41 +87,30 @@ export function renderOpsUnits() {
}); });
} }
async function selectOpsUnit(unitId) { function selectOpsUnit(unitId) {
state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId; state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId;
renderOpsUnits(); renderOpsUnits();
state.opsPointEls.clear();
if (!state.selectedOpsUnitId) { if (!state.selectedOpsUnitId) {
await loadAllEquipmentCards(); renderOpsEquipments(state.units.flatMap((u) => u.equipments || []));
return; return;
} }
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>'; const unit = state.unitMap.get(unitId);
state.opsPointEls.clear(); renderOpsEquipments(unit ? (unit.equipments || []) : []);
const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`);
renderOpsEquipments(detail.equipments || []);
} }
export async function loadAllEquipmentCards() { export function loadAllEquipmentCards() {
if (!dom.opsEquipmentArea) return; if (!dom.opsEquipmentArea) return;
if (!state.units.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">暂无控制单元</div>';
return;
}
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
state.opsPointEls.clear(); state.opsPointEls.clear();
renderOpsEquipments(state.units.flatMap((u) => u.equipments || []));
const details = await Promise.all(
state.units.map((u) => apiFetch(`/api/unit/${u.id}/detail`).catch(() => ({ equipments: [] })))
);
const allEquipments = details.flatMap((d) => d.equipments || []);
renderOpsEquipments(allEquipments);
} }
function renderOpsEquipments(equipments) { function renderOpsEquipments(equipments) {
dom.opsEquipmentArea.innerHTML = ""; dom.opsEquipmentArea.innerHTML = "";
state.opsUnitSyncFns.clear();
if (!equipments.length) { if (!equipments.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>'; dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
return; return;
@ -115,25 +120,18 @@ function renderOpsEquipments(equipments) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "ops-eq-card"; card.className = "ops-eq-card";
// Build role → point map
const roleMap = {}; const roleMap = {};
(eq.points || []).forEach((p) => { (eq.role_points || []).forEach((p) => { roleMap[p.signal_role] = p; });
if (p.signal_role) roleMap[p.signal_role] = p;
});
// Signal rows HTML (placeholders; WS will fill values) // Signal pills — one pill per bound role, text label inside
const signalRowsHtml = SIGNAL_ROLES.map((role) => { const signalRowsHtml = SIGNAL_ROLES.map((role) => {
const point = roleMap[role]; const point = roleMap[role];
if (!point) return ""; if (!point) return "";
return ` return `<span class="sig-pill sig-warn" data-ops-dot="${point.point_id}" data-ops-role="${role}">${ROLE_LABELS[role] || role}</span>`;
<div class="ops-signal-row">
<span class="ops-signal-label">${ROLE_LABELS[role] || role}</span>
<span class="badge quality-unknown" data-ops-quality="${point.id}">?</span>
<span class="ops-signal-value" data-ops-value="${point.id}">--</span>
</div>`;
}).join(""); }).join("");
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor"; const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
const unitId = eq.unit_id ?? null;
card.innerHTML = ` card.innerHTML = `
<div class="ops-eq-card-head"> <div class="ops-eq-card-head">
@ -141,55 +139,77 @@ function renderOpsEquipments(equipments) {
<span class="badge">${eq.kind || "--"}</span> <span class="badge">${eq.kind || "--"}</span>
</div> </div>
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div> <div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div>
${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${eq.unit_id || ""}"></div>` : ""} ${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${unitId || ""}"></div>` : ""}
`; `;
let syncBtns = null;
if (canControl) { if (canControl) {
const actions = card.querySelector(".ops-eq-card-actions"); const actions = card.querySelector(".ops-eq-card-actions");
const autoOn = !!(eq.unit_id && state.runtimes.get(eq.unit_id)?.auto_enabled); const remPointId = roleMap["rem"]?.point_id ?? null;
const fltPointId = roleMap["flt"]?.point_id ?? null;
const startBtn = document.createElement("button"); const startBtn = document.createElement("button");
startBtn.className = "secondary"; startBtn.className = "secondary";
startBtn.textContent = "Start"; startBtn.textContent = "Start";
startBtn.disabled = autoOn;
startBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
startBtn.addEventListener("click", () => startBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {}) apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
); );
const stopBtn = document.createElement("button"); const stopBtn = document.createElement("button");
stopBtn.className = "danger"; stopBtn.className = "danger";
stopBtn.textContent = "Stop"; stopBtn.textContent = "Stop";
stopBtn.disabled = autoOn;
stopBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
stopBtn.addEventListener("click", () => stopBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {}) apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
); );
actions.append(startBtn, stopBtn); actions.append(startBtn, stopBtn);
syncBtns = function () {
const autoOn = !!(unitId && state.runtimes.get(unitId)?.auto_enabled);
const remSig = remPointId ? state.opsSignalCache.get(remPointId) : null;
const fltSig = fltPointId ? state.opsSignalCache.get(fltPointId) : null;
const remOk = !remPointId || isSignalOn(remSig?.quality, remSig?.value_text);
const fltActive = !!(fltPointId && isSignalOn(fltSig?.quality, fltSig?.value_text));
const disabled = autoOn || !remOk || fltActive;
const title = autoOn ? "自动控制运行中,请先停止自动"
: !remOk ? "设备未切换至远程模式"
: fltActive ? "设备故障中"
: "";
startBtn.disabled = disabled;
stopBtn.disabled = disabled;
startBtn.title = title;
stopBtn.title = title;
};
} }
dom.opsEquipmentArea.appendChild(card); dom.opsEquipmentArea.appendChild(card);
// Register DOM elements for WS updates, then seed from cached monitor data // Register pills for WS updates; seed signal cache from initial point_monitor data
SIGNAL_ROLES.forEach((role) => { SIGNAL_ROLES.forEach((role) => {
const point = roleMap[role]; const point = roleMap[role];
if (!point) return; if (!point) return;
const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`); const pillEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`);
const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`); if (!pillEl) return;
if (valueEl && qualityEl) { if (point.point_monitor) {
state.opsPointEls.set(point.id, { valueEl, qualityEl }); const m = point.point_monitor;
if (point.point_monitor) { state.opsSignalCache.set(point.point_id, { quality: m.quality, value_text: m.value_text });
const m = point.point_monitor; pillEl.className = sigPillClass(role, m.quality, m.value_text);
valueEl.textContent = formatValue(m);
qualityEl.className = `badge quality-${(m.quality || "unknown").toLowerCase()}`;
qualityEl.textContent = (m.quality || "unknown").toUpperCase();
}
} }
const isSyncRole = canControl && (role === "rem" || role === "flt");
state.opsPointEls.set(point.point_id, { pillEl, syncBtns: isSyncRole ? syncBtns : null });
}); });
if (canControl) {
syncBtns();
if (unitId) {
if (!state.opsUnitSyncFns.has(unitId)) state.opsUnitSyncFns.set(unitId, new Set());
state.opsUnitSyncFns.get(unitId).add(syncBtns);
}
}
}); });
} }
export function startOps() { export function startOps() {
renderOpsUnits(); renderOpsUnits();
loadAllEquipmentCards();
dom.batchStartAutoBtn?.addEventListener("click", () => { dom.batchStartAutoBtn?.addEventListener("click", () => {
apiFetch("/api/control/unit/batch-start-auto", { method: "POST" }) apiFetch("/api/control/unit/batch-start-auto", { method: "POST" })
@ -204,15 +224,7 @@ export function startOps() {
}); });
} }
/** Called by WS handler when a unit's runtime changes — syncs manual button disabled state. */ /** Called by WS handler when a unit's runtime changes — re-evaluates all equipment button states. */
export function syncEquipmentButtonsForUnit(unitId, autoEnabled) { export function syncEquipmentButtonsForUnit(unitId) {
if (!dom.opsEquipmentArea) return; state.opsUnitSyncFns.get(unitId)?.forEach((fn) => fn());
dom.opsEquipmentArea
.querySelectorAll(`.ops-eq-card-actions[data-unit-id="${unitId}"]`)
.forEach((actions) => {
actions.querySelectorAll("button").forEach((btn) => {
btn.disabled = autoEnabled;
btn.title = autoEnabled ? "自动控制运行中,请先停止自动" : "";
});
});
} }

View File

@ -19,10 +19,12 @@ export const state = {
chartPointName: "", chartPointName: "",
chartData: [], chartData: [],
pointSocket: null, pointSocket: null,
apiDocLoaded: false, docDrawerSource: null, // null | "api" | "readme"
runtimes: new Map(), // unit_id -> UnitRuntime runtimes: new Map(), // unit_id -> UnitRuntime
activeView: "ops", // "ops" | "config" activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { valueEl, qualityEl } opsPointEls: new Map(), // point_id -> { pillEl, syncBtns? }
opsSignalCache: new Map(), // point_id -> { quality, value_text }
opsUnitSyncFns: new Map(), // unit_id -> Set<syncBtns fn>
logSource: null, logSource: null,
selectedOpsUnitId: null, selectedOpsUnitId: null,
}; };

View File

@ -34,10 +34,10 @@ export function resetUnitForm() {
dom.unitId.value = ""; dom.unitId.value = "";
dom.unitEnabled.checked = true; dom.unitEnabled.checked = true;
dom.unitManualAck.checked = true; dom.unitManualAck.checked = true;
dom.unitRunTimeSec.value = "0"; dom.unitRunTimeSec.value = "10";
dom.unitStopTimeSec.value = "0"; dom.unitStopTimeSec.value = "10";
dom.unitAccTimeSec.value = "0"; dom.unitAccTimeSec.value = "20";
dom.unitBlTimeSec.value = "0"; dom.unitBlTimeSec.value = "10";
} }
function openUnitModal() { function openUnitModal() {
@ -117,7 +117,7 @@ export function renderUnits() {
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span> <span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
</div> </div>
<div>${unit.name}</div> <div>${unit.name}</div>
<div class="muted">设备 ${equipmentCount(unit.id)} | Acc ${runtime ? Math.floor(runtime.accumulated_run_sec / 1000) : 0}s</div> <div class="muted">设备 ${equipmentCount(unit.id)} | Acc ${runtime ? Math.floor(runtime.display_acc_sec / 1000) : 0}s</div>
<div class="muted">Run ${unit.run_time_sec}s / Stop ${unit.stop_time_sec}s / Acc ${unit.acc_time_sec}s / BL ${unit.bl_time_sec}s</div> <div class="muted">Run ${unit.run_time_sec}s / Stop ${unit.stop_time_sec}s / Acc ${unit.acc_time_sec}s / BL ${unit.bl_time_sec}s</div>
<div class="row unit-card-actions"></div> <div class="row unit-card-actions"></div>
`; `;
@ -151,10 +151,14 @@ export function renderUnits() {
actions.append(editBtn, deleteBtn); actions.append(editBtn, deleteBtn);
const isAutoOn = runtime?.auto_enabled; const isAutoOn = runtime?.auto_enabled;
const startBlocked = !isAutoOn && (runtime?.fault_locked || runtime?.manual_ack_required);
const autoBtn = document.createElement("button"); const autoBtn = document.createElement("button");
autoBtn.className = isAutoOn ? "danger" : "secondary"; autoBtn.className = isAutoOn ? "danger" : "secondary";
autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto"; autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto";
autoBtn.title = isAutoOn ? "停止自动控制" : "启动自动控制"; autoBtn.disabled = startBlocked;
autoBtn.title = startBlocked
? (runtime?.fault_locked ? "设备故障中,无法启动自动控制" : "需人工确认故障后才可启动自动控制")
: (isAutoOn ? "停止自动控制" : "启动自动控制");
autoBtn.addEventListener("click", (e) => { autoBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const url = `/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`; const url = `/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`;
@ -188,6 +192,10 @@ export async function loadUnits() {
state.selectedUnitId = null; state.selectedUnitId = null;
} }
state.units.forEach((unit) => {
if (unit.runtime) state.runtimes.set(unit.id, unit.runtime);
});
renderUnits(); renderUnits();
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId); renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId); renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);

View File

@ -237,29 +237,29 @@ body {
.ops-signal-rows { .ops-signal-rows {
padding: 6px 10px; padding: 6px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 3px; gap: 4px;
}
.ops-signal-row {
display: flex;
align-items: center; align-items: center;
gap: 6px;
font-size: 12px;
} }
.ops-signal-label { .sig-pill {
width: 36px; display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 20px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
background: var(--surface-2, #e0e0e0);
color: var(--text-3); color: var(--text-3);
font-size: 11px; transition: background 0.2s, color 0.2s;
text-transform: uppercase; user-select: none;
flex-shrink: 0;
}
.ops-signal-value {
flex: 1;
font-weight: 500;
} }
.sig-pill.sig-on { background: var(--success); color: #fff; }
.sig-pill.sig-fault { background: var(--danger); color: #fff; }
.sig-pill.sig-warn { background: var(--warning); color: #333; }
.ops-eq-card-actions { .ops-eq-card-actions {
padding: 6px 10px 8px; padding: 6px 10px 8px;
@ -875,38 +875,45 @@ button.danger:hover { background: var(--danger-hover); }
} }
.event-card { .event-card {
padding: 4px 8px; display: flex;
align-items: baseline;
gap: 6px;
padding: 3px 8px;
font-size: 12px; font-size: 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
white-space: nowrap;
overflow: hidden;
flex-shrink: 0;
} }
.event-card:hover { .event-card:hover {
background: var(--surface-hover, var(--surface)); background: var(--surface-hover, var(--surface));
} }
.event-meta { .event-badge {
display: flex; flex-shrink: 0;
align-items: baseline;
gap: 6px;
} }
.event-type { .badge.level-info { background: rgba(52, 211, 153, 0.1); color: #34d399; }
overflow: hidden; .badge.level-warn { background: rgba(251, 191, 36, 0.1); color: #fbbf24; }
text-overflow: ellipsis; .badge.level-error { background: rgba(239, 68, 68, 0.1); color: #f87171; }
white-space: nowrap; .badge.level-critical { background: rgba(239, 68, 68, 0.15); color: #dc2626; }
}
.event-time { .event-time {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 11px;
} }
.event-type {
flex-shrink: 0;
font-weight: 600;
}
.event-message { .event-message {
color: var(--text-muted, #888); color: var(--text-2);
font-size: 11px; font-size: 11px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.equipment-select-row { .equipment-select-row {