diff --git a/.gitignore b/.gitignore
index 4f80e35..7214573 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ tmp/
# Local runtime files
logs/
+data/
*.log
managerd.local.json
diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go
index 9326bda..ed1c083 100644
--- a/cmd/managerd/main.go
+++ b/cmd/managerd/main.go
@@ -9,6 +9,7 @@ import (
"3588AdminBackend/internal/api"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/service"
+ "3588AdminBackend/internal/storage"
"3588AdminBackend/internal/web"
"github.com/go-chi/chi/v5"
@@ -31,7 +32,21 @@ func main() {
agentClient := service.NewAgentClient(cfg)
regSvc := service.NewRegistryService(cfg, agentClient)
discoSvc := service.NewDiscoveryService(cfg, regSvc)
- taskSvc := service.NewTaskService(cfg, agentClient, regSvc)
+ store, err := storage.OpenSQLite(cfg.DBPathOrDefault())
+ if err != nil {
+ log.Fatalf("failed to open storage: %v", err)
+ }
+ defer store.Close()
+ taskRepo := storage.NewTasksRepo(store.DB())
+ assetsRepo := storage.NewAssetsRepo(store.DB())
+ stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
+ auditRepo := storage.NewAuditLogsRepo(store.DB())
+ taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo)
+ taskSvc.SetDeviceConfigStateRepo(stateRepo)
+ taskSvc.SetAuditLogRepo(auditRepo)
+ if err := taskSvc.LoadPersistedTasks(); err != nil {
+ log.Printf("load persisted tasks: %v", err)
+ }
tplSvc := service.NewTemplateService(cfg)
h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc)
@@ -53,10 +68,13 @@ func main() {
http.Redirect(w, r, "/ui", http.StatusFound)
})
- ui, err := web.NewUI(discoSvc, regSvc, agentClient, taskSvc, tplSvc)
+ ui, err := web.NewUI(discoSvc, regSvc, agentClient, taskSvc, tplSvc, service.NewConfigPreviewService(cfg, assetsRepo))
if err != nil {
log.Fatalf("failed to init ui: %v", err)
}
+ ui.SetStateRepo(stateRepo)
+ ui.SetAuditRepo(auditRepo)
+ ui.SetDBPath(cfg.DBPathOrDefault())
uiRouter, err := ui.Routes()
if err != nil {
log.Fatalf("failed to init ui routes: %v", err)
diff --git a/go.mod b/go.mod
index b63f2e3..0516ad1 100644
--- a/go.mod
+++ b/go.mod
@@ -7,3 +7,15 @@ require (
github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0
)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.22.0 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/sqlite v1.34.5 // indirect
+)
diff --git a/go.sum b/go.sum
index e85bfec..5be4d0b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,25 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
+modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
diff --git a/internal/config/config.go b/internal/config/config.go
index f9df226..480e666 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,6 +3,8 @@ package config
import (
"encoding/json"
"os"
+ "path/filepath"
+ "strings"
)
type Config struct {
@@ -12,6 +14,9 @@ type Config struct {
OfflineAfterMs int `json:"offline_after_ms"`
AgentToken string `json:"agent_token"`
Concurrency int `json:"concurrency"`
+ DataDir string `json:"data_dir,omitempty"`
+ DBPath string `json:"db_path,omitempty"`
+ LogDir string `json:"log_dir,omitempty"`
MediaRepoPath string `json:"media_repo_path,omitempty"`
DeviceAliases map[string]string `json:"device_aliases,omitempty"`
path string
@@ -44,3 +49,24 @@ func (c *Config) Save() error {
}
return os.WriteFile(c.path, append(body, '\n'), 0o644)
}
+
+func (c *Config) DataDirOrDefault() string {
+ if c != nil && strings.TrimSpace(c.DataDir) != "" {
+ return filepath.Clean(strings.TrimSpace(c.DataDir))
+ }
+ return "data"
+}
+
+func (c *Config) DBPathOrDefault() string {
+ if c != nil && strings.TrimSpace(c.DBPath) != "" {
+ return filepath.Clean(strings.TrimSpace(c.DBPath))
+ }
+ return filepath.Join(c.DataDirOrDefault(), "app.db")
+}
+
+func (c *Config) LogDirOrDefault() string {
+ if c != nil && strings.TrimSpace(c.LogDir) != "" {
+ return filepath.Clean(strings.TrimSpace(c.LogDir))
+ }
+ return filepath.Join(c.DataDirOrDefault(), "logs")
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..c6490b0
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,20 @@
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestConfigDefaultsLocalDataPaths(t *testing.T) {
+ cfg := &Config{}
+
+ if got := cfg.DataDirOrDefault(); got != "data" {
+ t.Fatalf("expected default data dir data, got %q", got)
+ }
+ if got := cfg.DBPathOrDefault(); got != filepath.Join("data", "app.db") {
+ t.Fatalf("expected default db path %q, got %q", filepath.Join("data", "app.db"), got)
+ }
+ if got := cfg.LogDirOrDefault(); got != filepath.Join("data", "logs") {
+ t.Fatalf("expected default log dir %q, got %q", filepath.Join("data", "logs"), got)
+ }
+}
diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go
index de7cb13..136122c 100644
--- a/internal/service/config_assets.go
+++ b/internal/service/config_assets.go
@@ -7,6 +7,8 @@ import (
"path/filepath"
"sort"
"strings"
+
+ "3588AdminBackend/internal/storage"
)
type ConfigTemplateAsset struct {
@@ -29,6 +31,7 @@ type ConfigProfileAsset struct {
Name string `json:"name"`
Path string `json:"path"`
Description string `json:"description"`
+ BusinessName string `json:"business_name"`
QueueSize int `json:"queue_size"`
QueueStrategy string `json:"queue_strategy"`
Instances []ConfigProfileInstanceAsset `json:"instances"`
@@ -174,6 +177,7 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
Name: firstString(raw["name"], name),
Path: path,
Description: stringValue(raw["description"]),
+ BusinessName: stringValue(raw["business_name"]),
QueueSize: intValue(queueMap["size"]),
QueueStrategy: stringValue(queueMap["strategy"]),
Instances: instances,
@@ -220,6 +224,15 @@ func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset
}
func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) {
+ if s != nil && s.assets != nil {
+ raw, path, ok, err := s.readRepoAssetJSON(kind, name)
+ if err != nil {
+ return nil, "", err
+ }
+ if ok {
+ return raw, path, nil
+ }
+ }
root := s.mediaRepoRoot()
if root == "" {
return nil, "", fmt.Errorf("media repo path is not configured")
@@ -239,6 +252,51 @@ func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[stri
return raw, path, nil
}
+func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) {
+ if err := validateConfigName(name); err != nil {
+ return nil, "", false, err
+ }
+ var (
+ record *storage.AssetRecord
+ err error
+ )
+ switch kind {
+ case "templates":
+ record, err = s.assets.GetTemplate(name)
+ case "profiles":
+ record, err = s.assets.GetProfile(name)
+ case "overlays":
+ record, err = s.assets.GetOverlay(name)
+ default:
+ return nil, "", false, fmt.Errorf("unsupported asset kind: %s", kind)
+ }
+ if err != nil {
+ return nil, "", true, err
+ }
+ if record == nil {
+ return nil, "", false, nil
+ }
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
+ return nil, "", true, err
+ }
+ if raw == nil {
+ raw = map[string]any{}
+ }
+ if strings.TrimSpace(record.Description) != "" {
+ raw["description"] = record.Description
+ }
+ if kind == "profiles" {
+ if strings.TrimSpace(record.TemplateName) != "" {
+ raw["template_name"] = record.TemplateName
+ }
+ if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" {
+ raw["business_name"] = record.BusinessName
+ }
+ }
+ return raw, repoAssetPath(kind, name), true, nil
+}
+
func cloneMap(in map[string]any) map[string]any {
if len(in) == 0 {
return map[string]any{}
diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go
index f59c152..6a7ab60 100644
--- a/internal/service/config_assets_test.go
+++ b/internal/service/config_assets_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"3588AdminBackend/internal/config"
+ "3588AdminBackend/internal/storage"
)
func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
@@ -299,3 +300,83 @@ func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) {
t.Fatal("expected json body")
}
}
+
+func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) {
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+
+ repo := storage.NewAssetsRepo(store.DB())
+ if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
+ t.Fatalf("SaveTemplate: %v", err)
+ }
+ if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
+ t.Fatalf("SaveProfile: %v", err)
+ }
+ if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil {
+ t.Fatalf("SaveOverlay: %v", err)
+ }
+
+ svc := NewConfigPreviewService(&config.Config{}, repo)
+ sources, err := svc.ListSources()
+ if err != nil {
+ t.Fatalf("ListSources: %v", err)
+ }
+ if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
+ t.Fatalf("unexpected templates: %#v", got)
+ }
+ if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "gate_a" {
+ t.Fatalf("unexpected profiles: %#v", got)
+ }
+ if got := sourceNames(sources.Overlays); len(got) != 1 || got[0] != "night_relaxed" {
+ t.Fatalf("unexpected overlays: %#v", got)
+ }
+}
+
+func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+
+ repo := storage.NewAssetsRepo(store.DB())
+ svc := NewConfigPreviewService(&config.Config{}, repo)
+ editor := ConfigProfileEditor{
+ Name: "gate_a",
+ BusinessName: "厂区入口",
+ Description: "白班识别",
+ SiteName: "A厂区",
+ Instances: []ConfigProfileInstanceEditor{
+ {
+ Name: "cam1",
+ Template: "helmet",
+ DisplayName: "东门入口",
+ RTSPURL: "rtsp://10.0.0.1/live",
+ },
+ },
+ }
+
+ if err := svc.SaveProfileEditor(editor); err != nil {
+ t.Fatalf("SaveProfileEditor: %v", err)
+ }
+
+ saved, err := repo.GetProfile("gate_a")
+ if err != nil {
+ t.Fatalf("GetProfile: %v", err)
+ }
+ if saved == nil {
+ t.Fatal("expected saved profile")
+ }
+ if saved.BusinessName != "厂区入口" {
+ t.Fatalf("expected business name, got %#v", saved)
+ }
+ if saved.TemplateName != "helmet" {
+ t.Fatalf("expected template name to be inferred, got %#v", saved)
+ }
+ if saved.Description != "白班识别" {
+ t.Fatalf("expected description, got %#v", saved)
+ }
+}
diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go
index 01d541f..87f7f0a 100644
--- a/internal/service/config_preview.go
+++ b/internal/service/config_preview.go
@@ -15,12 +15,14 @@ import (
"time"
"3588AdminBackend/internal/config"
+ "3588AdminBackend/internal/storage"
)
var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
type ConfigPreviewService struct {
- cfg *config.Config
+ cfg *config.Config
+ assets *storage.AssetsRepo
}
type ConfigSource struct {
@@ -52,11 +54,25 @@ type ConfigPreviewResult struct {
JSON string `json:"json"`
}
-func NewConfigPreviewService(cfg *config.Config) *ConfigPreviewService {
- return &ConfigPreviewService{cfg: cfg}
+type ConfigAssetImportResult struct {
+ Root string `json:"root"`
+ Templates int `json:"templates"`
+ Profiles int `json:"profiles"`
+ Overlays int `json:"overlays"`
+}
+
+func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *ConfigPreviewService {
+ var assets *storage.AssetsRepo
+ if len(repo) > 0 {
+ assets = repo[0]
+ }
+ return &ConfigPreviewService{cfg: cfg, assets: assets}
}
func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) {
+ if out, ok, err := s.listRepoSources(); ok || err != nil {
+ return out, err
+ }
root := s.mediaRepoRoot()
if root == "" {
return defaultConfigPreviewSources(""), nil
@@ -88,6 +104,38 @@ func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) {
return out, nil
}
+func (s *ConfigPreviewService) listRepoSources() (ConfigPreviewSources, bool, error) {
+ if s == nil || s.assets == nil {
+ return ConfigPreviewSources{}, false, nil
+ }
+ templates, err := s.assets.ListTemplates()
+ if err != nil {
+ return ConfigPreviewSources{}, true, err
+ }
+ profiles, err := s.assets.ListProfiles()
+ if err != nil {
+ return ConfigPreviewSources{}, true, err
+ }
+ overlays, err := s.assets.ListOverlays()
+ if err != nil {
+ return ConfigPreviewSources{}, true, err
+ }
+ if len(templates) == 0 && len(profiles) == 0 && len(overlays) == 0 {
+ return ConfigPreviewSources{}, false, nil
+ }
+ out := ConfigPreviewSources{Root: "SQLite"}
+ for _, item := range templates {
+ out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
+ }
+ for _, item := range profiles {
+ out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
+ }
+ for _, item := range overlays {
+ out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
+ }
+ return out, true, nil
+}
+
func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
root := s.mediaRepoRoot()
if root == "" {
@@ -284,3 +332,115 @@ func defaultConfigPreviewSources(root string) ConfigPreviewSources {
},
}
}
+
+func repoAssetPath(kind string, name string) string {
+ return "sqlite:" + kind + "/" + strings.TrimSpace(name)
+}
+
+func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportResult, error) {
+ if s == nil || s.assets == nil {
+ return nil, fmt.Errorf("assets repository is not configured")
+ }
+ root := s.mediaRepoRoot()
+ if root == "" {
+ return nil, fmt.Errorf("media repo path is not configured")
+ }
+ result := &ConfigAssetImportResult{Root: root}
+ for _, item := range []struct {
+ kind string
+ inc *int
+ }{
+ {kind: "templates", inc: &result.Templates},
+ {kind: "profiles", inc: &result.Profiles},
+ {kind: "overlays", inc: &result.Overlays},
+ } {
+ sources, err := listConfigSources(filepath.Join(root, "configs", item.kind))
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ body, err := os.ReadFile(source.Path)
+ if err != nil {
+ return nil, err
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, err
+ }
+ name := firstString(raw["name"], source.Name)
+ description := stringValue(raw["description"])
+ switch item.kind {
+ case "templates":
+ if err := s.assets.SaveTemplate(name, description, string(body)); err != nil {
+ return nil, err
+ }
+ case "profiles":
+ if err := s.assets.SaveProfile(name, profileRawTemplateName(raw), stringValue(raw["business_name"]), description, string(body)); err != nil {
+ return nil, err
+ }
+ case "overlays":
+ if err := s.assets.SaveOverlay(name, description, string(body)); err != nil {
+ return nil, err
+ }
+ }
+ *item.inc = *item.inc + 1
+ }
+ }
+ return result, nil
+}
+
+func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte, string, error) {
+ if err := validateConfigName(name); err != nil {
+ return nil, "", err
+ }
+ if s != nil && s.assets != nil {
+ if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
+ return body, name + ".json", err
+ }
+ }
+ root := s.mediaRepoRoot()
+ if root == "" {
+ return nil, "", fmt.Errorf("media repo path is not configured")
+ }
+ path := filepath.Join(root, "configs", kind, name+".json")
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return nil, "", err
+ }
+ return body, name + ".json", nil
+}
+
+func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) {
+ var (
+ record *storage.AssetRecord
+ err error
+ )
+ switch kind {
+ case "templates":
+ record, err = s.assets.GetTemplate(name)
+ case "profiles":
+ record, err = s.assets.GetProfile(name)
+ case "overlays":
+ record, err = s.assets.GetOverlay(name)
+ default:
+ return nil, true, fmt.Errorf("unsupported asset kind: %s", kind)
+ }
+ if err != nil {
+ return nil, true, err
+ }
+ if record == nil {
+ return nil, false, nil
+ }
+ return []byte(record.BodyJSON), true, nil
+}
+
+func profileRawTemplateName(raw map[string]any) string {
+ instances, _ := raw["instances"].([]any)
+ for _, item := range instances {
+ instanceMap, _ := item.(map[string]any)
+ if v := stringValue(instanceMap["template"]); v != "" {
+ return v
+ }
+ }
+ return stringValue(raw["template_name"])
+}
diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go
index bd4c177..f6f2a09 100644
--- a/internal/service/config_preview_test.go
+++ b/internal/service/config_preview_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"3588AdminBackend/internal/config"
+ "3588AdminBackend/internal/storage"
)
func TestConfigPreviewServiceListsSources(t *testing.T) {
@@ -50,6 +51,63 @@ func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
}
}
+func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
+ root := t.TempDir()
+ mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
+ mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
+ mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`)
+
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+
+ repo := storage.NewAssetsRepo(store.DB())
+ svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
+ result, err := svc.ImportAssetsFromMediaRepo()
+ if err != nil {
+ t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
+ }
+ if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
+ t.Fatalf("unexpected import result: %#v", result)
+ }
+
+ sources, err := svc.ListSources()
+ if err != nil {
+ t.Fatalf("ListSources: %v", err)
+ }
+ if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
+ t.Fatalf("unexpected templates after import: %#v", got)
+ }
+}
+
+func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) {
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+
+ repo := storage.NewAssetsRepo(store.DB())
+ const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n"
+ if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil {
+ t.Fatalf("SaveTemplate: %v", err)
+ }
+
+ svc := NewConfigPreviewService(&config.Config{}, repo)
+ body, filename, err := svc.ExportAssetJSON("templates", "helmet")
+ if err != nil {
+ t.Fatalf("ExportAssetJSON: %v", err)
+ }
+ if filename != "helmet.json" {
+ t.Fatalf("unexpected export filename: %q", filename)
+ }
+ if string(body) != raw {
+ t.Fatalf("unexpected export body: %s", string(body))
+ }
+}
+
func sourceNames(items []ConfigSource) []string {
out := make([]string, 0, len(items))
for _, item := range items {
diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go
index 12650b4..f5a9db1 100644
--- a/internal/service/profile_editor.go
+++ b/internal/service/profile_editor.go
@@ -197,15 +197,24 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
if err != nil {
return err
}
+ body, err := marshalConfigJSON(doc)
+ if err != nil {
+ return err
+ }
+ if s != nil && s.assets != nil {
+ return s.assets.SaveProfile(
+ strings.TrimSpace(editor.Name),
+ firstProfileTemplate(editor.Instances),
+ strings.TrimSpace(editor.BusinessName),
+ strings.TrimSpace(editor.Description),
+ string(body),
+ )
+ }
root := s.mediaRepoRoot()
if root == "" {
return fmt.Errorf("media repo path is not configured")
}
path := filepath.Join(root, "configs", "profiles", strings.TrimSpace(editor.Name)+".json")
- body, err := marshalConfigJSON(doc)
- if err != nil {
- return err
- }
return os.WriteFile(path, body, 0o644)
}
@@ -222,3 +231,15 @@ func marshalConfigJSON(doc map[string]any) ([]byte, error) {
}
return append(body, '\n'), nil
}
+
+func firstProfileTemplate(instances []ConfigProfileInstanceEditor) string {
+ for _, inst := range instances {
+ if inst.Delete {
+ continue
+ }
+ if v := strings.TrimSpace(inst.Template); v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/internal/service/registry.go b/internal/service/registry.go
index 8ec537a..ba0de05 100644
--- a/internal/service/registry.go
+++ b/internal/service/registry.go
@@ -13,14 +13,25 @@ import (
type RegistryService struct {
cfg *config.Config
agent *AgentClient
+ repo DeviceRepository
mu sync.RWMutex
devices map[string]*models.Device
}
-func NewRegistryService(cfg *config.Config, agent *AgentClient) *RegistryService {
+type DeviceRepository interface {
+ Upsert(dev *models.Device) error
+ List() ([]*models.Device, error)
+}
+
+func NewRegistryService(cfg *config.Config, agent *AgentClient, repo ...DeviceRepository) *RegistryService {
+ var deviceRepo DeviceRepository
+ if len(repo) > 0 {
+ deviceRepo = repo[0]
+ }
s := &RegistryService{
cfg: cfg,
agent: agent,
+ repo: deviceRepo,
devices: make(map[string]*models.Device),
}
go s.startPruning()
@@ -50,6 +61,7 @@ func (s *RegistryService) startGraphPolling() {
s.mu.Lock()
dev.Graphs = graphs
s.mu.Unlock()
+ s.persistDevice(dev)
}
}
}
@@ -62,10 +74,20 @@ func (s *RegistryService) UpdateDevice(dev *models.Device) {
dev.LastSeenMs = time.Now().UnixMilli()
dev.Online = true
- if s.cfg != nil && s.cfg.DeviceAliases != nil {
- dev.DeviceAlias = strings.TrimSpace(s.cfg.DeviceAliases[dev.DeviceID])
+ if current, ok := s.devices[dev.DeviceID]; ok && strings.TrimSpace(current.DeviceAlias) != "" {
+ dev.DeviceAlias = strings.TrimSpace(current.DeviceAlias)
+ } else if s.repo != nil {
+ if saved, err := s.repo.List(); err == nil {
+ for _, item := range saved {
+ if item != nil && item.DeviceID == dev.DeviceID && strings.TrimSpace(item.DeviceAlias) != "" {
+ dev.DeviceAlias = strings.TrimSpace(item.DeviceAlias)
+ break
+ }
+ }
+ }
}
s.devices[dev.DeviceID] = dev
+ s.persistDevice(dev)
}
func (s *RegistryService) SetDeviceAlias(deviceID string, alias string) error {
@@ -73,22 +95,12 @@ func (s *RegistryService) SetDeviceAlias(deviceID string, alias string) error {
defer s.mu.Unlock()
alias = strings.TrimSpace(alias)
- if s.cfg != nil {
- if s.cfg.DeviceAliases == nil {
- s.cfg.DeviceAliases = map[string]string{}
- }
- if alias == "" {
- delete(s.cfg.DeviceAliases, deviceID)
- } else {
- s.cfg.DeviceAliases[deviceID] = alias
- }
- if err := s.cfg.Save(); err != nil {
- return err
- }
- }
if dev, ok := s.devices[deviceID]; ok {
dev.DeviceAlias = alias
+ s.persistDevice(dev)
+ return nil
}
+ s.persistDevice(&models.Device{DeviceID: deviceID, DeviceAlias: alias})
return nil
}
@@ -112,6 +124,7 @@ func (s *RegistryService) TouchDevice(deviceID string) {
if dev, ok := s.devices[deviceID]; ok {
dev.LastSeenMs = time.Now().UnixMilli()
dev.Online = true
+ s.persistDevice(dev)
}
}
@@ -128,3 +141,10 @@ func (s *RegistryService) startPruning() {
s.mu.Unlock()
}
}
+
+func (s *RegistryService) persistDevice(dev *models.Device) {
+ if s == nil || s.repo == nil || dev == nil {
+ return
+ }
+ _ = s.repo.Upsert(dev)
+}
diff --git a/internal/service/registry_test.go b/internal/service/registry_test.go
index 6d37df2..c692923 100644
--- a/internal/service/registry_test.go
+++ b/internal/service/registry_test.go
@@ -3,6 +3,8 @@ package service
import (
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
+ "3588AdminBackend/internal/storage"
+ "path/filepath"
"testing"
"time"
)
@@ -36,9 +38,18 @@ func TestRegistryService_UpdateAndGet(t *testing.T) {
func TestRegistryService_DeviceAliasSurvivesAgentUpdate(t *testing.T) {
cfg := &config.Config{
OfflineAfterMs: 1000,
- DeviceAliases: map[string]string{"test-1": "备用盒子-01"},
}
- svc := NewRegistryService(cfg, nil)
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewDevicesRepo(store.DB())
+ svc := NewRegistryService(cfg, nil, repo)
+
+ if err := svc.SetDeviceAlias("test-1", "备用盒子-01"); err != nil {
+ t.Fatalf("SetDeviceAlias: %v", err)
+ }
svc.UpdateDevice(&models.Device{
DeviceID: "test-1",
@@ -65,6 +76,33 @@ func TestRegistryService_DeviceAliasSurvivesAgentUpdate(t *testing.T) {
}
}
+func TestRegistryService_SetDeviceAliasPersistsWithoutConfigSave(t *testing.T) {
+ cfg := &config.Config{OfflineAfterMs: 1000}
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewDevicesRepo(store.DB())
+ svc := NewRegistryService(cfg, nil, repo)
+
+ svc.UpdateDevice(&models.Device{DeviceID: "test-1", DeviceName: "rk3588_orangepi5plus", IP: "127.0.0.1"})
+ if err := svc.SetDeviceAlias("test-1", "备用盒子-01"); err != nil {
+ t.Fatalf("SetDeviceAlias: %v", err)
+ }
+
+ saved, err := repo.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(saved) != 1 || saved[0].DeviceAlias != "备用盒子-01" {
+ t.Fatalf("expected alias persisted in repo, got %#v", saved)
+ }
+ if len(cfg.DeviceAliases) != 0 {
+ t.Fatalf("expected config aliases to stay unused, got %#v", cfg.DeviceAliases)
+ }
+}
+
func TestRegistryService_Pruning(t *testing.T) {
cfg := &config.Config{
OfflineAfterMs: 100, // 100ms
diff --git a/internal/service/task.go b/internal/service/task.go
index 3a071f6..2fcccea 100644
--- a/internal/service/task.go
+++ b/internal/service/task.go
@@ -12,21 +12,56 @@ import (
"github.com/google/uuid"
)
+type TaskRepository interface {
+ Save(task *models.Task) error
+ List() ([]models.Task, error)
+}
+
+type DeviceConfigStateRepository interface {
+ UpsertState(deviceID string, templateName string, profileName string, overlaysJSON string, configID string, configVersion string, lastAppliedTaskID string) error
+}
+
+type AuditLogRepository interface {
+ AppendLog(actor string, action string, targetType string, targetID string, detailsJSON string) error
+}
+
type TaskService struct {
cfg *config.Config
agent *AgentClient
registry *RegistryService
+ repo TaskRepository
+ stateRepo DeviceConfigStateRepository
+ auditRepo AuditLogRepository
tasks map[string]*models.Task
mu sync.RWMutex
listeners map[string][]chan *models.DeviceTaskStatus
lmu sync.RWMutex
}
-func NewTaskService(cfg *config.Config, agent *AgentClient, registry *RegistryService) *TaskService {
+func (s *TaskService) SetDeviceConfigStateRepo(repo DeviceConfigStateRepository) {
+ if s == nil {
+ return
+ }
+ s.stateRepo = repo
+}
+
+func (s *TaskService) SetAuditLogRepo(repo AuditLogRepository) {
+ if s == nil {
+ return
+ }
+ s.auditRepo = repo
+}
+
+func NewTaskService(cfg *config.Config, agent *AgentClient, registry *RegistryService, repo ...TaskRepository) *TaskService {
+ var taskRepo TaskRepository
+ if len(repo) > 0 {
+ taskRepo = repo[0]
+ }
return &TaskService{
cfg: cfg,
agent: agent,
registry: registry,
+ repo: taskRepo,
tasks: make(map[string]*models.Task),
listeners: make(map[string][]chan *models.DeviceTaskStatus),
}
@@ -71,15 +106,47 @@ func (s *TaskService) CreateTask(tType string, deviceIDs []string, payload inter
s.mu.Lock()
s.tasks[id] = task
s.mu.Unlock()
+ s.persistTask(task)
go s.runTask(task)
return task, nil
}
+func (s *TaskService) LoadPersistedTasks() error {
+ if s == nil || s.repo == nil {
+ return nil
+ }
+ items, err := s.repo.List()
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for i := range items {
+ item := items[i]
+ s.tasks[item.ID] = models.NewTask(item.ID, item.Type, append([]string(nil), item.DeviceIDs...), item.Payload)
+ s.tasks[item.ID].Status = item.Status
+ for did, ds := range item.Devices {
+ if ds == nil {
+ continue
+ }
+ s.tasks[item.ID].Devices[did] = &models.DeviceTaskStatus{
+ DeviceID: ds.DeviceID,
+ Status: ds.Status,
+ Progress: ds.Progress,
+ Error: ds.Error,
+ }
+ }
+ }
+ return nil
+}
+
func (s *TaskService) runTask(task *models.Task) {
task.Mu.Lock()
task.Status = models.TaskRunning
task.Mu.Unlock()
+ s.persistTask(task)
// Concurrency control
concurrency := s.cfg.Concurrency
@@ -117,6 +184,7 @@ func (s *TaskService) runTask(task *models.Task) {
task.Status = models.TaskFailed
}
task.Mu.Unlock()
+ s.persistTask(task)
}
func extractConfigPayload(payload any) (any, error) {
@@ -206,6 +274,8 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
+ s.persistConfigState(task, did)
+ s.appendAuditLog(task, did, models.TaskSuccess, "")
case "reload":
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/reload", nil, "", 0)
@@ -218,6 +288,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
+ s.appendAuditLog(task, did, models.TaskSuccess, "")
case "rollback":
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/rollback", nil, "", 0)
@@ -230,6 +301,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
+ s.appendAuditLog(task, did, models.TaskSuccess, "")
case "media_start":
bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload)
@@ -247,6 +319,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
+ s.appendAuditLog(task, did, models.TaskSuccess, "")
case "media_restart":
bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload)
@@ -264,6 +337,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
+ s.appendAuditLog(task, did, models.TaskSuccess, "")
case "media_stop":
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/stop", nil, "", 0)
@@ -276,6 +350,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
+ s.appendAuditLog(task, did, models.TaskSuccess, "")
default:
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "unsupported task type")
@@ -298,6 +373,7 @@ func (s *TaskService) updateDeviceStatus(taskID, did string, status models.TaskS
ds.Error = errStr
}
task.Mu.Unlock()
+ s.persistTask(task)
// Notify listeners
s.lmu.RLock()
@@ -319,6 +395,102 @@ func (s *TaskService) updateDeviceStatus(taskID, did string, status models.TaskS
}
}
+func (s *TaskService) persistConfigState(task *models.Task, did string) {
+ if s == nil || s.stateRepo == nil || task == nil || task.Type != "config_apply" {
+ return
+ }
+ meta := taskPayloadMetadata(task.Payload)
+ overlaysJSON := "[]"
+ if len(meta.Overlays) > 0 {
+ if body, err := json.Marshal(meta.Overlays); err == nil {
+ overlaysJSON = string(body)
+ }
+ }
+ _ = s.stateRepo.UpsertState(did, meta.Template, meta.Profile, overlaysJSON, meta.ConfigID, meta.ConfigVersion, task.ID)
+}
+
+func (s *TaskService) appendAuditLog(task *models.Task, did string, status models.TaskStatus, errText string) {
+ if s == nil || s.auditRepo == nil || task == nil {
+ return
+ }
+ meta := taskPayloadMetadata(task.Payload)
+ details := map[string]any{
+ "task_id": task.ID,
+ "type": task.Type,
+ "status": status,
+ }
+ if meta.Template != "" {
+ details["template"] = meta.Template
+ }
+ if meta.Profile != "" {
+ details["profile"] = meta.Profile
+ }
+ if meta.ConfigID != "" {
+ details["config_id"] = meta.ConfigID
+ }
+ if meta.ConfigVersion != "" {
+ details["config_version"] = meta.ConfigVersion
+ }
+ if len(meta.Overlays) > 0 {
+ details["overlays"] = meta.Overlays
+ }
+ if errText != "" {
+ details["error"] = errText
+ }
+ body, _ := json.Marshal(details)
+ _ = s.auditRepo.AppendLog("system", task.Type, "device", did, string(body))
+}
+
+type taskMetadata struct {
+ Template string
+ Profile string
+ Overlays []string
+ ConfigID string
+ ConfigVersion string
+}
+
+func taskPayloadMetadata(payload any) taskMetadata {
+ var out taskMetadata
+ root, ok := payload.(map[string]any)
+ if !ok {
+ return out
+ }
+ configRoot, ok := root["config"].(map[string]any)
+ if !ok {
+ return out
+ }
+ metadata, ok := configRoot["metadata"].(map[string]any)
+ if !ok {
+ return out
+ }
+ out.Template = stringAny(metadata["template"])
+ out.Profile = stringAny(metadata["profile"])
+ out.ConfigID = stringAny(metadata["config_id"])
+ out.ConfigVersion = stringAny(metadata["config_version"])
+ if rawOverlays, ok := metadata["overlays"].([]any); ok {
+ for _, item := range rawOverlays {
+ if v := stringAny(item); v != "" {
+ out.Overlays = append(out.Overlays, v)
+ }
+ }
+ }
+ return out
+}
+
+func stringAny(v any) string {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ return ""
+}
+
+func (s *TaskService) persistTask(task *models.Task) {
+ if s == nil || s.repo == nil || task == nil {
+ return
+ }
+ _ = s.repo.Save(task)
+}
+
func (s *TaskService) Subscribe(taskID string) (chan *models.DeviceTaskStatus, func()) {
ch := make(chan *models.DeviceTaskStatus, 10)
s.lmu.Lock()
diff --git a/internal/service/task_test.go b/internal/service/task_test.go
index bbe8416..d8ad23e 100644
--- a/internal/service/task_test.go
+++ b/internal/service/task_test.go
@@ -3,12 +3,14 @@ package service
import (
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
+ "3588AdminBackend/internal/storage"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
+ "path/filepath"
"strconv"
"testing"
"time"
@@ -177,3 +179,67 @@ func TestTaskService_MediaStart_IgnoresInvalidConfigShape(t *testing.T) {
t.Fatalf("expected empty body, got %q", string(bodyBytes))
}
}
+
+func TestTaskService_ConfigApplyPersistsDeviceConfigStateAndAudit(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"ok":true}`))
+ }))
+ defer server.Close()
+
+ u, _ := url.Parse(server.URL)
+ host, portStr, err := net.SplitHostPort(u.Host)
+ if err != nil {
+ t.Fatalf("SplitHostPort(%q): %v", u.Host, err)
+ }
+ port, _ := strconv.Atoi(portStr)
+
+ cfg := &config.Config{Concurrency: 1}
+ agent := NewAgentClient(cfg)
+ reg := NewRegistryService(cfg, agent)
+ reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true})
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+
+ svc := NewTaskService(cfg, agent, reg)
+ svc.SetDeviceConfigStateRepo(storage.NewDeviceConfigStateRepo(store.DB()))
+ svc.SetAuditLogRepo(storage.NewAuditLogsRepo(store.DB()))
+
+ payload := map[string]any{
+ "config": map[string]any{
+ "metadata": map[string]any{
+ "template": "helmet",
+ "profile": "gate_a",
+ "overlays": []any{"night_relaxed"},
+ "config_id": "cfg-001",
+ "config_version": "20260427.1",
+ },
+ },
+ }
+ task, err := svc.CreateTask("config_apply", []string{"dev1"}, payload)
+ if err != nil {
+ t.Fatalf("CreateTask: %v", err)
+ }
+ if st := waitForTaskDone(t, task, 2*time.Second); st != models.TaskSuccess {
+ t.Fatalf("expected task success, got %s", st)
+ }
+
+ state, err := storage.NewDeviceConfigStateRepo(store.DB()).Get("dev1")
+ if err != nil {
+ t.Fatalf("Get state: %v", err)
+ }
+ if state == nil || state.ProfileName != "gate_a" || state.ConfigID != "cfg-001" || state.LastAppliedTaskID != task.ID {
+ t.Fatalf("unexpected state: %#v", state)
+ }
+
+ logs, err := storage.NewAuditLogsRepo(store.DB()).List()
+ if err != nil {
+ t.Fatalf("List audit logs: %v", err)
+ }
+ if len(logs) == 0 || logs[0].Action != "config_apply" || logs[0].TargetID != "dev1" {
+ t.Fatalf("unexpected audit logs: %#v", logs)
+ }
+}
diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go
new file mode 100644
index 0000000..cbba370
--- /dev/null
+++ b/internal/storage/assets_repo.go
@@ -0,0 +1,168 @@
+package storage
+
+import (
+ "database/sql"
+ "time"
+)
+
+type AssetRecord struct {
+ Name string
+ Description string
+ TemplateName string
+ BusinessName string
+ BodyJSON string
+ CreatedAt string
+ UpdatedAt string
+}
+
+type AssetsRepo struct {
+ db *sql.DB
+}
+
+func NewAssetsRepo(db *sql.DB) *AssetsRepo {
+ return &AssetsRepo{db: db}
+}
+
+func (r *AssetsRepo) SaveTemplate(name string, description string, bodyJSON string) error {
+ return r.saveAsset("templates", AssetRecord{
+ Name: name,
+ Description: description,
+ BodyJSON: bodyJSON,
+ })
+}
+
+func (r *AssetsRepo) SaveProfile(name string, templateName string, businessName string, description string, bodyJSON string) error {
+ return r.saveAsset("profiles", AssetRecord{
+ Name: name,
+ TemplateName: templateName,
+ BusinessName: businessName,
+ Description: description,
+ BodyJSON: bodyJSON,
+ })
+}
+
+func (r *AssetsRepo) SaveOverlay(name string, description string, bodyJSON string) error {
+ return r.saveAsset("overlays", AssetRecord{
+ Name: name,
+ Description: description,
+ BodyJSON: bodyJSON,
+ })
+}
+
+func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) {
+ return r.listAssets("templates")
+}
+
+func (r *AssetsRepo) ListProfiles() ([]AssetRecord, error) {
+ return r.listAssets("profiles")
+}
+
+func (r *AssetsRepo) ListOverlays() ([]AssetRecord, error) {
+ return r.listAssets("overlays")
+}
+
+func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) {
+ return r.getAsset("templates", name)
+}
+
+func (r *AssetsRepo) GetProfile(name string) (*AssetRecord, error) {
+ return r.getAsset("profiles", name)
+}
+
+func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) {
+ return r.getAsset("overlays", name)
+}
+
+func (r *AssetsRepo) saveAsset(table string, record AssetRecord) error {
+ if r == nil || r.db == nil {
+ return nil
+ }
+ now := time.Now().Format(time.RFC3339)
+ switch table {
+ case "templates", "overlays":
+ _, err := r.db.Exec(`
+INSERT INTO `+table+`(name, description, body_json, created_at, updated_at)
+VALUES(?, ?, ?, COALESCE((SELECT created_at FROM `+table+` WHERE name = ?), ?), ?)
+ON CONFLICT(name) DO UPDATE SET
+ description=excluded.description,
+ body_json=excluded.body_json,
+ updated_at=excluded.updated_at
+`, record.Name, record.Description, record.BodyJSON, record.Name, now, now)
+ return err
+ case "profiles":
+ _, err := r.db.Exec(`
+INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at)
+VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM profiles WHERE name = ?), ?), ?)
+ON CONFLICT(name) DO UPDATE SET
+ template_name=excluded.template_name,
+ business_name=excluded.business_name,
+ description=excluded.description,
+ body_json=excluded.body_json,
+ updated_at=excluded.updated_at
+`, record.Name, record.TemplateName, record.BusinessName, record.Description, record.BodyJSON, record.Name, now, now)
+ return err
+ default:
+ return nil
+ }
+}
+
+func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ query := `
+SELECT name, description, body_json, created_at, updated_at, '', ''
+FROM ` + table + `
+ORDER BY updated_at DESC, name ASC
+`
+ if table == "profiles" {
+ query = `
+SELECT name, description, body_json, created_at, updated_at, template_name, business_name
+FROM profiles
+ORDER BY updated_at DESC, name ASC
+`
+ }
+ rows, err := r.db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []AssetRecord
+ for rows.Next() {
+ var item AssetRecord
+ if err := rows.Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName); err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, rows.Err()
+}
+
+func (r *AssetsRepo) getAsset(table string, name string) (*AssetRecord, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ query := `
+SELECT name, description, body_json, created_at, updated_at, '', ''
+FROM ` + table + `
+WHERE name = ?
+`
+ if table == "profiles" {
+ query = `
+SELECT name, description, body_json, created_at, updated_at, template_name, business_name
+FROM profiles
+WHERE name = ?
+`
+ }
+
+ var item AssetRecord
+ err := r.db.QueryRow(query, name).Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go
new file mode 100644
index 0000000..e1abe3c
--- /dev/null
+++ b/internal/storage/assets_repo_test.go
@@ -0,0 +1,41 @@
+package storage
+
+import "testing"
+
+func TestAssetsRepoStoresTemplateProfileAndOverlayJSON(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+
+ repo := NewAssetsRepo(store.DB())
+ if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
+ t.Fatalf("SaveTemplate: %v", err)
+ }
+ if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A"}}]}`); err != nil {
+ t.Fatalf("SaveProfile: %v", err)
+ }
+ if err := repo.SaveOverlay("night_relaxed", "overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil {
+ t.Fatalf("SaveOverlay: %v", err)
+ }
+
+ templates, err := repo.ListTemplates()
+ if err != nil {
+ t.Fatalf("ListTemplates: %v", err)
+ }
+ profiles, err := repo.ListProfiles()
+ if err != nil {
+ t.Fatalf("ListProfiles: %v", err)
+ }
+ overlays, err := repo.ListOverlays()
+ if err != nil {
+ t.Fatalf("ListOverlays: %v", err)
+ }
+ if len(templates) != 1 || templates[0].Name != "helmet" {
+ t.Fatalf("unexpected templates: %#v", templates)
+ }
+ if len(profiles) != 1 || profiles[0].Name != "gate_a" || profiles[0].TemplateName != "helmet" {
+ t.Fatalf("unexpected profiles: %#v", profiles)
+ }
+ if len(overlays) != 1 || overlays[0].Name != "night_relaxed" {
+ t.Fatalf("unexpected overlays: %#v", overlays)
+ }
+}
diff --git a/internal/storage/audit_logs_repo.go b/internal/storage/audit_logs_repo.go
new file mode 100644
index 0000000..9160feb
--- /dev/null
+++ b/internal/storage/audit_logs_repo.go
@@ -0,0 +1,74 @@
+package storage
+
+import (
+ "database/sql"
+ "time"
+)
+
+type AuditLogRecord struct {
+ ID int64
+ Actor string
+ Action string
+ TargetType string
+ TargetID string
+ DetailsJSON string
+ CreatedAt string
+}
+
+type AuditLogsRepo struct {
+ db *sql.DB
+}
+
+func NewAuditLogsRepo(db *sql.DB) *AuditLogsRepo {
+ return &AuditLogsRepo{db: db}
+}
+
+func (r *AuditLogsRepo) Append(entry AuditLogRecord) error {
+ if r == nil || r.db == nil {
+ return nil
+ }
+ actor := entry.Actor
+ if actor == "" {
+ actor = "system"
+ }
+ _, err := r.db.Exec(`
+INSERT INTO audit_logs(actor, action, target_type, target_id, details_json, created_at)
+VALUES(?, ?, ?, ?, ?, ?)
+`, actor, entry.Action, entry.TargetType, entry.TargetID, entry.DetailsJSON, time.Now().Format(time.RFC3339))
+ return err
+}
+
+func (r *AuditLogsRepo) AppendLog(actor string, action string, targetType string, targetID string, detailsJSON string) error {
+ return r.Append(AuditLogRecord{
+ Actor: actor,
+ Action: action,
+ TargetType: targetType,
+ TargetID: targetID,
+ DetailsJSON: detailsJSON,
+ })
+}
+
+func (r *AuditLogsRepo) List() ([]AuditLogRecord, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ rows, err := r.db.Query(`
+SELECT id, actor, action, target_type, target_id, details_json, created_at
+FROM audit_logs
+ORDER BY id DESC
+`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []AuditLogRecord
+ for rows.Next() {
+ var item AuditLogRecord
+ if err := rows.Scan(&item.ID, &item.Actor, &item.Action, &item.TargetType, &item.TargetID, &item.DetailsJSON, &item.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/storage/audit_logs_repo_test.go b/internal/storage/audit_logs_repo_test.go
new file mode 100644
index 0000000..bf91a04
--- /dev/null
+++ b/internal/storage/audit_logs_repo_test.go
@@ -0,0 +1,30 @@
+package storage
+
+import "testing"
+
+func TestAuditLogsRepoAppendsAndListsEntries(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+
+ repo := NewAuditLogsRepo(store.DB())
+ if err := repo.Append(AuditLogRecord{
+ Actor: "system",
+ Action: "config_apply",
+ TargetType: "device",
+ TargetID: "edge-01",
+ DetailsJSON: `{"task_id":"task-1"}`,
+ }); err != nil {
+ t.Fatalf("Append: %v", err)
+ }
+
+ items, err := repo.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("expected one audit log, got %d", len(items))
+ }
+ if items[0].Action != "config_apply" || items[0].TargetID != "edge-01" {
+ t.Fatalf("unexpected audit log: %#v", items[0])
+ }
+}
diff --git a/internal/storage/device_config_state_repo.go b/internal/storage/device_config_state_repo.go
new file mode 100644
index 0000000..cefca59
--- /dev/null
+++ b/internal/storage/device_config_state_repo.go
@@ -0,0 +1,76 @@
+package storage
+
+import (
+ "database/sql"
+ "time"
+)
+
+type DeviceConfigStateRecord struct {
+ DeviceID string
+ TemplateName string
+ ProfileName string
+ OverlaysJSON string
+ ConfigID string
+ ConfigVersion string
+ LastAppliedTaskID string
+ UpdatedAt string
+}
+
+type DeviceConfigStateRepo struct {
+ db *sql.DB
+}
+
+func NewDeviceConfigStateRepo(db *sql.DB) *DeviceConfigStateRepo {
+ return &DeviceConfigStateRepo{db: db}
+}
+
+func (r *DeviceConfigStateRepo) Upsert(state DeviceConfigStateRecord) error {
+ if r == nil || r.db == nil {
+ return nil
+ }
+ now := time.Now().Format(time.RFC3339)
+ _, err := r.db.Exec(`
+INSERT INTO device_config_state(device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at)
+VALUES(?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(device_id) DO UPDATE SET
+ template_name=excluded.template_name,
+ profile_name=excluded.profile_name,
+ overlays_json=excluded.overlays_json,
+ config_id=excluded.config_id,
+ config_version=excluded.config_version,
+ last_applied_task_id=excluded.last_applied_task_id,
+ updated_at=excluded.updated_at
+`, state.DeviceID, state.TemplateName, state.ProfileName, state.OverlaysJSON, state.ConfigID, state.ConfigVersion, state.LastAppliedTaskID, now)
+ return err
+}
+
+func (r *DeviceConfigStateRepo) UpsertState(deviceID string, templateName string, profileName string, overlaysJSON string, configID string, configVersion string, lastAppliedTaskID string) error {
+ return r.Upsert(DeviceConfigStateRecord{
+ DeviceID: deviceID,
+ TemplateName: templateName,
+ ProfileName: profileName,
+ OverlaysJSON: overlaysJSON,
+ ConfigID: configID,
+ ConfigVersion: configVersion,
+ LastAppliedTaskID: lastAppliedTaskID,
+ })
+}
+
+func (r *DeviceConfigStateRepo) Get(deviceID string) (*DeviceConfigStateRecord, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ var item DeviceConfigStateRecord
+ err := r.db.QueryRow(`
+SELECT device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at
+FROM device_config_state
+WHERE device_id = ?
+`, deviceID).Scan(&item.DeviceID, &item.TemplateName, &item.ProfileName, &item.OverlaysJSON, &item.ConfigID, &item.ConfigVersion, &item.LastAppliedTaskID, &item.UpdatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
diff --git a/internal/storage/device_config_state_repo_test.go b/internal/storage/device_config_state_repo_test.go
new file mode 100644
index 0000000..75ed2ca
--- /dev/null
+++ b/internal/storage/device_config_state_repo_test.go
@@ -0,0 +1,33 @@
+package storage
+
+import "testing"
+
+func TestDeviceConfigStateRepoUpsertsAndGetsState(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+
+ repo := NewDeviceConfigStateRepo(store.DB())
+ err := repo.Upsert(DeviceConfigStateRecord{
+ DeviceID: "edge-01",
+ TemplateName: "helmet",
+ ProfileName: "gate_a",
+ OverlaysJSON: `["night_relaxed"]`,
+ ConfigID: "cfg-001",
+ ConfigVersion: "20260427.1",
+ LastAppliedTaskID: "task-1",
+ })
+ if err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+
+ item, err := repo.Get("edge-01")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if item == nil {
+ t.Fatal("expected config state")
+ }
+ if item.ProfileName != "gate_a" || item.ConfigVersion != "20260427.1" || item.LastAppliedTaskID != "task-1" {
+ t.Fatalf("unexpected state: %#v", item)
+ }
+}
diff --git a/internal/storage/devices_repo.go b/internal/storage/devices_repo.go
new file mode 100644
index 0000000..7167e82
--- /dev/null
+++ b/internal/storage/devices_repo.go
@@ -0,0 +1,93 @@
+package storage
+
+import (
+ "database/sql"
+ "encoding/json"
+ "time"
+
+ "3588AdminBackend/internal/models"
+)
+
+type DevicesRepo struct {
+ db *sql.DB
+}
+
+func NewDevicesRepo(db *sql.DB) *DevicesRepo {
+ return &DevicesRepo{db: db}
+}
+
+func (r *DevicesRepo) Upsert(dev *models.Device) error {
+ if r == nil || r.db == nil || dev == nil {
+ return nil
+ }
+ graphs, err := json.Marshal(dev.Graphs)
+ if err != nil {
+ return err
+ }
+ if string(graphs) == "null" {
+ graphs = []byte(`{}`)
+ }
+ _, err = r.db.Exec(`
+INSERT INTO devices(device_id, hostname, ip, agent_port, media_port, alias, device_name, version, git_sha, build_id, last_seen_ms, online, graphs_json, updated_at)
+VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(device_id) DO UPDATE SET
+ hostname=excluded.hostname,
+ ip=excluded.ip,
+ agent_port=excluded.agent_port,
+ media_port=excluded.media_port,
+ alias=excluded.alias,
+ device_name=excluded.device_name,
+ version=excluded.version,
+ git_sha=excluded.git_sha,
+ build_id=excluded.build_id,
+ last_seen_ms=excluded.last_seen_ms,
+ online=excluded.online,
+ graphs_json=excluded.graphs_json,
+ updated_at=excluded.updated_at
+`, dev.DeviceID, dev.Hostname, dev.IP, dev.AgentPort, dev.MediaPort, dev.DeviceAlias, dev.DeviceName, dev.Version, dev.GitSha, dev.BuildID, dev.LastSeenMs, boolToInt(dev.Online), string(graphs), time.Now().Format(time.RFC3339))
+ return err
+}
+
+func (r *DevicesRepo) List() ([]*models.Device, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ rows, err := r.db.Query(`
+SELECT device_id, hostname, ip, agent_port, media_port, alias, device_name, version, git_sha, build_id, last_seen_ms, online, graphs_json
+FROM devices
+ORDER BY updated_at DESC, device_id ASC
+`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []*models.Device
+ for rows.Next() {
+ var (
+ dev models.Device
+ onlineInt int
+ graphsJSON string
+ )
+ if err := rows.Scan(&dev.DeviceID, &dev.Hostname, &dev.IP, &dev.AgentPort, &dev.MediaPort, &dev.DeviceAlias, &dev.DeviceName, &dev.Version, &dev.GitSha, &dev.BuildID, &dev.LastSeenMs, &onlineInt, &graphsJSON); err != nil {
+ return nil, err
+ }
+ dev.Online = onlineInt == 1
+ if graphsJSON != "" && graphsJSON != "{}" {
+ var graphs any
+ if err := json.Unmarshal([]byte(graphsJSON), &graphs); err != nil {
+ return nil, err
+ }
+ dev.Graphs = graphs
+ }
+ out = append(out, &dev)
+ }
+ return out, rows.Err()
+}
+
+func boolToInt(v bool) int {
+ if v {
+ return 1
+ }
+ return 0
+}
diff --git a/internal/storage/devices_repo_test.go b/internal/storage/devices_repo_test.go
new file mode 100644
index 0000000..03b05cc
--- /dev/null
+++ b/internal/storage/devices_repo_test.go
@@ -0,0 +1,38 @@
+package storage
+
+import (
+ "testing"
+
+ "3588AdminBackend/internal/models"
+)
+
+func TestDevicesRepoUpsertsRuntimeSnapshot(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+
+ repo := NewDevicesRepo(store.DB())
+ dev := &models.Device{
+ DeviceID: "edge-01",
+ Hostname: "orangepi5plus",
+ IP: "10.0.0.8",
+ AgentPort: 9100,
+ MediaPort: 9000,
+ DeviceName: "入口识别节点",
+ Online: true,
+ Version: "1.0.0",
+ }
+ if err := repo.Upsert(dev); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+
+ saved, err := repo.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(saved) != 1 {
+ t.Fatalf("expected one device snapshot, got %d", len(saved))
+ }
+ if saved[0].DeviceID != "edge-01" || saved[0].IP != "10.0.0.8" || !saved[0].Online {
+ t.Fatalf("unexpected saved device snapshot: %#v", saved[0])
+ }
+}
diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go
new file mode 100644
index 0000000..b42d66b
--- /dev/null
+++ b/internal/storage/migrate.go
@@ -0,0 +1,88 @@
+package storage
+
+import "database/sql"
+
+const schema001 = `
+CREATE TABLE IF NOT EXISTS templates (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT NOT NULL DEFAULT '',
+ body_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS profiles (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ template_name TEXT NOT NULL DEFAULT '',
+ business_name TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ body_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS overlays (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT NOT NULL DEFAULT '',
+ body_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS devices (
+ device_id TEXT PRIMARY KEY,
+ hostname TEXT NOT NULL DEFAULT '',
+ ip TEXT NOT NULL DEFAULT '',
+ agent_port INTEGER NOT NULL DEFAULT 0,
+ media_port INTEGER NOT NULL DEFAULT 0,
+ alias TEXT NOT NULL DEFAULT '',
+ device_name TEXT NOT NULL DEFAULT '',
+ version TEXT NOT NULL DEFAULT '',
+ git_sha TEXT NOT NULL DEFAULT '',
+ build_id TEXT NOT NULL DEFAULT '',
+ last_seen_ms INTEGER NOT NULL DEFAULT 0,
+ online INTEGER NOT NULL DEFAULT 0,
+ graphs_json TEXT NOT NULL DEFAULT '{}',
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS device_config_state (
+ device_id TEXT PRIMARY KEY,
+ template_name TEXT NOT NULL DEFAULT '',
+ profile_name TEXT NOT NULL DEFAULT '',
+ overlays_json TEXT NOT NULL DEFAULT '[]',
+ config_id TEXT NOT NULL DEFAULT '',
+ config_version TEXT NOT NULL DEFAULT '',
+ last_applied_task_id TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS tasks (
+ task_id TEXT PRIMARY KEY,
+ type TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ status TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ finished_at TEXT NOT NULL DEFAULT ''
+);
+CREATE TABLE IF NOT EXISTS task_devices (
+ task_id TEXT NOT NULL,
+ device_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ progress REAL NOT NULL DEFAULT 0,
+ error_text TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (task_id, device_id)
+);
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id INTEGER PRIMARY KEY,
+ actor TEXT NOT NULL DEFAULT 'system',
+ action TEXT NOT NULL,
+ target_type TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ details_json TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL
+);
+`
+
+func migrate(db *sql.DB) error {
+ _, err := db.Exec(schema001)
+ return err
+}
diff --git a/internal/storage/paths.go b/internal/storage/paths.go
new file mode 100644
index 0000000..3cec361
--- /dev/null
+++ b/internal/storage/paths.go
@@ -0,0 +1,17 @@
+package storage
+
+import "path/filepath"
+
+type Paths struct {
+ DataDir string
+ DBPath string
+ LogDir string
+}
+
+func NewPaths(dataDir string) Paths {
+ return Paths{
+ DataDir: dataDir,
+ DBPath: filepath.Join(dataDir, "app.db"),
+ LogDir: filepath.Join(dataDir, "logs"),
+ }
+}
diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go
new file mode 100644
index 0000000..5d3f600
--- /dev/null
+++ b/internal/storage/sqlite.go
@@ -0,0 +1,51 @@
+package storage
+
+import (
+ "database/sql"
+ "os"
+ "path/filepath"
+
+ _ "modernc.org/sqlite"
+)
+
+type Store struct {
+ db *sql.DB
+}
+
+func OpenSQLite(path string) (*Store, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return nil, err
+ }
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ if err := migrate(db); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return &Store{db: db}, nil
+}
+
+func (s *Store) Close() error {
+ if s == nil || s.db == nil {
+ return nil
+ }
+ return s.db.Close()
+}
+
+func (s *Store) DB() *sql.DB {
+ if s == nil {
+ return nil
+ }
+ return s.db
+}
+
+func (s *Store) HasTable(name string) (bool, error) {
+ row := s.db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, name)
+ var count int
+ if err := row.Scan(&count); err != nil {
+ return false, err
+ }
+ return count > 0, nil
+}
diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go
new file mode 100644
index 0000000..126486b
--- /dev/null
+++ b/internal/storage/sqlite_test.go
@@ -0,0 +1,34 @@
+package storage
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "app.db")
+ store, err := OpenSQLite(dbPath)
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+
+ for _, table := range []string{
+ "templates",
+ "profiles",
+ "overlays",
+ "devices",
+ "device_config_state",
+ "tasks",
+ "task_devices",
+ "audit_logs",
+ } {
+ ok, err := store.HasTable(table)
+ if err != nil {
+ t.Fatalf("HasTable(%s): %v", table, err)
+ }
+ if !ok {
+ t.Fatalf("expected table %s to exist", table)
+ }
+ }
+}
diff --git a/internal/storage/tasks_repo.go b/internal/storage/tasks_repo.go
new file mode 100644
index 0000000..47351c4
--- /dev/null
+++ b/internal/storage/tasks_repo.go
@@ -0,0 +1,149 @@
+package storage
+
+import (
+ "database/sql"
+ "encoding/json"
+ "time"
+
+ "3588AdminBackend/internal/models"
+)
+
+type TasksRepo struct {
+ db *sql.DB
+}
+
+func NewTasksRepo(db *sql.DB) *TasksRepo {
+ return &TasksRepo{db: db}
+}
+
+func (r *TasksRepo) Save(task *models.Task) error {
+ if r == nil || r.db == nil || task == nil {
+ return nil
+ }
+
+ task.Mu.RLock()
+ payload, err := json.Marshal(task.Payload)
+ if err != nil {
+ task.Mu.RUnlock()
+ return err
+ }
+ status := task.Status
+ devices := make([]models.DeviceTaskStatus, 0, len(task.Devices))
+ for _, ds := range task.Devices {
+ if ds == nil {
+ continue
+ }
+ devices = append(devices, models.DeviceTaskStatus{
+ DeviceID: ds.DeviceID,
+ Status: ds.Status,
+ Progress: ds.Progress,
+ Error: ds.Error,
+ })
+ }
+ task.Mu.RUnlock()
+
+ now := time.Now().Format(time.RFC3339)
+ finishedAt := ""
+ if status == models.TaskSuccess || status == models.TaskFailed {
+ finishedAt = now
+ }
+
+ tx, err := r.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ _, err = tx.Exec(`
+INSERT INTO tasks(task_id, type, payload_json, status, created_at, finished_at)
+VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM tasks WHERE task_id = ?), ?), ?)
+ON CONFLICT(task_id) DO UPDATE SET
+ type=excluded.type,
+ payload_json=excluded.payload_json,
+ status=excluded.status,
+ finished_at=excluded.finished_at
+`, task.ID, task.Type, string(payload), string(status), task.ID, now, finishedAt)
+ if err != nil {
+ return err
+ }
+
+ if _, err := tx.Exec(`DELETE FROM task_devices WHERE task_id = ?`, task.ID); err != nil {
+ return err
+ }
+ for _, ds := range devices {
+ if _, err := tx.Exec(`
+INSERT INTO task_devices(task_id, device_id, status, progress, error_text)
+VALUES(?, ?, ?, ?, ?)
+`, task.ID, ds.DeviceID, string(ds.Status), ds.Progress, ds.Error); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (r *TasksRepo) List() ([]models.Task, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ rows, err := r.db.Query(`
+SELECT task_id, type, payload_json, status
+FROM tasks
+ORDER BY created_at DESC, task_id DESC
+`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []models.Task
+ for rows.Next() {
+ var (
+ id, tType, payloadJSON, status string
+ )
+ if err := rows.Scan(&id, &tType, &payloadJSON, &status); err != nil {
+ return nil, err
+ }
+ var payload any
+ if payloadJSON != "" {
+ if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
+ return nil, err
+ }
+ }
+ task := models.Task{
+ ID: id,
+ Type: tType,
+ Payload: payload,
+ Status: models.TaskStatus(status),
+ Devices: map[string]*models.DeviceTaskStatus{},
+ }
+
+ deviceRows, err := r.db.Query(`
+SELECT device_id, status, progress, error_text
+FROM task_devices
+WHERE task_id = ?
+ORDER BY rowid ASC
+`, id)
+ if err != nil {
+ return nil, err
+ }
+ for deviceRows.Next() {
+ var did, dsStatus, errText string
+ var progress float64
+ if err := deviceRows.Scan(&did, &dsStatus, &progress, &errText); err != nil {
+ deviceRows.Close()
+ return nil, err
+ }
+ task.DeviceIDs = append(task.DeviceIDs, did)
+ task.Devices[did] = &models.DeviceTaskStatus{
+ DeviceID: did,
+ Status: models.TaskStatus(dsStatus),
+ Progress: progress,
+ Error: errText,
+ }
+ }
+ deviceRows.Close()
+ out = append(out, task)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/storage/tasks_repo_test.go b/internal/storage/tasks_repo_test.go
new file mode 100644
index 0000000..de7d11b
--- /dev/null
+++ b/internal/storage/tasks_repo_test.go
@@ -0,0 +1,46 @@
+package storage
+
+import (
+ "path/filepath"
+ "testing"
+
+ "3588AdminBackend/internal/models"
+)
+
+func openTestStore(t *testing.T) *Store {
+ t.Helper()
+ store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ return store
+}
+
+func TestTasksRepoSavesAndLoadsTaskSnapshots(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+
+ repo := NewTasksRepo(store.DB())
+ task := models.NewTask("task-1", "reload", []string{"edge-01"}, nil)
+ task.Status = models.TaskSuccess
+ task.Devices["edge-01"].Status = models.TaskSuccess
+ task.Devices["edge-01"].Progress = 1
+
+ if err := repo.Save(task); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ items, err := repo.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("expected one task, got %d", len(items))
+ }
+ if items[0].ID != "task-1" || items[0].Type != "reload" || items[0].Status != models.TaskSuccess {
+ t.Fatalf("unexpected task snapshot: %#v", items[0])
+ }
+ if ds := items[0].Devices["edge-01"]; ds == nil || ds.Status != models.TaskSuccess || ds.Progress != 1 {
+ t.Fatalf("unexpected device snapshot: %#v", items[0].Devices["edge-01"])
+ }
+}
diff --git a/internal/web/ui.go b/internal/web/ui.go
index 7476b02..c50481d 100644
--- a/internal/web/ui.go
+++ b/internal/web/ui.go
@@ -5,9 +5,11 @@ import (
"encoding/json"
"fmt"
"html/template"
+ "io"
"io/fs"
"net/http"
"net/url"
+ "os"
"path/filepath"
"sort"
"strconv"
@@ -16,6 +18,7 @@ import (
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
+ "3588AdminBackend/internal/storage"
"github.com/go-chi/chi/v5"
)
@@ -26,6 +29,9 @@ type UI struct {
tasks *service.TaskService
templates *service.TemplateService
preview *service.ConfigPreviewService
+ stateRepo *storage.DeviceConfigStateRepo
+ auditRepo *storage.AuditLogsRepo
+ dbPath string
tpl *template.Template
}
@@ -76,6 +82,11 @@ type PageData struct {
SelectedQuery string
SelectedDevicesURL string
BatchConfigURL string
+ ReloadSummary string
+ RollbackSummary string
+ AuditEntries []storage.AuditLogRecord
+ PersistedConfig *storage.DeviceConfigStateRecord
+ DBPath string
RawJSON string
RawText string
@@ -141,7 +152,7 @@ type ConfigStatusLastGoodFile struct {
Metadata ConfigStatusMetadata `json:"metadata"`
}
-func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService) (*UI, error) {
+func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService, preview ...*service.ConfigPreviewService) (*UI, error) {
tpl, err := template.New("layout").Funcs(template.FuncMap{
"json": func(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
@@ -243,6 +254,48 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
return "pill"
}
},
+ "auditField": func(details string, key string) string {
+ var m map[string]any
+ if err := json.Unmarshal([]byte(details), &m); err != nil {
+ return ""
+ }
+ if v, ok := m[key].(string); ok {
+ return strings.TrimSpace(v)
+ }
+ return ""
+ },
+ "auditActionLabel": func(v string) string {
+ switch strings.TrimSpace(v) {
+ case "config_apply":
+ return "下发业务配置"
+ case "reload":
+ return "重载配置"
+ case "rollback":
+ return "回滚配置"
+ case "media_start":
+ return "启动服务"
+ case "media_restart":
+ return "重启服务"
+ case "media_stop":
+ return "停止服务"
+ default:
+ return strings.TrimSpace(v)
+ }
+ },
+ "auditStatusLabel": func(v string) string {
+ switch strings.TrimSpace(v) {
+ case "success":
+ return "成功"
+ case "failed":
+ return "失败"
+ case "running":
+ return "执行中"
+ case "pending":
+ return "待执行"
+ default:
+ return strings.TrimSpace(v)
+ }
+ },
"ago": func(ms int64) string {
if ms <= 0 {
return "-"
@@ -271,17 +324,42 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
return nil, err
}
+ previewSvc := service.NewConfigPreviewService(nil)
+ if len(preview) > 0 && preview[0] != nil {
+ previewSvc = preview[0]
+ }
return &UI{
discovery: discovery,
registry: registry,
agent: agent,
tasks: tasks,
templates: templates,
- preview: service.NewConfigPreviewService(nil),
+ preview: previewSvc,
tpl: tpl,
}, nil
}
+func (u *UI) SetStateRepo(repo *storage.DeviceConfigStateRepo) {
+ if u == nil {
+ return
+ }
+ u.stateRepo = repo
+}
+
+func (u *UI) SetAuditRepo(repo *storage.AuditLogsRepo) {
+ if u == nil {
+ return
+ }
+ u.auditRepo = repo
+}
+
+func (u *UI) SetDBPath(path string) {
+ if u == nil {
+ return
+ }
+ u.dbPath = strings.TrimSpace(path)
+}
+
func tablerIconSVG(name string) string {
icons := map[string]string{
"devices": ` `,
@@ -344,15 +422,21 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/devices", u.pageDevices)
r.Get("/devices/{id}/control", u.pageDeviceControl)
r.Get("/assets", u.pageAssets)
+ r.Post("/assets/import", u.actionAssetsImport)
r.Get("/assets/templates", u.pageAssetTemplates)
r.Get("/assets/templates/{name}", u.pageAssetTemplate)
+ r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport)
r.Get("/assets/profiles", u.pageAssetProfiles)
r.Get("/assets/profiles/{name}", u.pageAssetProfile)
r.Post("/assets/profiles/{name}", u.actionAssetProfileSave)
+ r.Get("/assets/profiles/{name}/export", u.pageAssetProfileExport)
r.Get("/assets/overlays", u.pageAssetOverlays)
r.Get("/assets/overlays/{name}", u.pageAssetOverlay)
+ r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport)
r.Get("/audit", u.pageAudit)
r.Get("/system", u.pageSystem)
+ r.Get("/system/db-backup", u.pageSystemDBBackup)
+ r.Post("/system/db-restore", u.actionSystemDBRestore)
r.Get("/device-config", u.pageDeviceConfig)
r.Get("/device-config/{id}", u.pageDeviceConfigDetail)
r.Get("/devices-add", u.pageDeviceAdd)
@@ -593,24 +677,17 @@ func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
- req := service.ConfigPreviewRequest{
- Template: strings.TrimSpace(r.FormValue("template")),
- Profile: strings.TrimSpace(r.FormValue("profile")),
- Overlays: cleanFormList(r.Form["overlay"]),
- ConfigID: strings.TrimSpace(r.FormValue("config_id")),
- ConfigVersion: strings.TrimSpace(r.FormValue("config_version")),
- }
+ req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))}
data := u.deviceBatchConfigPageData(r, selectedIDs)
- if req.Template != "" {
- data.SelectedTemplate = req.Template
- }
if req.Profile != "" {
data.SelectedProfile = req.Profile
}
- data.SelectedOverlays = append([]string(nil), req.Overlays...)
- data.SelectedConfigID = req.ConfigID
- if req.ConfigVersion != "" {
- data.SelectedVersion = req.ConfigVersion
+ for i := range data.AssetProfiles {
+ if strings.TrimSpace(data.AssetProfiles[i].Name) == data.SelectedProfile {
+ data.AssetProfile = &data.AssetProfiles[i]
+ data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i])
+ break
+ }
}
if len(selectedIDs) == 0 {
@@ -618,12 +695,20 @@ func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "device_batch_config", data)
return
}
- if req.Template == "" {
- req.Template = data.SelectedTemplate
- }
if req.Profile == "" {
req.Profile = data.SelectedProfile
}
+ if req.Profile == "" {
+ data.Error = "请先选择业务配置"
+ u.render(w, r, "device_batch_config", data)
+ return
+ }
+ if data.SelectedTemplate == "" {
+ data.Error = "所选业务配置缺少可用模板,无法生成下发内容"
+ u.render(w, r, "device_batch_config", data)
+ return
+ }
+ req.Template = data.SelectedTemplate
if u.tasks == nil {
data.Error = "task service not initialized"
u.render(w, r, "device_batch_config", data)
@@ -668,6 +753,11 @@ func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) {
func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
data := u.deviceControlPageData(dev)
data.Title = "设备详情"
+ if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil {
+ if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil {
+ data.PersistedConfig = state
+ }
+ }
return data
}
@@ -934,7 +1024,18 @@ func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *
}
func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
- u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()})
+ u.ensureDevicesLoaded()
+ devices := u.registry.GetDevices()
+ selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"]))
+ data := PageData{
+ Title: "任务",
+ Tasks: u.tasks.ListTasks(),
+ Devices: devices,
+ SelectedDeviceIDs: selectedIDs,
+ SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs),
+ DeviceIDs: strings.Join(selectedIDs, ","),
+ }
+ u.render(w, r, "tasks", data)
}
func (u *UI) taskPageData(task *models.Task) PageData {
@@ -980,11 +1081,16 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
}
ids := strings.TrimSpace(r.FormValue("device_ids"))
var deviceIDs []string
- for _, p := range strings.Split(ids, ",") {
- p = strings.TrimSpace(p)
- if p != "" {
- deviceIDs = append(deviceIDs, p)
+ if ids != "" {
+ for _, p := range strings.Split(ids, ",") {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ deviceIDs = append(deviceIDs, p)
+ }
}
+ } else {
+ deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
+ ids = strings.Join(deviceIDs, ",")
}
raw := strings.TrimSpace(r.FormValue("payload_json"))
if raw == "" {
@@ -1057,6 +1163,24 @@ func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "assets", data)
}
+func (u *UI) actionAssetsImport(w http.ResponseWriter, r *http.Request) {
+ data := u.assetPageData("overview")
+ if u.preview == nil {
+ data.Error = "配置资产服务未初始化"
+ u.render(w, r, "assets", data)
+ return
+ }
+ result, err := u.preview.ImportAssetsFromMediaRepo()
+ if err != nil {
+ data.Error = err.Error()
+ u.render(w, r, "assets", data)
+ return
+ }
+ data = u.assetPageData("overview")
+ data.Message = fmt.Sprintf("已导入 %d 个模板、%d 个业务配置、%d 个叠加项", result.Templates, result.Profiles, result.Overlays)
+ u.render(w, r, "assets", data)
+}
+
func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("templates")
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
@@ -1087,6 +1211,10 @@ func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "asset_templates", data)
}
+func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) {
+ u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name"))
+}
+
func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("profiles")
selected := strings.TrimSpace(r.URL.Query().Get("name"))
@@ -1119,6 +1247,10 @@ func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "asset_profiles", data)
}
+func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) {
+ u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name"))
+}
+
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
editor, data, err := u.profileEditorActionData(r, name)
@@ -1169,6 +1301,10 @@ func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "asset_overlays", data)
}
+func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) {
+ u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name"))
+}
+
func (u *UI) assetPageData(tab string) PageData {
data := PageData{
Title: "识别配置",
@@ -1254,16 +1390,99 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf
return editor, data, nil
}
-func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
- tasks := []models.Task(nil)
- if u.tasks != nil {
- tasks = u.tasks.ListTasks()
+func (u *UI) exportAssetJSON(w http.ResponseWriter, r *http.Request, kind string, name string) {
+ if u.preview == nil {
+ http.Error(w, "preview service not initialized", http.StatusInternalServerError)
+ return
}
- u.render(w, r, "audit", PageData{Title: "审计记录", Tasks: tasks})
+ body, filename, err := u.preview.ExportAssetJSON(kind, name)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
+ _, _ = w.Write(body)
+}
+
+func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
+ data := PageData{Title: "审计记录"}
+ if u.auditRepo != nil {
+ items, err := u.auditRepo.List()
+ if err != nil {
+ data.Error = err.Error()
+ } else {
+ data.AuditEntries = items
+ }
+ }
+ if len(data.AuditEntries) == 0 && u.tasks != nil {
+ data.Tasks = u.tasks.ListTasks()
+ }
+ u.render(w, r, "audit", data)
}
func (u *UI) pageSystem(w http.ResponseWriter, r *http.Request) {
- u.render(w, r, "system", PageData{Title: "系统状态", Devices: u.registry.GetDevices()})
+ u.renderSystemPage(
+ w,
+ r,
+ http.StatusOK,
+ strings.TrimSpace(r.URL.Query().Get("msg")),
+ strings.TrimSpace(r.URL.Query().Get("error")),
+ )
+}
+
+func (u *UI) pageSystemDBBackup(w http.ResponseWriter, r *http.Request) {
+ if strings.TrimSpace(u.dbPath) == "" {
+ http.Error(w, "database path is not configured", http.StatusNotFound)
+ return
+ }
+ body, err := os.ReadFile(u.dbPath)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ filename := "app-" + time.Now().Format("20060102-150405") + ".db"
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
+ _, _ = w.Write(body)
+}
+
+func (u *UI) renderSystemPage(w http.ResponseWriter, r *http.Request, status int, message string, errText string) {
+ w.WriteHeader(status)
+ u.render(w, r, "system", PageData{
+ Title: "系统状态",
+ Devices: u.registry.GetDevices(),
+ DBPath: u.dbPath,
+ Message: message,
+ Error: errText,
+ })
+}
+
+func (u *UI) actionSystemDBRestore(w http.ResponseWriter, r *http.Request) {
+ if strings.TrimSpace(u.dbPath) == "" {
+ http.Error(w, "database path is not configured", http.StatusNotFound)
+ return
+ }
+ if err := r.ParseMultipartForm(50 << 20); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ u.renderSystemPage(w, r, http.StatusBadRequest, "", "请先选择数据库备份文件")
+ return
+ }
+ defer file.Close()
+ body, err := io.ReadAll(file)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := os.WriteFile(u.dbPath, body, 0o644); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/ui/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound)
}
func urlQueryEscape(s string) string {
@@ -1742,6 +1961,8 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs
SelectedQuery: selectedQueryString(selectedIDs),
SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs),
BatchConfigURL: selectedURL("/ui/devices/batch-config", selectedIDs),
+ ReloadSummary: batchActionSummary(rows, selectedIDs, "reload"),
+ RollbackSummary: batchActionSummary(rows, selectedIDs, "rollback"),
}
if errMsg != "" {
data.Error = errMsg
@@ -1752,14 +1973,32 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs
func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData {
data := u.deviceOverviewPageData(r, selectedIDs, "")
sources, err := u.preview.ListSources()
- data.Title = "批量配置"
+ data.Title = "下发业务配置"
data.ConfigSources = sources
data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs)
- data.SelectedTemplate = "workshop_face_shoe_alarm"
- data.SelectedProfile = "local_3588_test"
- data.SelectedOverlays = []string{"face_debug"}
+ profiles, profileErr := u.preview.ListProfileAssets()
+ data.AssetProfiles = profiles
+ selectedProfile := strings.TrimSpace(r.URL.Query().Get("profile"))
+ if selectedProfile == "" {
+ selectedProfile = "local_3588_test"
+ }
+ for i := range profiles {
+ if strings.TrimSpace(profiles[i].Name) == selectedProfile {
+ data.AssetProfile = &profiles[i]
+ data.SelectedProfile = profiles[i].Name
+ data.SelectedTemplate = profileAssetTemplate(&profiles[i])
+ break
+ }
+ }
+ if data.AssetProfile == nil && len(profiles) > 0 {
+ data.AssetProfile = &profiles[0]
+ data.SelectedProfile = profiles[0].Name
+ data.SelectedTemplate = profileAssetTemplate(&profiles[0])
+ }
if err != nil {
data.Error = err.Error()
+ } else if profileErr != nil {
+ data.Error = profileErr.Error()
}
return data
}
@@ -1832,6 +2071,84 @@ func populateSelectionsFromPreview(data *PageData) {
}
}
+func profileAssetTemplate(asset *service.ConfigProfileAsset) string {
+ if asset == nil {
+ return ""
+ }
+ for _, item := range asset.Instances {
+ if v := strings.TrimSpace(item.Template); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func profileAssetBusinessName(asset *service.ConfigProfileAsset) string {
+ if asset == nil {
+ return ""
+ }
+ if v := strings.TrimSpace(asset.BusinessName); v != "" {
+ return v
+ }
+ return strings.TrimSpace(asset.Name)
+}
+
+func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action string) string {
+ if len(selectedIDs) == 0 {
+ return ""
+ }
+ rowByID := make(map[string]DeviceOverviewRow, len(rows))
+ for _, row := range rows {
+ if row.Device == nil {
+ continue
+ }
+ rowByID[strings.TrimSpace(row.Device.DeviceID)] = row
+ }
+ lines := make([]string, 0, len(selectedIDs))
+ for _, id := range selectedIDs {
+ row, ok := rowByID[strings.TrimSpace(id)]
+ if !ok || row.Device == nil {
+ continue
+ }
+ label := row.Device.DisplayName()
+ switch action {
+ case "reload":
+ summary := "未取到当前业务配置"
+ if row.ConfigStatus != nil {
+ meta := row.ConfigStatus.Metadata
+ if name := strings.TrimSpace(meta.BusinessName); name != "" {
+ summary = name
+ if profile := strings.TrimSpace(meta.Profile); profile != "" {
+ summary += " (" + profile + ")"
+ }
+ } else if profile := strings.TrimSpace(meta.Profile); profile != "" {
+ summary = profile
+ } else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
+ summary = configID
+ }
+ }
+ lines = append(lines, label+" -> "+summary)
+ case "rollback":
+ summary := "未取到可回滚业务配置"
+ if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil {
+ meta := row.ConfigStatus.PreviousConfig.Metadata
+ if name := strings.TrimSpace(meta.BusinessName); name != "" {
+ summary = name
+ if profile := strings.TrimSpace(meta.Profile); profile != "" {
+ summary += " (" + profile + ")"
+ }
+ } else if profile := strings.TrimSpace(meta.Profile); profile != "" {
+ summary = profile
+ } else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
+ summary = configID
+ }
+ }
+ lines = append(lines, label+" -> "+summary)
+ }
+ }
+ return strings.Join(lines, ";")
+}
+
func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css
index efd6da8..27b1743 100644
--- a/internal/web/ui/assets/style.css
+++ b/internal/web/ui/assets/style.css
@@ -140,8 +140,10 @@ tbody tr:hover{background:#f9fafb}
.asset-panel-body>.card:last-child,.asset-panel-body>details.card:last-child{margin-bottom:0}
.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
+.device-selector-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.quad-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
+.selector-card .actions{margin-top:auto}
.panel-block{border:1px solid var(--border);border-radius:8px;background:var(--surface-soft);padding:16px}
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
@@ -210,7 +212,7 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back
.sidebar{position:relative;height:auto}
.topbar{position:relative;height:auto;padding:18px;flex-direction:column;align-items:flex-start;gap:12px}
main{padding:18px}
- .stats,.detail-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr}
+ .stats,.detail-grid,.device-selector-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr}
.hero-band{flex-direction:column;align-items:flex-start}
.batch-toolbar{flex-direction:column}
}
diff --git a/internal/web/ui/templates/asset_overlays.html b/internal/web/ui/templates/asset_overlays.html
index c684f6b..686026c 100644
--- a/internal/web/ui/templates/asset_overlays.html
+++ b/internal/web/ui/templates/asset_overlays.html
@@ -18,7 +18,7 @@
{{range .AssetOverlays}}
- {{.Name}}
+ {{.Name}}
{{if .Description}}{{.Description}}{{else}}-{{end}}
{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
@@ -29,6 +29,36 @@
+
+{{if .AssetOverlay}}
+
+
+
+
{{icon "overlay"}}叠加项详情
+
+
+ {{icon "apply"}}另存为 JSON
+
+
+
+
叠加项 {{.AssetOverlay.Name}}
+
目标数量 {{.AssetOverlay.OverrideTargetNum}}
+
描述 {{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}
+
作用目标 {{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
+
路径 {{.AssetOverlay.Path}}
+
+
+
+
+ {{icon "tech"}}原始 JSON
+ {{json .AssetOverlay.Raw}}
+
+{{end}}
{{if .Error}}{{.Error}}
{{end}}
{{template "asset_tabs_end" .}}
{{end}}
diff --git a/internal/web/ui/templates/asset_profiles.html b/internal/web/ui/templates/asset_profiles.html
index 61a1b90..84f7992 100644
--- a/internal/web/ui/templates/asset_profiles.html
+++ b/internal/web/ui/templates/asset_profiles.html
@@ -19,7 +19,7 @@
{{range .AssetProfiles}}
- {{.Name}}
+ {{.Name}}
{{if .Description}}{{.Description}}{{else}}-{{end}}
{{len .Instances}}
{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}
@@ -31,6 +31,108 @@
+
+{{if .AssetProfileEditor}}
+
+
+
+ {{icon "tech"}}原始 JSON
+ {{json .AssetProfileEditor.Raw}}
+
+{{end}}
{{if .Error}}{{.Error}}
{{end}}
{{template "asset_tabs_end" .}}
{{end}}
diff --git a/internal/web/ui/templates/asset_templates.html b/internal/web/ui/templates/asset_templates.html
index a9a4c5d..4b7d75d 100644
--- a/internal/web/ui/templates/asset_templates.html
+++ b/internal/web/ui/templates/asset_templates.html
@@ -19,7 +19,7 @@
{{range .AssetTemplates}}
- {{.Name}}
+ {{.Name}}
{{if .Description}}{{.Description}}{{else}}-{{end}}
{{.NodeCount}} / {{.EdgeCount}}
{{if .Source}}{{.Source}}{{else}}-{{end}}
@@ -31,6 +31,49 @@
+
+{{if .AssetTemplate}}
+
+
+
+
{{icon "template"}}模板详情
+
+
+ {{icon "apply"}}另存为 JSON
+
+
+
+
模板名 {{.AssetTemplate.Name}}
+
来源文件 {{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}
+
节点数 {{.AssetTemplate.NodeCount}}
+
连线数 {{.AssetTemplate.EdgeCount}}
+
MinIO {{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}
+
Bucket {{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}
+
取 token 接口 {{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}
+
告警上报接口 {{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}
+
租户编码 {{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}
+
描述 {{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}
+
路径 {{.AssetTemplate.Path}}
+
+
+
+{{if .AssetTemplate.AdvancedParams}}
+
+ {{icon "tech"}}高级设置
+ {{json .AssetTemplate.AdvancedParams}}
+
+{{end}}
+
+
+ {{icon "tech"}}原始 JSON
+ {{json .AssetTemplate.Raw}}
+
+{{end}}
{{if .Error}}{{.Error}}
{{end}}
{{template "asset_tabs_end" .}}
{{end}}
diff --git a/internal/web/ui/templates/assets.html b/internal/web/ui/templates/assets.html
index 5d773fd..08c6733 100644
--- a/internal/web/ui/templates/assets.html
+++ b/internal/web/ui/templates/assets.html
@@ -51,6 +51,17 @@
+
+
+
+
{{icon "assets"}}资产操作
+
+
+ {{icon "apply"}}导入现有 JSON
+
+
+
+
diff --git a/internal/web/ui/templates/audit.html b/internal/web/ui/templates/audit.html
index a46612b..2a9f895 100644
--- a/internal/web/ui/templates/audit.html
+++ b/internal/web/ui/templates/audit.html
@@ -7,6 +7,30 @@
+ {{if .AuditEntries}}
+
+
+
+ 动作
+ 目标
+ 任务
+ 配置
+ 结果
+
+
+
+ {{range .AuditEntries}}
+
+ {{auditActionLabel .Action}}
+ {{.TargetID}}
+ {{if auditField .DetailsJSON "task_id"}}{{auditField .DetailsJSON "task_id"}}{{else}}-{{end}}
+ {{if auditField .DetailsJSON "profile"}}{{auditField .DetailsJSON "profile"}}{{else if auditField .DetailsJSON "config_id"}}{{auditField .DetailsJSON "config_id"}}{{else}}-{{end}}
+ {{if auditField .DetailsJSON "status"}}{{auditStatusLabel (auditField .DetailsJSON "status")}}{{else}}{{.Actor}}{{end}}
+
+ {{end}}
+
+
+ {{else}}
@@ -43,6 +67,7 @@
{{end}}
+ {{end}}
{{end}}
diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html
index 6853ef9..64f1b64 100644
--- a/internal/web/ui/templates/device.html
+++ b/internal/web/ui/templates/device.html
@@ -20,7 +20,7 @@
当前配置
-
{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}待读取{{end}}
+
{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}
服务状态
@@ -54,7 +54,7 @@
视频端口 {{.Device.MediaPort}}
最后心跳 {{ago .Device.LastSeenMs}}
版本 {{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}
-
当前业务配置 {{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else}}-{{end}}
+
当前业务配置 {{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}
通道名 {{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}
@@ -74,12 +74,24 @@
业务配置 {{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}
叠加项 {{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
配置文件 {{.ConfigStatus.ConfigPath}}
+ 配置 SHA {{shortHash .ConfigStatus.Sha256}}
{{else}}
-
-
暂未读到配置状态
-
{{if .ConfigStatusErr}}{{.ConfigStatusErr}}{{else}}设备未返回配置摘要。{{end}}
-
+ {{if .PersistedConfig}}
+
+
配置 ID {{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}
+
配置版本 {{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}
+
模板 {{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}
+
业务配置 {{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}
+
叠加项 {{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}
+
最近下发任务 {{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}
+
+ {{else}}
+
+
暂未读到配置状态
+
{{if .ConfigStatusErr}}{{.ConfigStatusErr}}{{else}}设备未返回配置摘要。{{end}}
+
+ {{end}}
{{end}}
@@ -123,7 +135,7 @@
{{icon "preview"}}编辑和上传候选配置
-
当前配置 {{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}待读取{{end}}
+
当前配置 {{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}
候选配置 {{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}}{{else}}已存在{{end}}{{else}}未上传{{end}}
diff --git a/internal/web/ui/templates/device_batch_config.html b/internal/web/ui/templates/device_batch_config.html
index 4efc2bf..47850b0 100644
--- a/internal/web/ui/templates/device_batch_config.html
+++ b/internal/web/ui/templates/device_batch_config.html
@@ -27,45 +27,25 @@
-
{{icon "config"}}批量配置
+
{{icon "config"}}下发业务配置
+
先选择一份已有业务配置,再为已选设备创建下发任务。
- {{if .ConfigSources.Root}}
{{.ConfigSources.Root}}
{{end}}
{{range .SelectedDeviceIDs}} {{end}}
- 创建批量下发任务
+ 创建下发任务
@@ -73,28 +53,51 @@
-
{{icon "preview"}}预览摘要
+ {{icon "preview"}}业务配置摘要
+ {{if .AssetProfile}}
-
模板 {{if .ConfigPreview}}{{index .ConfigPreview.Metadata "template"}}{{else}}{{.SelectedTemplate}}{{end}}
-
业务名称 {{if .ConfigPreview}}{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}{{else}}-{{end}}
-
业务配置 {{if .ConfigPreview}}{{index .ConfigPreview.Metadata "profile"}}{{else}}{{.SelectedProfile}}{{end}}
-
配置叠加项 {{if .ConfigPreview}}{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{else}}{{if .SelectedOverlays}}{{range $i, $name := .SelectedOverlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{end}}
-
目标设备 {{len .SelectedDeviceIDs}} 台
-
config_id {{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_id"}}{{else}}{{if .SelectedConfigID}}{{.SelectedConfigID}}{{else}}自动生成{{end}}{{end}}
-
config_version {{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_version"}}{{else}}{{if .SelectedVersion}}{{.SelectedVersion}}{{else}}自动生成{{end}}{{end}}
- {{if .ConfigPreview}}
-
大小 {{.ConfigPreview.Size}} bytes
-
SHA256 {{.ConfigPreview.Sha256}}
+
业务配置 {{.AssetProfile.Name}}
+
业务名称 {{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}
+
关联模板 {{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}
+
视频通道 {{len .AssetProfile.Instances}} 路
+ {{with index .AssetProfile.Instances 0}}
+
首个通道 {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}
+ {{end}}
+ {{if .AssetProfile.Description}}
+
说明 {{.AssetProfile.Description}}
{{end}}
+ {{if .AssetProfile.Instances}}
+
+
+
+
+ 通道
+ 显示名称
+ 站点
+ RTSP
+
+
+
+ {{range .AssetProfile.Instances}}
+
+ {{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}
+ {{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}
+ {{if .SiteName}}{{.SiteName}}{{else}}-{{end}}
+ {{if .RTSPURL}}{{.RTSPURL}}{{else}}-{{end}}
+
+ {{end}}
+
+
+
+ {{end}}
+ {{else}}
+
+
还没有可用业务配置
+
请先到识别配置中创建业务配置,再回来下发。
+
+ {{end}}
-
-{{if .ConfigPreview}}
-
- {{icon "tech"}}完整 JSON
- {{.ConfigPreview.JSON}}
-
-{{end}}
{{end}}
diff --git a/internal/web/ui/templates/device_control.html b/internal/web/ui/templates/device_control.html
index edf4f07..b4631ce 100644
--- a/internal/web/ui/templates/device_control.html
+++ b/internal/web/ui/templates/device_control.html
@@ -1,14 +1,17 @@
{{define "device_control"}}
{{template "device_header" .}}
-{{template "device_tabs" .}}
-
{{icon "control"}}设备控制
+
{{icon "control"}}单设备配置
+
当前工作台仅管理这一台设备的配置与服务。
{{if .Device.Online}}
可操作 {{else}}
设备离线 {{end}}
+
@@ -52,12 +55,12 @@
-
+
应用候选配置
-
+
回滚到上一份
@@ -72,17 +75,17 @@
-
+
启动服务
-
+
重启服务
-
+
停止服务
@@ -105,6 +108,4 @@
-
-{{template "device_panel_end" .}}
{{end}}
diff --git a/internal/web/ui/templates/device_nav.html b/internal/web/ui/templates/device_nav.html
index 5f6a757..1f1727d 100644
--- a/internal/web/ui/templates/device_nav.html
+++ b/internal/web/ui/templates/device_nav.html
@@ -17,27 +17,4 @@
{{define "device_nav"}}
{{template "device_header" .}}
-{{template "device_tabs" .}}
-{{template "device_panel_end" .}}
-{{end}}
-
-{{define "device_tabs"}}
-
-
-
-
-
-{{end}}
-
-{{define "device_panel_end"}}
-
-
-
{{end}}
diff --git a/internal/web/ui/templates/devices.html b/internal/web/ui/templates/devices.html
index 1fea16c..05666f2 100644
--- a/internal/web/ui/templates/devices.html
+++ b/internal/web/ui/templates/devices.html
@@ -42,7 +42,9 @@
重启服务
启动服务
停止服务
-
批量配置
+
重载配置
+
回滚配置
+
下发业务配置
清空选择
@@ -107,7 +109,6 @@
diff --git a/internal/web/ui/templates/diagnostics.html b/internal/web/ui/templates/diagnostics.html
index 732f1a7..61344e9 100644
--- a/internal/web/ui/templates/diagnostics.html
+++ b/internal/web/ui/templates/diagnostics.html
@@ -3,18 +3,11 @@
诊断工作台
-
诊断域集中承载日志分析、系统状态、审计记录和高级排障入口。
高级调试
-
-
日志分析
@@ -35,7 +28,6 @@
@@ -50,14 +42,12 @@
审计记录
-
统一查看任务执行和关键操作留痕。
diff --git a/internal/web/ui/templates/layout.html b/internal/web/ui/templates/layout.html
index 4ec2c10..345dd61 100644
--- a/internal/web/ui/templates/layout.html
+++ b/internal/web/ui/templates/layout.html
@@ -45,6 +45,81 @@
+