feat: config version history — auto-save on deploy, side-by-side JSON diff

This commit is contained in:
tian 2026-07-17 18:47:19 +08:00
parent 232526de06
commit ca5b687ee1
8 changed files with 279 additions and 6 deletions

View File

@ -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)

View File

@ -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 | 人脸库优化(搜索+质量评分) | ⬜ 待开始 | - | - |

View File

@ -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)

View File

@ -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
}

View File

@ -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]))

View File

@ -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": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="4" width="18" height="12" rx="1"/><path d="M7 20h10"/><path d="M9 16v4"/><path d="M15 16v4"/></svg>`,
@ -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)

View File

@ -0,0 +1,128 @@
{{define "config_versions"}}
{{template "device_header" .}}
<div class="card">
<div class="section-title">
<div>
<h2>配置历史</h2>
<div class="muted small">每次下发配置时自动保存的快照,可对比任意两个版本。</div>
</div>
<div class="actions">
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">← 返回设备</a>
</div>
</div>
{{if .ConfigVersions}}
<div style="margin-bottom:16px">
<span style="font-size:12px;color:var(--muted);margin-right:8px">对比:</span>
<select id="version-a" style="width:200px;font-size:12px">
<option value="">选择版本 A</option>
{{range .ConfigVersions}}
<option value="{{.ID}}">{{.CreatedAt}} · {{shortID .ConfigID}}</option>
{{end}}
</select>
<span style="margin:0 8px;color:var(--muted)">vs</span>
<select id="version-b" style="width:200px;font-size:12px">
<option value="">选择版本 B</option>
{{range .ConfigVersions}}
<option value="{{.ID}}">{{.CreatedAt}} · {{shortID .ConfigID}}</option>
{{end}}
</select>
</div>
<div id="diff-result" style="display:none">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px" id="label-a"></div>
<pre id="diff-a" style="height:400px;overflow:auto;font-size:11px;line-height:1.5;margin:0"></pre>
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px" id="label-b"></div>
<pre id="diff-b" style="height:400px;overflow:auto;font-size:11px;line-height:1.5;margin:0"></pre>
</div>
</div>
</div>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead><tr><th>时间</th><th>配置 ID</th><th>大小</th></tr></thead>
<tbody>
{{range .ConfigVersions}}
<tr>
<td class="mono small">{{.CreatedAt}}</td>
<td class="mono small">{{shortID .ConfigID}}</td>
<td class="small">{{len .ConfigJSON}} 字符</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<div class="empty-title">暂无配置历史</div>
<div class="muted">通过管控台或部署向导下发配置后,快照将自动保存在这里。</div>
</div>
{{end}}
</div>
<script>
(function(){
var selA = document.getElementById("version-a");
var selB = document.getElementById("version-b");
var diffResult = document.getElementById("diff-result");
if (!selA || !selB) return;
function formatJSON(s) {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch(e) {
return s;
}
}
function compare() {
var a = selA.value, b = selB.value;
if (!a || !b) { diffResult.style.display = "none"; return; }
var labelA = selA.options[selA.selectedIndex].text;
var labelB = selB.options[selB.selectedIndex].text;
document.getElementById("label-a").textContent = labelA;
document.getElementById("label-b").textContent = labelB;
Promise.all([
fetch("/ui/devices/{{.Device.DeviceID}}/config-versions/"+a).then(function(r){return r.json()}),
fetch("/ui/devices/{{.Device.DeviceID}}/config-versions/"+b).then(function(r){return r.json()})
]).then(function(results){
var linesA = formatJSON(results[0].config_json).split("\n");
var linesB = formatJSON(results[1].config_json).split("\n");
var maxLen = Math.max(linesA.length, linesB.length);
var outA = [], outB = [];
for (var i = 0; i < maxLen; i++) {
var la = linesA[i] || "", lb = linesB[i] || "";
if (la === lb) {
outA.push(la);
outB.push(lb);
} else {
outA.push('\x1b[41m' + la + '\x1b[0m');
outB.push('\x1b[42m' + lb + '\x1b[0m');
}
}
var preA = document.getElementById("diff-a");
var preB = document.getElementById("diff-b");
function render(lines) {
return lines.map(function(l){
if (l.indexOf('\x1b[41m') >= 0) return '<span style="background:rgba(220,70,70,.25);display:block">' + l.replace(/\x1b\[41m/g,"").replace(/\x1b\[0m/g,"") + '</span>';
if (l.indexOf('\x1b[42m') >= 0) return '<span style="background:rgba(70,180,70,.25);display:block">' + l.replace(/\x1b\[42m/g,"").replace(/\x1b\[0m/g,"") + '</span>';
return '<span style="display:block">' + l + '</span>';
}).join("");
}
preA.innerHTML = render(outA);
preB.innerHTML = render(outB);
diffResult.style.display = "";
});
}
selA.addEventListener("change", compare);
selB.addEventListener("change", compare);
})();
</script>
{{end}}

View File

@ -10,6 +10,7 @@
<div class="actions">
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览设备分配</span></a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-versions"><span>配置历史</span></a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
</div>
</div>