From ca5b687ee118bec728bf732c72363433cb1ad9f3 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Fri, 17 Jul 2026 18:47:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20config=20version=20history=20=E2=80=94?= =?UTF-8?q?=20auto-save=20on=20deploy,=20side-by-side=20JSON=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/safesightd/main.go | 3 + docs/实施跟踪.md | 2 +- internal/service/auto_config.go | 17 ++- internal/storage/config_versions_repo.go | 63 +++++++++ internal/storage/migrate.go | 26 +++- internal/web/ui.go | 45 +++++- .../web/ui/templates/config_versions.html | 128 ++++++++++++++++++ internal/web/ui/templates/device.html | 1 + 8 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 internal/storage/config_versions_repo.go create mode 100644 internal/web/ui/templates/config_versions.html diff --git a/cmd/safesightd/main.go b/cmd/safesightd/main.go index a30eda5..0a3e911 100644 --- a/cmd/safesightd/main.go +++ b/cmd/safesightd/main.go @@ -110,7 +110,10 @@ func main() { ui.SetResourcesRepo(resourcesRepo) ui.SetAlarmCollector(alarmCollector) autoCfgSvc := service.NewAutoConfigService(previewSvc, taskSvc, service.NewFeatureRegistry()) + configVersionsRepo := storage.NewConfigVersionsRepo(store.DB()) + autoCfgSvc.SetConfigVersionsRepo(configVersionsRepo) ui.SetAutoConfig(autoCfgSvc) + ui.SetConfigVersionsRepo(configVersionsRepo) uiRouter, err := ui.Routes() if err != nil { log.Fatalf("failed to init ui routes: %v", err) diff --git a/docs/实施跟踪.md b/docs/实施跟踪.md index e1be26b..b72aeaa 100644 --- a/docs/实施跟踪.md +++ b/docs/实施跟踪.md @@ -25,7 +25,7 @@ |---|------|------|------|------| | 10 | 时序图表(告警趋势) | ✅ 已完成 | 2026-07-17 | 2026-07-17 | | 11 | 设备分组与标签 | ⬜ 待开始 | - | - | -| 12 | 配置版本对比 | ⬜ 待开始 | - | - | +| 12 | 配置版本对比 | ✅ 已完成 | 2026-07-17 | 2026-07-17 | | 13 | 报告导出(CSV) | ✅ 已完成 | 2026-07-17 | 2026-07-17 | | 14 | 模型管理简化 | ⬜ 待开始 | - | - | | 15 | 人脸库优化(搜索+质量评分) | ⬜ 待开始 | - | - | diff --git a/internal/service/auto_config.go b/internal/service/auto_config.go index df16301..b7cbb54 100644 --- a/internal/service/auto_config.go +++ b/internal/service/auto_config.go @@ -7,6 +7,8 @@ import ( "fmt" "sort" "strings" + + "safesight-control/internal/storage" ) // FeatureDef describes a detection feature that users can toggle on/off. @@ -321,9 +323,10 @@ type AutoConfigResult struct { // AutoConfigService orchestrates the full pipeline from user intent to deployed config. type AutoConfigService struct { - preview *ConfigPreviewService - tasks *TaskService - registry *FeatureRegistry + preview *ConfigPreviewService + tasks *TaskService + registry *FeatureRegistry + configVersions *storage.ConfigVersionsRepo } // NewAutoConfigService creates a new AutoConfigService. @@ -335,6 +338,10 @@ func NewAutoConfigService(preview *ConfigPreviewService, tasks *TaskService, reg } } +func (s *AutoConfigService) SetConfigVersionsRepo(repo *storage.ConfigVersionsRepo) { + s.configVersions = repo +} + // BuildPipeline executes the full auto-config pipeline for a single device: // 1. Resolve template from feature selection // 2. If composed, generate the composed template @@ -472,6 +479,10 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (* } result.ConfigSHA256 = preview.Sha256 + if s.configVersions != nil { + _ = s.configVersions.Save(req.DeviceID, preview.Sha256, preview.JSON) + } + var configDoc any if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { result.Error = fmt.Sprintf("配置 JSON 无效: %v", err) diff --git a/internal/storage/config_versions_repo.go b/internal/storage/config_versions_repo.go new file mode 100644 index 0000000..b8d12e0 --- /dev/null +++ b/internal/storage/config_versions_repo.go @@ -0,0 +1,63 @@ +package storage + +import ( + "database/sql" + "time" +) + +type ConfigVersionRecord struct { + ID int `json:"id"` + DeviceID string `json:"device_id"` + ConfigID string `json:"config_id"` + ConfigJSON string `json:"config_json"` + CreatedAt string `json:"created_at"` +} + +type ConfigVersionsRepo struct { + db *sql.DB +} + +func NewConfigVersionsRepo(db *sql.DB) *ConfigVersionsRepo { + return &ConfigVersionsRepo{db: db} +} + +func (r *ConfigVersionsRepo) Save(deviceID, configID, configJSON string) error { + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`INSERT INTO config_versions(device_id, config_id, config_json, created_at) VALUES(?,?,?,?)`, + deviceID, configID, configJSON, time.Now().Format(time.RFC3339)) + return err +} + +func (r *ConfigVersionsRepo) List(deviceID string) ([]ConfigVersionRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(`SELECT id, device_id, config_id, config_json, created_at FROM config_versions WHERE device_id=? ORDER BY id DESC LIMIT 20`, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + var results []ConfigVersionRecord + for rows.Next() { + var rec ConfigVersionRecord + if err := rows.Scan(&rec.ID, &rec.DeviceID, &rec.ConfigID, &rec.ConfigJSON, &rec.CreatedAt); err != nil { + continue + } + results = append(results, rec) + } + return results, rows.Err() +} + +func (r *ConfigVersionsRepo) GetByID(id int) (*ConfigVersionRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + var rec ConfigVersionRecord + err := r.db.QueryRow(`SELECT id, device_id, config_id, config_json, created_at FROM config_versions WHERE id=?`, id).Scan(&rec.ID, &rec.DeviceID, &rec.ConfigID, &rec.ConfigJSON, &rec.CreatedAt) + if err != nil { + return nil, err + } + return &rec, nil +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index 00255cc..5b6ae87 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -192,7 +192,10 @@ func migrate(db *sql.DB) error { if err := migrateProfilesToSceneTemplates(db); err != nil { return err } - return migrateAlarmWorkflow(db) + if err := migrateAlarmWorkflow(db); err != nil { + return err + } + return migrateConfigVersions(db) } func migrateProfilePrimaryTemplateName(db *sql.DB) error { @@ -461,6 +464,27 @@ func migrateAlarmWorkflow(db *sql.DB) error { return nil } +func migrateConfigVersions(db *sql.DB) error { + if db == nil { + return nil + } + var count int + db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='config_versions'`).Scan(&count) + if count > 0 { + return nil + } + _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS config_versions ( + id INTEGER PRIMARY KEY, + device_id TEXT NOT NULL, + config_id TEXT NOT NULL DEFAULT '', + config_json TEXT NOT NULL, + created_at TEXT NOT NULL +) +`) + return err +} + func bindingStringFromAny(bindings map[string]any, slot string, field string) string { entry, _ := bindings[slot].(map[string]any) return strings.TrimSpace(stringAny(entry[field])) diff --git a/internal/web/ui.go b/internal/web/ui.go index 007b5d2..ccd9f30 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -36,7 +36,8 @@ type UI struct { dbPath string resourcesRepo *storage.ResourcesRepo alarmCollector *service.AlarmCollector - autoConfig *service.AutoConfigService + autoConfig *service.AutoConfigService + configVersionsRepo *storage.ConfigVersionsRepo tpl *template.Template } @@ -86,6 +87,7 @@ type PageData struct { ModelStatusBoard *service.ModelStatusBoard StandardResources []storage.StandardResourceRecord ResourceStatusBoard *service.ResourceStatusBoard + ConfigVersions []storage.ConfigVersionRecord AlarmRecords []service.AlarmRecord TotalAlarmCount int TotalAlarmPages int @@ -640,6 +642,13 @@ func (u *UI) SetAutoConfig(ac *service.AutoConfigService) { u.autoConfig = ac } +func (u *UI) SetConfigVersionsRepo(repo *storage.ConfigVersionsRepo) { + if u == nil { + return + } + u.configVersionsRepo = repo +} + func tablerIconSVG(name string) string { icons := map[string]string{ "devices": ``, @@ -775,6 +784,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/discovery/search", u.actionDiscoverySearch) r.Get("/devices/{id}", u.pageDevice) r.Post("/devices/{id}/alias", u.actionDeviceAliasSave) + r.Get("/devices/{id}/config-versions", u.pageDeviceConfigVersions) + r.Get("/devices/{id}/config-versions/{versionID}", u.apiDeviceConfigVersion) r.Post("/devices/{id}/action", u.actionDeviceAction) r.Get("/devices/{id}/logs", u.pageDeviceLogs) r.Get("/devices/{id}/graphs", u.pageDeviceGraphs) @@ -1847,6 +1858,38 @@ func (u *UI) actionDeviceAliasSave(w http.ResponseWriter, r *http.Request) { u.render(w, r, "device", data) } +func (u *UI) pageDeviceConfigVersions(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + data := u.deviceDetailPageData(dev) + data.Title = "配置历史" + if u.configVersionsRepo != nil { + versions, _ := u.configVersionsRepo.List(id) + data.ConfigVersions = versions + } + u.render(w, r, "config_versions", data) +} + +func (u *UI) apiDeviceConfigVersion(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "versionID") + if u.configVersionsRepo == nil { + http.Error(w, "not available", http.StatusServiceUnavailable) + return + } + vid, _ := strconv.Atoi(id) + rec, err := u.configVersionsRepo.GetByID(vid) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(rec) +} + func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) diff --git a/internal/web/ui/templates/config_versions.html b/internal/web/ui/templates/config_versions.html new file mode 100644 index 0000000..b01dbb7 --- /dev/null +++ b/internal/web/ui/templates/config_versions.html @@ -0,0 +1,128 @@ +{{define "config_versions"}} +{{template "device_header" .}} + +
+
+
+

配置历史

+
每次下发配置时自动保存的快照,可对比任意两个版本。
+
+ +
+ + {{if .ConfigVersions}} +
+ 对比: + + vs + +
+ + + +
+ + + + {{range .ConfigVersions}} + + + + + + {{end}} + +
时间配置 ID大小
{{.CreatedAt}}{{shortID .ConfigID}}{{len .ConfigJSON}} 字符
+
+ {{else}} +
+
暂无配置历史
+
通过管控台或部署向导下发配置后,快照将自动保存在这里。
+
+ {{end}} +
+ + +{{end}} diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html index b25b39c..405a5b1 100644 --- a/internal/web/ui/templates/device.html +++ b/internal/web/ui/templates/device.html @@ -10,6 +10,7 @@
返回设备列表 预览设备分配 + 配置历史 诊断日志