Compare commits

..

No commits in common. "13c4b515d7233e7551e3579da005849ba12e35cf" and "1f29eb3871effada35480f8124751280d8d4d6d1" have entirely different histories.

36 changed files with 804 additions and 3259 deletions

712
API.md
View File

@ -1,6 +1,6 @@
# PLC Control 接口说明
本文档基于当前服务端路由与处理器代码整理,覆盖 HTTP API 和 WebSocket 实时消息。
本文档基于当前服务端路由与处理器代码整理,覆盖 HTTP API、SSE 日志流和 WebSocket 实时消息。
## 基本信息
@ -24,7 +24,7 @@
常见状态码:
- `400 Bad Request`:参数错误
- `403 Forbidden`:写入权限不足或控制条件不满足
- `403 Forbidden`:写入权限不足
- `404 Not Found`:资源不存在
- `500 Internal Server Error`:服务端内部错误
@ -74,12 +74,16 @@
响应:
```json
{ "id": "uuid" }
{
"id": "uuid"
}
```
### PUT `/api/source/{source_id}`
更新数据源。请求体字段均可选:
更新数据源。
请求体字段均可选:
```json
{
@ -93,29 +97,63 @@
}
```
响应:
```json
{
"ok_msg": "Source updated successfully"
}
```
### DELETE `/api/source/{source_id}`
删除数据源。成功响应:`204 No Content`
删除数据源。
成功响应:`204 No Content`
### POST `/api/source/{source_id}/reconnect`
手动重连指定数据源。
响应:
```json
{ "ok_msg": "Source reconnected successfully" }
{
"ok_msg": "Source reconnected successfully"
}
```
### POST `/api/source/{source_id}/browse`
从 OPC UA 源浏览节点并写入本地 `node` 表。
响应:
```json
{ "ok_msg": "Browse completed", "total_nodes": 123 }
{
"ok_msg": "Browse completed",
"total_nodes": 123
}
```
### GET `/api/source/{source_id}/node-tree`
获取指定数据源的节点树(含 `children` 递归嵌套)。
获取指定数据源的节点树。
响应字段:
- `id`
- `source_id`
- `external_id`
- `namespace_uri`
- `namespace_index`
- `identifier_type`
- `identifier`
- `browse_name`
- `display_name`
- `node_class`
- `parent_id`
- `children`
---
@ -123,14 +161,13 @@
### GET `/api/point`
分页获取点位列表,同时返回实时监测值
分页获取点位列表。
查询参数:
- `source_id`:可选,按数据源过滤
- `equipment_id`:可选,按设备过滤
- `page`:页码
- `page_size`:每页条数`-1` 表示全量)
- `page_size`:每页条数
响应示例:
@ -138,22 +175,28 @@
{
"data": [
{
"point": {
"id": "uuid",
"node_id": "uuid",
"name": "Temperature",
"equipment_id": "uuid",
"signal_role": "run",
"description": null,
"unit": null,
"tag_id": null,
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000"
},
"updated_at": "2026-03-20 10:00:00.000",
"point_monitor": {
"protocol": "opcua",
"source_id": "uuid",
"point_id": "uuid",
"client_handle": 1001,
"scan_mode": "subscribe",
"timestamp": "2026-03-20 10:05:00.000",
"quality": "good",
"value": 12.3,
"value_type": "float",
"value_text": "12.3"
"value_text": "12.3",
"old_value": 12.1,
"old_timestamp": "2026-03-20 10:04:59.000",
"value_changed": true
}
}
],
@ -169,9 +212,13 @@
### GET `/api/point/{point_id}/history`
获取点位最近历史样本(进程内存环形缓冲,重启后清空)
获取点位最近历史样本。数据来自进程内存中的环形缓冲,不是持久化历史库
查询参数:`limit`(可选,默认 `120`,最大 `1000`
查询参数:
- `limit`:可选,默认 `120`,最大 `1000`
响应示例:
```json
[
@ -185,17 +232,23 @@
]
```
说明:
- `value_number` 便于前端直接绘图
- 非数值型点位时,`value_number` 可能为 `null`
### PUT `/api/point/{point_id}`
更新点位元数据,字段均可选:
更新点位元数据。
请求体:
```json
{
"name": "Temperature",
"description": "Room temperature",
"unit": "°C",
"equipment_id": "uuid",
"signal_role": "run"
"tag_id": "uuid"
}
```
@ -203,12 +256,24 @@
删除单个点位。
响应:
```json
{
"ok_msg": "Point deleted successfully"
}
```
### POST `/api/point/batch`
根据节点批量创建点位。
请求体:
```json
{ "node_ids": ["uuid1", "uuid2"] }
{
"node_ids": ["uuid1", "uuid2"]
}
```
响应:
@ -226,25 +291,27 @@
批量删除点位。
```json
{ "point_ids": ["uuid1", "uuid2"] }
```
### PUT `/api/point/batch/set-equipment`
批量设置点位的设备绑定和信号角色。
请求体:
```json
{
"point_ids": ["uuid1", "uuid2"],
"equipment_id": "uuid",
"signal_role": "run"
"point_ids": ["uuid1", "uuid2"]
}
```
响应:
```json
{
"deleted_count": 2
}
```
### PUT `/api/point/batch/set-tags`
批量设置点位的标签(`tag_id`,传 `null` 可清除绑定)。
批量设置点位标签。
请求体:
```json
{
@ -253,573 +320,224 @@
}
```
响应:
```json
{ "ok_msg": "Point tags updated successfully", "updated_count": 2 }
```
### POST `/api/point/value/batch`
批量写点。
请求头:`X-Write-Key: <key>`
请求头:
- `X-Write-Key: <key>`
请求体:
```json
{
"items": [
{ "point_id": "uuid", "value": 12.3 }
]
}
```
---
## Equipment
### GET `/api/equipment`
分页获取设备列表,包含每台设备绑定的点位数量。
查询参数:`page`、`page_size``-1` 全量)、`keyword`(可选,按 code/name 模糊搜索)
响应示例:
```json
{
"data": [
{
"id": "uuid",
"unit_id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"description": null,
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000",
"point_count": 5
}
],
"total": 1,
"page": 1,
"page_size": 20
}
```
### POST `/api/equipment`
创建设备。
```json
{
"unit_id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"description": null
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Equipment created successfully" }
```
### PUT `/api/equipment/{equipment_id}`
更新设备,字段均可选:
```json
{
"unit_id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"description": null
}
```
### DELETE `/api/equipment/{equipment_id}`
删除设备。成功响应:`204 No Content`
### GET `/api/equipment/{equipment_id}/points`
获取指定设备下所有绑定点位。
### PUT `/api/equipment/batch/set-unit`
批量将设备绑定到控制单元。
```json
{
"equipment_ids": ["uuid1", "uuid2"],
"unit_id": "uuid"
}
```
---
## Unit控制单元
### GET `/api/unit`
分页获取控制单元列表。
查询参数:`page`、`page_size`、`keyword`(可选)
响应字段包含:`id`、`code`、`name`、`enabled`、`run_time_sec`、`stop_time_sec`、`acc_time_sec`、`bl_time_sec`、`require_manual_ack_after_fault`
### POST `/api/unit`
创建控制单元。
```json
{
"code": "U01",
"name": "1号机组",
"description": null,
"enabled": true,
"run_time_sec": 60,
"stop_time_sec": 30,
"acc_time_sec": 3600,
"bl_time_sec": 10,
"require_manual_ack_after_fault": true
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Unit created successfully" }
```
### GET `/api/unit/{unit_id}`
获取单个控制单元。
### PUT `/api/unit/{unit_id}`
更新控制单元,字段均可选。
### DELETE `/api/unit/{unit_id}`
删除控制单元。成功响应:`204 No Content`
### GET `/api/unit/{unit_id}/runtime`
获取控制单元的当前运行时状态(内存中,不持久化)。
响应示例:
```json
{
"unit_id": "uuid",
"state": "running",
"auto_enabled": true,
"accumulated_run_sec": 3600000,
"current_run_elapsed_sec": 60000,
"current_stop_elapsed_sec": 0,
"distributor_run_elapsed_sec": 0,
"fault_locked": false,
"flt_active": false,
"comm_locked": false,
"manual_ack_required": false,
"last_tick_at": "2026-03-25 10:00:00.000"
}
```
`state` 枚举值:`stopped` / `running` / `distributor_running` / `fault_locked` / `comm_locked`
注意时间字段单位为毫秒ms
### GET `/api/unit/{unit_id}/detail`
获取控制单元及其下所有设备和点位的完整嵌套结构。
响应示例:
```json
{
"id": "uuid",
"code": "U01",
"name": "1号机组",
"enabled": true,
"equipments": [
{
"id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"points": [
{
"id": "uuid",
"name": "启动命令",
"signal_role": "start_cmd",
"equipment_id": "uuid"
"point_id": "uuid",
"value": 12.3
}
]
}
```
响应:
```json
{
"success": true,
"err_msg": null,
"success_count": 1,
"failed_count": 0,
"results": [
{
"point_id": "uuid",
"success": true,
"err_msg": null
}
]
}
```
---
## Event系统事件
### GET `/api/event`
分页获取系统控制事件记录。
查询参数:
- `unit_id`:可选,按控制单元过滤
- `event_type`:可选,按事件类型过滤
- `page`、`page_size`
响应示例:
```json
{
"data": [
{
"id": "uuid",
"event_type": "equipment.start_command_sent",
"level": "info",
"unit_id": "uuid",
"equipment_id": "uuid",
"message": "Start command sent to equipment ...",
"payload": {},
"created_at": "2026-03-25 10:00:00.000"
}
],
"total": 1,
"page": 1,
"page_size": 20
}
```
---
## Control控制命令
所有控制命令在执行前会校验信号质量、REM 状态、FLT 状态、单元通讯锁、单元故障锁。
### POST `/api/control/equipment/{equipment_id}/start`
向设备发送启动脉冲命令(写 HIGH → 延迟 300ms → 写 LOW
响应示例:
```json
{
"ok_msg": "Equipment start command sent",
"equipment_id": "uuid",
"unit_id": "uuid",
"command_role": "start_cmd",
"command_point_id": "uuid",
"pulse_ms": 300
}
```
失败(设备未处于可启动状态)返回 `403 Forbidden`
### POST `/api/control/equipment/{equipment_id}/stop`
向设备发送停止脉冲命令。响应结构同上。
### POST `/api/control/unit/{unit_id}/start-auto`
启动指定控制单元的自动控制循环。单元须已启用(`enabled = true`)。
```json
{ "ok_msg": "Auto control started", "unit_id": "uuid" }
```
### POST `/api/control/unit/{unit_id}/stop-auto`
停止自动控制循环。设备当前状态保持不变,不会自动停机。
```json
{ "ok_msg": "Auto control stopped", "unit_id": "uuid" }
```
### POST `/api/control/unit/batch-start-auto`
批量启动所有已启用(`enabled = true`)控制单元的自动控制。已在运行、故障锁或通讯锁的单元将跳过。
```json
{
"started": ["uuid1", "uuid2"],
"skipped": ["uuid3"]
}
```
### POST `/api/control/unit/batch-stop-auto`
批量停止所有自动控制中的控制单元。
```json
{ "stopped": ["uuid1", "uuid2"] }
```
### POST `/api/control/unit/{unit_id}/ack-fault`
人工确认故障,解除故障锁定。要求:`fault_locked = true` 且 `flt_active = false`(故障信号已消失)。
```json
{ "ok_msg": "Fault acknowledged", "unit_id": "uuid" }
```
---
## Tag标签
## Tag
### GET `/api/tag`
分页获取标签列表。
查询参数:`page`、`page_size`
查询参数:
响应示例:
- `page`
- `page_size`
```json
{
"data": [
{
"id": "uuid",
"name": "主蒸汽",
"description": null,
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000"
}
],
"total": 1,
"page": 1,
"page_size": 20
}
```
### GET `/api/tag/{tag_id}`
当前实现返回该标签下的点位列表。
### POST `/api/tag`
创建标签,可同时绑定点位。
创建标签。
请求体:
```json
{
"name": "主蒸汽",
"description": null,
"name": "Area-A",
"description": "Area A points",
"point_ids": ["uuid1", "uuid2"]
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Tag created successfully" }
```
### GET `/api/tag/{tag_id}`
获取标签下所有绑定点位。
响应:点位对象数组。
### PUT `/api/tag/{tag_id}`
更新标签,字段均可选(`point_ids` 传入时全量替换绑定关系):
更新标签。
请求体:
```json
{
"name": "主蒸汽",
"description": "描述",
"point_ids": ["uuid1"]
"name": "Area-A",
"description": "Updated",
"point_ids": ["uuid1", "uuid2"]
}
```
### DELETE `/api/tag/{tag_id}`
删除标签。成功响应:`204 No Content`
删除标签。
成功响应:`204 No Content`
---
## Page自定义页面
## Page
`page` 用于保存页面布局或组件映射数据。
### GET `/api/page`
获取所有页面,按 `created_at` 排序
查询页面列表。
查询参数:`name`(可选,模糊搜索)
查询参数:
响应Page 对象数组。
```json
[
{
"id": "uuid",
"name": "总览",
"data": { "slot_key": "point-uuid" },
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000"
}
]
```
`data``{ slot_key: point_id }` 映射,用于页面布局与点位绑定。
### POST `/api/page`
创建页面。
```json
{
"name": "总览",
"data": { "slot_a": "uuid1", "slot_b": "uuid2" }
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Page created successfully" }
```
- `name`:可选,按名称模糊搜索
### GET `/api/page/{page_id}`
获取单个页面。
### PUT `/api/page/{page_id}`
### POST `/api/page`
更新页面,字段均可选:
创建页面。
请求体:
```json
{
"name": "总览",
"data": { "slot_a": "uuid1" }
"name": "Dashboard",
"data": {
"widgetA": "uuid1",
"widgetB": "uuid2"
}
}
```
### PUT `/api/page/{page_id}`
更新页面。
请求体字段均可选:
```json
{
"name": "Dashboard",
"data": {
"widgetA": "uuid1"
}
}
```
### DELETE `/api/page/{page_id}`
删除页面。成功响应:`204 No Content`
删除页面。
成功响应:`204 No Content`
---
## Log运行日志
## Log
### GET `/api/logs`
读取服务端日志文件内容(默认取最新 `app.log*` 文件尾部 200 行)。
读取日志文件内容。
查询参数:
- `file`:可选,指定文件名(仅允许 `app.log` 前缀)
- `cursor`:可选,上次返回的字节偏移量;传入时增量读取 cursor 之后的内容
- `tail_lines`:可选,不传 cursor 时返回的尾部行数(默认 200最大 2000
- `max_bytes`:可选,单次最多返回字节数(默认 64 KB最大 512 KB
- `file`:可选,指定日志文件名,仅允许 `app.log*`
- `cursor`:可选,从指定游标后读取
- `tail_lines`:可选,默认 `200`
- `max_bytes`:可选
响应示例
响应:
```json
{
"file": "app.log",
"cursor": 204800,
"lines": ["2026-03-25 10:00:00 INFO ..."],
"cursor": 1024,
"lines": ["..."],
"truncated": false,
"reset": false
}
```
- `truncated``true` 表示本次未读完,可用新 cursor 继续请求
- `reset``true` 表示文件已轮转cursor > 文件大小),已从头读取
### GET `/api/logs/stream`
**SSE**Server-Sent Events流式推送日志增量每 800 ms 检查一次文件变化
SSE 实时日志流。
查询参数:`file`、`cursor`(可选,默认从文件末尾开始)、`max_bytes`(默认 32 KB
事件类型:
事件格式:
- `log`
- `error`
```
event: log
data: { "file": "app.log", "cursor": 204800, "lines": [...], "truncated": false, "reset": false }
event: error
data: log stream read failed
```
客户端可使用 `EventSource` 订阅。
---
## WebSocket
### 连接地址
## 连接地址
- 公共广播:`/ws/public`
- 客户端专属:`/ws/client/{client_id}`
### 服务端主动推送消息
## 服务端主动消息
#### `PointNewValue`
点位实时值更新:
### `PointNewValue`
```json
{
"type": "PointNewValue",
"data": {
"protocol": "opcua",
"source_id": "uuid",
"point_id": "uuid",
"client_handle": 1001,
"scan_mode": "subscribe",
"timestamp": "2026-03-20 10:05:00.000",
"quality": "good",
"value": 12.3,
"value_type": "float",
"value_text": "12.3"
"value_text": "12.3",
"old_value": 12.1,
"old_timestamp": "2026-03-20 10:04:59.000",
"value_changed": true
}
}
```
#### `EventCreated`
系统事件创建(控制操作、故障、状态变更等):
```json
{
"type": "EventCreated",
"data": {
"id": "uuid",
"event_type": "equipment.start_command_sent",
"level": "info",
"unit_id": "uuid",
"equipment_id": "uuid",
"message": "...",
"created_at": "2026-03-25 10:00:00.000"
}
}
```
#### `UnitRuntimeChanged`
控制单元运行时状态变更(每 500ms tick 后广播):
```json
{
"type": "UnitRuntimeChanged",
"data": {
"unit_id": "uuid",
"state": "running",
"auto_enabled": true,
"fault_locked": false,
"comm_locked": false,
"manual_ack_required": false,
"accumulated_run_sec": 3600000
}
}
```
#### `PointSetValueBatchResult`
批量写点结果回调:
### `PointSetValueBatchResult`
```json
{
@ -834,25 +552,30 @@ data: log stream read failed
}
```
### 客户端发送消息
## 客户端发送消息
#### 写权限认证
### 写权限认证
```json
{
"type": "auth_write",
"data": { "key": "your-write-key" }
"data": {
"key": "your-write-key"
}
}
```
#### 批量写点
### 批量写点
```json
{
"type": "point_set_value_batch",
"data": {
"items": [
{ "point_id": "uuid", "value": 12.3 }
{
"point_id": "uuid",
"value": 12.3
}
]
}
}
@ -862,7 +585,6 @@ data: log stream read failed
## 备注
- 运行时状态(`/runtime`)存储在内存中,服务重启后重置。
- 历史曲线数据(`/history`)同样是内存环形缓冲,重启后清空。
- 控制单元时间配置字段(`run_time_sec` 等)单位为秒,运行时 elapsed 字段单位为毫秒。
- 自动控制启动后,状态机以 500ms 为周期运行,实时状态通过 WebSocket `UnitRuntimeChanged` 推送。
- 历史曲线接口当前使用内存缓存,服务重启后历史会清空。
- 实时遥测与 WebSocket 推送是“最新值优先”的设计,在高压场景下允许丢弃部分中间消息。
- `/api/tag/{tag_id}` 当前返回的是标签下点位,而不是标签自身详情。

View File

@ -1,6 +1,4 @@
# 投煤器布料机远程监控与控制功能实现方案
> 最后更新2026-03-24基于当前代码重新审阅
# 投煤器布料机远程监控与控制功能实现方案
## 1. 目标
@ -13,424 +11,551 @@
- 支持故障锁定、人工复位、通讯异常冻结
- 支持通过配置适配不同现场,不改代码完成项目复用
---
## 2. 现有系统能力盘点
## 2. 当前系统能力盘点
当前项目已经具备较好的通用工业采集平台基础:
### 2.1 通用平台基础(已有)
- OPC UA 数据源接入与自动重连(`connection.rs`
- 节点浏览、批量建点、点位实时订阅(`handler/point.rs`, `handler/source.rs`
- 点位批量写入能力(`connection.rs: write_point_values_batch`
- WebSocket 实时推送(`websocket.rs`
- OPC UA 数据源接入与自动重连
- 节点浏览、批量建点、点位实时订阅
- 点位批量写入能力
- 设备 `equipment` 模型
- 点位到设备绑定 `equipment_id`
- 点位信号角色字段 `signal_role`
- WebSocket 实时推送
- 前端设备、点位、日志、趋势图基础界面
- 页面配置 `page` 能力
- 进程内事件总线 `event.rs`control + telemetry 双通道)
- HTTP 中间件、鉴权、分页等工具链
- 进程内事件总线 `event.rs`
### 2.2 业务模型(已有)
现状更接近“通用点位监控平台”,还不是“投煤器/布料机业务控制系统”。
- `unit` 表(`migrations/20260324090000_add_unit_and_event.sql`
- 包含 `run_time_sec`, `stop_time_sec`, `acc_time_sec`, `bl_time_sec`, `require_manual_ack_after_fault`
- `event` 表(同上迁移),含 `unit_id`, `equipment_id`, `source_id`, `level`, `payload`
- `equipment.unit_id` FK
- `equipment.kind``point.signal_role`
- `ControlUnit`, `EventRecord` 模型(`model.rs`
## 3. 与需求的差距
### 2.3 业务逻辑(已有)
需求文档要求的软件能力,当前尚未落地的核心部分如下:
- Unit CRUD 接口:`GET/POST/PUT/DELETE /api/unit`, `GET /api/unit/{id}`
- 设备手动控制接口:`POST /api/control/equipment/{id}/start|stop`
- 内置脉冲写入(写 1 → 等 300ms → 写 0
- 前置校验:`rem == 1`, `flt == 0`, 通讯质量 good`control/validator.rs`
- `AppEvent::EquipmentStartCommandSent/EquipmentStopCommandSent`,自动持久化 `event` 表并推送 WebSocket
- 内存运行态结构体(`control/runtime.rs`
- `UnitRuntimeState`: Stopped / Running / DistributorRunning / FaultLocked / CommLocked
- `UnitRuntime`:含 `accumulated_run_sec`, `fault_locked`, `comm_locked`, `manual_ack_required` 等全部字段
- `ControlRuntimeStore`:全局 `HashMap<Uuid, UnitRuntime>`,已注册到 `AppState`
- `control/validator.rs`:独立的控制前置校验模块
- `control/engine.rs`:模块骨架已建立,`start()` 函数为空桩
- 前端Unit 列表(`units.js`),事件列表(`events.js`),设备 Unit 绑定(`equipment.js`
- 事件列表接口:`GET /api/event`(支持 `unit_id`, `event_type` 过滤)
---
## 3. 与需求的差距(当前真实状态)
| 需求项 | 状态 | 说明 |
|--------|------|------|
| Unit / 设备 / 点位数据模型 | ✅ 已完成 | 迁移、模型、CRUD 全部到位 |
| 脉冲写入封装 | ✅ 已完成 | 内置在 `handler/control.rs` |
| 手动控制前置校验 | ✅ 已完成 | `validator.rs` 实现 REM/FLT/quality 检查 |
| 手动启停接口 | ✅ 已完成 | start/stop 接口含脉冲写入和事件持久化 |
| 运行态内存模型 | ✅ 已完成 | `runtime.rs` 数据结构完整,已注入 AppState |
| 事件持久化与查询 | ✅ 已完成 | event 表 + 事件处理 + GET /api/event |
| **自动控制状态机** | ❌ 未实现 | `engine.rs` 是空桩,无任何调度逻辑 |
| **自动控制接口** | ❌ 未实现 | 缺 `start-auto`, `stop-auto` 接口 |
| **故障锁定联动** | ❌ 未实现 | `runtime.fault_locked` 有字段但无联动逻辑 |
| **通讯冻结联动** | ❌ 未实现 | `runtime.comm_locked` 有字段但无联动逻辑 |
| **人工确认解锁接口** | ❌ 未实现 | 缺 `ack-fault` 接口 |
| **手动控制检查运行态** | ❌ 未实现 | `validator.rs` 未读取 `ControlRuntimeStore` |
| **运行态 WebSocket 推送** | ❌ 未实现 | 单元状态变更未推送前端 |
| **运行态查询接口** | ❌ 未实现 | 缺 `GET /api/unit/{id}/runtime` |
| 前端设备控制面板 | ❌ 未实现 | 缺 REM/RUN/FLT 展示和手动启停按钮 |
| 前端单元详情/总览 | ❌ 未实现 | 缺状态机状态展示、自动/手动切换 |
| 前端参数在线编辑 | 🔶 部分 | Unit 编辑 Modal 有表单,但无运行态反馈 |
---
- 缺少“控制单元”概念,无法表达一组投煤器对应一组布料机
- 缺少业务配置模型,无法配置 `RunTime`、`StopTime`、`AccTime`、`BLTime`
- 缺少设备业务类型约束,尚未明确区分投煤器、布料机
- 缺少设备归属单元字段,尚未形成 `unit -> equipment -> point` 的业务链路
- 缺少业务信号角色规范,尚未标准化 `REM/RUN/FLT/STA/STP`
- 缺少自动控制状态机
- 缺少故障锁定与人工确认恢复流程
- 缺少通讯异常冻结与恢复后重同步机制
- 缺少脉冲写入封装,当前只有通用批量写点
- 缺少单元总览、设备详情、控制面板、报警面板等业务界面
- 缺少统一事件持久化与后续报警模型
## 4. 推荐实现思路
继续在现有平台上"增量扩展"
推荐在现有平台上“增量扩展”,而不是重写:
- 现有 `unit` / `equipment` / `point` / `event` 底座不动
- 填充 `control/engine.rs` 实现状态机调度
- 新增自动控制和 ack-fault 接口
- `validator.rs` 增加对运行态的检查
- 扩展 `AppEvent` 业务事件,通过 WebSocket 推送运行态变更
- 前端增加业务控制页面
- 保留现有 `source/node/point/equipment` 通用底座
- 新增面向业务控制的配置表和运行态管理
- 将控制逻辑放在 Rust 服务端,复用当前 OPC UA 连接和写点能力
- 前端增加业务页面,不破坏现有通用点位管理页面
- 基于现有 `event.rs` 扩展事件体系,而不是再造一套事件机制
---
这样做的好处是:
## 5. 命名与模型设计(已落地,确认)
- 现有 OPC UA、点位、设备、WebSocket 基础都能继续复用
- 后续不同现场只需要换设备映射和参数
- 手动调试仍然可以通过现有点位/设备页面完成
- 未来做报警、审计、统计时可以直接复用统一事件体系
### 5.1 设备分类(`equipment.kind`
## 5. 命名与模型设计
### 5.1 命名原则
建议区分“代码命名”和“表命名”:
- 代码模型名保留语义完整性,如 `ControlUnit`
- 数据库表名尽量简洁,如 `unit`
不建议使用 `group` 作为表名,原因是语义过泛,后续容易与权限分组、界面分组、标签分组等概念冲突。
### 5.2 设备分类
继续复用 `equipment` 表中的 `kind` 字段,约定:
- `coal_feeder`:投煤器
- `distributor`:布料机
### 5.2 点位角色规范(`point.signal_role`
### 5.3 点位角色规范
继续复用 `point.signal_role`,建议统一枚举值:
- 状态点:`rem` `run` `flt` `ii` `q`
- 控制点:`start_cmd` `stop_cmd`
- 可选扩展:`estop` `mode_auto` `mode_manual` `reset_cmd`
### 5.3 Unit 运行态放内存(已确认)
这样每台设备都可以通过“设备 + 点位角色”完成映射,而不是在代码里写死点名。
运行态字段已在 `control/runtime.rs:UnitRuntime` 中定义完整,不落库。
服务重启后重新从 `REM/RUN/FLT/Q` 重建运行态,不自动补发命令。
### 5.4 新增 `unit`
### 5.4 统一 `event` 表,预留 `alarm` 表(已落地)
建议新增 `unit` 表,对应代码模型 `ControlUnit`,表示一个业务控制单元。
- `event`:记录"发生了什么",已上线
- `alarm`:记录"需要被告警管理的异常",第二阶段实现
建议字段:
---
- `id`
- `code`
- `name`
- `description`
- `enabled`
- `run_time_sec`
- `stop_time_sec`
- `acc_time_sec`
- `bl_time_sec`
- `require_manual_ack_after_fault`
- `created_at`
- `updated_at`
### 5.5 设备直接归属 Unit
当前业务前提下,一台设备只属于一个控制单元,不会跨单元复用,因此不建议单独建立关系表。
更合适的方式是直接在 `equipment` 表新增:
- `unit_id`
这样模型会更简单:
- 一个 `unit` 对多台 `equipment`
- 一台 `equipment` 只属于一个 `unit`
- `equipment.kind` 用于区分 `coal_feeder``distributor`
如果后续现场出现“一台设备可挂多个单元”或“单元内设备编排顺序复杂”的需求,再演进成关系表会更合适。第一阶段不建议设计过重。
### 5.6 Unit 运行态放内存
`unit` 运行态不建议优先落数据库,建议由控制引擎保存在内存中。
建议维护的内存运行态字段:
- `state`
- `accumulated_run_sec`
- `current_run_elapsed_sec`
- `current_stop_elapsed_sec`
- `distributor_run_elapsed_sec`
- `fault_locked`
- `comm_locked`
- `manual_ack_required`
- `last_tick_at`
状态值建议:
- `stopped`
- `running`
- `distributor_running`
- `fault_locked`
- `comm_locked`
原因:
- 这些字段变化频率高,不适合高频写库
- 服务重启后直接恢复旧控制态并不安全
- 更合理的方式是重启后重新读取 `REM/RUN/FLT/Q` 并重建运行态
- 通讯恢复或服务重启后不自动补发控制命令,更符合工业控制安全原则
如后续确实需要“断电恢复上下文”或运行分析,再补充轻量级快照能力即可,但不是第一阶段必须项。
### 5.7 统一 `event` 表,预留 `alarm`
建议不要命名为 `control_event`,而是使用统一的 `event` 表。
原因:
- 当前不仅有控制事件,后续还会有配置事件、通讯事件、数据源事件
- `event` 更适合作为统一审计与业务时间线
- 现有 `event.rs` 已经是进程内事件总线,命名保持一致更自然
建议 `event` 表记录:
- 手动启动/停止
- 自动启动/停止
- 故障锁定
- 人工解除故障锁定
- 通讯异常/恢复
- 数据源创建、更新、删除
- 控制参数变更
- 关键状态切换
关键字段建议:
- `id`
- `event_type`
- `level`
- `unit_id`
- `equipment_id`
- `source_id`
- `message`
- `payload`
- `created_at`
同时建议未来单独设计 `alarm` 表,而不是把报警状态硬塞进 `event` 表。
原因:
- 报警通常有独立生命周期:触发、确认、恢复、清除
- 报警需要独立字段,如 `severity`、`active`、`acked`、`acked_by`、`acked_at`、`cleared_at`
- 把报警硬塞到 `event` 中会让通用事件表越来越臃肿
因此推荐边界是:
- `event`:记录“发生了什么”
- `alarm`:记录“需要被告警管理的异常”
第一阶段可以先只落 `event` 表,`alarm` 表先在方案中预留,不急着实现。
## 6. 控制逻辑设计
### 6.1 手动控制(已实现,待补充运行态检查)
### 6.1 手动控制
当前已实现:
- `rem == 1` 检查
- `flt == 0` 检查
- 通讯质量检查
- 脉冲写入300ms
手动控制前置校验:
**待补充**:在 `validator.rs` 中增加对 `ControlRuntimeStore` 的检查:
- `fault_locked == true` → 拒绝
- `comm_locked == true` → 拒绝
- `manual_ack_required == true` → 拒绝(等待人工确认)
- `REM == 1`
- `FLT == 0`
- 通讯正常
- 未处于故障锁定
- 如有急停点,`ESTOP == 0`
### 6.2 自动控制状态机(待实现)
控制命令统一走脉冲写入:
每个 `unit` 独立维护状态机,在 `control/engine.rs` 中以后台任务驱动。
1. 写入 `1`
2. 延时 `200-500ms`
3. 写回 `0`
服务端需要封装 `pulse_write(point_id, high_ms)`,前端不能直接拼两次写点。
### 6.2 自动控制状态机
每个 `unit` 独立维护状态机。
#### `STOPPED`
- 累计停止时间 `current_stop_elapsed_sec`
- 若 `stop_elapsed >= StopTime` → 校验投煤器 REM/FLT/质量 → 发启动命令 → 切换到 `RUNNING`
- 累计停止时间
- 若 `stop_elapsed >= StopTime`,尝试启动投煤器
- 成功后切换到 `RUNNING`
#### `RUNNING`
- 累计运行时间 `current_run_elapsed_sec`
- 累计运行时间
- `accumulated_run_sec += delta`
- 若 `run_elapsed >= RunTime` → 停止投煤器 → 切换回 `STOPPED`
- 若 `accumulated_run_sec >= AccTime` → 进入 `DISTRIBUTOR_RUNNING`
- 若 `run_elapsed >= RunTime`,尝试停止投煤器,切换回 `STOPPED`
- 若 `accumulated_run_sec >= AccTime`,进入布料机触发流程
#### `DISTRIBUTOR_RUNNING`
- 校验布料机 REM/FLT/质量
- 启动布料机,等待 `RUN == 1`
- 校验布料机 `REM/FLT/通讯`
- 启动布料机
- 等待 `RUN == 1` 反馈
- 计时 `BLTime`
- 停止布料机
- 清零 `accumulated_run_sec`
- 切回 `STOPPED` 或自动节拍起点
- 累计时间清零
- 回 `STOPPED`回到自动节拍起点
### 6.3 故障机制(待实现)
### 6.3 故障机制
任意设备检测到 `FLT == 1`
- 停止该单元自动控制
- `state = FaultLocked`, `fault_locked = true`
- 发送并持久化 `FaultLocked` 事件
- 标记 `fault_locked = true`
- 禁止再次自动发命令
- 发送并持久化关键事件
`FLT``1 -> 0` 恢复:
`FLT``1 → 0` 恢复:
- 不自动解锁
- `manual_ack_required = true`
- 等待人工调用 `POST /api/control/unit/{id}/ack-fault`
- 标记 `manual_ack_required = true`
- 等待人工在界面点击“解除故障锁定”
### 6.4 通讯异常机制(待实现)
### 6.4 通讯异常机制
当 OPC UA 质量位异常或连接中断:
- `state = CommLocked`, `comm_locked = true`
当质量位异常或 OPC 连接中断:
- 标记 `comm_locked = true`
- 冻结全部控制动作
- 前端按钮灰化
- 不允许任何自动/手动写入
通讯恢复后:
- 重新读取 `REM/RUN/FLT`
- 重同步运行态
- 不自动补发控制命令
- 持久化恢复事件
- 发送并持久化恢复事件
- 等待人工操作或下一次自动触发
---
## 7. 事件体系设计
### 7.1 继续复用 `src/event.rs`
当前 `AppEvent` 已有:
- `SourceCreate/Update/Delete`
- `PointCreateBatch/PointDeleteBatch`
- `EquipmentStartCommandSent/EquipmentStopCommandSent`
- `PointNewValue`(遥测)
建议不要另起一套业务事件中心,而是在现有 [src/event.rs](D:/projects/plc_control/src/event.rs) 上扩展。
**待扩展**
当前它已经承担两类职责:
```rust
AutoControlStarted { unit_id }
AutoControlStopped { unit_id }
FaultLocked { unit_id, equipment_id }
FaultAcked { unit_id }
CommLocked { unit_id }
CommRecovered { unit_id }
UnitStateChanged { unit_id, from_state, to_state }
```
- 控制类内部事件分发
- 遥测类高频事件分发
推荐继续保留这个结构:
- `AppEvent` 作为统一进程内事件枚举
- 高频遥测事件继续走内存和 WebSocket
- 低频且有审计价值的事件选择性落库到 `event`
### 7.2 哪些事件适合落库
- 适合:所有手动/自动启停、故障、通讯、参数变更、状态切换
- 不适合:`PointNewValue`、高频遥测、内部轮询过程
适合落库的:
---
- `SourceCreate`
- `SourceUpdate`
- `SourceDelete`
- 自动控制启动/停止
- 手动启动/停止命令发送
- 故障锁定
- 人工确认恢复
- 通讯异常/恢复
- 参数配置变更
- 单元状态切换
不适合直接落库的:
- `PointNewValue`
- 高频实时遥测
- 细碎的内部轮询过程
### 7.3 推荐扩展方向
建议在 `AppEvent` 中逐步增加业务事件,例如:
- `AutoControlStarted`
- `AutoControlStopped`
- `EquipmentStartCommandSent`
- `EquipmentStopCommandSent`
- `FaultLocked`
- `FaultAcked`
- `CommLocked`
- `CommRecovered`
- `UnitStateChanged`
这样后续无论是写日志、落库、推送 WebSocket、做报警触发都可以基于同一个事件入口。
## 8. 后端改造方案
### 8.1 已有模块(确认现状)
### 8.1 新增模块
| 模块 | 文件 | 状态 |
|------|------|------|
| HTTP 控制接口 | `src/handler/control.rs` | ✅ 有 Unit CRUD + start/stop + event list |
| 控制前置校验 | `src/control/validator.rs` | ✅ REM/FLT/质量检查,**待加运行态检查** |
| 内存运行态 | `src/control/runtime.rs` | ✅ 数据结构完整 |
| 自动控制引擎 | `src/control/engine.rs` | ❌ 空桩,待实现 |
| 服务层 Unit | `src/service/` | ✅ CRUD 完整 |
建议新增:
### 8.2 待新增接口
- `src/handler/control.rs`
- `src/service/control.rs`
- `src/control/engine.rs`
- `src/control/runtime.rs`
- `src/control/validator.rs`
```
POST /api/control/unit/{id}/start-auto 启动自动控制
POST /api/control/unit/{id}/stop-auto 停止自动控制
POST /api/control/unit/{id}/ack-fault 人工确认故障解锁
GET /api/unit/{id}/runtime 查询运行态state, elapsed, fault_locked 等)
```
职责划分:
说明:
- `start/stop-auto` 修改运行态 `auto_enabled`,引擎轮询时读取
- `ack-fault` 仅在 `manual_ack_required == true` 时允许操作,否则返回 400
- `handler`HTTP 接口
- `service`:数据库读写
- `control/engine`:状态机与调度
- `control/runtime`:内存运行态缓存与同步
- `control/validator`:控制前置校验
### 8.3 控制引擎运行方式(待实现)
### 8.2 新增接口
`control/engine.rs: start()` 中实现后台任务:
建议新增接口
```
每 500ms 扫描所有 enabled unit
从 ControlRuntimeStore 读取运行态
从 connection_manager.get_point_monitor_data_read_guard() 取实时点值
检查质量位 → 更新 comm_locked
检查 FLT → 更新 fault_locked
驱动状态机 tick
有状态变化 → 更新 ControlRuntimeStore → 发 AppEvent → 推 WebSocket
```
### 8.4 手动控制补充运行态检查
`control/validator.rs: validate_manual_control()` 中增加:
```rust
let runtime = state.control_runtime.get(unit_id).await;
if let Some(runtime) = runtime {
if runtime.fault_locked {
return Err(ApiErr::Forbidden("Unit is fault locked", ...));
}
if runtime.comm_locked {
return Err(ApiErr::Forbidden("Unit is comm locked", ...));
}
if runtime.manual_ack_required {
return Err(ApiErr::Forbidden("Fault ack required before control", ...));
}
}
```
### 8.5 关键复用点
可直接复用当前已有能力:
- `connection_manager.get_point_monitor_data_read_guard()` → 读取实时点值
- `connection_manager.write_point_values_batch()` → 写点(自动控制也走此接口)
- `event_manager.send()` → 统一事件入口
- `ws_manager.send_to_public()` → 推送运行态变更
- `unit_id + equipment.kind + point.signal_role` 三元组 → 业务映射
---
## 9. 前端改造方案
### 9.1 已有页面(确认现状)
| 页面/功能 | 状态 |
|-----------|------|
| Unit 列表(含 CRUD Modal | ✅ 基础实现 |
| 事件列表 | ✅ 基础实现 |
| 设备列表(含 Unit 归属绑定) | ✅ 实现 |
| 点位绑定equipment/signal_role | ✅ 实现 |
| 趋势图 | ✅ 实现 |
### 9.2 待新增页面/功能
**设备控制面板**(单台投煤器/布料机):
- REM / RUN / FLT / Q / II 实时显示
- 启动 / 停止按钮灰化逻辑comm_locked / fault_locked / manual_ack_required
- 通讯异常、故障锁定提示
- 最近控制事件
**单元总览**Unit 卡片增强):
- 当前状态机状态STOPPED / RUNNING / DISTRIBUTOR_RUNNING / FAULT_LOCKED / COMM_LOCKED
- 自动/手动切换按钮
- 故障确认按钮(`manual_ack_required == true` 时显示)
- 累计运行时间进度
- 投煤器 + 布料机运行状态摘要
**参数在线编辑**
- 现有 Unit Modal 已有表单
- 需补充:保存后通知引擎重新加载(或引擎每次 tick 从 DB 读取配置)
**WebSocket 运行态更新**
- 新增 `WsMessage::UnitRuntimeChanged { unit_id, runtime }` 消息类型
- 前端收到后实时更新 Unit 卡片状态,无需轮询
---
## 10. 分阶段实施建议
### 第一阶段:补全控制闭环(当前阶段)
**目标**:让自动控制可以跑起来,故障/通讯保护机制生效。
待完成工作:
1. **补充运行态检查到 `validator.rs`**
- 手动控制时检查 `fault_locked`, `comm_locked`, `manual_ack_required`
2. **实现 `control/engine.rs`**
- 后台 500ms 轮询任务
- 质量检查 → `comm_locked` 更新
- FLT 检测 → `fault_locked` 更新
- 状态机 tickSTOPPED / RUNNING / DISTRIBUTOR_RUNNING
3. **新增接口**
- `GET /api/control/unit`
- `POST /api/control/unit`
- `PUT /api/control/unit/{id}`
- `GET /api/control/unit/{id}`
- `POST /api/control/unit/{id}/start-auto`
- `POST /api/control/unit/{id}/stop-auto`
- `POST /api/control/unit/{id}/ack-fault`
- `GET /api/unit/{id}/runtime`
- `POST /api/control/equipment/{id}/start`
- `POST /api/control/equipment/{id}/stop`
- `GET /api/events`
4. **扩展 `AppEvent`**
- `FaultLocked`, `FaultAcked`, `CommLocked`, `CommRecovered`, `UnitStateChanged`, `AutoControlStarted`, `AutoControlStopped`
说明:
5. **WebSocket 运行态推送**
- `WsMessage::UnitRuntimeChanged`
- 设备手动控制必须走业务接口,不建议继续直接暴露给页面做原始点位写入
- 原 `/api/point/value/batch` 保留给调试或底层工具能力
- 事件查询接口可以直接面向统一 `event`
6. **前端设备控制面板**
- REM/RUN/FLT 展示 + 启停按钮 + 灰化逻辑
### 8.3 控制引擎运行方式
7. **前端 Unit 卡片增强**
- 状态机状态展示、自动/手动切换、故障确认
建议服务启动后增加一个后台任务:
交付后可验证:
- 单台设备手动启停(含故障/通讯拦截)
- 单元自动定时启停
- 每 `500ms``1s` 扫描所有启用的 `unit`
- 从内存读取运行态缓存
- 从当前点位监控数据中取 `REM/RUN/FLT/Q`
- 驱动状态机执行
控制引擎不要直接查 OPC应复用当前 `connection_manager` 已维护的实时点值。
### 8.4 关键复用点
可直接复用当前已有能力:
- `connection_manager` 的点位实时缓存
- `get_point_monitor_data_read_guard`
- 批量写点能力
- WebSocket 实时推送
- `event.rs` 的统一事件入口
- `unit_id + equipment.kind + point.signal_role` 的业务映射关系
## 9. 前端改造方案
建议在现有通用页面之外新增业务页面,避免混杂。
### 9.1 新增页面
- 单元总览页
- 单元详情页
- 设备控制面板
- 事件记录页
- 报警页
- 参数配置页
### 9.2 单元总览页内容
每个 `unit` 展示:
- 单元名称
- 自动/手动状态
- 当前状态机状态
- 投煤器运行状态
- 布料机运行状态
- 当前累计运行时间
- 故障锁定状态
- 通讯状态
并提供按钮:
- 启动自动
- 停止自动
- 故障确认/解除锁定
### 9.3 设备控制页内容
针对单台投煤器/布料机提供:
- REM/RUN/FLT/Q/II 实时显示
- 启动按钮
- 停止按钮
- 通讯异常、故障锁定提示
- 最近事件
### 9.4 趋势、事件与报警
复用已有趋势图能力:
- 电流 `II` 趋势
- 运行状态变化曲线
- 事件时间线
后续报警页面基于独立 `alarm` 表实现:
- 当前活动报警
- 已确认报警
- 已恢复报警
- 报警确认操作
## 10. 分阶段实施建议
### 第一阶段:最小可用版
目标:先让系统具备业务闭环,但不追求复杂页面。
内容:
- 新增 `unit`
- 为 `equipment` 增加 `unit_id`
- 约定设备 `kind` 和点位 `signal_role`
- 新增手动控制接口
- 实现脉冲写入
- 实现故障锁定与通讯冻结
- 实现自动控制状态机
- 基于 `event.rs` 落统一 `event`
- 前端增加一个“控制单元”面板和事件列表
交付后即可验证:
- 单台投煤器启停
- 单元级自动启停
- 累计触发布料机运行
- 故障恢复后人工确认才能操作
- 通讯异常冻结后恢复自动同步
- 故障恢复后人工确认
- 关键操作和状态切换可追溯
### 第二阶段:增强版
- 单元详情页(运行趋势、事件时间线、参数在线编辑)
- 更丰富的趋势图(电流 II、运行状态变化曲线
内容:
- 单元总览页
- 单元详情页
- 参数在线编辑
- 更丰富的趋势图
- WebSocket 业务事件推送
- 报警规则与 `alarm`
- 报警确认与恢复流程
### 第三阶段:现场适配版
内容:
- 导入导出配置
- 项目模板
- 配置校验工具
- 启停联锁自检
- 操作权限控制
---
## 11. 建议优先落地顺序
## 11. 当前优先落地顺序
从当前代码基础出发,建议按下面顺序开发:
从当前代码基础出发,第一阶段建议按下面顺序开发:
1. `validator.rs` 补充运行态检查
2. `engine.rs` 实现质量检查与 `comm_locked` 更新
3. `engine.rs` 实现 FLT 检测与 `fault_locked` 更新
4. `engine.rs` 实现状态机主循环
5. `handler/control.rs` 新增 `start-auto`, `stop-auto`, `ack-fault`
6. `handler/control.rs` 新增 `GET /api/unit/{id}/runtime`
7. 扩展 `AppEvent` 业务事件类型并落库
8. `websocket.rs` 新增 `UnitRuntimeChanged` 消息
9. 前端设备控制面板
10. 前端 Unit 卡片增强
11. 第二阶段再引入独立 `alarm`
---
1. 补齐业务数据模型和数据库迁移
2. 新增 `unit` 表并为 `equipment` 增加 `unit_id`
3. 规范 `equipment.kind``point.signal_role`
4. 实现服务端脉冲写入能力
5. 实现手动控制接口
6. 实现 `unit` 自动控制状态机
7. 扩展 `event.rs` 并实现统一 `event` 表持久化
8. 实现故障锁定、通讯冻结、人工确认
9. 增加前端业务页面
10. 第二阶段再引入独立 `alarm`
## 12. 对当前代码的具体落点
| 文件 | 改动内容 |
|------|---------|
| `src/control/engine.rs` | 填充后台轮询任务,实现状态机 tick |
| `src/control/validator.rs` | 增加运行态检查fault/comm/ack |
| `src/handler/control.rs` | 新增 start-auto, stop-auto, ack-fault, runtime 接口 |
| `src/event.rs` | 扩展 `AppEvent` 业务事件枚举 |
| `src/websocket.rs` | 新增 `WsMessage::UnitRuntimeChanged` |
| `web/js/units.js` | Unit 卡片增加状态机状态、auto 切换、fault ack 按钮 |
| `web/js/equipment.js` | 增加设备控制面板REM/RUN/FLT + 启停) |
| `web/js/app.js` | 绑定新按钮事件,处理 WebSocket 运行态消息 |
基于现有代码,建议主要改动点如下:
不需要改动的:
- `src/model.rs`(数据模型完整)
- `src/control/runtime.rs`(运行态结构体完整)
- 所有迁移文件schema 已完整)
- `src/handler/point.rs`(保留底层写点,不承载业务控制)
---
- [src/model.rs](D:/projects/plc_control/src/model.rs)
- 增加 `ControlUnit` 模型
- 为 `Equipment` 增加 `unit_id`
- 后续增加 `EventRecord`、`AlarmRecord` 模型
- [src/event.rs](D:/projects/plc_control/src/event.rs)
- 扩展 `AppEvent` 业务事件类型
- 增加低频关键事件的持久化逻辑
- [src/main.rs](D:/projects/plc_control/src/main.rs)
- 注册控制相关路由
- 启动控制引擎后台任务
- [src/handler/point.rs](D:/projects/plc_control/src/handler/point.rs)
- 保留底层写点接口,不直接承载业务控制
- [src/handler/equipment.rs](D:/projects/plc_control/src/handler/equipment.rs)
- 继续作为设备基础资料管理
- [web/index.html](D:/projects/plc_control/web/index.html)
- 增加业务控制页面入口或独立面板
- [web/js/app.js](D:/projects/plc_control/web/js/app.js)
- 增加控制单元页面事件绑定
## 13. 本次结论
当前项目已经完成了业务底座的建设(数据模型、脉冲写入、手动控制、事件持久化、运行态数据结构),具备了较好的基础。
当前项目不需要推倒重来,可以直接演进成投煤器与布料机远程监控控制系统
下一步核心工作集中在:
最合理的路径是
1. **填充 `control/engine.rs`**——这是最核心的缺口,所有自动控制、故障保护、通讯冻结都需要它来驱动
2. **前端业务控制面板**——让操作员看到并操作设备状态
- 以现有 OPC UA 与点位平台为底座
- 数据库使用简洁表名 `unit`、`event`
- 代码层保留语义化命名,如 `ControlUnit`、`AppEvent`
- 在 `equipment` 上直接增加 `unit_id`
- 在服务端以内存运行态实现状态机和脉冲控制
- 在现有 [src/event.rs](D:/projects/plc_control/src/event.rs) 上扩展统一事件体系
- 第一阶段先做统一 `event`,第二阶段再拆分独立 `alarm`
其余部分接口、事件、WebSocket属于连接层工作量相对可控。
如果进入下一步开发,建议先做“第一阶段最小可用版”

View File

@ -6,9 +6,6 @@ pub struct AppConfig {
pub server_host: String,
pub server_port: u16,
pub write_api_key: Option<String>,
/// When true, simulate RUN signal feedback after start/stop commands.
/// Set SIMULATE_PLC=true in .env for use with OPC UA proxy simulators.
pub simulate_plc: bool,
}
@ -25,16 +22,11 @@ impl AppConfig {
.ok()
.or_else(|| env::var("WRITE_KEY").ok());
let simulate_plc = env::var("SIMULATE_PLC")
.unwrap_or_default()
.to_lowercase() == "true";
Ok(Self {
database_url,
server_host,
server_port,
write_api_key,
simulate_plc,
})
}

View File

@ -328,7 +328,7 @@ impl ConnectionManager {
let manager = self.clone();
let handle = tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(8)); // 每8秒检测一次心跳
let mut ticker = tokio::time::interval(Duration::from_secs(4)); // 每4秒检测一次心跳
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
@ -1153,8 +1153,8 @@ impl ConnectionManager {
match session
.create_subscription(
Duration::from_secs(1),
15,
5,
10,
30,
0,
0,
true,

View File

@ -1,188 +0,0 @@
use crate::{
connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem},
telemetry::ValueType,
AppState,
};
use serde_json::json;
use std::sync::Arc;
use uuid::Uuid;
/// Write a pulse (high → delay → low) to a command point.
/// Returns Ok(()) on success, Err(msg) on any failure.
pub async fn send_pulse_command(
connection_manager: &Arc<ConnectionManager>,
point_id: Uuid,
value_type: Option<&ValueType>,
pulse_ms: u64,
) -> Result<(), String> {
let high = pulse_value(true, value_type);
let low = pulse_value(false, value_type);
let high_result = connection_manager
.write_point_values_batch(BatchSetPointValueReq {
items: vec![SetPointValueReqItem { point_id, value: high }],
})
.await?;
if !high_result.success {
return Err(format!("Pulse high write failed: {:?}", high_result.err_msg));
}
tokio::time::sleep(std::time::Duration::from_millis(pulse_ms)).await;
let low_result = connection_manager
.write_point_values_batch(BatchSetPointValueReq {
items: vec![SetPointValueReqItem { point_id, value: low }],
})
.await?;
if !low_result.success {
return Err(format!("Pulse low write failed: {:?}", low_result.err_msg));
}
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 {
match value_type {
Some(ValueType::Bool) => serde_json::Value::Bool(high),
_ => if high { json!(1) } else { json!(0) },
}
}

View File

@ -1,431 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use crate::{
control::{
command::send_pulse_command,
runtime::{ControlRuntimeStore, UnitRuntime, UnitRuntimeState},
},
event::AppEvent,
service::EquipmentRolePoint,
telemetry::{DataValue, PointMonitorInfo, PointQuality},
websocket::WsMessage,
AppState,
};
pub fn start(state: AppState, runtime_store: Arc<ControlRuntimeStore>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(500));
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) {
let units = match crate::service::get_all_enabled_units(&state.pool).await {
Ok(u) => u,
Err(e) => {
tracing::error!("Engine: failed to load units: {}", e);
return;
}
};
for unit in units {
tick_unit(state, store, &unit).await;
}
}
async fn tick_unit(
state: &AppState,
store: &ControlRuntimeStore,
unit: &crate::model::ControlUnit,
) {
let mut runtime = store.get_or_init(unit.id).await;
// ── Load equipment role-point maps by kind ───────────────
let equipment_list = match crate::service::get_equipment_by_unit_id(&state.pool, unit.id).await {
Ok(e) => e,
Err(e) => {
tracing::error!(
"Engine: equipment load failed for unit {}: {}",
unit.id,
e
);
return;
}
};
// kind -> role -> EquipmentRolePoint (first equipment per kind wins)
let mut kind_roles: HashMap<String, HashMap<String, EquipmentRolePoint>> = HashMap::new();
// kind -> equipment id (first equipment per kind)
let mut kind_eq_ids: HashMap<String, Uuid> = HashMap::new();
// all role maps for fault/comm scanning across all equipment
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = Vec::new();
for equip in &equipment_list {
match crate::service::get_equipment_role_points(&state.pool, equip.id).await {
Ok(role_points) => {
let role_map: HashMap<String, EquipmentRolePoint> = role_points
.into_iter()
.map(|rp| (rp.signal_role.clone(), rp))
.collect();
if let Some(kind) = &equip.kind {
if kind_roles.contains_key(kind.as_str()) {
tracing::warn!(
"Engine: unit {} has multiple {} equipment; using first",
unit.id,
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
.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
.ws_manager
.send_to_public(WsMessage::UnitRuntimeChanged(runtime))
.await
{
tracing::debug!("Engine: WS push skipped (no subscribers): {}", e);
}
}
/// Drive one state-machine tick for a unit.
/// All elapsed counters accumulate in **milliseconds**; comparisons use `*_time_sec * 1000`.
async fn tick_state_machine(
state: &AppState,
runtime: &mut UnitRuntime,
unit: &crate::model::ControlUnit,
kind_roles: &HashMap<String, HashMap<String, EquipmentRolePoint>>,
kind_eq_ids: &HashMap<String, Uuid>,
delta_ms: i64,
) {
let feeder_roles = kind_roles.get("coal_feeder");
let dist_roles = kind_roles.get("distributor");
let feeder_eq_id = kind_eq_ids.get("coal_feeder").copied();
let dist_eq_id = kind_eq_ids.get("distributor").copied();
match runtime.state {
UnitRuntimeState::Stopped => {
// stop_time_sec == 0 means start immediately (no wait)
if unit.stop_time_sec > 0 {
runtime.current_stop_elapsed_sec += delta_ms; // field holds ms
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 => {
runtime.current_run_elapsed_sec += delta_ms;
runtime.accumulated_run_sec += delta_ms;
// Check RunTime first — stop feeder before considering distributor trigger
if unit.run_time_sec > 0
&& runtime.current_run_elapsed_sec >= unit.run_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: 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
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 => {
// First tick in this state (distributor_run_elapsed_sec == 0): send start pulse then return.
// Time advance happens on subsequent ticks.
if runtime.distributor_run_elapsed_sec == 0 {
let monitor = state
.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;
if unit.bl_time_sec > 0
&& runtime.distributor_run_elapsed_sec >= unit.bl_time_sec as i64 * 1000
{
let monitor = state
.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 => {}
}
}
/// Find a command point by role in a single equipment's role map.
/// Returns `None` if REM==0 or FLT==1 or quality is bad.
fn find_cmd(
roles: &HashMap<String, EquipmentRolePoint>,
role: &str,
monitor: &HashMap<Uuid, PointMonitorInfo>,
) -> Option<(Uuid, Option<crate::telemetry::ValueType>)> {
let cmd_rp = roles.get(role)?;
let rem_ok = roles
.get("rem")
.and_then(|rp| monitor.get(&rp.point_id))
.map(|m| monitor_value_as_bool(m) && m.quality == PointQuality::Good)
.unwrap_or(true);
let flt_ok = roles
.get("flt")
.and_then(|rp| monitor.get(&rp.point_id))
.map(|m| !monitor_value_as_bool(m) && m.quality == PointQuality::Good)
.unwrap_or(true);
if rem_ok && flt_ok {
let vtype = monitor
.get(&cmd_rp.point_id)
.and_then(|m| m.value_type.clone());
Some((cmd_rp.point_id, vtype))
} else {
None
}
}
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")
}
_ => false,
}
}

View File

@ -1,4 +0,0 @@
pub mod command;
pub mod engine;
pub mod runtime;
pub mod validator;

View File

@ -1,79 +0,0 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc};
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum UnitRuntimeState {
Stopped,
Running,
DistributorRunning,
FaultLocked,
CommLocked,
}
#[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 current_run_elapsed_sec: i64,
pub current_stop_elapsed_sec: i64,
pub distributor_run_elapsed_sec: i64,
pub fault_locked: bool,
pub flt_active: bool,
pub comm_locked: bool,
pub manual_ack_required: bool,
pub last_tick_at: Option<DateTime<Utc>>,
}
impl UnitRuntime {
pub fn new(unit_id: Uuid) -> Self {
Self {
unit_id,
state: UnitRuntimeState::Stopped,
auto_enabled: false,
accumulated_run_sec: 0,
current_run_elapsed_sec: 0,
current_stop_elapsed_sec: 0,
distributor_run_elapsed_sec: 0,
fault_locked: false,
flt_active: false,
comm_locked: false,
manual_ack_required: false,
last_tick_at: None,
}
}
}
#[derive(Clone, Default)]
pub struct ControlRuntimeStore {
inner: Arc<RwLock<HashMap<Uuid, UnitRuntime>>>,
}
impl ControlRuntimeStore {
pub fn new() -> Self {
Self::default()
}
pub async fn get(&self, unit_id: Uuid) -> Option<UnitRuntime> {
self.inner.read().await.get(&unit_id).cloned()
}
pub async fn get_or_init(&self, unit_id: Uuid) -> UnitRuntime {
if let Some(runtime) = self.get(unit_id).await {
return runtime;
}
let runtime = UnitRuntime::new(unit_id);
self.inner.write().await.insert(unit_id, runtime.clone());
runtime
}
pub async fn upsert(&self, runtime: UnitRuntime) {
self.inner.write().await.insert(runtime.unit_id, runtime);
}
}

View File

@ -1,214 +0,0 @@
use std::collections::HashMap;
use serde_json::json;
use uuid::Uuid;
use crate::{
service::EquipmentRolePoint,
telemetry::{DataValue, PointMonitorInfo, PointQuality, ValueType},
util::response::ApiErr,
AppState,
};
#[derive(Debug, Clone, Copy)]
pub enum ControlAction {
Start,
Stop,
}
impl ControlAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Start => "start",
Self::Stop => "stop",
}
}
pub fn command_role(self) -> &'static str {
match self {
Self::Start => "start_cmd",
Self::Stop => "stop_cmd",
}
}
}
pub struct ManualControlContext {
pub unit_id: Option<Uuid>,
pub command_point: EquipmentRolePoint,
pub command_value_type: Option<ValueType>,
}
pub async fn validate_manual_control(
state: &AppState,
equipment_id: Uuid,
action: ControlAction,
) -> Result<ManualControlContext, ApiErr> {
let equipment = crate::service::get_equipment_by_id(&state.pool, equipment_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Equipment not found".to_string(), None))?;
let role_points = crate::service::get_equipment_role_points(&state.pool, equipment_id).await?;
if role_points.is_empty() {
return Err(ApiErr::BadRequest(
"Equipment has no bound role points".to_string(),
Some(json!({ "equipment_id": equipment_id })),
));
}
let role_map: HashMap<&str, &EquipmentRolePoint> = role_points
.iter()
.map(|point| (point.signal_role.as_str(), point))
.collect();
let command_point = role_map
.get(action.command_role())
.copied()
.ok_or_else(|| {
ApiErr::BadRequest(
format!("Equipment missing role point {}", action.command_role()),
Some(json!({
"equipment_id": equipment_id,
"required_role": action.command_role()
})),
)
})?
.clone();
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
validate_quality(
role_map.get("rem").copied(),
&monitor_guard,
"REM",
equipment_id,
)?;
validate_quality(
role_map.get("flt").copied(),
&monitor_guard,
"FLT",
equipment_id,
)?;
if let Some(rem_point) = role_map.get("rem").copied() {
let rem_monitor = monitor_guard
.get(&rem_point.point_id)
.ok_or_else(|| missing_monitor_err("REM", equipment_id))?;
if !monitor_value_as_bool(rem_monitor) {
return Err(ApiErr::Forbidden(
"Remote control not allowed, REM is not enabled".to_string(),
Some(json!({ "equipment_id": equipment_id })),
));
}
}
if let Some(flt_point) = role_map.get("flt").copied() {
let flt_monitor = monitor_guard
.get(&flt_point.point_id)
.ok_or_else(|| missing_monitor_err("FLT", equipment_id))?;
if monitor_value_as_bool(flt_monitor) {
return Err(ApiErr::Forbidden(
"Equipment fault is active, command denied".to_string(),
Some(json!({ "equipment_id": equipment_id })),
));
}
}
drop(monitor_guard);
// Runtime state checks — block commands if unit is locked
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(
"Auto control is active; disable auto first".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
if runtime.comm_locked {
return Err(ApiErr::Forbidden(
"Unit communication is locked".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
if runtime.fault_locked {
return Err(ApiErr::Forbidden(
"Unit is fault locked".to_string(),
Some(json!({ "unit_id": unit_id, "manual_ack_required": runtime.manual_ack_required })),
));
}
if runtime.manual_ack_required {
return Err(ApiErr::Forbidden(
"Fault acknowledgement required before issuing commands".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
}
}
let command_value_type = state
.connection_manager
.get_point_monitor_data_read_guard()
.await
.get(&command_point.point_id)
.and_then(|item| item.value_type.clone());
Ok(ManualControlContext {
unit_id: equipment.unit_id,
command_point,
command_value_type,
})
}
fn validate_quality(
role_point: Option<&EquipmentRolePoint>,
monitor_map: &HashMap<Uuid, PointMonitorInfo>,
role: &str,
equipment_id: Uuid,
) -> Result<(), ApiErr> {
let Some(role_point) = role_point else {
return Ok(());
};
let monitor = monitor_map
.get(&role_point.point_id)
.ok_or_else(|| missing_monitor_err(role, equipment_id))?;
if monitor.quality != PointQuality::Good {
return Err(ApiErr::Forbidden(
format!("Communication abnormal for role {}", role),
Some(json!({
"equipment_id": equipment_id,
"role": role,
"quality": monitor.quality
})),
));
}
Ok(())
}
fn missing_monitor_err(role: &str, equipment_id: Uuid) -> ApiErr {
ApiErr::Forbidden(
format!("No realtime value for role {}", role),
Some(json!({
"equipment_id": equipment_id,
"role": role
})),
)
}
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

@ -24,23 +24,6 @@ pub enum AppEvent {
source_id: Uuid,
point_ids: Vec<Uuid>,
},
EquipmentStartCommandSent {
equipment_id: Uuid,
unit_id: Option<Uuid>,
point_id: Uuid,
},
EquipmentStopCommandSent {
equipment_id: Uuid,
unit_id: Option<Uuid>,
point_id: Uuid,
},
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 },
PointNewValue(crate::telemetry::PointNewValue),
}
@ -62,11 +45,9 @@ impl EventManager {
let control_cm = connection_manager.clone();
let control_pool = pool.clone();
let control_ws_manager = ws_manager.clone();
tokio::spawn(async move {
while let Some(event) = control_receiver.recv().await {
handle_control_event(event, &control_pool, &control_cm, control_ws_manager.as_ref())
.await;
handle_control_event(event, &control_pool, &control_cm).await;
}
});
@ -142,9 +123,8 @@ async fn handle_control_event(
event: AppEvent,
pool: &sqlx::PgPool,
connection_manager: &std::sync::Arc<crate::connection::ConnectionManager>,
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
) {
persist_event_if_needed(&event, pool, ws_manager).await;
persist_event_if_needed(&event, pool).await;
match event {
AppEvent::SourceCreate { source_id } => {
@ -202,62 +182,13 @@ async fn handle_control_event(
tracing::error!("Failed to unsubscribe points: {}", e);
}
}
AppEvent::EquipmentStartCommandSent {
equipment_id,
unit_id,
point_id,
} => {
tracing::info!(
"Equipment start command sent: equipment={}, unit={:?}, point={}",
equipment_id,
unit_id,
point_id
);
}
AppEvent::EquipmentStopCommandSent {
equipment_id,
unit_id,
point_id,
} => {
tracing::info!(
"Equipment stop command sent: equipment={}, unit={:?}, point={}",
equipment_id,
unit_id,
point_id
);
}
AppEvent::AutoControlStarted { unit_id } => {
tracing::info!("Auto control started for unit {}", unit_id);
}
AppEvent::AutoControlStopped { unit_id } => {
tracing::info!("Auto control stopped for unit {}", unit_id);
}
AppEvent::FaultLocked { unit_id, equipment_id } => {
tracing::warn!("Fault locked: unit={}, equipment={}", unit_id, equipment_id);
}
AppEvent::FaultAcked { unit_id } => {
tracing::info!("Fault acked for unit {}", unit_id);
}
AppEvent::CommLocked { unit_id } => {
tracing::warn!("Comm locked for unit {}", unit_id);
}
AppEvent::CommRecovered { unit_id } => {
tracing::info!("Comm recovered for unit {}", unit_id);
}
AppEvent::UnitStateChanged { unit_id, from_state, to_state } => {
tracing::info!("Unit {} state: {} → {}", unit_id, from_state, to_state);
}
AppEvent::PointNewValue(_) => {
tracing::warn!("PointNewValue routed to control worker unexpectedly");
}
}
}
async fn persist_event_if_needed(
event: &AppEvent,
pool: &sqlx::PgPool,
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
) {
async fn persist_event_if_needed(event: &AppEvent, pool: &sqlx::PgPool) {
let record = match event {
AppEvent::SourceCreate { source_id } => Some((
"source.created",
@ -282,7 +213,7 @@ async fn persist_event_if_needed(
"warn",
None,
None,
None,
Some(*source_id),
format!("Source {} deleted", source_id),
serde_json::json!({ "source_id": source_id }),
)),
@ -304,82 +235,6 @@ async fn persist_event_if_needed(
format!("{} points deleted for source {}", point_ids.len(), source_id),
serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
)),
AppEvent::EquipmentStartCommandSent {
equipment_id,
unit_id,
point_id,
} => Some((
"equipment.start_command_sent",
"info",
*unit_id,
Some(*equipment_id),
None,
format!("Start command sent to equipment {}", equipment_id),
serde_json::json!({
"equipment_id": equipment_id,
"unit_id": unit_id,
"point_id": point_id
}),
)),
AppEvent::EquipmentStopCommandSent {
equipment_id,
unit_id,
point_id,
} => Some((
"equipment.stop_command_sent",
"info",
*unit_id,
Some(*equipment_id),
None,
format!("Stop command sent to equipment {}", equipment_id),
serde_json::json!({
"equipment_id": equipment_id,
"unit_id": unit_id,
"point_id": point_id
}),
)),
AppEvent::AutoControlStarted { unit_id } => Some((
"unit.auto_control_started", "info",
Some(*unit_id), None, None,
format!("Auto control started for unit {}", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::AutoControlStopped { unit_id } => Some((
"unit.auto_control_stopped", "info",
Some(*unit_id), None, None,
format!("Auto control stopped for unit {}", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::FaultLocked { unit_id, equipment_id } => Some((
"unit.fault_locked", "error",
Some(*unit_id), Some(*equipment_id), None,
format!("Unit {} fault locked by equipment {}", unit_id, equipment_id),
serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }),
)),
AppEvent::FaultAcked { unit_id } => Some((
"unit.fault_acked", "info",
Some(*unit_id), None, None,
format!("Unit {} fault acknowledged", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::CommLocked { unit_id } => Some((
"unit.comm_locked", "warn",
Some(*unit_id), None, None,
format!("Unit {} communication locked", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
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,
};
@ -387,11 +242,10 @@ async fn persist_event_if_needed(
return;
};
let inserted = sqlx::query_as::<_, crate::model::EventRecord>(
if let Err(err) = sqlx::query(
r#"
INSERT INTO event (event_type, level, unit_id, equipment_id, source_id, message, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
)
.bind(event_type)
@ -401,23 +255,12 @@ async fn persist_event_if_needed(
.bind(source_id)
.bind(message)
.bind(sqlx::types::Json(payload))
.fetch_one(pool)
.await;
match inserted {
Ok(record) => {
if let Some(ws_manager) = ws_manager {
let ws_message = crate::websocket::WsMessage::EventCreated(record);
if let Err(err) = ws_manager.send_to_public(ws_message).await {
tracing::warn!("Failed to broadcast event websocket message: {}", err);
}
}
}
Err(err) => {
.execute(pool)
.await
{
tracing::warn!("Failed to persist event: {}", err);
}
}
}
async fn process_point_new_value(
payload: crate::telemetry::PointNewValue,

View File

@ -5,12 +5,10 @@ use axum::{
Json,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use validator::Validate;
use crate::{
control::validator::{validate_manual_control, ControlAction},
util::{
pagination::{PaginatedResponse, PaginationParams},
response::ApiErr,
@ -49,71 +47,6 @@ pub async fn get_unit_list(
)))
}
pub async fn start_equipment(
State(state): State<AppState>,
Path(equipment_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
send_equipment_command(state, equipment_id, ControlAction::Start).await
}
pub async fn stop_equipment(
State(state): State<AppState>,
Path(equipment_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
send_equipment_command(state, equipment_id, ControlAction::Stop).await
}
async fn send_equipment_command(
state: AppState,
equipment_id: Uuid,
action: ControlAction,
) -> Result<impl IntoResponse, ApiErr> {
let context = validate_manual_control(&state, equipment_id, action).await?;
let pulse_ms = 300u64;
crate::control::command::send_pulse_command(
&state.connection_manager,
context.command_point.point_id,
context.command_value_type.as_ref(),
pulse_ms,
)
.await
.map_err(|e| ApiErr::Internal(e, None))?;
if state.config.simulate_plc {
crate::control::command::simulate_run_feedback(
&state,
equipment_id,
matches!(action, ControlAction::Start),
)
.await;
}
let event = match action {
ControlAction::Start => crate::event::AppEvent::EquipmentStartCommandSent {
equipment_id,
unit_id: context.unit_id,
point_id: context.command_point.point_id,
},
ControlAction::Stop => crate::event::AppEvent::EquipmentStopCommandSent {
equipment_id,
unit_id: context.unit_id,
point_id: context.command_point.point_id,
},
};
let _ = state.event_manager.send(event);
Ok(Json(json!({
"ok_msg": format!("Equipment {} command sent", action.as_str()),
"equipment_id": equipment_id,
"unit_id": context.unit_id,
"command_role": context.command_point.signal_role,
"command_point_id": context.command_point.point_id,
"pulse_ms": pulse_ms
})))
}
pub async fn get_unit(
State(state): State<AppState>,
Path(unit_id): Path<Uuid>,
@ -124,62 +57,6 @@ pub async fn get_unit(
}
}
#[derive(serde::Serialize)]
pub struct PointDetail {
#[serde(flatten)]
pub point: crate::model::Point,
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
}
#[derive(serde::Serialize)]
pub struct EquipmentDetail {
#[serde(flatten)]
pub equipment: crate::model::Equipment,
pub points: Vec<PointDetail>,
}
#[derive(serde::Serialize)]
pub struct UnitDetail {
#[serde(flatten)]
pub unit: crate::model::ControlUnit,
pub equipments: Vec<EquipmentDetail>,
}
pub async fn get_unit_detail(
State(state): State<AppState>,
Path(unit_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
let unit = crate::service::get_unit_by_id(&state.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
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 all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?;
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
let equipments = equipments
.into_iter()
.map(|eq| {
let points = all_points
.iter()
.filter(|p| p.equipment_id == Some(eq.id))
.map(|p| PointDetail {
point_monitor: monitor_guard.get(&p.id).cloned(),
point: p.clone(),
})
.collect();
EquipmentDetail { equipment: eq, points }
})
.collect();
Ok(Json(UnitDetail { unit, equipments }))
}
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUnitReq {
#[validate(length(min = 1, max = 100))]
@ -369,140 +246,3 @@ pub async fn get_event_list(
query.pagination.page_size,
)))
}
pub async fn start_auto_unit(
State(state): State<AppState>,
Path(unit_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
let unit = crate::service::get_unit_by_id(&state.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
if !unit.enabled {
return Err(ApiErr::BadRequest("Unit is disabled".to_string(), None));
}
let mut runtime = state.control_runtime.get_or_init(unit_id).await;
runtime.auto_enabled = true;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
state.control_runtime.upsert(runtime).await;
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStarted { unit_id });
Ok(Json(json!({ "ok_msg": "Auto control started", "unit_id": unit_id })))
}
pub async fn stop_auto_unit(
State(state): State<AppState>,
Path(unit_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
crate::service::get_unit_by_id(&state.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let mut runtime = state.control_runtime.get_or_init(unit_id).await;
runtime.auto_enabled = false;
state.control_runtime.upsert(runtime).await;
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStopped { unit_id });
Ok(Json(json!({ "ok_msg": "Auto control stopped", "unit_id": unit_id })))
}
pub async fn batch_start_auto(
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiErr> {
let units = crate::service::get_all_enabled_units(&state.pool).await?;
let mut started = Vec::new();
let mut skipped = Vec::new();
for unit in units {
let mut runtime = state.control_runtime.get_or_init(unit.id).await;
if runtime.auto_enabled {
skipped.push(unit.id);
continue;
}
if runtime.fault_locked || runtime.comm_locked {
skipped.push(unit.id);
continue;
}
runtime.auto_enabled = true;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
state.control_runtime.upsert(runtime).await;
let _ = state
.event_manager
.send(crate::event::AppEvent::AutoControlStarted { unit_id: unit.id });
started.push(unit.id);
}
Ok(Json(json!({ "started": started, "skipped": skipped })))
}
pub async fn batch_stop_auto(
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiErr> {
let units = crate::service::get_all_enabled_units(&state.pool).await?;
let mut stopped = Vec::new();
for unit in units {
let mut runtime = state.control_runtime.get_or_init(unit.id).await;
if !runtime.auto_enabled {
continue;
}
runtime.auto_enabled = false;
state.control_runtime.upsert(runtime).await;
let _ = state
.event_manager
.send(crate::event::AppEvent::AutoControlStopped { unit_id: unit.id });
stopped.push(unit.id);
}
Ok(Json(json!({ "stopped": stopped })))
}
pub async fn ack_fault_unit(
State(state): State<AppState>,
Path(unit_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
crate::service::get_unit_by_id(&state.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let mut runtime = state.control_runtime.get_or_init(unit_id).await;
if !runtime.fault_locked {
return Err(ApiErr::BadRequest(
"Unit is not fault locked".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
if runtime.flt_active {
return Err(ApiErr::BadRequest(
"FLT is still active, cannot acknowledge".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
runtime.fault_locked = false;
runtime.manual_ack_required = false;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
state.control_runtime.upsert(runtime).await;
let _ = state.event_manager.send(crate::event::AppEvent::FaultAcked { unit_id });
Ok(Json(json!({ "ok_msg": "Fault acknowledged", "unit_id": unit_id })))
}
pub async fn get_unit_runtime(
State(state): State<AppState>,
Path(unit_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
crate::service::get_unit_by_id(&state.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let runtime = state.control_runtime.get_or_init(unit_id).await;
Ok(Json(runtime))
}

View File

@ -5,7 +5,6 @@ use axum::{
Json,
};
use serde::{Deserialize, Serialize};
use serde_with::rust::double_option;
use sqlx::{QueryBuilder, Row};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
@ -146,16 +145,11 @@ pub async fn get_point_history(
#[derive(Deserialize, Validate)]
pub struct UpdatePointReq {
pub name: Option<String>,
#[serde(default, with = "double_option")]
pub description: Option<Option<String>>,
#[serde(default, with = "double_option")]
pub unit: Option<Option<String>>,
#[serde(default, with = "double_option")]
pub tag_id: Option<Option<Uuid>>,
#[serde(default, with = "double_option")]
pub equipment_id: Option<Option<Uuid>>,
#[serde(default, with = "double_option")]
pub signal_role: Option<Option<String>>,
pub description: Option<String>,
pub unit: Option<String>,
pub tag_id: Option<Uuid>,
pub equipment_id: Option<Uuid>,
pub signal_role: Option<String>,
}
/// Request payload for batch setting point tags.
@ -193,7 +187,7 @@ pub async fn update_point(
}
// If tag_id is provided, ensure tag exists.
if let Some(Some(tag_id)) = payload.tag_id {
if let Some(tag_id) = payload.tag_id {
let tag_exists = sqlx::query(r#"SELECT 1 FROM tag WHERE id = $1"#)
.bind(tag_id)
.fetch_optional(pool)
@ -205,7 +199,7 @@ pub async fn update_point(
}
}
if let Some(Some(equipment_id)) = payload.equipment_id {
if let Some(equipment_id) = payload.equipment_id {
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
.bind(equipment_id)
.fetch_optional(pool)
@ -226,56 +220,29 @@ pub async fn update_point(
return Err(ApiErr::NotFound("Point not found".to_string(), None));
}
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("UPDATE point SET ");
let mut wrote_field = false;
let mut qb = QueryBuilder::new("UPDATE point SET ");
let mut sep = qb.separated(", ");
if let Some(name) = &payload.name {
if wrote_field {
qb.push(", ");
}
qb.push("name = ").push_bind(name);
wrote_field = true;
sep.push("name = ").push_bind(name);
}
if let Some(description) = &payload.description {
if wrote_field {
qb.push(", ");
}
qb.push("description = ").push_bind(description.as_deref());
wrote_field = true;
sep.push("description = ").push_bind(description);
}
if let Some(unit) = &payload.unit {
if wrote_field {
qb.push(", ");
}
qb.push("unit = ").push_bind(unit.as_deref());
wrote_field = true;
sep.push("unit = ").push_bind(unit);
}
if let Some(tag_id) = &payload.tag_id {
if wrote_field {
qb.push(", ");
}
qb.push("tag_id = ").push_bind(tag_id.as_ref());
wrote_field = true;
sep.push("tag_id = ").push_bind(tag_id);
}
if let Some(equipment_id) = &payload.equipment_id {
if wrote_field {
qb.push(", ");
}
qb.push("equipment_id = ").push_bind(equipment_id.as_ref());
wrote_field = true;
sep.push("equipment_id = ").push_bind(equipment_id);
}
if let Some(signal_role) = &payload.signal_role {
if wrote_field {
qb.push(", ");
}
qb.push("signal_role = ").push_bind(signal_role.as_deref());
wrote_field = true;
sep.push("signal_role = ").push_bind(signal_role);
}
if wrote_field {
qb.push(", ");
}
qb.push("updated_at = NOW()");
sep.push("updated_at = NOW()");
qb.push(" WHERE id = ").push_bind(point_id);
qb.build().execute(pool).await?;

View File

@ -1,4 +1,3 @@
mod control;
mod config;
mod connection;
mod db;
@ -11,7 +10,7 @@ mod telemetry;
mod util;
mod websocket;
use axum::{
routing::{get, post, put},
routing::{get, put},
Router,
};
use config::AppConfig;
@ -21,19 +20,9 @@ use event::EventManager;
use middleware::simple_logger;
use std::sync::Arc;
use tokio::sync::mpsc;
use axum::{extract::Request, middleware::Next, response::Response};
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
async fn no_cache(req: Request, next: Next) -> Response {
let mut res = next.run(req).await;
res.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
res
}
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
@ -41,7 +30,6 @@ pub struct AppState {
pub connection_manager: Arc<ConnectionManager>,
pub event_manager: Arc<EventManager>,
pub ws_manager: Arc<websocket::WebSocketManager>,
pub control_runtime: Arc<control::runtime::ControlRuntimeStore>,
}
#[tokio::main]
async fn main() {
@ -64,7 +52,6 @@ async fn main() {
connection_manager.set_pool_and_start_reconnect_task(Arc::new(pool.clone()));
let connection_manager = Arc::new(connection_manager);
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
// Connect to all enabled sources concurrently
let sources = service::get_all_enabled_sources(&pool)
@ -101,9 +88,7 @@ async fn main() {
connection_manager: connection_manager.clone(),
event_manager,
ws_manager,
control_runtime: control_runtime.clone(),
};
control::engine::start(state.clone(), control_runtime);
let app = build_router(state.clone());
let addr = format!("{}:{}", config.server_host, config.server_port);
tracing::info!("Starting server at http://{}", addr);
@ -219,42 +204,6 @@ fn build_router(state: AppState) -> Router {
"/api/event",
get(handler::control::get_event_list),
)
.route(
"/api/control/equipment/{equipment_id}/start",
post(handler::control::start_equipment),
)
.route(
"/api/control/equipment/{equipment_id}/stop",
post(handler::control::stop_equipment),
)
.route(
"/api/control/unit/{unit_id}/start-auto",
post(handler::control::start_auto_unit),
)
.route(
"/api/control/unit/{unit_id}/stop-auto",
post(handler::control::stop_auto_unit),
)
.route(
"/api/control/unit/batch-start-auto",
post(handler::control::batch_start_auto),
)
.route(
"/api/control/unit/batch-stop-auto",
post(handler::control::batch_stop_auto),
)
.route(
"/api/control/unit/{unit_id}/ack-fault",
post(handler::control::ack_fault_unit),
)
.route(
"/api/unit/{unit_id}/runtime",
get(handler::control::get_unit_runtime),
)
.route(
"/api/unit/{unit_id}/detail",
get(handler::control::get_unit_detail),
)
.route(
"/api/tag",
get(handler::tag::get_tag_list).post(handler::tag::create_tag),
@ -281,11 +230,9 @@ fn build_router(state: AppState) -> Router {
Router::new()
.merge(all_route)
.nest(
.nest_service(
"/ui",
Router::new()
.fallback_service(ServeDir::new("web").append_index_html_on_directories(true))
.layer(axum::middleware::from_fn(no_cache)),
ServeDir::new("web").append_index_html_on_directories(true),
)
.route("/ws/public", get(websocket::public_websocket_handler))
.route(

View File

@ -86,7 +86,7 @@ pub struct Node {
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[derive(Debug, Serialize, Deserialize, FromRow)]
#[allow(dead_code)]
pub struct Point {
pub id: Uuid,

View File

@ -1,13 +1,7 @@
use crate::model::{ControlUnit, EventRecord};
use sqlx::{PgPool, QueryBuilder, Row};
use sqlx::{PgPool, QueryBuilder};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct EquipmentRolePoint {
pub point_id: Uuid,
pub signal_role: String,
}
pub async fn get_units_count(pool: &PgPool, keyword: Option<&str>) -> Result<i64, sqlx::Error> {
match keyword {
Some(keyword) => {
@ -307,67 +301,3 @@ pub async fn get_events_paginated(
qb.build_query_as::<EventRecord>().fetch_all(pool).await
}
pub async fn get_all_enabled_units(pool: &PgPool) -> Result<Vec<ControlUnit>, sqlx::Error> {
sqlx::query_as::<_, ControlUnit>(
r#"SELECT * FROM unit WHERE enabled = TRUE ORDER BY created_at"#,
)
.fetch_all(pool)
.await
}
pub async fn get_equipment_by_unit_id(
pool: &PgPool,
unit_id: Uuid,
) -> Result<Vec<crate::model::Equipment>, sqlx::Error> {
sqlx::query_as::<_, crate::model::Equipment>(
r#"SELECT * FROM equipment WHERE unit_id = $1 ORDER BY created_at"#,
)
.bind(unit_id)
.fetch_all(pool)
.await
}
pub async fn get_points_by_equipment_ids(
pool: &PgPool,
equipment_ids: &[Uuid],
) -> Result<Vec<crate::model::Point>, sqlx::Error> {
if equipment_ids.is_empty() {
return Ok(vec![]);
}
sqlx::query_as::<_, crate::model::Point>(
r#"SELECT * FROM point WHERE equipment_id = ANY($1) ORDER BY equipment_id, created_at"#,
)
.bind(equipment_ids)
.fetch_all(pool)
.await
}
pub async fn get_equipment_role_points(
pool: &PgPool,
equipment_id: Uuid,
) -> Result<Vec<EquipmentRolePoint>, sqlx::Error> {
let rows = sqlx::query(
r#"
SELECT
p.id AS point_id,
p.signal_role
FROM equipment e
INNER JOIN point p ON p.equipment_id = e.id
WHERE e.id = $1
AND p.signal_role IS NOT NULL
ORDER BY p.created_at
"#,
)
.bind(equipment_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| EquipmentRolePoint {
point_id: row.get("point_id"),
signal_role: row.get("signal_role"),
})
.collect())
}

View File

@ -17,8 +17,6 @@ use uuid::Uuid;
pub enum WsMessage {
PointNewValue(crate::telemetry::PointMonitorInfo),
PointSetValueBatchResult(crate::connection::BatchSetPointValueRes),
EventCreated(crate::model::EventRecord),
UnitRuntimeChanged(crate::control::runtime::UnitRuntime),
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -1,5 +1,5 @@
<div class="drawer-backdrop hidden" id="apiDocDrawer">
<aside class="drawer api-drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
<div class="drawer-head">
<h3 id="apiDocTitle">API.md</h3>
<button type="button" class="secondary" id="closeApiDoc">关闭</button>

View File

@ -1,6 +0,0 @@
<section class="panel bottom-mid">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</section>

View File

@ -1,7 +1,18 @@
<section class="panel ops-bottom">
<section class="panel bottom-middle">
<div class="stack-panel">
<div class="stack-section event-section">
<div class="panel-head">
<h2>系统事件</h2>
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
</div>
<div class="list event-list" id="eventList"></div>
</div>
<div class="stack-section stack-section-bordered log-section">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</div>
</div>
</section>

View File

@ -72,7 +72,7 @@
</label>
<label>
类型
<select id="equipmentKind"></select>
<input id="equipmentKind" placeholder="coal_feeder / distributor" />
</label>
<label>
说明

View File

@ -1,17 +0,0 @@
<section class="panel ops-main">
<div class="ops-layout">
<aside class="ops-unit-sidebar">
<div class="panel-head">
<h2>控制单元</h2>
<div class="ops-batch-actions">
<button type="button" class="secondary" id="batchStartAutoBtn" title="启动所有未锁定单元的自动控制">全部启动</button>
<button type="button" class="danger" id="batchStopAutoBtn" title="停止所有单元的自动控制">全部停止</button>
</div>
</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>

View File

@ -1,15 +1,8 @@
<header class="topbar">
<div class="title">PLC Control</div>
<div class="tab-bar">
<button type="button" class="tab-btn active" id="tabOps">运维</button>
<button type="button" class="tab-btn" id="tabConfig">配置</button>
</div>
<div class="topbar-actions">
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
<button type="button" class="secondary" id="openApiDoc">API.md</button>
<div class="status" id="statusText">
<span class="ws-dot" id="wsDot"></span>
<span id="wsLabel">连接中…</span>
</div>
<div class="status" id="statusText">Ready</div>
</div>
</header>

View File

@ -4,17 +4,15 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PLC Control</title>
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
<link rel="stylesheet" href="/ui/styles.css?v=20260323e" />
</head>
<body>
<div data-partial="/ui/html/topbar.html"></div>
<main class="grid-ops">
<div data-partial="/ui/html/ops-panel.html"></div>
<main class="grid">
<div data-partial="/ui/html/equipment-panel.html"></div>
<div data-partial="/ui/html/points-panel.html"></div>
<div data-partial="/ui/html/source-panel.html"></div>
<div data-partial="/ui/html/log-stream-panel.html"></div>
<div data-partial="/ui/html/logs-panel.html"></div>
<div data-partial="/ui/html/chart-panel.html"></div>
</main>
@ -22,6 +20,6 @@
<div data-partial="/ui/html/modals.html"></div>
<div data-partial="/ui/html/api-doc-drawer.html"></div>
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
<script type="module" src="/ui/js/index.js?v=20260323f"></script>
</body>
</html>

View File

@ -4,58 +4,6 @@ export function setStatus(text) {
dom.statusText.textContent = text;
}
// ── Toast ─────────────────────────────────────────
function getContainer() {
let el = document.getElementById("toast-container");
if (!el) {
el = document.createElement("div");
el.id = "toast-container";
document.body.appendChild(el);
}
return el;
}
const ICONS = { error: "✕", warning: "!", success: "✓", info: "i" };
/**
* 显示 toast 通知
* @param {string} title 主要文字
* @param {object} [opts]
* @param {string} [opts.message] 次要说明文字
* @param {"error"|"warning"|"success"|"info"} [opts.level="error"]
* @param {number} [opts.duration=4000] 自动关闭毫秒数0 表示不自动关闭
* @param {boolean} [opts.shake=false] 出现时加抖动动画
* @returns {{ dismiss: () => void }}
*/
export function showToast(title, { message, level = "error", duration = 4000, shake = false } = {}) {
const container = getContainer();
const el = document.createElement("div");
el.className = `toast ${level}${shake ? " shake" : ""}`;
el.innerHTML = `
<span class="toast-icon">${ICONS[level] ?? "i"}</span>
<div class="toast-body">
<div class="toast-title">${title}</div>
${message ? `<div class="toast-message">${message}</div>` : ""}
</div>`;
const dismiss = () => {
if (!el.parentNode) return;
el.classList.remove("shake");
el.classList.add("hiding");
el.addEventListener("animationend", () => el.remove(), { once: true });
};
el.addEventListener("click", dismiss);
container.appendChild(el);
if (duration > 0) setTimeout(dismiss, duration);
return { dismiss };
}
// ── apiFetch ──────────────────────────────────────
export async function apiFetch(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
@ -63,9 +11,7 @@ export async function apiFetch(url, options = {}) {
});
if (!response.ok) {
const text = (await response.text()) || response.statusText;
showToast(`请求失败 ${response.status}`, { message: text });
throw new Error(text);
throw new Error((await response.text()) || response.statusText);
}
if (response.status === 204) {

View File

@ -14,8 +14,7 @@ import {
resetEquipmentForm,
saveEquipment,
} from "./equipment.js";
import { startPointSocket, startLogs, stopLogs } from "./logs.js";
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
import { startLogs, startPointSocket } from "./logs.js";
import {
clearBatchBinding,
browseAndLoadTree,
@ -35,36 +34,6 @@ import { state } from "./state.js";
import { loadSources, saveSource } from "./sources.js";
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.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");
// config-only panels
["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => {
const el = main.querySelector(`.panel.${cls}`);
if (el) el.classList.toggle("hidden", view === "ops");
});
// bottom-mid is log-stream in config, hidden in ops
const logStreamPanel = main.querySelector(".panel.bottom-mid");
if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view === "ops");
// ops-only panels
const opsMain = main.querySelector(".panel.ops-main");
const opsBottom = main.querySelector(".panel.ops-bottom");
if (opsMain) opsMain.classList.toggle("hidden", view === "config");
if (opsBottom) opsBottom.classList.toggle("hidden", view === "config");
if (view === "config") {
startLogs();
} else {
stopLogs();
}
}
function bindEvents() {
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
@ -156,31 +125,21 @@ function bindEvents() {
}
});
dom.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabConfig.addEventListener("click", () => switchView("config"));
document.addEventListener("equipments-updated", () => {
renderUnits();
renderOpsUnits();
});
document.addEventListener("units-loaded", () => {
renderOpsUnits();
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
});
}
async function bootstrap() {
bindEvents();
switchView("ops");
renderSelectedNodes();
updateSelectedPointSummary();
updatePointFilterSummary();
renderChart();
startLogs();
startPointSocket();
await withStatus(loadUnits());
startOps();
await withStatus(loadSources());
await withStatus(loadEquipments());
await withStatus(loadEvents());

View File

@ -103,11 +103,10 @@ export async function loadApiDoc() {
if (!id) {
return;
}
const target = dom.apiDocContent.querySelector(`#${CSS.escape(id)}`);
if (target) {
const offset = target.getBoundingClientRect().top - dom.apiDocContent.getBoundingClientRect().top;
dom.apiDocContent.scrollBy({ top: offset, behavior: "smooth" });
}
dom.apiDocContent.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
});

View File

@ -2,15 +2,6 @@ const byId = (id) => document.getElementById(id);
export const dom = {
statusText: byId("statusText"),
wsDot: byId("wsDot"),
wsLabel: byId("wsLabel"),
batchStartAutoBtn: byId("batchStartAutoBtn"),
batchStopAutoBtn: byId("batchStopAutoBtn"),
tabOps: byId("tabOps"),
tabConfig: byId("tabConfig"),
opsUnitList: byId("opsUnitList"),
opsEquipmentArea: byId("opsEquipmentArea"),
logView: byId("logView"),
sourceList: byId("sourceList"),
unitList: byId("unitList"),
eventList: byId("eventList"),
@ -24,6 +15,7 @@ export const dom = {
pointSourceSelect: byId("pointSourceSelect"),
pointSourceNodeCount: byId("pointSourceNodeCount"),
openPointModalBtn: byId("openPointModal"),
logView: byId("logView"),
chartCanvas: byId("chartCanvas"),
chartTitle: byId("chartTitle"),
chartSummary: byId("chartSummary"),

View File

@ -1,6 +1,6 @@
import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { renderEquipmentKindOptions, renderRoleOptions } from "./roles.js";
import { renderRoleOptions } from "./roles.js";
import { clearSelectedPoints, loadPoints, updatePointFilterSummary } from "./points.js";
import { state } from "./state.js";
@ -81,7 +81,6 @@ export function resetEquipmentForm() {
dom.equipmentForm.reset();
dom.equipmentId.value = "";
renderEquipmentUnitOptions("");
dom.equipmentKind.innerHTML = renderEquipmentKindOptions("");
}
function openEquipmentModal() {
@ -94,9 +93,6 @@ export function closeEquipmentModal() {
export function openCreateEquipmentModal() {
resetEquipmentForm();
if (state.selectedUnitId && dom.equipmentUnitId) {
dom.equipmentUnitId.value = state.selectedUnitId;
}
openEquipmentModal();
}
@ -105,7 +101,7 @@ function openEditEquipmentModal(equipment) {
dom.equipmentUnitId.value = equipment.unit_id || "";
dom.equipmentCode.value = equipment.code || "";
dom.equipmentName.value = equipment.name || "";
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(equipment.kind || "");
dom.equipmentKind.value = equipment.kind || "";
dom.equipmentDescription.value = equipment.description || "";
openEquipmentModal();
}
@ -213,29 +209,6 @@ export function renderEquipments() {
});
actionRow.append(editBtn, deleteBtn);
if (equipment.kind === "coal_feeder" || equipment.kind === "distributor") {
const startBtn = document.createElement("button");
startBtn.className = "secondary";
startBtn.textContent = "Start";
startBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/equipment/${equipment.id}/start`, { method: "POST" })
.catch(() => {});
});
const stopBtn = document.createElement("button");
stopBtn.className = "danger";
stopBtn.textContent = "Stop";
stopBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/equipment/${equipment.id}/stop`, { method: "POST" })
.catch(() => {});
});
actionRow.append(startBtn, stopBtn);
}
dom.equipmentList.appendChild(box);
});
}
@ -262,7 +235,6 @@ export async function loadEquipments() {
renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
renderBatchUnitOptions(dom.equipmentBatchUnitId?.value || "");
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(dom.equipmentKind?.value || "");
renderBindingEquipmentOptions();
renderBatchBindingDefaults();
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {

View File

@ -2,80 +2,47 @@ import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { state } from "./state.js";
const PAGE_SIZE = 10;
let _page = 1;
let _hasMore = false;
let _loading = false;
function formatTime(value) {
return value || "--";
if (!value) {
return "--";
}
return value;
}
function makeCard(item) {
const row = document.createElement("div");
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>`;
return row;
}
async function loadMore() {
if (_loading || !_hasMore) return;
_loading = true;
const params = new URLSearchParams({ page: String(_page), page_size: String(PAGE_SIZE) });
if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId);
try {
const response = await apiFetch(`/api/event?${params.toString()}`);
const items = response.data || [];
items.forEach((item) => dom.eventList.appendChild(makeCard(item)));
_hasMore = items.length === PAGE_SIZE;
_page += 1;
} finally {
_loading = false;
}
}
export async function loadEvents() {
_page = 1;
_hasMore = false;
_loading = false;
export function renderEvents() {
dom.eventList.innerHTML = "";
const params = new URLSearchParams({ page: "1", page_size: String(PAGE_SIZE) });
if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId);
_loading = true;
try {
const response = await apiFetch(`/api/event?${params.toString()}`);
const items = response.data || [];
if (!items.length) {
if (!state.events.length) {
dom.eventList.innerHTML = '<div class="list-item"><div class="muted">暂无事件</div></div>';
return;
}
items.forEach((item) => dom.eventList.appendChild(makeCard(item)));
_hasMore = items.length === PAGE_SIZE;
_page = 2;
} finally {
_loading = false;
}
}
export function prependEvent(item) {
if (state.selectedUnitId && item.unit_id !== state.selectedUnitId) return;
const placeholder = dom.eventList.querySelector(".list-item");
if (placeholder) placeholder.remove();
dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild);
}
dom.eventList.addEventListener("scroll", () => {
const el = dom.eventList;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) {
loadMore();
}
state.events.forEach((item) => {
const row = document.createElement("div");
row.className = "list-item event-card";
row.innerHTML = `
<div class="row">
<strong>${item.event_type}</strong>
<span class="badge">${(item.level || "info").toUpperCase()}</span>
</div>
<div>${item.message}</div>
<div class="muted">${formatTime(item.created_at)}</div>
`;
dom.eventList.appendChild(row);
});
}
export async function loadEvents() {
const params = new URLSearchParams({
page: "1",
page_size: "20",
});
if (state.selectedUnitId) {
params.set("unit_id", state.selectedUnitId);
}
const response = await apiFetch(`/api/event?${params.toString()}`);
state.events = response.data || [];
renderEvents();
}

View File

@ -1,26 +1,33 @@
import { appendChartPoint } from "./chart.js";
import { dom } from "./dom.js";
import { prependEvent } from "./events.js";
import { formatValue } from "./points.js";
import { state } from "./state.js";
import { renderUnits } from "./units.js";
import { showToast } from "./api.js";
function escapeHtml(text) {
return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function parseLogLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try { return JSON.parse(trimmed); } catch { return null; }
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
export function appendLog(line) {
if (!dom.logView) return;
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
const div = document.createElement("div");
const parsed = parseLogLine(line);
if (!parsed) {
div.className = "log-line";
div.textContent = line;
@ -32,51 +39,31 @@ export function appendLog(line) {
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
`<span class="message">${escapeHtml(
parsed.fields?.message || parsed.message || parsed.msg || line,
)}</span>`,
].join("");
}
dom.logView.appendChild(div);
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
if (atBottom) {
dom.logView.scrollTop = dom.logView.scrollHeight;
}
}
export function startLogs() {
if (state.logSource) return;
if (state.logSource) {
state.logSource.close();
}
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;
}
}
let _disconnectToast = null;
function setWsStatus(connected) {
if (dom.wsDot) {
dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`;
}
if (dom.wsLabel) {
dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…";
}
if (!connected && !_disconnectToast) {
_disconnectToast = showToast("后端连接断开", {
message: "正在重连,请稍候…",
level: "error",
duration: 0,
shake: true,
state.logSource.addEventListener("error", () => {
appendLog("[log stream error]");
});
} else if (connected && _disconnectToast) {
_disconnectToast.dismiss();
_disconnectToast = null;
showToast("连接已恢复", { level: "success", duration: 3000 });
}
}
export function startPointSocket() {
@ -84,15 +71,14 @@ export function startPointSocket() {
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
state.pointSocket = ws;
ws.onopen = () => setWsStatus(true);
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
const data = payload.data;
if (payload.type !== "PointNewValue" && payload.type !== "point_new_value") {
return;
}
// config view point table
const data = payload.data;
const entry = state.pointEls.get(data.point_id);
if (entry) {
entry.value.textContent = formatValue(data);
@ -101,44 +87,15 @@ export function startPointSocket() {
entry.time.textContent = data.timestamp || "--";
}
// ops view signal cell
const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) {
opsEntry.valueEl.textContent = formatValue(data);
opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase();
}
if (state.chartPointId === data.point_id) {
appendChartPoint(data);
}
return;
}
if (payload.type === "EventCreated" || payload.type === "event_created") {
prependEvent(payload.data);
}
if (payload.type === "UnitRuntimeChanged") {
const runtime = payload.data;
state.runtimes.set(runtime.unit_id, runtime);
renderUnits();
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => {
renderOpsUnits();
syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled);
});
return;
}
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
setWsStatus(false);
window.setTimeout(startPointSocket, 2000);
};
ws.onerror = () => setWsStatus(false);
}

View File

@ -1,218 +0,0 @@
import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { formatValue } from "./points.js";
import { state } from "./state.js";
import { loadUnits } from "./units.js";
const SIGNAL_ROLES = ["rem", "run", "flt"];
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
function runtimeBadge(runtime) {
if (!runtime) return '<span class="badge offline">OFFLINE</span>';
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
const labels = { stopped: "STOPPED", running: "RUNNING", distributor_running: "DIST RUN", fault_locked: "FAULT", comm_locked: "COMM ERR" };
const cls = { stopped: "", running: "online", distributor_running: "online", fault_locked: "danger", comm_locked: "offline" };
return `<span class="badge ${cls[runtime.state] ?? ""}">${labels[runtime.state] ?? runtime.state}</span>`;
}
export function renderOpsUnits() {
if (!dom.opsUnitList) return;
dom.opsUnitList.innerHTML = "";
if (!state.units.length) {
dom.opsUnitList.innerHTML = '<div class="muted" style="padding:12px">暂无控制单元</div>';
return;
}
state.units.forEach((unit) => {
const runtime = state.runtimes.get(unit.id);
const item = document.createElement("div");
item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`;
item.innerHTML = `
<div class="ops-unit-item-name">${unit.code} / ${unit.name}</div>
<div class="ops-unit-item-meta">
${runtimeBadge(runtime)}
<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>` : ""}
</div>
<div class="ops-unit-item-actions"></div>
`;
item.addEventListener("click", () => selectOpsUnit(unit.id));
const actions = item.querySelector(".ops-unit-item-actions");
const isAutoOn = runtime?.auto_enabled;
const autoBtn = document.createElement("button");
autoBtn.className = isAutoOn ? "danger" : "secondary";
autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto";
autoBtn.title = isAutoOn ? "停止自动控制" : "启动自动控制";
autoBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`, { method: "POST" })
.then(() => loadUnits()).catch(() => {});
});
actions.append(autoBtn);
if (runtime?.manual_ack_required) {
const ackBtn = document.createElement("button");
ackBtn.className = "danger";
ackBtn.textContent = "Ack Fault";
ackBtn.title = "人工确认解除故障锁定";
ackBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/unit/${unit.id}/ack-fault`, { method: "POST" })
.then(() => loadUnits()).catch(() => {});
});
actions.append(ackBtn);
}
dom.opsUnitList.appendChild(item);
});
}
async function selectOpsUnit(unitId) {
state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId;
renderOpsUnits();
if (!state.selectedOpsUnitId) {
await loadAllEquipmentCards();
return;
}
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
state.opsPointEls.clear();
const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`);
renderOpsEquipments(detail.equipments || []);
}
export async function loadAllEquipmentCards() {
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();
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) {
dom.opsEquipmentArea.innerHTML = "";
if (!equipments.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
return;
}
equipments.forEach((eq) => {
const card = document.createElement("div");
card.className = "ops-eq-card";
// Build role → point map
const roleMap = {};
(eq.points || []).forEach((p) => {
if (p.signal_role) roleMap[p.signal_role] = p;
});
// Signal rows HTML (placeholders; WS will fill values)
const signalRowsHtml = SIGNAL_ROLES.map((role) => {
const point = roleMap[role];
if (!point) return "";
return `
<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("");
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
card.innerHTML = `
<div class="ops-eq-card-head">
<strong title="${eq.name}">${eq.code}</strong>
<span class="badge">${eq.kind || "--"}</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>` : ""}
`;
if (canControl) {
const actions = card.querySelector(".ops-eq-card-actions");
const autoOn = !!(eq.unit_id && state.runtimes.get(eq.unit_id)?.auto_enabled);
const startBtn = document.createElement("button");
startBtn.className = "secondary";
startBtn.textContent = "Start";
startBtn.disabled = autoOn;
startBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
startBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
);
const stopBtn = document.createElement("button");
stopBtn.className = "danger";
stopBtn.textContent = "Stop";
stopBtn.disabled = autoOn;
stopBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
stopBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
);
actions.append(startBtn, stopBtn);
}
dom.opsEquipmentArea.appendChild(card);
// Register DOM elements for WS updates, then seed from cached monitor data
SIGNAL_ROLES.forEach((role) => {
const point = roleMap[role];
if (!point) return;
const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`);
const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`);
if (valueEl && qualityEl) {
state.opsPointEls.set(point.id, { valueEl, qualityEl });
if (point.point_monitor) {
const m = point.point_monitor;
valueEl.textContent = formatValue(m);
qualityEl.className = `badge quality-${(m.quality || "unknown").toLowerCase()}`;
qualityEl.textContent = (m.quality || "unknown").toUpperCase();
}
}
});
});
}
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(() => {});
});
}
/** Called by WS handler when a unit's runtime changes — syncs manual button disabled state. */
export function syncEquipmentButtonsForUnit(unitId, autoEnabled) {
if (!dom.opsEquipmentArea) return;
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

@ -10,7 +10,7 @@ import { renderRoleOptions } from "./roles.js";
import { state } from "./state.js";
function updatePointSourceNodeCount() {
const count = dom.nodeTree.querySelectorAll("details").length;
const count = dom.nodeTree.querySelectorAll("details, summary").length;
dom.pointSourceNodeCount.textContent = `Nodes: ${count}`;
}

View File

@ -1,17 +1,19 @@
export const SIGNAL_ROLE_OPTIONS = [
{ value: "", label: "Unset" },
{ value: "rem", label: "REM Remote Enable" },
{ value: "run", label: "RUN Running" },
{ value: "flt", label: "FLT Fault" },
{ value: "ii", label: "II Current" },
{ value: "remote_status", label: "Remote Status" },
{ value: "run_status", label: "Run Status" },
{ value: "fault_status", label: "Fault Status" },
{ value: "ready_status", label: "Ready Status" },
{ value: "alarm_status", label: "Alarm Status" },
{ value: "interlock_status", label: "Interlock Status" },
{ value: "auto_enable", label: "Auto Enable" },
{ value: "mode_auto", label: "Auto Mode" },
{ value: "mode_manual", label: "Manual Mode" },
{ value: "start_cmd", label: "Start Command" },
{ value: "stop_cmd", label: "Stop Command" },
];
export const EQUIPMENT_KIND_OPTIONS = [
{ value: "", label: "Unset" },
{ value: "coal_feeder", label: "Coal Feeder" },
{ value: "distributor", label: "Distributor" },
{ value: "reset_cmd", label: "Reset Command" },
{ value: "runtime_value", label: "Runtime Value" },
{ value: "counter_value", label: "Counter Value" },
];
export function renderRoleOptions(selected = "") {
@ -20,10 +22,3 @@ export function renderRoleOptions(selected = "") {
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
}).join("");
}
export function renderEquipmentKindOptions(selected = "") {
return EQUIPMENT_KIND_OPTIONS.map((item) => {
const isSelected = item.value === selected ? "selected" : "";
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
}).join("");
}

View File

@ -18,11 +18,7 @@ export const state = {
chartPointId: null,
chartPointName: "",
chartData: [],
logSource: null,
pointSocket: null,
apiDocLoaded: false,
runtimes: new Map(), // unit_id -> UnitRuntime
activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { valueEl, qualityEl }
logSource: null,
selectedOpsUnitId: null,
};

View File

@ -74,29 +74,6 @@ async function selectUnit(unitId) {
await loadEvents();
}
function runtimeBadge(runtime) {
if (!runtime) return '<span class="badge offline">OFFLINE</span>';
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
const stateLabels = {
stopped: 'STOPPED',
running: 'RUNNING',
distributor_running: 'DIST RUN',
fault_locked: 'FAULT',
comm_locked: 'COMM ERR',
};
const stateCls = {
stopped: '',
running: 'online',
distributor_running: 'online',
fault_locked: 'danger',
comm_locked: 'offline',
};
const label = stateLabels[runtime.state] ?? runtime.state;
const cls = stateCls[runtime.state] ?? '';
return `<span class="badge ${cls}">${label}</span>`;
}
export function renderUnits() {
dom.unitList.innerHTML = "";
@ -109,15 +86,13 @@ export function renderUnits() {
const card = document.createElement("div");
const selected = state.selectedUnitId === unit.id;
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
const runtime = state.runtimes.get(unit.id);
card.innerHTML = `
<div class="row">
<strong>${unit.code}</strong>
${runtimeBadge(runtime)}
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "ENABLED" : "DISABLED"}</span>
</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)} </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>
`;
@ -149,32 +124,6 @@ export function renderUnits() {
});
actions.append(editBtn, deleteBtn);
const isAutoOn = runtime?.auto_enabled;
const autoBtn = document.createElement("button");
autoBtn.className = isAutoOn ? "danger" : "secondary";
autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto";
autoBtn.title = isAutoOn ? "停止自动控制" : "启动自动控制";
autoBtn.addEventListener("click", (e) => {
e.stopPropagation();
const url = `/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`;
apiFetch(url, { method: "POST" }).then(() => loadUnits()).catch(() => {});
});
actions.append(autoBtn);
if (runtime?.manual_ack_required) {
const ackBtn = document.createElement("button");
ackBtn.className = "danger";
ackBtn.textContent = "Ack Fault";
ackBtn.title = "人工确认解除故障锁定";
ackBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/unit/${unit.id}/ack-fault`, { method: "POST" })
.then(() => loadUnits()).catch(() => {});
});
actions.append(ackBtn);
}
dom.unitList.appendChild(card);
});
}
@ -191,7 +140,6 @@ export async function loadUnits() {
renderUnits();
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
document.dispatchEvent(new Event("units-loaded"));
}
export async function saveUnit(event) {

View File

@ -51,20 +51,7 @@ body {
.status {
font-size: 12px;
color: var(--text-3);
display: flex;
align-items: center;
gap: 5px;
}
.ws-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-3);
flex-shrink: 0;
transition: background 0.3s;
}
.ws-dot.connected { background: #22c55e; }
.ws-dot.disconnected { background: #ef4444; }
.topbar-actions {
display: flex;
@ -72,30 +59,6 @@ body {
gap: 10px;
}
/* ── Tabs ───────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 2px;
}
.tab-btn {
padding: 0 16px;
height: 28px;
font-size: 13px;
font-weight: 500;
background: transparent;
border: 1px solid var(--border);
color: var(--text-2);
cursor: pointer;
}
.tab-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.link-button {
display: inline-flex;
align-items: center;
@ -116,33 +79,19 @@ body {
/* ── Grid Layout ────────────────────────────────── */
.grid-ops,
.grid-config {
.grid {
display: grid;
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
grid-template-rows: 1fr 380px;
gap: 1px;
height: calc(100vh - var(--topbar-h));
}
.grid-config {
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
grid-template-rows: 1fr 380px;
}
.grid-ops {
grid-template-columns: 260px minmax(0, 1fr);
grid-template-rows: 1fr 260px;
}
/* config view slot assignments */
.grid-config .panel.top-left { grid-column: 1; grid-row: 1; }
.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; }
.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; }
.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; }
/* ops view slot assignments */
.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; }
.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; }
.panel.top-left { grid-column: 1; grid-row: 1; }
.panel.top-right { grid-column: 2 / 4; grid-row: 1; }
.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; }
.panel.bottom-middle { grid-column: 2; grid-row: 2; min-height: 0; }
.panel.bottom-right { grid-column: 3; grid-row: 2; min-height: 0; }
.panel {
background: var(--surface);
@ -170,150 +119,6 @@ body {
border-top: 1px solid var(--border-light);
}
/* ── Ops View ───────────────────────────────────── */
.ops-layout {
display: flex;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.ops-unit-sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.ops-unit-list {
flex: 1 1 auto;
overflow-y: auto;
}
.ops-equipment-area {
flex: 1 1 auto;
overflow: auto;
padding: 12px;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 12px;
}
.ops-placeholder {
padding: 20px;
}
/* Equipment ops card */
.ops-eq-card {
width: 220px;
border: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
gap: 0;
}
.ops-eq-card-head {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 6px;
}
.ops-eq-card-head strong {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-signal-rows {
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 3px;
}
.ops-signal-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.ops-signal-label {
width: 36px;
color: var(--text-3);
font-size: 11px;
text-transform: uppercase;
flex-shrink: 0;
}
.ops-signal-value {
flex: 1;
font-weight: 500;
}
.ops-eq-card-actions {
padding: 6px 10px 8px;
display: flex;
gap: 6px;
border-top: 1px solid var(--border-light);
}
.ops-eq-card-actions button {
flex: 1;
padding: 3px 0;
font-size: 12px;
}
/* ops unit list item */
.ops-unit-item {
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
display: flex;
flex-direction: column;
gap: 3px;
}
.ops-unit-item:hover { background: var(--accent-bg); }
.ops-unit-item.selected {
background: var(--accent-bg);
border-left: 3px solid var(--accent);
}
.ops-unit-item-name {
font-size: 13px;
font-weight: 600;
}
.ops-unit-item-meta {
font-size: 11px;
color: var(--text-3);
display: flex;
align-items: center;
gap: 6px;
}
.ops-unit-item-actions {
display: flex;
gap: 4px;
padding-top: 4px;
}
.ops-unit-item-actions button {
padding: 2px 8px;
font-size: 11px;
}
/* ── Panel Header ───────────────────────────────── */
.panel-head {
@ -323,16 +128,6 @@ body {
padding: 7px 12px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
}
.ops-batch-actions {
display: flex;
gap: 4px;
}
.ops-batch-actions button {
font-size: 11px;
padding: 2px 8px;
}
h2, h3 {
@ -790,7 +585,6 @@ button.danger:hover { background: var(--danger-hover); }
backdrop-filter: blur(2px);
}
.hidden { display: none !important; }
.modal.hidden { display: none; }
.modal-content {
@ -866,7 +660,8 @@ button.danger:hover { background: var(--danger-hover); }
max-height: 50vh;
}
.unit-list {
.unit-list,
.event-list {
padding-top: 6px;
}
@ -875,38 +670,20 @@ button.danger:hover { background: var(--danger-hover); }
}
.event-card {
padding: 4px 8px;
font-size: 12px;
border-bottom: 1px solid var(--border);
cursor: default;
}
.event-card:hover {
background: var(--surface-hover, var(--surface));
background: var(--surface);
border-color: var(--border);
}
.event-meta {
display: flex;
align-items: baseline;
gap: 6px;
.event-section {
flex-basis: 42%;
}
.event-type {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-time {
flex-shrink: 0;
font-size: 11px;
}
.event-message {
color: var(--text-muted, #888);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.log-section {
flex-basis: 58%;
}
.equipment-select-row {
@ -968,10 +745,6 @@ button.danger:hover { background: var(--danger-hover); }
grid-template-columns: 220px minmax(0, 1fr);
}
.api-drawer {
width: min(1100px, 96vw);
}
.equipment-drawer {
width: min(1120px, 96vw);
}
@ -1227,71 +1000,6 @@ button.danger:hover { background: var(--danger-hover); }
color: var(--text);
}
/* ── Toast ────────────────────────────────────────── */
#toast-container {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 9999;
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 240px;
max-width: 380px;
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--text-3);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
font-size: 13px;
color: var(--text);
pointer-events: auto;
animation: toast-in 0.15s ease;
cursor: pointer;
}
.toast.error { border-left-color: var(--danger); }
.toast.warning { border-left-color: var(--warning); }
.toast.success { border-left-color: var(--success); }
.toast-icon {
flex-shrink: 0;
font-size: 14px;
line-height: 1.5;
}
.toast-body { flex: 1; word-break: break-word; }
.toast-title { font-weight: 600; margin-bottom: 2px; }
.toast-title:only-child { margin-bottom: 0; }
.toast-message { color: var(--text-2); font-size: 12px; }
.toast.hiding { animation: toast-out 0.15s ease forwards; }
.toast.shake { animation: toast-shake 0.4s ease; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(8px); }
}
@keyframes toast-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
/* ── Scrollbar ────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
@ -1302,8 +1010,7 @@ button.danger:hover { background: var(--danger-hover); }
/* ── Responsive ───────────────────────────────────── */
@media (max-width: 900px) {
.grid-config,
.grid-ops {
.grid {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto;
height: auto;
@ -1311,9 +1018,9 @@ button.danger:hover { background: var(--danger-hover); }
body { height: auto; overflow: auto; }
.panel.top-left { min-height: 200px; }
.panel.top-right { min-height: 300px; }
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
.grid-config .panel.bottom-mid { grid-column: 1; grid-row: 4; min-height: 200px; }
.grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
.panel.bottom-middle { grid-column: 1; grid-row: 4; min-height: 200px; }
.panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
.drawer { width: 100vw; }
.drawer-body { grid-template-columns: 1fr; }
.equipment-layout { grid-template-columns: 1fr; }
@ -1322,5 +1029,3 @@ button.danger:hover { background: var(--danger-hover); }
.doc-view { padding: 0; }
.doc-card { border-left: none; border-right: none; }
}