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 21ca473..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": ``, @@ -299,7 +377,9 @@ func tablerIconSVG(name string) string { "preview": ``, "apply": ``, "service": ``, + "task": ``, "result": ``, + "logs": ``, "meta": ``, "template": ``, "profile": ``, @@ -335,25 +415,30 @@ func (u *UI) Routes() (chi.Router, error) { })) r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/devices", http.StatusFound) + http.Redirect(w, r, "/ui/dashboard", http.StatusFound) }) - r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/devices", http.StatusFound) - }) + r.Get("/dashboard", u.pageDashboard) 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) r.Post("/devices-add", u.actionDeviceAdd) r.Post("/devices/batch-action", u.actionDevicesBatchAction) @@ -442,7 +527,31 @@ func (u *UI) ensureDevicesLoaded() { } func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/devices", http.StatusFound) + data := u.deviceOverviewPageData(r, nil, "") + if u.tasks != nil { + for _, task := range u.tasks.ListTasks() { + switch task.Status { + case models.TaskRunning: + data.RunningTaskCount++ + case models.TaskFailed: + data.FailedTaskCount++ + case models.TaskSuccess: + data.SuccessTaskCount++ + } + } + } + data.Title = "总览" + data.Tasks = nil + if u.tasks != nil { + data.Tasks = u.tasks.ListTasks() + } + data.AttentionDevices = nil + for _, dev := range data.Devices { + if dev != nil && !dev.Online { + data.AttentionDevices = append(data.AttentionDevices, dev) + } + } + u.render(w, r, "dashboard", data) } func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) { @@ -454,7 +563,16 @@ func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) { } func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/assets", http.StatusFound) + u.ensureDevicesLoaded() + u.render(w, r, "device_config", PageData{ + Title: "设备配置入口", + Devices: u.registry.GetDevices(), + }) +} + +func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) } func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) { @@ -559,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 { @@ -584,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) @@ -628,31 +747,23 @@ func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - status, raw, statusErr := u.loadConfigStatus(dev) - data := PageData{Title: "设备详情", Device: dev, ConfigStatus: status, ConfigStatusText: raw} - if statusErr != nil { - data.ConfigStatusErr = statusErr.Error() - } - u.render(w, r, "device", data) + u.render(w, r, "device", u.deviceDetailPageData(dev)) } func (u *UI) deviceDetailPageData(dev *models.Device) PageData { - status, raw, statusErr := u.loadConfigStatus(dev) - data := PageData{Title: "设备详情", Device: dev, ConfigStatus: status, ConfigStatusText: raw} - if statusErr != nil { - data.ConfigStatusErr = statusErr.Error() + 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 } func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - dev, ok := u.findDevice(id) - if !ok { - http.NotFound(w, r) - return - } - u.render(w, r, "device_control", u.deviceControlPageData(dev)) + http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) } func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) { @@ -692,15 +803,15 @@ func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) { body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil) msg := fmt.Sprintf("%s %s -> %d", method, path, code) returnTo := strings.TrimSpace(r.FormValue("return_to")) - if returnTo == "control" { - data := u.deviceControlPageData(dev) + if returnTo == "control" || returnTo == "config" { + data := u.deviceDetailPageData(dev) data.Message = msg data.RawText = string(body) data.ResultTitle = "执行结果摘要" if err != nil { data.Error = err.Error() } - u.render(w, r, "device_control", data) + u.render(w, r, "device", data) return } data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)} @@ -913,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 { @@ -959,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 == "" { @@ -1016,7 +1143,7 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { } func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) { - u.render(w, r, "diagnostics", PageData{Title: "日志分析", Devices: u.registry.GetDevices()}) + u.render(w, r, "diagnostics", PageData{Title: "诊断", Devices: u.registry.GetDevices()}) } func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) { @@ -1024,11 +1151,11 @@ func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) { } func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/audit", http.StatusFound) + http.Redirect(w, r, "/ui/diagnostics", http.StatusFound) } func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/system", http.StatusFound) + u.render(w, r, "api", PageData{Title: "高级调试"}) } func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) { @@ -1036,8 +1163,39 @@ 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 != "" { + if item, err := u.preview.GetTemplateAsset(name); err == nil { + data.AssetTemplate = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if len(data.AssetTemplates) > 0 { + if item, err := u.preview.GetTemplateAsset(data.AssetTemplates[0].Name); err == nil { + data.AssetTemplate = item + } else if data.Error == "" { + data.Error = err.Error() + } + } u.render(w, r, "asset_templates", data) } @@ -1049,13 +1207,32 @@ func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - data.Title = "模板详情" data.AssetTemplate = item - u.render(w, r, "asset_template", data) + 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")) + if selected == "" && len(data.AssetProfiles) > 0 { + selected = data.AssetProfiles[0].Name + } + if selected != "" { + editor, err := u.preview.GetProfileEditor(selected) + if err == nil { + data.AssetProfileEditor = editor + data.SelectedProfile = editor.Name + if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { + data.SelectedTemplate = editor.Instances[0].Template + } + } else if data.Error == "" { + data.Error = err.Error() + } + } u.render(w, r, "asset_profiles", data) } @@ -1066,8 +1243,12 @@ func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - data.Title = "业务配置编辑" - u.render(w, r, "asset_profile", data) + data.Title = "识别配置" + 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) { @@ -1079,7 +1260,7 @@ func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { } if err := u.preview.SaveProfileEditor(editor); err != nil { data.Error = err.Error() - u.render(w, r, "asset_profile", data) + u.render(w, r, "asset_profiles", data) return } if editor.Name != name { @@ -1087,11 +1268,24 @@ func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { } else { data.Message = "业务配置已保存" } - u.render(w, r, "asset_profile", data) + u.render(w, r, "asset_profiles", data) } func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("overlays") + if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { + if item, err := u.preview.GetOverlayAsset(name); err == nil { + data.AssetOverlay = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if len(data.AssetOverlays) > 0 { + if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil { + data.AssetOverlay = item + } else if data.Error == "" { + data.Error = err.Error() + } + } u.render(w, r, "asset_overlays", data) } @@ -1103,14 +1297,17 @@ func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - data.Title = "配置叠加项详情" data.AssetOverlay = item - u.render(w, r, "asset_overlay", data) + 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: "配置资产", + Title: "识别配置", AssetTab: tab, } if u.preview == nil { @@ -1193,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 { @@ -1388,8 +1668,8 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req } returnTo := strings.TrimSpace(r.FormValue("return_to")) var data PageData - if returnTo == "control" { - data = u.deviceControlPageData(dev) + if returnTo == "control" || returnTo == "config" { + data = u.deviceDetailPageData(dev) } else { data = u.configPreviewPageData(dev) } @@ -1401,11 +1681,7 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`)) data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code) data.RawText = prettyJSON(body) - if returnTo == "control" { - data.ResultTitle = "应用候选配置结果" - } else { - data.ResultTitle = "应用候选配置结果" - } + data.ResultTitle = "应用候选配置结果" if err != nil { data.Error = err.Error() } else { @@ -1417,8 +1693,8 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req data.ConfigStatusErr = "" } } - if returnTo == "control" { - u.render(w, r, "device_control", data) + if returnTo == "control" || returnTo == "config" { + u.render(w, r, "device", data) return } u.render(w, r, "config_preview", data) @@ -1460,6 +1736,12 @@ func (u *UI) deviceControlPageData(dev *models.Device) PageData { return data } +func (u *UI) deviceConfigWorkspacePageData(dev *models.Device) PageData { + data := u.deviceControlPageData(dev) + data.Title = "配置管理" + return data +} + func (u *UI) listTemplatesSafe() ([]service.Template, error) { if u.templates == nil { return nil, nil @@ -1679,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 @@ -1689,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 } @@ -1769,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/api.html b/internal/web/ui/templates/api.html index 629783a..920b72a 100644 --- a/internal/web/ui/templates/api.html +++ b/internal/web/ui/templates/api.html @@ -1,5 +1,6 @@ {{define "api"}}
{{json .AssetOverlay.Raw}}
+{{json .AssetProfileEditor.Raw}}
+{{json .AssetTemplate.AdvancedParams}}
+{{json .AssetTemplate.Raw}}
+| 动作 | +目标 | +任务 | +配置 | +结果 | +
|---|---|---|---|---|
| {{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}} | +
| 通道 | +显示名称 | +站点 | +RTSP | +
|---|---|---|---|
| {{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}} | +{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}} | +{{if .SiteName}}{{.SiteName}}{{else}}-{{end}} | +{{if .RTSPURL}}{{.RTSPURL}}{{else}}-{{end}} | +
{{.ConfigPreview.JSON}}
-| 设备 | 状态 | 管理地址 | 服务状态 | 配置操作 | 服务管理 |
|---|---|---|---|---|---|
| {{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}} {{.DeviceID}} |
- {{if .Online}}在线{{else}}离线{{end}} | -{{.IP}}:{{.AgentPort}} | -- - | -- - - - | -
-
-
-
-
-
-
-
- |
-
| 暂无设备。请先在“新增设备”页扫描或手动添加。 | |||||