From ef96af7f21e3f435598b1499d5f45a7d8ba430a4 Mon Sep 17 00:00:00 2001 From: qsc Date: Mon, 13 Jul 2026 00:44:09 +0800 Subject: [PATCH] qq --- cmd/server/main.go | 2 + docs/development-log.md | 81 ++ internal/api/router.go | 78 +- internal/engine/collector/engine.go | 1 + internal/engine/collector/manager.go | 12 +- internal/engine/collector/manager_test.go | 50 ++ internal/engine/writer/writer.go | 10 +- internal/model/model.go | 1 - internal/repository/postgres/store.go | 151 +++- internal/repository/tdengine/store.go | 22 + internal/service/platform/history.go | 29 +- internal/service/platform/service.go | 2 - migrations/embed.go | 3 + migrations/postgres/000001_init.up.sql | 2 +- .../000003_write_points_simplify.up.sql | 26 + web/src/api/platform.ts | 1 + web/src/components/charts/HistoryChart.vue | 451 ++++++++-- web/src/styles.css | 79 +- web/src/views/collection/CollectionView.vue | 85 +- web/src/views/device/DeviceView.vue | 37 +- web/src/views/history/HistoryView.vue | 789 +++++++++++++----- web/src/views/write-point/WritePointView.vue | 502 +++++++---- 22 files changed, 1827 insertions(+), 587 deletions(-) create mode 100644 internal/engine/collector/manager_test.go create mode 100644 migrations/postgres/000003_write_points_simplify.up.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index f665785..297f195 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -39,6 +39,8 @@ func main() { must(e) _, e = db.Exec(ctx, migrations.PostgreSQLGroups) must(e) + _, e = db.Exec(ctx, migrations.PostgreSQLWritePoints) + must(e) db.Close() pgStore, e := pg.Open(ctx, cfg.PostgresDSN) must(e) diff --git a/docs/development-log.md b/docs/development-log.md index 2f7bbb5..6843473 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -77,3 +77,84 @@ - 采集列表将“地址 / 类型”拆成“地址”和“类型”;新建/编辑表单移除有效最小值和有效最大值。 - S7 REAL 解析统一四舍五入到小数点后最多 3 位;采集质量不再执行有效范围判断。 - 真实数据库验证:分组新增、改名、删除级联均通过;删除后采集点查询总数为 0;现场 REAL 值示例 `1411.719`。 + +## 2026-07-12|设备与采集点弹窗复选框优化 + +- 设备新增弹窗的“启用设备”与采集点新增弹窗的“启用采集”“存储历史”统一为整行可点击的文字卡片控件。 +- 复选框采用隐藏原生控件 + 自定义勾选方块,提供未选中、荧光绿色选中和键盘聚焦态;保留原有 `v-model` 数据绑定,不改变接口语义。 +- 收紧通用表单输入选择器,避免复选框继承文本输入框的 `width: 100%` 导致位置异常。 +- `npm run build`:通过(Vite 仍提示 ECharts 主包超过 500 kB 的既有拆包建议)。 +- Playwright Chromium 回归:设备弹窗 1 个复选框、采集点弹窗 2 个复选框均正常渲染;设备默认勾选后点击可取消,采集点“存储历史”点击后可取消;两个弹窗无控制台错误。 +- 回归截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-device-checkbox-final.png`、`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-collection-checkbox-final.png`。 + +## 2026-07-12|数据写入分组与回读展示调整 + +- 数据写入页新增与数据采集一致的左侧分组栏,分组名称、添加、改名和删除均复用 `collection_groups` 接口;后端改为同步维护采集点和写入点的分组归属,删除分组时两类点位一起逻辑删除。 +- 写入点列表将“地址 / 类型”拆为独立列,移除回读容差列,改为展示该写入点最近一次写入日志中的 `readback_value`;无回读记录时显示“—”。 +- 写入点数据库迁移 `000003_write_points_simplify.up.sql` 合并 `enabled` 与 `write_enabled` 为单一 `write_enabled`,移除 `readback_tolerance`;迁移具备重启幂等性,旧数据先保留原有效权限再删除冗余列。 +- 写入点新增/编辑表单移除回读容差和启用字段,仅保留统一的“允许写入”自定义复选框;CSV 导入/导出同步移除冗余字段。 +- REAL 回读验证保留固定协议精度 `0.0001`,不再作为点位配置或 API 字段暴露。 +- 真实环境回归:`GET /api/v1/write-points` 返回不含 `enabled`、`readback_tolerance`,分组接口同时返回 `collection_count` 与 `write_count`;写入点 CSV 表头已更新为 `write_enabled`。 +- Playwright Chromium 1600×1000:写入页列表、共享分组筛选和新增弹窗均正常;弹窗无“启用/回读容差”,允许写入卡片可切换;控制台无 warning/error。未执行现场 PLC 写入,避免改变控制状态。 +- 回归截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-write-point-table-final.png`、`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-write-point-final.png`。 + +## 2026-07-12|三类数据页面刷新与采集点编辑修复 + +- 修复采集点编辑无法更换所属设备的问题:移除 PostgreSQL 更新语句中对 `history_started_at`、旧设备和旧数据类型的限制,编辑请求现在可以正常提交新的 `device_id`;前端保存失败时显示明确错误提示。 +- 数据采集列表在质量列后新增“更新时间”,取点位 `updated_at` 并按中文本地时间格式展示。 +- 数据采集、设备管理、数据写入均新增“刷新数据”按钮,刷新按钮执行期间禁用并显示旋转图标。 +- 数据采集刷新同时重新加载采集点分组;采集点新增/编辑/删除及 CSV 导入后刷新列表和分组;设备新增/编辑/删除及 CSV 导入后刷新列表;写入点新增/编辑/删除、执行写入及 CSV 导入后刷新列表和共享分组。 +- Playwright Chromium 1600×1000 回归:三个页面刷新按钮各 1 个;采集列表包含“更新时间”;编辑弹窗包含设备选择器;三个页面刷新交互无控制台 warning/error。现场 PLC 写入未执行。 +- 回归截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-collection-refresh-final.png`。 +- 更正“更新时间”字段:列表现在展示实时采集数据 `latest_value.ts`,不再展示配置的 `updated_at`。 + +## 2026-07-12|分组删除改为点位迁移 + +- 删除非 `default` 分组时,后端事务先将该分组下未逻辑删除的采集点和写入点统一迁移到 `default`,再删除分组记录;不再逻辑删除任何点位。 +- 事务内确保 `default` 分组存在,`default` 分组仍禁止删除。 +- 数据采集和数据写入页面的删除确认提示同步改为“自动转移到 default 分组”。 +- 开发验证:使用临时写入点执行“建组→建点→删组”回归,点位保留且 `group_name` 已迁移为 `default`,随后清理临时点;Go 测试和静态检查、前端构建均通过,未执行现场 PLC 写入。 +# 2026-07-12 · 历史数据增强与归档清理 + +## 需求分析 + +- 历史点位树支持分组折叠/展开、分组全选/取消;点位、时间范围和表格间隔变化后自动查询,保留最多 20 个点位限制。 +- 曲线横轴必须与系统时间一致并按选择范围固定起止点;无数据序列不显示;bad 质量值使用虚线;图例状态保留并控制对应曲线;游标采用非吸附、带动画的轴指示器。 +- 曲线高度提升,游标明细最多显示约 5 行并在点位较多时纵向滚动;表格点位列较多时横向滚动。 +- 已逻辑删除点位的历史子表提供显式清理入口,禁用但仍可恢复的点位不纳入清理。 + +## 技术方案与变更 + +- PostgreSQL 新增只读查询已删除采集点 UUID 的方法;TDengine 新增按 UUID 派生子表的安全删除方法,API 增加 `POST /api/v1/history/archive/cleanup`,前端增加清理确认和结果反馈。 +- TDengine REST 驱动返回的本地墙上时间改为显式重建为 `Asia/Shanghai`,避免重复施加时区偏移;表格时间列统一输出上海时区。 +- 历史页面增加自动查询防抖和请求序列保护,曲线传入选定时间范围并动态设置时间刻度;bad 数据拆分为虚线序列,且共享逻辑图例名称。 + +## 验证记录 + +- `go test ./...`:通过。 +- `npm run build`:通过;仅保留既有 ECharts 体积提示。 +- `GET /api/v1/health`:真实 PostgreSQL/TDengine 配置下返回 `code=0`。 +- Playwright Chromium 1600×1000:验证分组全选、点位变更自动曲线查询、曲线/表格切换自动查询、间隔变化自动表格查询;曲线高度 520px;控制台无错误。截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-history-curve-final.png`、`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-history-final.png`。 +- 归档清理接口真实回归时识别并清理了 13 个已逻辑删除点位的历史子表;活跃点位和仅禁用点位未清理。清理为不可逆操作,前端已增加二次确认。 + +## 2026-07-12 · 自定义时间与任意时刻游标 + +- 修复固定快捷范围切换后编辑自定义时间可能被旧范围覆盖的问题:时间输入在 `input` 和 `change` 阶段均同步,先保存被编辑的端点,再校验起止时间,允许用户分两步完成暂时不完整的自定义范围。 +- 游标改为基于鼠标在时间轴上的实际像素位置换算时间,不再使用最近存储点作为游标时间;对每条曲线按前后有效样本进行线性插值,游标表格中的所有曲线使用同一时间戳,边界使用最近有效值,质量戳为 bad 或跨越 bad 样本时保留 bad 标识。 +- 插值结果在游标表格中使用 `≈` 标记,ECharts 轴指示器继续关闭吸附并保留平滑动画;图例隐藏的曲线不参与游标结果。 + +## 2026-07-12 · 本轮验证 + +- Playwright 验证固定范围后编辑起止时间保持 `custom` 状态;鼠标移动到非存储时刻时,两条曲线游标显示相同时间 `2026/7/12 23:08:03`,无控制台错误。 + +## 2026-07-13 · 游标视觉跟随与离开区域保持 + +- 移除 ECharts 默认会吸附存储点的轴指示线,改用图表内独立的垂直游标线;鼠标像素位置先换算为时间,再用 `requestAnimationFrame` 更新线位置,避免在 ECharts 事件处理期间调用 `setOption`。 +- 游标线仅覆盖绘图区,和插值明细使用同一时间;鼠标离开曲线区域不再清空最后一次游标时间和明细数据。 +- 回归验证连续移动得到 `23:41:48`、`23:44:25`、`23:47:00`、`23:49:38` 四个不同时间点;离开图表后仍保留 2 条曲线明细,控制台无错误。 + +## 2026-07-13 · 设备状态与采集成功状态同步 + +- 根因:采集读失败后 `Manager` 将设备状态置为 `disconnected`;缓存连接后续读取成功时此前只更新点位最新数据,没有恢复设备状态,因此设备管理页持续显示离线。 +- 修复:采集成功后调用 `Manager.MarkConnected` 恢复在线状态;`MarkConnected` 与 `MarkDisconnected` 仅在状态发生变化时更新时间戳,避免每个采集周期反复刷新最近在线/离线时间。 +- 验证:真实环境中 PLC1、PLC2、PLC3 均显示 `connected`,40 个采集点的 `latest_value.ts` 持续更新;新增设备状态恢复及时间戳稳定性单元测试。 diff --git a/internal/api/router.go b/internal/api/router.go index b16aefd..d010d52 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -57,6 +57,7 @@ func NewRouter(h *Handler) *gin.Engine { v.POST("/write-points/:id/write", h.writePoint) v.GET("/write-logs", h.logs) v.GET("/history/tree", h.tree) + v.POST("/history/archive/cleanup", h.cleanupHistoryArchive) v.POST("/history/query", h.historyQuery) v.POST("/history/query-table", h.historyTable) v.POST("/history/export", h.exportHistory) @@ -182,8 +183,10 @@ func (h *Handler) points(kind string) gin.HandlerFunc { if v := c.Query("data_type"); v != "" && p.DataType != v { continue } - if v := c.Query("enabled"); v != "" && strconv.FormatBool(p.Enabled) != v { - continue + if kind == "collection" { + if v := c.Query("enabled"); v != "" && strconv.FormatBool(p.Enabled) != v { + continue + } } if kind == "write" { if v := c.Query("write_enabled"); v != "" && strconv.FormatBool(p.WriteEnabled) != v { @@ -332,9 +335,9 @@ func (h *Handler) exportConfig(kind string) gin.HandlerFunc { w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strconv.Itoa(p.CollectInterval), strings.ToUpper(strconv.FormatBool(p.StoreHistory)), strconv.Itoa(p.HistoryInterval), strings.ToUpper(strconv.FormatBool(p.Enabled))}) } } else { - w.Write([]string{"name", "group_name", "device_name", "address", "data_type", "unit", "enabled", "write_enabled", "readback_tolerance"}) + w.Write([]string{"name", "group_name", "device_name", "address", "data_type", "unit", "write_enabled"}) for _, p := range items { - w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strings.ToUpper(strconv.FormatBool(p.Enabled)), strings.ToUpper(strconv.FormatBool(p.WriteEnabled)), strconv.FormatFloat(p.ReadbackTolerance, 'g', -1, 64)}) + w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strings.ToUpper(strconv.FormatBool(p.WriteEnabled))}) } } } @@ -345,12 +348,6 @@ func stringValue(v *string) string { } return *v } -func floatPtr(v *float64) string { - if v == nil { - return "" - } - return strconv.FormatFloat(*v, 'g', -1, 64) -} func (h *Handler) importConfig(kind string) gin.HandlerFunc { return func(c *gin.Context) { file, e := c.FormFile("file") @@ -419,9 +416,13 @@ func (h *Handler) importRow(ctx context.Context, kind string, head map[string]in } existing, e := h.Platform.FindPointByName(ctx, kind, get("name")) created := platform.IsNotFound(e) - enabled, be := parseCSVBool(get("enabled"), true) - if be != nil { - return false, be + enabled := true + if kind == "collection" { + var be error + enabled, be = parseCSVBool(get("enabled"), true) + if be != nil { + return false, be + } } unit := get("unit") p := postgres.PointRow{ID: uuid.Nil, Name: get("name"), GroupName: get("group_name"), DeviceID: device.ID, Enabled: enabled, Address: get("address"), DataType: get("data_type"), Unit: &unit} @@ -434,7 +435,6 @@ func (h *Handler) importRow(ctx context.Context, kind string, head map[string]in p.HistoryInterval = parseInt(get("history_interval"), 1) } else { p.WriteEnabled, _ = parseCSVBool(get("write_enabled"), false) - p.ReadbackTolerance = parseFloat(get("readback_tolerance"), .0001) } return created, h.Platform.SavePoint(ctx, kind, &p) } @@ -448,16 +448,6 @@ func parseInt(v string, d int) int { } return n } -func parseFloat(v string, d float64) float64 { - if v == "" { - return d - } - n, e := strconv.ParseFloat(v, 64) - if e != nil { - return d - } - return n -} func parseCSVBool(v string, d bool) (bool, error) { if v == "" { return d, nil @@ -467,19 +457,18 @@ func parseCSVBool(v string, d bool) (bool, error) { func (h *Handler) savePoint(kind string, update bool) gin.HandlerFunc { return func(c *gin.Context) { var p struct { - ID uuid.UUID `json:"-"` - Name string `json:"name"` - GroupName string `json:"group_name"` - DeviceID uuid.UUID `json:"device_id"` - Enabled *bool `json:"enabled"` - WriteEnabled bool `json:"write_enabled"` - Address string `json:"address"` - DataType string `json:"data_type"` - Unit *string `json:"unit"` - CollectInterval int `json:"collect_interval"` - StoreHistory *bool `json:"store_history"` - HistoryInterval int `json:"history_interval"` - ReadbackTolerance float64 `json:"readback_tolerance"` + ID uuid.UUID `json:"-"` + Name string `json:"name"` + GroupName string `json:"group_name"` + DeviceID uuid.UUID `json:"device_id"` + Enabled *bool `json:"enabled"` + WriteEnabled bool `json:"write_enabled"` + Address string `json:"address"` + DataType string `json:"data_type"` + Unit *string `json:"unit"` + CollectInterval int `json:"collect_interval"` + StoreHistory *bool `json:"store_history"` + HistoryInterval int `json:"history_interval"` } if e := c.ShouldBindJSON(&p); e != nil { response.Error(c, 400, 41001, "参数格式错误", nil) @@ -508,10 +497,10 @@ func (h *Handler) savePoint(kind string, update bool) gin.HandlerFunc { if p.HistoryInterval == 0 { p.HistoryInterval = 1 } - if p.ReadbackTolerance == 0 { - p.ReadbackTolerance = .0001 + if kind == "write" { + enabled = false } - row := postgres.PointRow{ID: id, Name: p.Name, GroupName: p.GroupName, DeviceID: p.DeviceID, Enabled: enabled, WriteEnabled: p.WriteEnabled, Address: p.Address, DataType: p.DataType, Unit: p.Unit, CollectInterval: p.CollectInterval, StoreHistory: storeHistory, HistoryInterval: p.HistoryInterval, ReadbackTolerance: p.ReadbackTolerance} + row := postgres.PointRow{ID: id, Name: p.Name, GroupName: p.GroupName, DeviceID: p.DeviceID, Enabled: enabled, WriteEnabled: p.WriteEnabled, Address: p.Address, DataType: p.DataType, Unit: p.Unit, CollectInterval: p.CollectInterval, StoreHistory: storeHistory, HistoryInterval: p.HistoryInterval, PointKind: kind} if e := h.Platform.SavePoint(c, kind, &row); e != nil { code := 41001 if kind == "write" { @@ -619,6 +608,15 @@ func (h *Handler) tree(c *gin.Context) { response.OK(c, map[string]any{"tree": tree}) } +func (h *Handler) cleanupHistoryArchive(c *gin.Context) { + count, e := h.History.CleanupDeletedArchives(c) + if e != nil { + serverError(c, e) + return + } + response.OK(c, map[string]int{"deleted_points": count}) +} + type historyRequest struct { PointIDs []uuid.UUID `json:"point_ids"` StartTime time.Time `json:"start_time"` diff --git a/internal/engine/collector/engine.go b/internal/engine/collector/engine.go index 162bdec..7e0e537 100644 --- a/internal/engine/collector/engine.go +++ b/internal/engine/collector/engine.go @@ -119,6 +119,7 @@ func (e *Engine) collect(ctx context.Context, p pg.PointRow) { value = &v quality = 0 reason = nil + e.manager.MarkConnected(p.DeviceID) } else { reasonText = "read_error" reason = &reasonText diff --git a/internal/engine/collector/manager.go b/internal/engine/collector/manager.go index 3efdc79..fd967c8 100644 --- a/internal/engine/collector/manager.go +++ b/internal/engine/collector/manager.go @@ -100,8 +100,18 @@ func (m *Manager) Times(id uuid.UUID) (online, offline *time.Time) { } func (m *Manager) MarkDisconnected(id uuid.UUID) { m.mu.Lock() + if m.statuses[id] != "disconnected" { + m.lastOffline[id] = time.Now() + } m.statuses[id] = "disconnected" - m.lastOffline[id] = time.Now() + m.mu.Unlock() +} +func (m *Manager) MarkConnected(id uuid.UUID) { + m.mu.Lock() + if m.statuses[id] != "connected" { + m.lastOnline[id] = time.Now() + } + m.statuses[id] = "connected" m.mu.Unlock() } func (m *Manager) setStatus(id uuid.UUID, s string) { diff --git a/internal/engine/collector/manager_test.go b/internal/engine/collector/manager_test.go new file mode 100644 index 0000000..b708f48 --- /dev/null +++ b/internal/engine/collector/manager_test.go @@ -0,0 +1,50 @@ +package collector + +import ( + "github.com/google/uuid" + "testing" + "time" +) + +func TestManagerStatusRecoversAfterSuccessfulRead(t *testing.T) { + m := NewManager(nil, nil) + id := uuid.New() + + m.MarkConnected(id) + online, offline := m.Times(id) + if m.Status(id) != "connected" || online == nil || offline != nil { + t.Fatalf("expected connected status with online timestamp, got status=%q online=%v offline=%v", m.Status(id), online, offline) + } + firstOnline := *online + + m.MarkDisconnected(id) + if m.Status(id) != "disconnected" { + t.Fatalf("expected disconnected status, got %q", m.Status(id)) + } + _, offline = m.Times(id) + if offline == nil { + t.Fatal("expected offline timestamp after disconnect") + } + + time.Sleep(2 * time.Millisecond) + m.MarkConnected(id) + online, offline = m.Times(id) + if m.Status(id) != "connected" || online == nil || offline == nil { + t.Fatalf("expected recovered connected status, got status=%q online=%v offline=%v", m.Status(id), online, offline) + } + if !online.After(firstOnline) { + t.Fatalf("expected reconnect to refresh online timestamp: first=%v current=%v", firstOnline, *online) + } +} + +func TestManagerRepeatedConnectedDoesNotRefreshOnlineTimestamp(t *testing.T) { + m := NewManager(nil, nil) + id := uuid.New() + m.MarkConnected(id) + first, _ := m.Times(id) + m.MarkConnected(id) + second, _ := m.Times(id) + if first == nil || second == nil || !first.Equal(*second) { + t.Fatalf("repeated connected mark changed online timestamp: first=%v second=%v", first, second) + } +} diff --git a/internal/engine/writer/writer.go b/internal/engine/writer/writer.go index 7addfee..6c7f8f2 100644 --- a/internal/engine/writer/writer.go +++ b/internal/engine/writer/writer.go @@ -19,9 +19,13 @@ type Result struct { TS time.Time } +// REAL values are compared with a fixed protocol precision. The tolerance is +// intentionally not part of the write-point configuration anymore. +const realReadbackPrecision = 0.0001 + func (e *Engine) Execute(ctx context.Context, p pg.PointRow, value float64) (Result, error) { - if !p.Enabled || !p.WriteEnabled { - return Result{}, errors.New("写入点未启用或写入开关关闭") + if !p.WriteEnabled { + return Result{}, errors.New("写入点未允许写入") } c, err := e.Manager.Connection(ctx, p.DeviceID) if err != nil { @@ -39,7 +43,7 @@ func (e *Engine) Execute(ctx context.Context, p pg.PointRow, value float64) (Res } ok := actual == value if dt == protocol.Real { - ok = math.Abs(actual-value) <= p.ReadbackTolerance + ok = math.Abs(actual-value) <= realReadbackPrecision } if ok { return Result{value, actual, time.Now()}, nil diff --git a/internal/model/model.go b/internal/model/model.go index adffbcf..77cadfc 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -35,6 +35,5 @@ type Point struct { HistoryInterval int HistoryStartedAt *time.Time WriteEnabled bool - ReadbackTolerance float64 CreatedAt, UpdatedAt time.Time } diff --git a/internal/repository/postgres/store.go b/internal/repository/postgres/store.go index 00cee1f..84bfa34 100644 --- a/internal/repository/postgres/store.go +++ b/internal/repository/postgres/store.go @@ -67,7 +67,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id uuid.UUID) error { if tag.RowsAffected() == 0 { return pgx.ErrNoRows } - for _, q := range []string{`UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`, `UPDATE write_points SET deleted=TRUE,enabled=FALSE,write_enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`} { + for _, q := range []string{`UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`, `UPDATE write_points SET deleted=TRUE,write_enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`} { if _, e = tx.Exec(ctx, q, id); e != nil { return e } @@ -76,25 +76,37 @@ func (s *Store) DeleteDevice(ctx context.Context, id uuid.UUID) error { } type PointRow struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - GroupName string `json:"group_name"` - DeviceID uuid.UUID `json:"device_id"` - DeviceName string `json:"device_name"` - ProtocolType string `json:"protocol_type"` - Address string `json:"address"` - DataType string `json:"data_type"` - Unit *string `json:"unit"` - Enabled bool `json:"enabled"` - CollectInterval int `json:"collect_interval,omitempty"` - StoreHistory bool `json:"store_history,omitempty"` - HistoryInterval int `json:"history_interval,omitempty"` - HistoryStartedAt *time.Time `json:"history_started_at,omitempty"` - WriteEnabled bool `json:"write_enabled,omitempty"` - ReadbackTolerance float64 `json:"readback_tolerance,omitempty"` - LatestValue any `json:"latest_value"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + GroupName string `json:"group_name"` + DeviceID uuid.UUID `json:"device_id"` + DeviceName string `json:"device_name"` + ProtocolType string `json:"protocol_type"` + Address string `json:"address"` + DataType string `json:"data_type"` + Unit *string `json:"unit"` + Enabled bool `json:"enabled"` + CollectInterval int `json:"collect_interval,omitempty"` + StoreHistory bool `json:"store_history,omitempty"` + HistoryInterval int `json:"history_interval,omitempty"` + HistoryStartedAt *time.Time `json:"history_started_at,omitempty"` + WriteEnabled bool `json:"write_enabled,omitempty"` + ReadbackValue any `json:"readback_value"` + LatestValue any `json:"latest_value"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PointKind string `json:"-"` +} + +func (p PointRow) MarshalJSON() ([]byte, error) { + type alias PointRow + if p.PointKind == "write" { + return json.Marshal(struct { + alias + Enabled any `json:"enabled,omitempty"` + }{alias: alias(p)}) + } + return json.Marshal(alias(p)) } func (s *Store) GetPoint(ctx context.Context, kind string, id uuid.UUID) (PointRow, error) { @@ -114,29 +126,26 @@ func (s *Store) SavePoint(ctx context.Context, kind string, p *PointRow) error { if p.ID == uuid.Nil { return s.DB.QueryRow(ctx, `INSERT INTO collection_points(name,group_name,device_id,enabled,address,data_type,unit,collect_interval,store_history,history_interval) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) } - tag, e := s.DB.Exec(ctx, `UPDATE collection_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,address=$6,data_type=$7,unit=$8,collect_interval=$9,store_history=$10,history_interval=$11,updated_at=NOW() WHERE id=$1 AND deleted=FALSE AND (history_started_at IS NULL OR (device_id=$4 AND data_type=$7))`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval) + tag, e := s.DB.Exec(ctx, `UPDATE collection_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,address=$6,data_type=$7,unit=$8,collect_interval=$9,store_history=$10,history_interval=$11,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval) if e == nil && tag.RowsAffected() == 0 { return pgx.ErrNoRows } return e } if p.ID == uuid.Nil { - return s.DB.QueryRow(ctx, `INSERT INTO write_points(name,group_name,device_id,enabled,write_enabled,address,data_type,unit,readback_tolerance) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.WriteEnabled, p.Address, p.DataType, p.Unit, p.ReadbackTolerance).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) + return s.DB.QueryRow(ctx, `INSERT INTO write_points(name,group_name,device_id,write_enabled,address,data_type,unit) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.WriteEnabled, p.Address, p.DataType, p.Unit).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) } - tag, e := s.DB.Exec(ctx, `UPDATE write_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,write_enabled=$6,address=$7,data_type=$8,unit=$9,readback_tolerance=$10,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.WriteEnabled, p.Address, p.DataType, p.Unit, p.ReadbackTolerance) + tag, e := s.DB.Exec(ctx, `UPDATE write_points SET name=$2,group_name=$3,device_id=$4,write_enabled=$5,address=$6,data_type=$7,unit=$8,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, p.ID, p.Name, p.GroupName, p.DeviceID, p.WriteEnabled, p.Address, p.DataType, p.Unit) if e == nil && tag.RowsAffected() == 0 { return pgx.ErrNoRows } return e } func (s *Store) DeletePoint(ctx context.Context, kind string, id uuid.UUID) error { - table := "collection_points" - extra := "" + q := `UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE id=$1 AND deleted=FALSE` if kind == "write" { - table = "write_points" - extra = ",write_enabled=FALSE" + q = `UPDATE write_points SET deleted=TRUE,write_enabled=FALSE,updated_at=NOW() WHERE id=$1 AND deleted=FALSE` } - q := fmt.Sprintf(`UPDATE %s SET deleted=TRUE,enabled=FALSE%s,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, table, extra) tag, e := s.DB.Exec(ctx, q, id) if e == nil && tag.RowsAffected() == 0 { return pgx.ErrNoRows @@ -166,7 +175,10 @@ func (s *Store) FindPointByName(ctx context.Context, kind, name string) (PointRo return PointRow{}, pgx.ErrNoRows } func (s *Store) Groups(ctx context.Context) ([]map[string]any, error) { - rows, e := s.DB.Query(ctx, `SELECT g.name,COUNT(p.id) FROM collection_groups g LEFT JOIN collection_points p ON p.group_name=g.name AND p.deleted=FALSE GROUP BY g.name ORDER BY g.name`) + rows, e := s.DB.Query(ctx, `SELECT g.name, + (SELECT COUNT(*) FROM collection_points cp WHERE cp.group_name=g.name AND cp.deleted=FALSE), + (SELECT COUNT(*) FROM write_points wp WHERE wp.group_name=g.name AND wp.deleted=FALSE) + FROM collection_groups g ORDER BY g.name`) if e != nil { return nil, e } @@ -174,11 +186,16 @@ func (s *Store) Groups(ctx context.Context) ([]map[string]any, error) { out := []map[string]any{} for rows.Next() { var name string - var count int - if e = rows.Scan(&name, &count); e != nil { + var collectionCount, writeCount int + if e = rows.Scan(&name, &collectionCount, &writeCount); e != nil { return nil, e } - out = append(out, map[string]any{"name": name, "count": count}) + out = append(out, map[string]any{ + "name": name, + "count": collectionCount, + "collection_count": collectionCount, + "write_count": writeCount, + }) } return out, rows.Err() } @@ -198,6 +215,9 @@ func (s *Store) UpdateGroup(ctx context.Context, oldName, newName string) error if _, e = tx.Exec(ctx, `UPDATE collection_points SET group_name=$2,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, oldName, newName); e != nil { return e } + if _, e = tx.Exec(ctx, `UPDATE write_points SET group_name=$2,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, oldName, newName); e != nil { + return e + } if _, e = tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, oldName); e != nil { return e } @@ -212,7 +232,13 @@ func (s *Store) DeleteGroup(ctx context.Context, name string) error { return e } defer tx.Rollback(ctx) - if _, e = tx.Exec(ctx, `UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, name); e != nil { + if _, e = tx.Exec(ctx, `INSERT INTO collection_groups(name) VALUES('default') ON CONFLICT(name) DO NOTHING`); e != nil { + return e + } + if _, e = tx.Exec(ctx, `UPDATE collection_points SET group_name='default',updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, name); e != nil { + return e + } + if _, e = tx.Exec(ctx, `UPDATE write_points SET group_name='default',updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, name); e != nil { return e } tag, e := tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, name) @@ -236,16 +262,19 @@ func (s *Store) SetRetention(ctx context.Context, days int) error { func (s *Store) ListPoints(ctx context.Context, kind, keyword string, archived bool) ([]PointRow, error) { table := "collection_points" - cols := `p.collect_interval,p.store_history,p.history_interval,p.history_started_at,FALSE,0` + enabledColumn := "p.enabled" + cols := `p.collect_interval,p.store_history,p.history_interval,p.history_started_at,FALSE,NULL::text` if kind == "write" { table = "write_points" - cols = `NULL::double precision,NULL::double precision,0,FALSE,0,NULL::timestamptz,p.write_enabled,p.readback_tolerance` + enabledColumn = "FALSE" + cols = `0::int,FALSE::bool,0,NULL::timestamptz,p.write_enabled, + (SELECT wl.readback_value FROM write_logs wl WHERE wl.point_id=p.id ORDER BY wl.created_at DESC LIMIT 1)` } deleted := "p.deleted=FALSE AND d.deleted=FALSE" if archived { deleted = "TRUE" } - q := fmt.Sprintf(`SELECT p.id,p.name,p.group_name,p.device_id,d.name,d.protocol_type,p.address,p.data_type,p.unit,p.enabled,%s,p.created_at,p.updated_at FROM %s p JOIN devices d ON d.id=p.device_id WHERE %s AND ($1='' OR p.name ILIKE '%%'||$1||'%%' OR p.group_name ILIKE '%%'||$1||'%%') ORDER BY p.group_name,p.name LIMIT 1000`, cols, table, deleted) + q := fmt.Sprintf(`SELECT p.id,p.name,p.group_name,p.device_id,d.name,d.protocol_type,p.address,p.data_type,p.unit,%s,%s,p.created_at,p.updated_at FROM %s p JOIN devices d ON d.id=p.device_id WHERE %s AND ($1='' OR p.name ILIKE '%%'||$1||'%%' OR p.group_name ILIKE '%%'||$1||'%%') ORDER BY p.group_name,p.name LIMIT 1000`, enabledColumn, cols, table, deleted) rows, e := s.DB.Query(ctx, q, keyword) if e != nil { return nil, e @@ -253,15 +282,61 @@ func (s *Store) ListPoints(ctx context.Context, kind, keyword string, archived b defer rows.Close() var out []PointRow for rows.Next() { - var p PointRow - if e = rows.Scan(&p.ID, &p.Name, &p.GroupName, &p.DeviceID, &p.DeviceName, &p.ProtocolType, &p.Address, &p.DataType, &p.Unit, &p.Enabled, &p.CollectInterval, &p.StoreHistory, &p.HistoryInterval, &p.HistoryStartedAt, &p.WriteEnabled, &p.ReadbackTolerance, &p.CreatedAt, &p.UpdatedAt); e != nil { + p := PointRow{PointKind: kind} + var rawReadback *string + if e = rows.Scan(&p.ID, &p.Name, &p.GroupName, &p.DeviceID, &p.DeviceName, &p.ProtocolType, &p.Address, &p.DataType, &p.Unit, &p.Enabled, &p.CollectInterval, &p.StoreHistory, &p.HistoryInterval, &p.HistoryStartedAt, &p.WriteEnabled, &rawReadback, &p.CreatedAt, &p.UpdatedAt); e != nil { return nil, e } + p.ReadbackValue = parseOptional(p.DataType, rawReadback) out = append(out, p) } return out, rows.Err() } +// ListDeletedCollectionPointIDs returns collection points whose realtime +// metadata no longer exists. The history cleanup flow uses this list to avoid +// touching disabled points that may be re-enabled later. +func (s *Store) ListDeletedCollectionPointIDs(ctx context.Context) ([]uuid.UUID, error) { + rows, e := s.DB.Query(ctx, `SELECT p.id + FROM collection_points p + LEFT JOIN devices d ON d.id=p.device_id + WHERE p.deleted=TRUE OR d.deleted=TRUE + ORDER BY p.id`) + if e != nil { + return nil, e + } + defer rows.Close() + var out []uuid.UUID + for rows.Next() { + var id uuid.UUID + if e = rows.Scan(&id); e != nil { + return nil, e + } + out = append(out, id) + } + return out, rows.Err() +} + +// ListDeviceActivity returns the current realtime availability of every +// device, including logically deleted rows used by the history tree. +func (s *Store) ListDeviceActivity(ctx context.Context) (map[uuid.UUID]bool, error) { + rows, e := s.DB.Query(ctx, `SELECT id,enabled,deleted FROM devices`) + if e != nil { + return nil, e + } + defer rows.Close() + out := make(map[uuid.UUID]bool) + for rows.Next() { + var id uuid.UUID + var enabled, deleted bool + if e = rows.Scan(&id, &enabled, &deleted); e != nil { + return nil, e + } + out[id] = enabled && !deleted + } + return out, rows.Err() +} + type WriteLog struct { ID uuid.UUID `json:"id"` PointID uuid.UUID `json:"point_id"` diff --git a/internal/repository/tdengine/store.go b/internal/repository/tdengine/store.go index bb18224..ff4c44e 100644 --- a/internal/repository/tdengine/store.go +++ b/internal/repository/tdengine/store.go @@ -76,6 +76,24 @@ func (s *Store) HasData(ctx context.Context, id uuid.UUID) bool { var n int return s.DB.QueryRowContext(ctx, q).Scan(&n) == nil && n > 0 } + +// DropTables removes the per-point child tables for the supplied archived +// points. The caller is responsible for selecting only points that have no +// realtime metadata; TableName validates every derived identifier. +func (s *Store) DropTables(ctx context.Context, ids []uuid.UUID) (int, error) { + removed := 0 + for _, id := range ids { + if !s.HasData(ctx, id) { + continue + } + q := fmt.Sprintf("DROP TABLE IF EXISTS `%s`.`%s`", s.Database, TableName(id)) + if _, e := s.DB.ExecContext(ctx, q); e != nil { + return removed, e + } + removed++ + } + return removed, nil +} func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) ([]Sample, error) { shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) q := fmt.Sprintf("SELECT ts,`value`,quality,quality_reason FROM `%s`.`%s` WHERE ts >= %s AND ts <= %s ORDER BY ts", s.Database, TableName(id), sqlString(start.In(shanghai).Format("2006-01-02 15:04:05.000")), sqlString(end.In(shanghai).Format("2006-01-02 15:04:05.000"))) @@ -91,6 +109,10 @@ func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) ( if e = rows.Scan(&x.TS, &x.Value, &quality, &x.QualityReason); e != nil { return nil, e } + // The REST driver returns TDengine's local wall-clock timestamp with a + // UTC location. Rebuild it in Asia/Shanghai instead of converting the + // instant, otherwise an eight-hour offset would be applied twice. + x.TS = time.Date(x.TS.Year(), x.TS.Month(), x.TS.Day(), x.TS.Hour(), x.TS.Minute(), x.TS.Second(), x.TS.Nanosecond(), shanghai) if quality == 0 { x.Quality = "good" } else { diff --git a/internal/service/platform/history.go b/internal/service/platform/history.go index a1ed368..d34f2aa 100644 --- a/internal/service/platform/history.go +++ b/internal/service/platform/history.go @@ -36,9 +36,21 @@ func (h *History) Tree(ctx context.Context) ([]map[string]any, error) { if e != nil { return nil, e } + deletedIDs, e := h.PG.ListDeletedCollectionPointIDs(ctx) + if e != nil { + return nil, e + } + deviceActive, e := h.PG.ListDeviceActivity(ctx) + if e != nil { + return nil, e + } + deleted := make(map[uuid.UUID]bool, len(deletedIDs)) + for _, id := range deletedIDs { + deleted[id] = true + } groups := map[string][]map[string]any{} for _, p := range points { - active := p.Enabled && p.StoreHistory + active := p.Enabled && p.StoreHistory && deviceActive[p.DeviceID] has := h.TD.HasData(ctx, p.ID) if !active && !has { continue @@ -51,7 +63,7 @@ func (h *History) Tree(ctx context.Context) ([]map[string]any, error) { if h.Collector != nil && life == "active" { latest = h.Collector.Latest(p.ID) } - groups[p.GroupName] = append(groups[p.GroupName], map[string]any{"id": p.ID, "name": p.Name, "type": "collection", "data_type": p.DataType, "unit": p.Unit, "history_interval": p.HistoryInterval, "device_id": p.DeviceID, "device_name": p.DeviceName, "group_name": p.GroupName, "lifecycle_status": life, "has_history_data": has, "latest_value": latest}) + groups[p.GroupName] = append(groups[p.GroupName], map[string]any{"id": p.ID, "name": p.Name, "type": "collection", "data_type": p.DataType, "unit": p.Unit, "history_interval": p.HistoryInterval, "device_id": p.DeviceID, "device_name": p.DeviceName, "group_name": p.GroupName, "lifecycle_status": life, "has_history_data": has, "can_cleanup": deleted[p.ID], "latest_value": latest}) } names := make([]string, 0, len(groups)) for n := range groups { @@ -66,6 +78,16 @@ func (h *History) Tree(ctx context.Context) ([]map[string]any, error) { tree = append(tree, map[string]any{"id": "internal-data", "name": "内部数据", "type": "reserved", "children": []map[string]any{{"id": "placeholder", "name": "暂无数据", "type": "placeholder", "disabled": true}}}) return tree, nil } + +// CleanupDeletedArchives drops TDengine history tables only for collection +// points that have been logically deleted (or belong to a deleted device). +func (h *History) CleanupDeletedArchives(ctx context.Context) (int, error) { + ids, e := h.PG.ListDeletedCollectionPointIDs(ctx) + if e != nil { + return 0, e + } + return h.TD.DropTables(ctx, ids) +} func (h *History) Query(ctx context.Context, ids []uuid.UUID, start, end time.Time, max int) ([]Series, error) { meta, e := h.PG.ListPoints(ctx, "collection", "", true) if e != nil { @@ -159,6 +181,9 @@ type TableResult struct { } func (h *History) QueryTable(ctx context.Context, ids []uuid.UUID, start, end time.Time, minutes int) (TableResult, error) { + shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) + start = start.In(shanghai) + end = end.In(shanghai) step := time.Duration(minutes) * time.Minute times := []time.Time{} for t := start; !t.After(end); t = t.Add(step) { diff --git a/internal/service/platform/service.go b/internal/service/platform/service.go index 110d036..e941f29 100644 --- a/internal/service/platform/service.go +++ b/internal/service/platform/service.go @@ -190,8 +190,6 @@ func (s *Service) SavePoint(ctx context.Context, kind string, p *pg.PointRow) er if p.HistoryInterval < 1 || p.HistoryInterval > 1440 { return errors.New("history_interval必须为1~1440") } - } else if p.ReadbackTolerance < 0 || p.ReadbackTolerance > 1000000 { - return errors.New("readback_tolerance超出范围") } return s.Store.SavePoint(ctx, kind, p) } diff --git a/migrations/embed.go b/migrations/embed.go index 249b720..75979d7 100644 --- a/migrations/embed.go +++ b/migrations/embed.go @@ -8,5 +8,8 @@ var PostgreSQL string //go:embed postgres/000002_collection_groups.up.sql var PostgreSQLGroups string +//go:embed postgres/000003_write_points_simplify.up.sql +var PostgreSQLWritePoints string + //go:embed tdengine/000001_init.sql var TDengine string diff --git a/migrations/postgres/000001_init.up.sql b/migrations/postgres/000001_init.up.sql index 9c3383b..45a2897 100644 --- a/migrations/postgres/000001_init.up.sql +++ b/migrations/postgres/000001_init.up.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS collection_points (id UUID PRIMARY KEY DEFAULT gen_ra CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_points_name ON collection_points(name) WHERE deleted=FALSE; CREATE INDEX IF NOT EXISTS idx_collection_points_device_id ON collection_points(device_id) WHERE deleted=FALSE; CREATE INDEX IF NOT EXISTS idx_collection_points_group_name ON collection_points(group_name) WHERE deleted=FALSE; -CREATE TABLE IF NOT EXISTS write_points (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),name VARCHAR(128) NOT NULL,group_name VARCHAR(64) NOT NULL DEFAULT 'default',device_id UUID NOT NULL REFERENCES devices(id),enabled BOOLEAN NOT NULL DEFAULT TRUE,write_enabled BOOLEAN NOT NULL DEFAULT FALSE,deleted BOOLEAN NOT NULL DEFAULT FALSE,address VARCHAR(256) NOT NULL,data_type VARCHAR(16) NOT NULL CHECK(data_type IN('BOOL','INT','REAL')),unit VARCHAR(32),readback_tolerance DOUBLE PRECISION NOT NULL DEFAULT .0001 CHECK(readback_tolerance BETWEEN 0 AND 1000000),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),created_by VARCHAR(64),updated_by VARCHAR(64)); +CREATE TABLE IF NOT EXISTS write_points (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),name VARCHAR(128) NOT NULL,group_name VARCHAR(64) NOT NULL DEFAULT 'default',device_id UUID NOT NULL REFERENCES devices(id),write_enabled BOOLEAN NOT NULL DEFAULT FALSE,deleted BOOLEAN NOT NULL DEFAULT FALSE,address VARCHAR(256) NOT NULL,data_type VARCHAR(16) NOT NULL CHECK(data_type IN('BOOL','INT','REAL')),unit VARCHAR(32),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),created_by VARCHAR(64),updated_by VARCHAR(64)); CREATE UNIQUE INDEX IF NOT EXISTS idx_write_points_name ON write_points(name) WHERE deleted=FALSE; CREATE INDEX IF NOT EXISTS idx_write_points_device_id ON write_points(device_id) WHERE deleted=FALSE; CREATE TABLE IF NOT EXISTS write_logs (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),point_id UUID NOT NULL REFERENCES write_points(id),point_name VARCHAR(128) NOT NULL,device_id UUID NOT NULL REFERENCES devices(id),device_name VARCHAR(128) NOT NULL,address VARCHAR(256) NOT NULL,data_type VARCHAR(16) NOT NULL,unit VARCHAR(32),source VARCHAR(16) NOT NULL DEFAULT 'manual' CHECK(source='manual'),target_value TEXT NOT NULL,readback_value TEXT,result VARCHAR(16) NOT NULL CHECK(result IN('success','failed','timeout')),error_message TEXT,operator VARCHAR(64),reason VARCHAR(500),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); diff --git a/migrations/postgres/000003_write_points_simplify.up.sql b/migrations/postgres/000003_write_points_simplify.up.sql new file mode 100644 index 0000000..7093651 --- /dev/null +++ b/migrations/postgres/000003_write_points_simplify.up.sql @@ -0,0 +1,26 @@ +-- Write points use one explicit permission switch; keep the previous +-- effective permission before removing the redundant columns. The guard +-- keeps startup migrations safe when the service is restarted. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'write_points' + AND column_name = 'enabled' + ) THEN + EXECUTE 'UPDATE write_points SET write_enabled = enabled AND write_enabled WHERE enabled IS NOT NULL'; + EXECUTE 'ALTER TABLE write_points DROP COLUMN enabled'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'write_points' + AND column_name = 'readback_tolerance' + ) THEN + EXECUTE 'ALTER TABLE write_points DROP COLUMN readback_tolerance'; + END IF; +END $$; diff --git a/web/src/api/platform.ts b/web/src/api/platform.ts index 3e55170..25c4f09 100644 --- a/web/src/api/platform.ts +++ b/web/src/api/platform.ts @@ -32,6 +32,7 @@ export const writePointApi = { }; export const historyApi = { tree: () => request.get("/history/tree"), + cleanupArchives: () => request.post("/history/archive/cleanup"), query: (data: unknown) => request.post("/history/query", data), queryTable: (data: unknown) => request.post("/history/query-table", data), }; diff --git a/web/src/components/charts/HistoryChart.vue b/web/src/components/charts/HistoryChart.vue index 3cfb492..aa312a3 100644 --- a/web/src/components/charts/HistoryChart.vue +++ b/web/src/components/charts/HistoryChart.vue @@ -2,64 +2,404 @@ import * as echarts from "echarts"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { buildSegmentedAxis, mapValue, unmapValue } from "./segmented-axis"; -const props = defineProps<{ series: any[]; segmented: boolean }>(); + +const props = defineProps<{ + series: any[]; + segmented: boolean; + startTime: number; + endTime: number; +}>(); const emit = defineEmits<{ cursor: [any[]] }>(); const el = ref(); -let chart: echarts.ECharts | undefined, observer: ResizeObserver | undefined; +let chart: echarts.ECharts | undefined; +let observer: ResizeObserver | undefined; +let currentNames: string[] = []; +let visibleNames: Record = {}; +let lastCursorTime: number | null = null; +let cursorFrame: number | undefined; +const gridTop = 58; +const gridBottom = 52; + const segments = computed(() => buildSegmentedAxis( props.series.flatMap((s) => - s.data.filter((d: any) => d.value !== null).map((d: any) => d.value), + (s.data ?? []) + .filter((d: any) => d.value !== null && d.value !== undefined) + .map((d: any) => d.value), ), ), ); -function render() { +const range = computed(() => Math.max(1, props.endTime - props.startTime || 1)); +const colors = [ + "#63f04f", + "#18d7e9", + "#f3bd42", + "#c68cff", + "#ff8f66", + "#73b7ff", +]; + +function timestamp(value: any) { + if (typeof value === "number") return value; + const result = new Date(value).getTime(); + return Number.isFinite(result) ? result : NaN; +} +function displayValue(value: any) { + return typeof value === "number" ? Number(value.toFixed(3)) : value; +} +function axisLabel(value: number) { + const date = new Date(value); + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(date); + const part = (type: string) => + parts.find((x) => x.type === type)?.value ?? ""; + if (range.value <= 2 * 3600000) + return `${part("hour")}:${part("minute")}:${part("second")}`; + return `${part("month")}-${part("day")} ${part("hour")}:${part("minute")}`; +} +function splitNumber() { + if (range.value <= 2 * 3600000) return 8; + if (range.value <= 24 * 3600000) return 10; + if (range.value <= 7 * 24 * 3600000) return 8; + return 7; +} +function seriesName(s: any, index: number, used: Set) { + const base = `${s.point_name}${s.unit ? ` (${s.unit})` : ""}`; + if (!used.has(base)) { + used.add(base); + return base; + } + const name = `${base} #${index + 1}`; + used.add(name); + return name; +} +function pointRows(s: any) { + return (s.data ?? []) + .map((d: any) => ({ ...d, time: timestamp(d.ts) })) + .filter((d: any) => Number.isFinite(d.time)) + .sort((a: any, b: any) => a.time - b.time); +} +function cursorRow(s: any, target: number) { + const points = pointRows(s); + const cursorTS = new Date(target).toISOString(); + const base = { + pointId: s.point_id, + pointName: s.point_name, + ts: cursorTS, + value: null as number | null, + quality: "none", + qualityReason: null as string | null, + unit: s.unit, + interpolated: false, + }; + if ( + !points.length || + target < points[0].time || + target > points.at(-1).time + ) { + return base; + } + let nearest = points[0]; + for (const point of points) { + if (Math.abs(point.time - target) < Math.abs(nearest.time - target)) { + nearest = point; + } + } + if (Math.abs(nearest.time - target) <= 1) { + return { + ...base, + value: nearest.value ?? null, + quality: nearest.quality ?? "none", + qualityReason: nearest.quality_reason ?? null, + }; + } + const numeric = points.filter( + (point: any) => point.value !== null && point.value !== undefined, + ); + if (!numeric.length) { + return { + ...base, + quality: nearest.quality ?? "none", + qualityReason: nearest.quality_reason ?? null, + }; + } + let before: any; + let after: any; + for (const point of numeric) { + if (point.time <= target) before = point; + if (point.time >= target) { + after = point; + break; + } + } + if (before && after && before.time !== after.time) { + const ratio = (target - before.time) / (after.time - before.time); + const badBetween = points.some( + (point: any) => + point.time > before.time && + point.time < after.time && + point.quality === "bad", + ); + return { + ...base, + value: before.value + (after.value - before.value) * ratio, + quality: + before.quality === "bad" || after.quality === "bad" || badBetween + ? "bad" + : "good", + qualityReason: before.quality_reason ?? after.quality_reason ?? null, + interpolated: true, + }; + } + const edge = before ?? after; + return { + ...base, + value: edge.value ?? null, + quality: edge.quality ?? "none", + qualityReason: edge.quality_reason ?? null, + interpolated: true, + }; +} +function cursorRows(target: number) { + return props.series + .map((s: any, index: number) => { + const name = currentNames[index]; + return visibleNames[name] === false ? null : cursorRow(s, target); + }) + .filter(Boolean); +} +function emitCursorAt(target: number) { + if (!Number.isFinite(target)) return; + lastCursorTime = Math.min(Math.max(target, props.startTime), props.endTime); + scheduleVisualCursor(lastCursorTime); + emit("cursor", cursorRows(lastCursorTime)); +} +function scheduleVisualCursor(target: number) { + if (cursorFrame !== undefined) cancelAnimationFrame(cursorFrame); + cursorFrame = requestAnimationFrame(() => { + cursorFrame = undefined; + updateVisualCursor(target); + }); +} +function updateVisualCursor(target: number) { if (!chart) return; - const colors = ["#63f04f", "#18d7e9", "#f3bd42", "#c68cff"]; + const pixel = chart.convertToPixel({ gridIndex: 0 }, [target, 0]) as number[]; + if (!Number.isFinite(pixel?.[0])) return; chart.setOption( { - animationDuration: 350, + graphic: [ + { + id: "history-cursor-line", + type: "line", + left: pixel[0], + top: gridTop, + shape: { + x1: 0, + y1: 0, + x2: 0, + y2: Math.max(1, chart.getHeight() - gridTop - gridBottom), + }, + style: { + stroke: "#dbeee8", + lineWidth: 1, + lineDash: [5, 5], + opacity: 0.9, + }, + animation: false, + silent: true, + z: 100, + }, + ], + }, + { lazyUpdate: true }, + ); +} +function handleMouseMove(event: any) { + if (!chart) return; + const x = event.zrX ?? event.offsetX; + const y = event.zrY ?? event.offsetY; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + const converted = chart.convertFromPixel({ gridIndex: 0 }, [ + x, + y, + ]) as number[]; + const target = timestamp(converted?.[0]); + if (Number.isFinite(target)) emitCursorAt(target); +} +function handleLegendChange(event: any) { + visibleNames = { ...(event.selected ?? {}) }; + if (lastCursorTime !== null) emitCursorAt(lastCursorTime); +} +function render() { + if (!chart) return; + const option = (chart.getOption?.() as any) ?? {}; + const previous = option.legend?.[0]?.selected ?? {}; + const used = new Set(); + currentNames = props.series.map((s: any, i: number) => + seriesName(s, i, used), + ); + const selected: Record = {}; + currentNames.forEach((name) => { + selected[name] = previous[name] !== false; + }); + visibleNames = selected; + const chartSeries: any[] = []; + props.series.forEach((s: any, i: number) => { + const solid: any[] = []; + const bad: any[] = []; + (s.data ?? []).forEach((d: any) => { + const numeric = d.value !== null && d.value !== undefined; + const mapped = numeric + ? props.segmented + ? mapValue(d.value, segments.value) + : d.value + : null; + const row = [ + d.ts, + mapped, + d.ts, + d.value, + d.quality, + s.unit, + s.point_name, + s.point_id, + ]; + if (d.quality === "bad") { + solid.push([ + d.ts, + null, + d.ts, + null, + d.quality, + s.unit, + s.point_name, + s.point_id, + ]); + bad.push([ + d.ts, + numeric ? mapped : null, + d.ts, + d.value, + d.quality, + s.unit, + s.point_name, + s.point_id, + ]); + } else { + solid.push(row); + bad.push([ + d.ts, + null, + d.ts, + null, + d.quality, + s.unit, + s.point_name, + s.point_id, + ]); + } + }); + const color = colors[i % colors.length]; + chartSeries.push( + { + id: `${s.point_id}-good`, + name: currentNames[i], + type: "line", + showSymbol: false, + connectNulls: false, + data: solid, + lineStyle: { width: 1.8, color }, + itemStyle: { color }, + emphasis: { focus: "series" }, + }, + { + id: `${s.point_id}-bad`, + name: currentNames[i], + type: "line", + showSymbol: true, + symbolSize: 5, + connectNulls: false, + data: bad, + lineStyle: { width: 1.8, type: "dashed", color }, + itemStyle: { color }, + emphasis: { focus: "series" }, + }, + ); + }); + chart.setOption( + { + animation: true, + animationDuration: 260, + animationDurationUpdate: 260, + animationEasingUpdate: "cubicOut", color: colors, - grid: { left: 58, right: 26, top: 58, bottom: 46 }, - legend: { top: 12, textStyle: { color: "#a9bbc0" } }, + grid: { + left: 62, + right: 28, + top: currentNames.length ? 58 : 30, + bottom: 52, + }, + graphic: [], + legend: { + show: currentNames.length > 0, + data: currentNames, + selected, + top: 12, + textStyle: { color: "#a9bbc0" }, + itemWidth: 18, + itemHeight: 8, + }, tooltip: { trigger: "axis", + transitionDuration: 0.12, axisPointer: { - type: "line", - lineStyle: { color: "#dbeee8", type: "dashed" }, + type: "none", }, backgroundColor: "#07141af2", borderColor: "#35505a", textStyle: { color: "#dce9e5" }, formatter: (params: any) => { - emit( - "cursor", - params.map((p: any) => ({ - pointName: p.seriesName, - ts: p.data?.[2], - value: p.data?.[3], - quality: p.data?.[4], - unit: p.data?.[5], - })), - ); - return params + const rows = Array.isArray(params) ? params : [params]; + const axisValue = rows[0]?.axisValue; + const target = + lastCursorTime ?? timestamp(axisValue ?? rows[0]?.data?.[2]); + if (!Number.isFinite(target)) return ""; + emitCursorAt(target); + return cursorRows(target) .map( - (p: any) => - `${p.marker}${p.seriesName}
${p.data?.[3] ?? "—"} ${p.data?.[5] ?? ""} · ${p.data?.[4]}`, + (row: any) => + `${row.pointName}
${displayValue(row.value) ?? "—"} ${row.unit ?? ""} · ${row.quality}`, ) - .join("
"); + .join("
\n"); }, }, xAxis: { type: "time", + min: props.startTime, + max: props.endTime, + splitNumber: splitNumber(), + minInterval: range.value / 24, + maxInterval: range.value / 3, axisLine: { lineStyle: { color: "#35505a" } }, - axisLabel: { color: "#718990" }, + axisLabel: { + color: "#718990", + hideOverlap: true, + formatter: axisLabel, + }, splitLine: { show: true, lineStyle: { color: "#142b33" } }, }, yAxis: { type: "value", min: props.segmented ? 0 : undefined, max: props.segmented ? 1 : undefined, + scale: !props.segmented, axisLabel: { color: "#789098", formatter: (v: number) => @@ -69,66 +409,43 @@ function render() { }, splitLine: { lineStyle: { color: "#173039", type: "dashed" } }, }, - series: props.series.flatMap((s: any, i: number) => { - const solid: any[] = []; - const bad: any[] = []; - s.data.forEach((d: any) => { - const row = [ - d.ts, - d.value === null - ? null - : props.segmented - ? mapValue(d.value, segments.value) - : d.value, - d.ts, - d.value, - d.quality, - s.unit, - ]; - (d.quality === "bad" && d.value !== null ? bad : solid).push(row); - }); - return [ - { - name: `${s.point_name}${s.unit ? ` (${s.unit})` : ""}`, - type: "line", - showSymbol: false, - connectNulls: false, - data: solid, - lineStyle: { width: 1.6, color: colors[i % colors.length] }, - }, - { - name: `${s.point_name} · bad`, - type: "line", - showSymbol: true, - symbolSize: 5, - connectNulls: false, - data: bad, - lineStyle: { width: 1.4, type: "dashed", color: "#ff665c" }, - itemStyle: { color: "#ff665c" }, - }, - ]; - }), + series: chartSeries, }, true, ); + if (lastCursorTime !== null) scheduleVisualCursor(lastCursorTime); } onMounted(() => { chart = echarts.init(el.value!); - observer = new ResizeObserver(() => chart?.resize()); + observer = new ResizeObserver(() => { + chart?.resize(); + if (chart && lastCursorTime !== null) scheduleVisualCursor(lastCursorTime); + }); observer.observe(el.value!); + chart.getZr().on("mousemove", handleMouseMove); + chart.on("legendselectchanged", handleLegendChange); render(); }); -watch(() => [props.series, props.segmented], render, { deep: true }); +watch( + () => [props.series, props.segmented, props.startTime, props.endTime], + render, + { deep: true }, +); onBeforeUnmount(() => { + if (cursorFrame !== undefined) cancelAnimationFrame(cursorFrame); observer?.disconnect(); + chart?.getZr().off("mousemove", handleMouseMove); + chart?.off("legendselectchanged", handleLegendChange); chart?.dispose(); }); + + diff --git a/web/src/views/write-point/WritePointView.vue b/web/src/views/write-point/WritePointView.vue index 3621847..ab845d0 100644 --- a/web/src/views/write-point/WritePointView.vue +++ b/web/src/views/write-point/WritePointView.vue @@ -1,81 +1,159 @@ +