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"}}叠加项详情

+
+
+ +
+
+
+
叠加项{{.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 "profile"}}业务配置

+
+
+ +
+
+
+ + + + + + +
+
+ +
+
+
+

{{icon "device"}}视频通道

+
+
+ {{len .AssetProfileEditor.Instances}} 路 + +
+
+ + + + + + + + + + + + + {{range $i, $inst := .AssetProfileEditor.Instances}} + + + + + + + + + {{end}} + +
通道通道显示名RTSP 输入HLS 输出RTSP 输出
{{$inst.Name}}{{if $inst.Delete}}待删除{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}}{{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}}{{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}}{{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}} +
+ 编辑 + {{if $inst.Delete}} + + {{else}} + + {{end}} +
+
+
+ + {{range $i, $inst := .AssetProfileEditor.Instances}} +
+ {{icon "device"}}{{$inst.Name}} +
+ + + + + + + + + + +
+
+ {{end}} + +
+
+ +
+
+
+ +
+ {{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"}}模板详情

+
+
+ +
+
+
+
模板名{{.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"}}资产操作

+
+
+ +
+
+
+
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}} + + + + + + + + {{end}} + +
动作目标任务配置结果
{{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}}
+ {{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}} +
+ + + + + + + + + + + {{range .AssetProfile.Instances}} + + + + + + + {{end}} + +
通道显示名称站点RTSP
{{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}{{if .SiteName}}{{.SiteName}}{{else}}-{{end}}{{if .RTSPURL}}{{.RTSPURL}}{{else}}-{{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 @@
{{icon "detail"}}详情 - {{icon "control"}}控制
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 @@

诊断工作台

-
诊断域集中承载日志分析、系统状态、审计记录和高级排障入口。
高级调试
-
-
日志分析
Logs
按设备查看诊断日志和运行指标
-
系统状态
System
查看发现、健康和接口状态
-
审计记录
Audit
追踪任务与关键操作
-
-

日志分析

@@ -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 @@
+ {{end}} diff --git a/internal/web/ui/templates/models.html b/internal/web/ui/templates/models.html index 4ce678a..1cc78a1 100644 --- a/internal/web/ui/templates/models.html +++ b/internal/web/ui/templates/models.html @@ -9,7 +9,7 @@
目标节点
{{len .Devices}}
可选择单台设备上传模型
-
部署方式
单节点
首版沿用设备详情上传
+
部署方式
单节点
在本页直接上传到目标设备
模型类型
检测/识别
二进制模型文件
人脸库
DB
通过识别配置页维护
diff --git a/internal/web/ui/templates/system.html b/internal/web/ui/templates/system.html index e84a074..55aaf68 100644 --- a/internal/web/ui/templates/system.html +++ b/internal/web/ui/templates/system.html @@ -45,4 +45,25 @@
+ +
+
+

{{icon "audit"}}数据备份 / 恢复

+
+
数据库文件{{if .DBPath}}{{.DBPath}}{{else}}未配置{{end}}
+
备份方式另存为数据库文件
+
+
+ +
+
+
+ +
+
+ +
+
+
+
{{end}} diff --git a/internal/web/ui/templates/tasks.html b/internal/web/ui/templates/tasks.html index 9a99993..3681fc0 100644 --- a/internal/web/ui/templates/tasks.html +++ b/internal/web/ui/templates/tasks.html @@ -1,35 +1,4 @@ {{define "tasks"}} -
-

批量操作

-
任务域负责批量下发、批量重启、批量回滚和执行历史。
-
-
-
-
任务类型
- -
-
-
目标节点标识(逗号分隔)
- -
-
-
-
高级参数(JSON)
- -
-
-
-
-

执行历史

@@ -52,19 +21,13 @@ {{taskStatusLabel .Status}} + {{else}} + + 还没有批量任务。从设备页选择设备后发起操作,这里会展示执行记录。 + {{end}}
- -
-

常用动作

-
- 批量下发 - 批量重启 - 批量回滚 - 高级调试 -
-
{{end}} diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index 3121fb5..ef56d44 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -4,8 +4,11 @@ import ( "3588AdminBackend/internal/config" "3588AdminBackend/internal/models" "3588AdminBackend/internal/service" + "3588AdminBackend/internal/storage" + "bytes" "context" "encoding/json" + "mime/multipart" "net" "net/http" "net/http/httptest" @@ -129,9 +132,9 @@ func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) { body := rr.Body.String() for _, want := range []string{ "视觉识别运维平台", - "配置管理", - "操作审计", - "系统", + "总览", + "任务", + "诊断", "

设备

", } { if !strings.Contains(body, want) { @@ -164,7 +167,12 @@ func TestUI_ConsoleTypographyStaysModerate(t *testing.T) { func newTestUI(t *testing.T) *UI { t.Helper() cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - reg := service.NewRegistryService(cfg, nil) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0"}) tasks := service.NewTaskService(cfg, nil, reg) ui, err := NewUI(nil, reg, nil, tasks, nil) @@ -203,7 +211,7 @@ func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, forbidden := range []string{"batch-toolbar", "已选", "批量配置", "重启服务", "启动服务", "停止服务", "重载服务", "清空选择"} { + for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择"} { if strings.Contains(body, forbidden) { t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body) } @@ -222,13 +230,15 @@ func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "批量配置", "清空选择"} { + for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择", "将重载当前业务配置", "将回滚到上一版业务配置"} { if !strings.Contains(body, want) { t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body) } } - if strings.Contains(body, "重载服务") { - t.Fatalf("device overview batch controls should not contain reload action") + for _, forbidden := range []string{"批量配置", "重载服务"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device overview batch controls should not contain %q, got:\n%s", forbidden, body) + } } if !strings.Contains(body, `href="/ui/devices/batch-config?selected=edge-01&selected=edge-02"`) { t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body) @@ -250,24 +260,25 @@ func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) { } body := rr.Body.String() for _, want := range []string{ - "批量配置", - "模板", + "下发业务配置", "业务配置", - "配置叠加项", "已选设备", "入口识别节点", "辅助节点", - "预览摘要", - "workshop_face_shoe_alarm", + "业务配置摘要", "local_3588_test", - "face_debug", + "A厂区视觉识别", + "workshop_face_shoe_alarm", + "东门入口", } { if !strings.Contains(body, want) { t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body) } } - if strings.Contains(body, "已选 2 台设备") { - t.Fatalf("batch config page should not contain explanatory selected-count copy") + for _, forbidden := range []string{"config_id", "config_version", "完整 JSON"} { + if strings.Contains(body, forbidden) { + t.Fatalf("batch config page should not expose internal config fields %q, got:\n%s", forbidden, body) + } } } @@ -279,11 +290,7 @@ func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) { form := url.Values{} form.Add("device_id", "edge-01") form.Add("device_id", "edge-02") - form.Set("template", "workshop_face_shoe_alarm") form.Set("profile", "local_3588_test") - form.Add("overlay", "face_debug") - form.Set("config_id", "batch_edge") - form.Set("config_version", "20260420.090000") req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() @@ -364,17 +371,17 @@ func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(t *testing.T) `name="device_id" value="edge-02"`, "入口识别节点", "辅助节点", - `name="config_id" value=""`, + `name="profile"`, + `value="local_3588_test" selected`, } { if !strings.Contains(body, want) { t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body) } } - if strings.Contains(body, `name="overlay" value="face_debug" checked`) { - t.Fatalf("expected empty overlay selection to stay empty, got:\n%s", body) - } - if strings.Contains(body, "完整 JSON 放在折叠区") { - t.Fatalf("expected no JSON foldout hint on render failure, got:\n%s", body) + for _, forbidden := range []string{`name="config_id"`, `name="overlay"`, "完整 JSON"} { + if strings.Contains(body, forbidden) { + t.Fatalf("expected failure UI to avoid internal config field %q, got:\n%s", forbidden, body) + } } } @@ -533,8 +540,28 @@ func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) { func createBatchConfigMediaRepo(t *testing.T) string { t.Helper() root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`) + writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{ + "name":"workshop_face_shoe_alarm", + "description":"helmet and shoe alarm", + "template":{"nodes":[],"edges":[]} +}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + "name":"local_3588_test", + "business_name":"A厂区视觉识别", + "description":"默认班次识别配置", + "instances":[ + { + "name":"cam1", + "template":"workshop_face_shoe_alarm", + "params":{ + "display_name":"东门入口", + "site_name":"A厂区", + "rtsp_url":"rtsp://10.0.0.1/live", + "channel_no":"cam1" + } + } + ] +}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse import json @@ -647,8 +674,12 @@ func withChiURLParam(req *http.Request, key string, value string) *http.Request func createBatchConfigBrokenMediaRepo(t *testing.T) string { t.Helper() root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`) + writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + "name":"local_3588_test", + "business_name":"A厂区视觉识别", + "instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}] +}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) return root } @@ -700,7 +731,7 @@ func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) { t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String()) } body := rrTask.Body.String() - for _, want := range []string{"任务详情", "返回任务列表", "设备结果表", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} { + for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} { if !strings.Contains(body, want) { t.Fatalf("expected task page to contain %q, got:\n%s", want, body) } @@ -891,7 +922,7 @@ func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) { } } -func TestUI_DeviceDetailIncludesTabs(t *testing.T) { +func TestUI_DeviceDetailIncludesWorkspaceSections(t *testing.T) { ui := newTestUI(t) routes, err := ui.Routes() if err != nil { @@ -902,19 +933,19 @@ func TestUI_DeviceDetailIncludesTabs(t *testing.T) { routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) body := rr.Body.String() - for _, want := range []string{"设备详情", "当前设备", "最近状态摘要"} { + for _, want := range []string{"设备详情", "当前设备", "设备工作台", "概览", "运行与服务", "设备配置"} { if !strings.Contains(body, want) { t.Fatalf("expected device detail HTML to contain %q", want) } } - for _, forbidden := range []string{"device-tabs", "设备控制", "返回设备总览", "进入管理"} { + for _, forbidden := range []string{"device-tabs", "返回设备总览", "进入管理"} { if strings.Contains(body, forbidden) { t.Fatalf("device detail should not contain redundant nav %q", forbidden) } } } -func TestUI_DeviceDetailIsReadOnly(t *testing.T) { +func TestUI_DeviceDetailUsesUnifiedWorkspace(t *testing.T) { ui := newTestUI(t) routes, err := ui.Routes() if err != nil { @@ -925,9 +956,9 @@ func TestUI_DeviceDetailIsReadOnly(t *testing.T) { routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) body := rr.Body.String() - for _, want := range []string{"设备详情", "设备状态", "当前运行配置", "最近状态摘要"} { + for _, want := range []string{"设备详情", "设备工作台", "运行与服务", "设备配置", "模型与资源", "日志与指标"} { if !strings.Contains(body, want) { - t.Fatalf("expected read-only device detail HTML to contain %q", want) + t.Fatalf("expected unified device workspace HTML to contain %q", want) } } for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} { @@ -937,17 +968,9 @@ func TestUI_DeviceDetailIsReadOnly(t *testing.T) { } for _, forbidden := range []string{ `action="/ui/devices/edge-01/alias"`, - `type="file"`, - "部署到设备", - "启动视频分析", - "重启视频分析", - "停止视频分析", - "重载识别服务", - "回滚识别配置", - "上传视频分析配置", } { if strings.Contains(body, forbidden) { - t.Fatalf("device detail should be read-only and not contain %q", forbidden) + t.Fatalf("device detail workspace should not contain obsolete entry %q", forbidden) } } } @@ -1052,9 +1075,17 @@ func TestUI_DeviceDetailDoesNotUseChannelDisplayNameAsDeviceName(t *testing.T) { t.Fatalf("parse test server port: %v", err) } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000, DeviceAliases: map[string]string{"edge-01": "备用盒子-01"}} + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + reg := service.NewRegistryService(cfg, agent, storage.NewDevicesRepo(store.DB())) + if err := reg.SetDeviceAlias("edge-01", "备用盒子-01"); err != nil { + t.Fatalf("SetDeviceAlias: %v", err) + } reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"}) ui, err := NewUI(nil, reg, agent, service.NewTaskService(cfg, agent, reg), nil) if err != nil { @@ -1639,22 +1670,22 @@ func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) { body := rr.Body.String() for _, want := range []string{ + "总览", "设备", - "配置管理", "识别配置", - "操作审计", - "系统", + "任务", + "诊断", + `href="/ui/dashboard"`, `href="/ui/devices"`, - `href="/ui/device-config"`, `href="/ui/assets"`, - `href="/ui/audit"`, - `href="/ui/system"`, + `href="/ui/tasks"`, + `href="/ui/diagnostics"`, } { if !strings.Contains(body, want) { t.Fatalf("expected sidebar to contain %q", want) } } - for _, old := range []string{"模型管理", "高级调试", "日志分析"} { + for _, old := range []string{"配置管理", "系统状态", "操作审计"} { if strings.Contains(body, old) { t.Fatalf("sidebar should not contain old label %q", old) } @@ -1672,7 +1703,7 @@ func TestUI_DeviceConfigPageShowsDeviceSelector(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, want := range []string{"配置管理", "选择设备", "edge-01", "入口识别节点", `href="/ui/device-config/edge-01"`} { + for _, want := range []string{"单设备配置已并入设备详情", "设备入口", "edge-01", "入口识别节点", `href="/ui/devices/edge-01#device-config"`} { if !strings.Contains(body, want) { t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body) } @@ -1693,34 +1724,11 @@ func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) { rr := httptest.NewRecorder() routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/device-config/edge-01", nil)) - body := rr.Body.String() - for _, want := range []string{ - "配置管理", - "单设备配置", - "配置预览", - "配置应用", - "服务控制", - "执行结果摘要", - `action="/ui/devices/edge-01/action"`, - `action="/ui/devices/edge-01/config-candidate/apply"`, - `href="/ui/devices/edge-01/config-preview"`, - "回滚到上一份", - "打开预览器", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected device control HTML to contain %q", want) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } - if strings.Contains(body, "设备详情") { - t.Fatalf("device config workspace should not cross-link back into device detail") - } - if strings.Contains(body, "重载服务") { - t.Fatalf("device control page should not contain reload action") - } - for _, forbidden := range []string{"框架版", "结构样机", "待接入", `disabled>`, `value="workshop_face_shoe_alarm" disabled`} { - if strings.Contains(body, forbidden) { - t.Fatalf("device control page should not contain placeholder marker %q", forbidden) - } + if got := rr.Header().Get("Location"); got != "/ui/devices/edge-01#device-config" { + t.Fatalf("expected device-config detail redirect to device workspace, got %q", got) } } @@ -1776,7 +1784,7 @@ func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) { ui.actionDeviceAction(rr, req) body := rr.Body.String() - for _, want := range []string{"配置管理", "POST /v1/media-server/reload", "执行结果摘要"} { + for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/media-server/reload", "执行结果摘要"} { if !strings.Contains(body, want) { t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body) } @@ -1840,7 +1848,7 @@ func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(t *testing.T) { t.Fatalf("expected status to be refreshed for control page, got %d calls", statusCalls) } body := rr.Body.String() - for _, want := range []string{"配置管理", "应用候选配置结果", "preview_edge-01", "local_3588_face_debug"} { + for _, want := range []string{"设备工作台", "应用候选配置结果", "preview_edge-01", "local_3588_face_debug"} { if !strings.Contains(body, want) { t.Fatalf("expected control page apply result HTML to contain %q, got:\n%s", want, body) } @@ -2033,7 +2041,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) { rrAudit := httptest.NewRecorder() ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil)) - for _, want := range []string{"操作审计", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发识别配置", "重启视频分析服务"} { + for _, want := range []string{"诊断 / 审计记录", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发识别配置", "重启视频分析服务"} { if !strings.Contains(rrAudit.Body.String(), want) { t.Fatalf("expected audit HTML to contain %q", want) } @@ -2051,12 +2059,12 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) { rrTasks := httptest.NewRecorder() ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil)) - for _, want := range []string{"任务列表", "批量配置", "批量服务", "目标设备数"} { + for _, want := range []string{"执行历史", "目标设备数"} { if !strings.Contains(rrTasks.Body.String(), want) { t.Fatalf("expected tasks HTML to contain %q", want) } } - for _, forbidden := range []string{"任务中心", "节点执行情况"} { + for _, forbidden := range []string{"任务中心", "节点执行情况", "创建任务", "

目标设备

", "高级参数(JSON)", `name="device_ids"`, `name="payload_json"`} { if strings.Contains(rrTasks.Body.String(), forbidden) { t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden) } @@ -2068,7 +2076,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) { rctx.URLParams.Add("id", taskConfig.ID) reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx)) ui.pageTask(rrTaskConfig, reqTask) - for _, want := range []string{"批量配置", "下发识别配置", "返回任务列表"} { + for _, want := range []string{"任务概览", "下发识别配置", "返回任务列表"} { if !strings.Contains(rrTaskConfig.Body.String(), want) { t.Fatalf("expected task detail HTML to contain %q", want) } @@ -2081,7 +2089,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) { rrSystem := httptest.NewRecorder() ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil)) - for _, want := range []string{"系统", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} { + for _, want := range []string{"诊断 / 系统状态", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} { if !strings.Contains(rrSystem.Body.String(), want) { t.Fatalf("expected system HTML to contain %q", want) } @@ -2124,7 +2132,7 @@ func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/tasks") - for _, text := range []string{"批量下发", "批量重启", "批量回滚", "执行历史"} { + for _, text := range []string{"执行历史", "目标设备数"} { if !strings.Contains(html, text) { t.Fatalf("expected tasks text %q in html: %s", text, html) } @@ -2204,3 +2212,342 @@ func TestUI_DiagnosticsSecondaryPagesUseDiagnosticsCrumb(t *testing.T) { } } } + +func TestUI_TasksPageDoesNotExposeTaskCreationForm(t *testing.T) { + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + + html := renderPage(t, ui, "/ui/tasks?selected=edge-02") + for _, forbidden := range []string{"

目标设备

", `name="device_id"`, `name="device_ids"`, `name="payload_json"`, "创建任务", ""} { + if strings.Contains(html, forbidden) { + t.Fatalf("tasks page should not expose task-creation UI %q, got: %s", forbidden, html) + } + } +} + +func TestUI_TasksPageDoesNotShowTransitionQuickActions(t *testing.T) { + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/tasks") + for _, forbidden := range []string{"常用动作", "批量下发", "批量重启", "批量回滚"} { + if strings.Contains(html, forbidden) { + t.Fatalf("tasks page should not contain transition quick action block %q, got: %s", forbidden, html) + } + } +} + +func TestUI_ActionCreateTaskUsesSelectedDeviceCheckboxes(t *testing.T) { + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + + form := url.Values{} + form.Set("type", "media_restart") + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-02") + form.Set("payload_json", `{}`) + req := httptest.NewRequest(http.MethodPost, "/ui/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + ui.actionCreateTask(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + + loc := rr.Header().Get("Location") + taskID := strings.TrimPrefix(loc, "/ui/tasks/") + var created *models.Task + for _, item := range ui.tasks.ListTasks() { + if item.ID == taskID { + task := item + created = &task + break + } + } + if created == nil { + t.Fatalf("expected created task %q to exist", taskID) + } + if got := strings.Join(created.DeviceIDs, ","); got != "edge-01,edge-02" { + t.Fatalf("expected selected devices preserved in order, got %q", got) + } +} + +func TestUI_TasksPageShowsPersistedHistory(t *testing.T) { + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + reg := service.NewRegistryService(cfg, nil) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewTasksRepo(store.DB()) + + task := models.NewTask("task-persisted", "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) + } + + tasks := service.NewTaskService(cfg, nil, reg, repo) + if err := tasks.LoadPersistedTasks(); err != nil { + t.Fatalf("LoadPersistedTasks: %v", err) + } + + ui, err := NewUI(nil, reg, nil, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + html := renderPage(t, ui, "/ui/tasks") + for _, want := range []string{"task-persisted", "重载识别服务", "1 台"} { + if !strings.Contains(html, want) { + t.Fatalf("expected persisted task history to contain %q, got: %s", want, html) + } + } +} + +func TestUI_AssetsOverviewShowsImportAction(t *testing.T) { + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/assets") + if !strings.Contains(html, "导入现有 JSON") { + t.Fatalf("expected assets overview to contain import action, got: %s", html) + } +} + +func TestUI_AssetTemplateExportsJSON(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) + } + + ui := newTestUI(t) + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + + req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates/helmet/export", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", "helmet") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + + ui.pageAssetTemplateExport(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "helmet.json") { + t.Fatalf("expected attachment filename, got %q", got) + } + if rr.Body.String() != raw { + t.Fatalf("unexpected export body: %s", rr.Body.String()) + } +} + +func TestUI_AssetTemplateShowsSaveAsExportButton(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) + } + + ui := newTestUI(t) + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + html := renderPage(t, ui, "/ui/assets/templates?name=helmet") + + for _, want := range []string{"另存为 JSON", `data-export-url="/ui/assets/templates/helmet/export"`, `data-default-filename="helmet.json"`, "showSaveFilePicker", "当前浏览器不支持选择保存目录和文件名"} { + if !strings.Contains(html, want) { + t.Fatalf("expected asset template html to contain %q, got: %s", want, html) + } + } +} + +func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) { + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + auditRepo := storage.NewAuditLogsRepo(store.DB()) + if err := auditRepo.Append(storage.AuditLogRecord{ + Actor: "system", + Action: "config_apply", + TargetType: "device", + TargetID: "edge-01", + DetailsJSON: `{"task_id":"task-99","profile":"gate_a","status":"success"}`, + }); err != nil { + t.Fatalf("Append: %v", err) + } + ui.auditRepo = auditRepo + + html := renderPage(t, ui, "/ui/audit") + for _, want := range []string{"task-99", "gate_a", "下发业务配置", "成功", "edge-01"} { + if !strings.Contains(html, want) { + t.Fatalf("expected audit page to contain %q, got: %s", want, html) + } + } + if strings.Contains(html, "暂无审计记录") { + t.Fatalf("expected persisted audit logs to replace empty state, got: %s", html) + } + if strings.Contains(html, "config_apply") || strings.Contains(html, "success") { + t.Fatalf("expected audit page to avoid raw enums, got: %s", html) + } +} + +func TestUI_DeviceDetailFallsBackToPersistedConfigState(t *testing.T) { + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) + if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{ + DeviceID: "edge-01", + TemplateName: "helmet", + ProfileName: "gate_a", + OverlaysJSON: `["night_relaxed"]`, + ConfigID: "cfg-001", + ConfigVersion: "20260427.1", + LastAppliedTaskID: "task-1", + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + ui.stateRepo = stateRepo + + html := renderPage(t, ui, "/ui/devices/edge-01") + for _, want := range []string{"cfg-001", "20260427.1", "helmet", "gate_a", "night_relaxed"} { + if !strings.Contains(html, want) { + t.Fatalf("expected device detail to contain %q, got: %s", want, html) + } + } +} + +func TestUI_SystemPageShowsDatabaseBackupAction(t *testing.T) { + ui := newTestUI(t) + ui.dbPath = filepath.Join(t.TempDir(), "app.db") + + html := renderPage(t, ui, "/ui/system") + for _, want := range []string{ + "数据备份 / 恢复", + "恢复数据库", + `class="btn ghost js-export-db"`, + `data-export-url="/ui/system/db-backup"`, + `data-default-filename="app.db"`, + "showSaveFilePicker", + "当前浏览器不支持选择保存目录和文件名", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected system page to contain %q, got: %s", want, html) + } + } +} + +func TestUI_SystemPageShowsFlashMessageFromQuery(t *testing.T) { + ui := newTestUI(t) + ui.dbPath = filepath.Join(t.TempDir(), "app.db") + + html := renderPage(t, ui, "/ui/system?msg=%E6%95%B0%E6%8D%AE%E5%BA%93%E6%81%A2%E5%A4%8D%E5%AE%8C%E6%88%90") + if !strings.Contains(html, "数据库恢复完成") { + t.Fatalf("expected system page to show success message, got: %s", html) + } +} + +func TestUI_SystemDBBackupUsesTimestampedFilename(t *testing.T) { + ui := newTestUI(t) + dir := t.TempDir() + ui.dbPath = filepath.Join(dir, "app.db") + if err := os.WriteFile(ui.dbPath, []byte("sqlite-bytes"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/ui/system/db-backup", nil) + rr := httptest.NewRecorder() + ui.pageSystemDBBackup(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + got := rr.Header().Get("Content-Disposition") + if !strings.Contains(got, "attachment;") || !strings.Contains(got, "app-") || !strings.Contains(got, ".db") { + t.Fatalf("expected timestamped filename, got %q", got) + } +} + +func TestUI_SystemDBRestoreReplacesDatabaseFile(t *testing.T) { + ui := newTestUI(t) + dir := t.TempDir() + ui.dbPath = filepath.Join(dir, "app.db") + if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "restore.db") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := part.Write([]byte("new-db")); err != nil { + t.Fatalf("Write part: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + ui.actionSystemDBRestore(rr, req) + + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + restored, err := os.ReadFile(ui.dbPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(restored) != "new-db" { + t.Fatalf("expected restored db contents, got %q", string(restored)) + } +} + +func TestUI_SystemDBRestoreRequiresSelectedFile(t *testing.T) { + ui := newTestUI(t) + dir := t.TempDir() + ui.dbPath = filepath.Join(dir, "app.db") + if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.Close(); err != nil { + t.Fatalf("Close writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + ui.actionSystemDBRestore(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + html := rr.Body.String() + if !strings.Contains(html, "请先选择数据库备份文件") { + t.Fatalf("expected friendly error message, got: %s", html) + } + if strings.Contains(html, "http: no such file") { + t.Fatalf("expected raw form-file error to stay hidden, got: %s", html) + } +} diff --git a/managerd.json b/managerd.json index d7d28ee..696cc1f 100644 --- a/managerd.json +++ b/managerd.json @@ -5,6 +5,9 @@ "offline_after_ms": 10000, "agent_token": "4fe2d69fda23d0d5d04a1486d4920e68", "concurrency": 5, + "data_dir": "data", + "db_path": "data/app.db", + "log_dir": "data/logs", "device_aliases": { "d12a4719c91641df91b76ab271280797": "盒子A" }