feat: config version history — auto-save on deploy, side-by-side JSON diff
This commit is contained in:
parent
232526de06
commit
ca5b687ee1
@ -110,7 +110,10 @@ func main() {
|
|||||||
ui.SetResourcesRepo(resourcesRepo)
|
ui.SetResourcesRepo(resourcesRepo)
|
||||||
ui.SetAlarmCollector(alarmCollector)
|
ui.SetAlarmCollector(alarmCollector)
|
||||||
autoCfgSvc := service.NewAutoConfigService(previewSvc, taskSvc, service.NewFeatureRegistry())
|
autoCfgSvc := service.NewAutoConfigService(previewSvc, taskSvc, service.NewFeatureRegistry())
|
||||||
|
configVersionsRepo := storage.NewConfigVersionsRepo(store.DB())
|
||||||
|
autoCfgSvc.SetConfigVersionsRepo(configVersionsRepo)
|
||||||
ui.SetAutoConfig(autoCfgSvc)
|
ui.SetAutoConfig(autoCfgSvc)
|
||||||
|
ui.SetConfigVersionsRepo(configVersionsRepo)
|
||||||
uiRouter, err := ui.Routes()
|
uiRouter, err := ui.Routes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to init ui routes: %v", err)
|
log.Fatalf("failed to init ui routes: %v", err)
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
|---|------|------|------|------|
|
|---|------|------|------|------|
|
||||||
| 10 | 时序图表(告警趋势) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
| 10 | 时序图表(告警趋势) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||||
| 11 | 设备分组与标签 | ⬜ 待开始 | - | - |
|
| 11 | 设备分组与标签 | ⬜ 待开始 | - | - |
|
||||||
| 12 | 配置版本对比 | ⬜ 待开始 | - | - |
|
| 12 | 配置版本对比 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||||
| 13 | 报告导出(CSV) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
| 13 | 报告导出(CSV) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||||
| 14 | 模型管理简化 | ⬜ 待开始 | - | - |
|
| 14 | 模型管理简化 | ⬜ 待开始 | - | - |
|
||||||
| 15 | 人脸库优化(搜索+质量评分) | ⬜ 待开始 | - | - |
|
| 15 | 人脸库优化(搜索+质量评分) | ⬜ 待开始 | - | - |
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"safesight-control/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FeatureDef describes a detection feature that users can toggle on/off.
|
// 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.
|
// AutoConfigService orchestrates the full pipeline from user intent to deployed config.
|
||||||
type AutoConfigService struct {
|
type AutoConfigService struct {
|
||||||
preview *ConfigPreviewService
|
preview *ConfigPreviewService
|
||||||
tasks *TaskService
|
tasks *TaskService
|
||||||
registry *FeatureRegistry
|
registry *FeatureRegistry
|
||||||
|
configVersions *storage.ConfigVersionsRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAutoConfigService creates a new AutoConfigService.
|
// 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:
|
// BuildPipeline executes the full auto-config pipeline for a single device:
|
||||||
// 1. Resolve template from feature selection
|
// 1. Resolve template from feature selection
|
||||||
// 2. If composed, generate the composed template
|
// 2. If composed, generate the composed template
|
||||||
@ -472,6 +479,10 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*
|
|||||||
}
|
}
|
||||||
result.ConfigSHA256 = preview.Sha256
|
result.ConfigSHA256 = preview.Sha256
|
||||||
|
|
||||||
|
if s.configVersions != nil {
|
||||||
|
_ = s.configVersions.Save(req.DeviceID, preview.Sha256, preview.JSON)
|
||||||
|
}
|
||||||
|
|
||||||
var configDoc any
|
var configDoc any
|
||||||
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
|
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
|
||||||
result.Error = fmt.Sprintf("配置 JSON 无效: %v", err)
|
result.Error = fmt.Sprintf("配置 JSON 无效: %v", err)
|
||||||
|
|||||||
63
internal/storage/config_versions_repo.go
Normal file
63
internal/storage/config_versions_repo.go
Normal 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
|
||||||
|
}
|
||||||
@ -192,7 +192,10 @@ func migrate(db *sql.DB) error {
|
|||||||
if err := migrateProfilesToSceneTemplates(db); err != nil {
|
if err := migrateProfilesToSceneTemplates(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateAlarmWorkflow(db)
|
if err := migrateAlarmWorkflow(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return migrateConfigVersions(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
|
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
|
||||||
@ -461,6 +464,27 @@ func migrateAlarmWorkflow(db *sql.DB) error {
|
|||||||
return nil
|
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 {
|
func bindingStringFromAny(bindings map[string]any, slot string, field string) string {
|
||||||
entry, _ := bindings[slot].(map[string]any)
|
entry, _ := bindings[slot].(map[string]any)
|
||||||
return strings.TrimSpace(stringAny(entry[field]))
|
return strings.TrimSpace(stringAny(entry[field]))
|
||||||
|
|||||||
@ -36,7 +36,8 @@ type UI struct {
|
|||||||
dbPath string
|
dbPath string
|
||||||
resourcesRepo *storage.ResourcesRepo
|
resourcesRepo *storage.ResourcesRepo
|
||||||
alarmCollector *service.AlarmCollector
|
alarmCollector *service.AlarmCollector
|
||||||
autoConfig *service.AutoConfigService
|
autoConfig *service.AutoConfigService
|
||||||
|
configVersionsRepo *storage.ConfigVersionsRepo
|
||||||
|
|
||||||
tpl *template.Template
|
tpl *template.Template
|
||||||
}
|
}
|
||||||
@ -86,6 +87,7 @@ type PageData struct {
|
|||||||
ModelStatusBoard *service.ModelStatusBoard
|
ModelStatusBoard *service.ModelStatusBoard
|
||||||
StandardResources []storage.StandardResourceRecord
|
StandardResources []storage.StandardResourceRecord
|
||||||
ResourceStatusBoard *service.ResourceStatusBoard
|
ResourceStatusBoard *service.ResourceStatusBoard
|
||||||
|
ConfigVersions []storage.ConfigVersionRecord
|
||||||
AlarmRecords []service.AlarmRecord
|
AlarmRecords []service.AlarmRecord
|
||||||
TotalAlarmCount int
|
TotalAlarmCount int
|
||||||
TotalAlarmPages int
|
TotalAlarmPages int
|
||||||
@ -640,6 +642,13 @@ func (u *UI) SetAutoConfig(ac *service.AutoConfigService) {
|
|||||||
u.autoConfig = ac
|
u.autoConfig = ac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UI) SetConfigVersionsRepo(repo *storage.ConfigVersionsRepo) {
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.configVersionsRepo = repo
|
||||||
|
}
|
||||||
|
|
||||||
func tablerIconSVG(name string) string {
|
func tablerIconSVG(name string) string {
|
||||||
icons := map[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>`,
|
"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.Post("/discovery/search", u.actionDiscoverySearch)
|
||||||
r.Get("/devices/{id}", u.pageDevice)
|
r.Get("/devices/{id}", u.pageDevice)
|
||||||
r.Post("/devices/{id}/alias", u.actionDeviceAliasSave)
|
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.Post("/devices/{id}/action", u.actionDeviceAction)
|
||||||
r.Get("/devices/{id}/logs", u.pageDeviceLogs)
|
r.Get("/devices/{id}/logs", u.pageDeviceLogs)
|
||||||
r.Get("/devices/{id}/graphs", u.pageDeviceGraphs)
|
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)
|
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) {
|
func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
dev, ok := u.findDevice(id)
|
dev, ok := u.findDevice(id)
|
||||||
|
|||||||
128
internal/web/ui/templates/config_versions.html
Normal file
128
internal/web/ui/templates/config_versions.html
Normal 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}}
|
||||||
@ -10,6 +10,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
|
<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-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>
|
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user