feat: add local sqlite storage foundation
This commit is contained in:
parent
fdd7a03378
commit
43d18d0c7b
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@ tmp/
|
||||
|
||||
# Local runtime files
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
managerd.local.json
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
12
go.mod
12
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
|
||||
)
|
||||
|
||||
19
go.sum
19
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=
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
20
internal/config/config_test.go
Normal file
20
internal/config/config_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
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"])
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
168
internal/storage/assets_repo.go
Normal file
168
internal/storage/assets_repo.go
Normal file
@ -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
|
||||
}
|
||||
41
internal/storage/assets_repo_test.go
Normal file
41
internal/storage/assets_repo_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
74
internal/storage/audit_logs_repo.go
Normal file
74
internal/storage/audit_logs_repo.go
Normal file
@ -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()
|
||||
}
|
||||
30
internal/storage/audit_logs_repo_test.go
Normal file
30
internal/storage/audit_logs_repo_test.go
Normal file
@ -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])
|
||||
}
|
||||
}
|
||||
76
internal/storage/device_config_state_repo.go
Normal file
76
internal/storage/device_config_state_repo.go
Normal file
@ -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
|
||||
}
|
||||
33
internal/storage/device_config_state_repo_test.go
Normal file
33
internal/storage/device_config_state_repo_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
93
internal/storage/devices_repo.go
Normal file
93
internal/storage/devices_repo.go
Normal file
@ -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
|
||||
}
|
||||
38
internal/storage/devices_repo_test.go
Normal file
38
internal/storage/devices_repo_test.go
Normal file
@ -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])
|
||||
}
|
||||
}
|
||||
88
internal/storage/migrate.go
Normal file
88
internal/storage/migrate.go
Normal file
@ -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
|
||||
}
|
||||
17
internal/storage/paths.go
Normal file
17
internal/storage/paths.go
Normal file
@ -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"),
|
||||
}
|
||||
}
|
||||
51
internal/storage/sqlite.go
Normal file
51
internal/storage/sqlite.go
Normal file
@ -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
|
||||
}
|
||||
34
internal/storage/sqlite_test.go
Normal file
34
internal/storage/sqlite_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
internal/storage/tasks_repo.go
Normal file
149
internal/storage/tasks_repo.go
Normal file
@ -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()
|
||||
}
|
||||
46
internal/storage/tasks_repo_test.go
Normal file
46
internal/storage/tasks_repo_test.go
Normal file
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="4" width="18" height="12" rx="1"/><path d="M7 20h10"/><path d="M9 16v4"/><path d="M15 16v4"/></svg>`,
|
||||
@ -344,15 +422,21 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Get("/devices", u.pageDevices)
|
||||
r.Get("/devices/{id}/control", u.pageDeviceControl)
|
||||
r.Get("/assets", u.pageAssets)
|
||||
r.Post("/assets/import", u.actionAssetsImport)
|
||||
r.Get("/assets/templates", u.pageAssetTemplates)
|
||||
r.Get("/assets/templates/{name}", u.pageAssetTemplate)
|
||||
r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport)
|
||||
r.Get("/assets/profiles", u.pageAssetProfiles)
|
||||
r.Get("/assets/profiles/{name}", u.pageAssetProfile)
|
||||
r.Post("/assets/profiles/{name}", u.actionAssetProfileSave)
|
||||
r.Get("/assets/profiles/{name}/export", u.pageAssetProfileExport)
|
||||
r.Get("/assets/overlays", u.pageAssetOverlays)
|
||||
r.Get("/assets/overlays/{name}", u.pageAssetOverlay)
|
||||
r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport)
|
||||
r.Get("/audit", u.pageAudit)
|
||||
r.Get("/system", u.pageSystem)
|
||||
r.Get("/system/db-backup", u.pageSystemDBBackup)
|
||||
r.Post("/system/db-restore", u.actionSystemDBRestore)
|
||||
r.Get("/device-config", u.pageDeviceConfig)
|
||||
r.Get("/device-config/{id}", u.pageDeviceConfigDetail)
|
||||
r.Get("/devices-add", u.pageDeviceAdd)
|
||||
@ -593,24 +677,17 @@ func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
|
||||
req := service.ConfigPreviewRequest{
|
||||
Template: strings.TrimSpace(r.FormValue("template")),
|
||||
Profile: strings.TrimSpace(r.FormValue("profile")),
|
||||
Overlays: cleanFormList(r.Form["overlay"]),
|
||||
ConfigID: strings.TrimSpace(r.FormValue("config_id")),
|
||||
ConfigVersion: strings.TrimSpace(r.FormValue("config_version")),
|
||||
}
|
||||
req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))}
|
||||
data := u.deviceBatchConfigPageData(r, selectedIDs)
|
||||
if req.Template != "" {
|
||||
data.SelectedTemplate = req.Template
|
||||
}
|
||||
if req.Profile != "" {
|
||||
data.SelectedProfile = req.Profile
|
||||
}
|
||||
data.SelectedOverlays = append([]string(nil), req.Overlays...)
|
||||
data.SelectedConfigID = req.ConfigID
|
||||
if req.ConfigVersion != "" {
|
||||
data.SelectedVersion = req.ConfigVersion
|
||||
for i := range data.AssetProfiles {
|
||||
if strings.TrimSpace(data.AssetProfiles[i].Name) == data.SelectedProfile {
|
||||
data.AssetProfile = &data.AssetProfiles[i]
|
||||
data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(selectedIDs) == 0 {
|
||||
@ -618,12 +695,20 @@ func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
if req.Template == "" {
|
||||
req.Template = data.SelectedTemplate
|
||||
}
|
||||
if req.Profile == "" {
|
||||
req.Profile = data.SelectedProfile
|
||||
}
|
||||
if req.Profile == "" {
|
||||
data.Error = "请先选择业务配置"
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
if data.SelectedTemplate == "" {
|
||||
data.Error = "所选业务配置缺少可用模板,无法生成下发内容"
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
req.Template = data.SelectedTemplate
|
||||
if u.tasks == nil {
|
||||
data.Error = "task service not initialized"
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
@ -668,6 +753,11 @@ func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) {
|
||||
func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
|
||||
data := u.deviceControlPageData(dev)
|
||||
data.Title = "设备详情"
|
||||
if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil {
|
||||
if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil {
|
||||
data.PersistedConfig = state
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@ -934,7 +1024,18 @@ func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *
|
||||
}
|
||||
|
||||
func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()})
|
||||
u.ensureDevicesLoaded()
|
||||
devices := u.registry.GetDevices()
|
||||
selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"]))
|
||||
data := PageData{
|
||||
Title: "任务",
|
||||
Tasks: u.tasks.ListTasks(),
|
||||
Devices: devices,
|
||||
SelectedDeviceIDs: selectedIDs,
|
||||
SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs),
|
||||
DeviceIDs: strings.Join(selectedIDs, ","),
|
||||
}
|
||||
u.render(w, r, "tasks", data)
|
||||
}
|
||||
|
||||
func (u *UI) taskPageData(task *models.Task) PageData {
|
||||
@ -980,12 +1081,17 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ids := strings.TrimSpace(r.FormValue("device_ids"))
|
||||
var deviceIDs []string
|
||||
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 == "" {
|
||||
raw = `{"config":{}}`
|
||||
@ -1057,6 +1163,24 @@ func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "assets", data)
|
||||
}
|
||||
|
||||
func (u *UI) actionAssetsImport(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("overview")
|
||||
if u.preview == nil {
|
||||
data.Error = "配置资产服务未初始化"
|
||||
u.render(w, r, "assets", data)
|
||||
return
|
||||
}
|
||||
result, err := u.preview.ImportAssetsFromMediaRepo()
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
u.render(w, r, "assets", data)
|
||||
return
|
||||
}
|
||||
data = u.assetPageData("overview")
|
||||
data.Message = fmt.Sprintf("已导入 %d 个模板、%d 个业务配置、%d 个叠加项", result.Templates, result.Profiles, result.Overlays)
|
||||
u.render(w, r, "assets", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("templates")
|
||||
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
|
||||
@ -1087,6 +1211,10 @@ func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "asset_templates", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) {
|
||||
u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name"))
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("profiles")
|
||||
selected := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
@ -1119,6 +1247,10 @@ func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "asset_profiles", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) {
|
||||
u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name"))
|
||||
}
|
||||
|
||||
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
editor, data, err := u.profileEditorActionData(r, name)
|
||||
@ -1169,6 +1301,10 @@ func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "asset_overlays", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) {
|
||||
u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name"))
|
||||
}
|
||||
|
||||
func (u *UI) assetPageData(tab string) PageData {
|
||||
data := PageData{
|
||||
Title: "识别配置",
|
||||
@ -1254,16 +1390,99 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf
|
||||
return editor, data, nil
|
||||
}
|
||||
|
||||
func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
|
||||
tasks := []models.Task(nil)
|
||||
if u.tasks != nil {
|
||||
tasks = u.tasks.ListTasks()
|
||||
func (u *UI) exportAssetJSON(w http.ResponseWriter, r *http.Request, kind string, name string) {
|
||||
if u.preview == nil {
|
||||
http.Error(w, "preview service not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
u.render(w, r, "audit", PageData{Title: "审计记录", Tasks: tasks})
|
||||
body, filename, err := u.preview.ExportAssetJSON(kind, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{Title: "审计记录"}
|
||||
if u.auditRepo != nil {
|
||||
items, err := u.auditRepo.List()
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
} else {
|
||||
data.AuditEntries = items
|
||||
}
|
||||
}
|
||||
if len(data.AuditEntries) == 0 && u.tasks != nil {
|
||||
data.Tasks = u.tasks.ListTasks()
|
||||
}
|
||||
u.render(w, r, "audit", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageSystem(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "system", PageData{Title: "系统状态", Devices: u.registry.GetDevices()})
|
||||
u.renderSystemPage(
|
||||
w,
|
||||
r,
|
||||
http.StatusOK,
|
||||
strings.TrimSpace(r.URL.Query().Get("msg")),
|
||||
strings.TrimSpace(r.URL.Query().Get("error")),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *UI) pageSystemDBBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(u.dbPath) == "" {
|
||||
http.Error(w, "database path is not configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
body, err := os.ReadFile(u.dbPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filename := "app-" + time.Now().Format("20060102-150405") + ".db"
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (u *UI) renderSystemPage(w http.ResponseWriter, r *http.Request, status int, message string, errText string) {
|
||||
w.WriteHeader(status)
|
||||
u.render(w, r, "system", PageData{
|
||||
Title: "系统状态",
|
||||
Devices: u.registry.GetDevices(),
|
||||
DBPath: u.dbPath,
|
||||
Message: message,
|
||||
Error: errText,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *UI) actionSystemDBRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(u.dbPath) == "" {
|
||||
http.Error(w, "database path is not configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
u.renderSystemPage(w, r, http.StatusBadRequest, "", "请先选择数据库备份文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
body, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(u.dbPath, body, 0o644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound)
|
||||
}
|
||||
|
||||
func urlQueryEscape(s string) string {
|
||||
@ -1742,6 +1961,8 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs
|
||||
SelectedQuery: selectedQueryString(selectedIDs),
|
||||
SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs),
|
||||
BatchConfigURL: selectedURL("/ui/devices/batch-config", selectedIDs),
|
||||
ReloadSummary: batchActionSummary(rows, selectedIDs, "reload"),
|
||||
RollbackSummary: batchActionSummary(rows, selectedIDs, "rollback"),
|
||||
}
|
||||
if errMsg != "" {
|
||||
data.Error = errMsg
|
||||
@ -1752,14 +1973,32 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs
|
||||
func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData {
|
||||
data := u.deviceOverviewPageData(r, selectedIDs, "")
|
||||
sources, err := u.preview.ListSources()
|
||||
data.Title = "批量配置"
|
||||
data.Title = "下发业务配置"
|
||||
data.ConfigSources = sources
|
||||
data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs)
|
||||
data.SelectedTemplate = "workshop_face_shoe_alarm"
|
||||
data.SelectedProfile = "local_3588_test"
|
||||
data.SelectedOverlays = []string{"face_debug"}
|
||||
profiles, profileErr := u.preview.ListProfileAssets()
|
||||
data.AssetProfiles = profiles
|
||||
selectedProfile := strings.TrimSpace(r.URL.Query().Get("profile"))
|
||||
if selectedProfile == "" {
|
||||
selectedProfile = "local_3588_test"
|
||||
}
|
||||
for i := range profiles {
|
||||
if strings.TrimSpace(profiles[i].Name) == selectedProfile {
|
||||
data.AssetProfile = &profiles[i]
|
||||
data.SelectedProfile = profiles[i].Name
|
||||
data.SelectedTemplate = profileAssetTemplate(&profiles[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
if data.AssetProfile == nil && len(profiles) > 0 {
|
||||
data.AssetProfile = &profiles[0]
|
||||
data.SelectedProfile = profiles[0].Name
|
||||
data.SelectedTemplate = profileAssetTemplate(&profiles[0])
|
||||
}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
} else if profileErr != nil {
|
||||
data.Error = profileErr.Error()
|
||||
}
|
||||
return data
|
||||
}
|
||||
@ -1832,6 +2071,84 @@ func populateSelectionsFromPreview(data *PageData) {
|
||||
}
|
||||
}
|
||||
|
||||
func profileAssetTemplate(asset *service.ConfigProfileAsset) string {
|
||||
if asset == nil {
|
||||
return ""
|
||||
}
|
||||
for _, item := range asset.Instances {
|
||||
if v := strings.TrimSpace(item.Template); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func profileAssetBusinessName(asset *service.ConfigProfileAsset) string {
|
||||
if asset == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(asset.BusinessName); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(asset.Name)
|
||||
}
|
||||
|
||||
func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action string) string {
|
||||
if len(selectedIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
rowByID := make(map[string]DeviceOverviewRow, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.Device == nil {
|
||||
continue
|
||||
}
|
||||
rowByID[strings.TrimSpace(row.Device.DeviceID)] = row
|
||||
}
|
||||
lines := make([]string, 0, len(selectedIDs))
|
||||
for _, id := range selectedIDs {
|
||||
row, ok := rowByID[strings.TrimSpace(id)]
|
||||
if !ok || row.Device == nil {
|
||||
continue
|
||||
}
|
||||
label := row.Device.DisplayName()
|
||||
switch action {
|
||||
case "reload":
|
||||
summary := "未取到当前业务配置"
|
||||
if row.ConfigStatus != nil {
|
||||
meta := row.ConfigStatus.Metadata
|
||||
if name := strings.TrimSpace(meta.BusinessName); name != "" {
|
||||
summary = name
|
||||
if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||||
summary += " (" + profile + ")"
|
||||
}
|
||||
} else if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||||
summary = profile
|
||||
} else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
|
||||
summary = configID
|
||||
}
|
||||
}
|
||||
lines = append(lines, label+" -> "+summary)
|
||||
case "rollback":
|
||||
summary := "未取到可回滚业务配置"
|
||||
if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil {
|
||||
meta := row.ConfigStatus.PreviousConfig.Metadata
|
||||
if name := strings.TrimSpace(meta.BusinessName); name != "" {
|
||||
summary = name
|
||||
if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||||
summary += " (" + profile + ")"
|
||||
}
|
||||
} else if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||||
summary = profile
|
||||
} else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
|
||||
summary = configID
|
||||
}
|
||||
}
|
||||
lines = append(lines, label+" -> "+summary)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, ";")
|
||||
}
|
||||
|
||||
func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
dev, ok := u.findDevice(id)
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
<tbody>
|
||||
{{range .AssetOverlays}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/assets/overlays/{{.Name}}">{{.Name}}</a></td>
|
||||
<td><a class="mono" href="/ui/assets/overlays?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
@ -29,6 +29,36 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .AssetOverlay}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>叠加项详情</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/overlays/{{.AssetOverlay.Name}}/export"
|
||||
data-default-filename="{{.AssetOverlay.Name}}.json"
|
||||
>{{icon "apply"}}<span>另存为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>叠加项</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
|
||||
<div><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
|
||||
<div class="full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
|
||||
<pre>{{json .AssetOverlay.Raw}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{template "asset_tabs_end" .}}
|
||||
{{end}}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<tbody>
|
||||
{{range .AssetProfiles}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/assets/profiles/{{.Name}}">{{.Name}}</a></td>
|
||||
<td><a class="mono" href="/ui/assets/profiles?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{len .Instances}}</td>
|
||||
<td class="mono">{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}</td>
|
||||
@ -31,6 +31,108 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .AssetProfileEditor}}
|
||||
<form method="post" action="/ui/assets/profiles/{{.AssetProfileEditor.Name}}">
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/profiles/{{.AssetProfileEditor.Name}}/export"
|
||||
data-default-filename="{{.AssetProfileEditor.Name}}.json"
|
||||
>{{icon "apply"}}<span>另存为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label><span>业务配置名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
|
||||
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
|
||||
<label><span>队列大小</span><input class="mono" name="queue_size" value="{{.AssetProfileEditor.Queue.Size}}" /></label>
|
||||
<label><span>队列策略</span><input name="queue_strategy" value="{{.AssetProfileEditor.Queue.Strategy}}" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<span class="pill">{{len .AssetProfileEditor.Instances}} 路</span>
|
||||
<button class="btn secondary" type="submit" name="add_instance" value="1">{{icon "apply"}}<span>新增通道</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>通道</th>
|
||||
<th>通道显示名</th>
|
||||
<th>RTSP 输入</th>
|
||||
<th>HLS 输出</th>
|
||||
<th>RTSP 输出</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
<tr {{if $inst.Delete}}class="muted-row"{{end}}>
|
||||
<td class="mono">{{$inst.Name}}</td>
|
||||
<td>{{if $inst.Delete}}<span class="pill warn">待删除</span>{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}}</td>
|
||||
<td>
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="#profile-instance-{{$i}}">编辑</a>
|
||||
{{if $inst.Delete}}
|
||||
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="0">撤销删除</button>
|
||||
{{else}}
|
||||
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="1">删除</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
<details id="profile-instance-{{$i}}" class="card collapsible profile-instance-editor" {{if or $inst.Delete (not $inst.Name)}}open{{end}}>
|
||||
<summary class="title-with-icon">{{icon "device"}}<span>{{$inst.Name}}</span></summary>
|
||||
<div class="field-grid profile-instance-grid">
|
||||
<input type="hidden" name="instances[{{$i}}].delete" value="{{if $inst.Delete}}1{{else}}0{{end}}" />
|
||||
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
|
||||
<label><span>通道名</span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label>
|
||||
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label>
|
||||
<label><span>通道号</span><input name="instances[{{$i}}].channel_no" value="{{$inst.ChannelNo}}" /></label>
|
||||
<label class="full"><span>RTSP 输入</span><input class="mono" name="instances[{{$i}}].rtsp_url" value="{{$inst.RTSPURL}}" /></label>
|
||||
<label class="full"><span>HLS 输出</span><input class="mono" name="instances[{{$i}}].publish_hls_path" value="{{$inst.PublishHLSPath}}" /></label>
|
||||
<label><span>RTSP 输出端口</span><input class="mono" name="instances[{{$i}}].publish_rtsp_port" value="{{$inst.PublishRTSPPort}}" /></label>
|
||||
<label><span>RTSP 输出路径</span><input class="mono" name="instances[{{$i}}].publish_rtsp_path" value="{{$inst.PublishRTSPPath}}" /></label>
|
||||
<label class="full"><span>高级设置 JSON</span><textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
|
||||
<pre>{{json .AssetProfileEditor.Raw}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{template "asset_tabs_end" .}}
|
||||
{{end}}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<tbody>
|
||||
{{range .AssetTemplates}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/assets/templates/{{.Name}}">{{.Name}}</a></td>
|
||||
<td><a class="mono" href="/ui/assets/templates?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{.NodeCount}} / {{.EdgeCount}}</td>
|
||||
<td class="mono">{{if .Source}}{{.Source}}{{else}}-{{end}}</td>
|
||||
@ -31,6 +31,49 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .AssetTemplate}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>模板详情</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
|
||||
data-default-filename="{{.AssetTemplate.Name}}.json"
|
||||
>{{icon "apply"}}<span>另存为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>
|
||||
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
|
||||
<div><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
|
||||
<div><span>MinIO</span><strong class="mono">{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>Bucket</span><strong>{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>取 token 接口</span><strong class="mono">{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>告警上报接口</span><strong class="mono">{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>租户编码</span><strong>{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>描述</span><strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .AssetTemplate.AdvancedParams}}
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>高级设置</span></summary>
|
||||
<pre>{{json .AssetTemplate.AdvancedParams}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
|
||||
<pre>{{json .AssetTemplate.Raw}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{template "asset_tabs_end" .}}
|
||||
{{end}}
|
||||
|
||||
@ -51,6 +51,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "assets"}}<span>资产操作</span></h2>
|
||||
</div>
|
||||
<form method="post" action="/ui/assets/import">
|
||||
<button type="submit" class="btn secondary">{{icon "apply"}}<span>导入现有 JSON</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quad-grid">
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
|
||||
@ -7,6 +7,30 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
{{if .AuditEntries}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>动作</th>
|
||||
<th>目标</th>
|
||||
<th>任务</th>
|
||||
<th>配置</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AuditEntries}}
|
||||
<tr>
|
||||
<td>{{auditActionLabel .Action}}</td>
|
||||
<td><span class="mono">{{.TargetID}}</span></td>
|
||||
<td class="mono">{{if auditField .DetailsJSON "task_id"}}{{auditField .DetailsJSON "task_id"}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if auditField .DetailsJSON "profile"}}{{auditField .DetailsJSON "profile"}}{{else if auditField .DetailsJSON "config_id"}}{{auditField .DetailsJSON "config_id"}}{{else}}-{{end}}</td>
|
||||
<td>{{if auditField .DetailsJSON "status"}}{{auditStatusLabel (auditField .DetailsJSON "status")}}{{else}}{{.Actor}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -43,6 +67,7 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">当前配置</div>
|
||||
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}待读取{{end}}</div>
|
||||
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}</div>
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">服务状态</div>
|
||||
@ -54,7 +54,7 @@
|
||||
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
|
||||
<div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div>
|
||||
<div><span>版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>当前业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>当前业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>通道名</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -74,6 +74,17 @@
|
||||
<div><span>业务配置</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>叠加项</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div>
|
||||
<div><span>配置 SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{if .PersistedConfig}}
|
||||
<div class="info-list">
|
||||
<div><span>配置 ID</span><strong class="mono">{{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>配置版本</span><strong class="mono">{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>叠加项</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>最近下发任务</span><strong class="mono">{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
@ -81,6 +92,7 @@
|
||||
<div class="muted">{{if .ConfigStatusErr}}{{.ConfigStatusErr}}{{else}}设备未返回配置摘要。{{end}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -123,7 +135,7 @@
|
||||
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>编辑和上传候选配置</span></a>
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>当前配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}待读取{{end}}</strong></div>
|
||||
<div><span>当前配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}</strong></div>
|
||||
<div><span>候选配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
|
||||
@ -27,45 +27,25 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>批量配置</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>下发业务配置</span></h2>
|
||||
<div class="muted">先选择一份已有业务配置,再为已选设备创建下发任务。</div>
|
||||
</div>
|
||||
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-config">
|
||||
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
|
||||
<div class="field-grid">
|
||||
<label><span>模板</span>
|
||||
<select name="template">
|
||||
{{range .ConfigSources.Templates}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedTemplate}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>业务配置</span>
|
||||
<label class="full"><span>业务配置</span>
|
||||
<select name="profile">
|
||||
{{range .ConfigSources.Profiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}</option>
|
||||
{{range .AssetProfiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" placeholder="留空自动生成" /></label>
|
||||
<label><span>config_version</span><input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" /></label>
|
||||
<div class="full">
|
||||
<span class="muted small">配置叠加项</span>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
{{range .ConfigSources.Overlays}}
|
||||
<label class="btn secondary">
|
||||
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary">创建批量下发任务</button>
|
||||
<button type="submit" class="primary">创建下发任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -73,28 +53,51 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>预览摘要</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>业务配置摘要</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetProfile}}
|
||||
<div class="info-list">
|
||||
<div><span>模板</span><strong>{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "template"}}{{else}}{{.SelectedTemplate}}{{end}}</strong></div>
|
||||
<div><span>业务名称</span><strong>{{if .ConfigPreview}}{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "profile"}}{{else}}{{.SelectedProfile}}{{end}}</strong></div>
|
||||
<div><span>配置叠加项</span><strong class="mono">{{if .ConfigPreview}}{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{else}}{{if .SelectedOverlays}}{{range $i, $name := .SelectedOverlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{end}}</strong></div>
|
||||
<div><span>目标设备</span><strong>{{len .SelectedDeviceIDs}} 台</strong></div>
|
||||
<div><span>config_id</span><strong class="mono">{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_id"}}{{else}}{{if .SelectedConfigID}}{{.SelectedConfigID}}{{else}}自动生成{{end}}{{end}}</strong></div>
|
||||
<div><span>config_version</span><strong class="mono">{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_version"}}{{else}}{{if .SelectedVersion}}{{.SelectedVersion}}{{else}}自动生成{{end}}{{end}}</strong></div>
|
||||
{{if .ConfigPreview}}
|
||||
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
|
||||
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{.AssetProfile.Name}}</strong></div>
|
||||
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div>
|
||||
{{with index .AssetProfile.Instances 0}}
|
||||
<div><span>首个通道</span><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</strong></div>
|
||||
{{end}}
|
||||
{{if .AssetProfile.Description}}
|
||||
<div class="full"><span>说明</span><strong>{{.AssetProfile.Description}}</strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .AssetProfile.Instances}}
|
||||
<div class="table-wrap" style="margin-top:14px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>通道</th>
|
||||
<th>显示名称</th>
|
||||
<th>站点</th>
|
||||
<th>RTSP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetProfile.Instances}}
|
||||
<tr>
|
||||
<td class="mono">{{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}</td>
|
||||
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}</td>
|
||||
<td>{{if .SiteName}}{{.SiteName}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .RTSPURL}}{{.RTSPURL}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .ConfigPreview}}
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>完整 JSON</span></summary>
|
||||
<pre>{{.ConfigPreview.JSON}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">还没有可用业务配置</div>
|
||||
<div class="muted">请先到识别配置中创建业务配置,再回来下发。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
{{define "device_control"}}
|
||||
{{template "device_header" .}}
|
||||
{{template "device_tabs" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "control"}}<span>设备控制</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "control"}}<span>单设备配置</span></h2>
|
||||
<div class="muted small">当前工作台仅管理这一台设备的配置与服务。</div>
|
||||
</div>
|
||||
{{if .Device.Online}}<span class="pill ok">可操作</span>{{else}}<span class="pill bad">设备离线</span>{{end}}
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom:12px">
|
||||
<a class="btn secondary" href="/ui/device-config">返回设备选择</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-strip control-summary">
|
||||
<div class="summary-chip">
|
||||
@ -52,12 +55,12 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="primary">应用候选配置</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="rollback" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="secondary">回滚到上一份</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -72,17 +75,17 @@
|
||||
<div class="actions stack">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_start" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="secondary">启动服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_restart" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="primary">重启服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_stop" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="danger">停止服务</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -105,6 +108,4 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "device_panel_end" .}}
|
||||
{{end}}
|
||||
|
||||
@ -17,27 +17,4 @@
|
||||
|
||||
{{define "device_nav"}}
|
||||
{{template "device_header" .}}
|
||||
{{template "device_tabs" .}}
|
||||
{{template "device_panel_end" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "device_tabs"}}
|
||||
<div class="card-tabs device-tab-wrap">
|
||||
<ul class="nav nav-tabs device-tabs" role="tablist" aria-label="设备页面">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}" class="nav-link{{if eq .Title "设备详情"}} active{{end}}" role="tab" {{if eq .Title "设备详情"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>设备详情</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/control" class="nav-link{{if eq .Title "设备控制"}} active{{end}}" role="tab" {{if eq .Title "设备控制"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>设备控制</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="card tab-pane active show device-tab-card">
|
||||
<div class="card-body device-panel-body">
|
||||
{{end}}
|
||||
|
||||
{{define "device_panel_end"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -42,7 +42,9 @@
|
||||
<button type="submit" name="action" value="media_restart" class="primary">重启服务</button>
|
||||
<button type="submit" name="action" value="media_start" class="secondary">启动服务</button>
|
||||
<button type="submit" name="action" value="media_stop" class="danger">停止服务</button>
|
||||
<a class="btn secondary" href="{{.BatchConfigURL}}">批量配置</a>
|
||||
<button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前业务配置:{{.ReloadSummary}}")'{{end}}>重载配置</button>
|
||||
<button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版业务配置:{{.RollbackSummary}}")'{{end}}>回滚配置</button>
|
||||
<a class="btn secondary" href="{{.BatchConfigURL}}">下发业务配置</a>
|
||||
<a class="btn secondary" href="/ui/devices">清空选择</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -107,7 +109,6 @@
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/control">{{icon "control"}}<span>控制</span></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -3,18 +3,11 @@
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>诊断工作台</h2>
|
||||
<div class="muted small">诊断域集中承载日志分析、系统状态、审计记录和高级排障入口。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/api">高级调试</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="k">日志分析</div><div class="v">Logs</div><div class="hint">按设备查看诊断日志和运行指标</div></div>
|
||||
<div class="stat"><div class="k">系统状态</div><div class="v">System</div><div class="hint">查看发现、健康和接口状态</div></div>
|
||||
<div class="stat"><div class="k">审计记录</div><div class="v">Audit</div><div class="hint">追踪任务与关键操作</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>日志分析</h2>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
@ -35,7 +28,6 @@
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/graphs">运行指标</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}">节点详情</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -50,14 +42,12 @@
|
||||
<div class="row">
|
||||
<div class="card">
|
||||
<h2>系统状态</h2>
|
||||
<div class="muted small">查看平台健康、接口和发现能力。</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<a class="btn ghost" href="/ui/system">进入系统状态</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>审计记录</h2>
|
||||
<div class="muted small">统一查看任务执行和关键操作留痕。</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<a class="btn ghost" href="/ui/audit">进入审计记录</a>
|
||||
</div>
|
||||
|
||||
@ -45,6 +45,81 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/assets/vendor/tabler.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function suggestedFilenameFromHeader(headerValue, fallback) {
|
||||
if (!headerValue) return fallback;
|
||||
const match = /filename="?([^"]+)"?/i.exec(headerValue);
|
||||
if (!match || !match[1]) return fallback;
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function saveBlobFromURL(url, defaultFilename, types, errorMessage) {
|
||||
if (window.showSaveFilePicker) {
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const filename = suggestedFilenameFromHeader(response.headers.get("Content-Disposition"), defaultFilename);
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: types
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
throw new Error("当前浏览器不支持选择保存目录和文件名,请使用支持文件保存对话框的浏览器。");
|
||||
}
|
||||
|
||||
async function saveJSONFromURL(url, defaultFilename) {
|
||||
return saveBlobFromURL(
|
||||
url,
|
||||
defaultFilename || "config.json",
|
||||
[{
|
||||
description: "JSON 文件",
|
||||
accept: { "application/json": [".json"] }
|
||||
}],
|
||||
"导出失败"
|
||||
);
|
||||
}
|
||||
|
||||
async function saveDBFromURL(url, defaultFilename) {
|
||||
return saveBlobFromURL(
|
||||
url,
|
||||
defaultFilename || "app.db",
|
||||
[{
|
||||
description: "SQLite 数据库",
|
||||
accept: { "application/octet-stream": [".db"], "application/x-sqlite3": [".db"] }
|
||||
}],
|
||||
"备份失败"
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
const btn = event.target.closest(".js-export-json");
|
||||
if (btn) {
|
||||
event.preventDefault();
|
||||
const url = btn.getAttribute("data-export-url");
|
||||
const filename = btn.getAttribute("data-default-filename");
|
||||
saveJSONFromURL(url, filename).catch(function (err) {
|
||||
window.alert(err && err.message ? err.message : "导出失败");
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dbBtn = event.target.closest(".js-export-db");
|
||||
if (!dbBtn) return;
|
||||
event.preventDefault();
|
||||
const url = dbBtn.getAttribute("data-export-url");
|
||||
const filename = dbBtn.getAttribute("data-default-filename");
|
||||
saveDBFromURL(url, filename).catch(function (err) {
|
||||
window.alert(err && err.message ? err.message : "备份失败");
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
</div>
|
||||
<div class="model-summary">
|
||||
<div class="summary-item"><div class="summary-label">目标节点</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">可选择单台设备上传模型</div></div>
|
||||
<div class="summary-item"><div class="summary-label">部署方式</div><div class="summary-value">单节点</div><div class="summary-hint">首版沿用设备详情上传</div></div>
|
||||
<div class="summary-item"><div class="summary-label">部署方式</div><div class="summary-value">单节点</div><div class="summary-hint">在本页直接上传到目标设备</div></div>
|
||||
<div class="summary-item"><div class="summary-label">模型类型</div><div class="summary-value">检测/识别</div><div class="summary-hint">二进制模型文件</div></div>
|
||||
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">DB</div><div class="summary-hint">通过识别配置页维护</div></div>
|
||||
</div>
|
||||
|
||||
@ -45,4 +45,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "audit"}}<span>数据备份 / 恢复</span></h2>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>数据库文件</span><strong class="mono">{{if .DBPath}}{{.DBPath}}{{else}}未配置{{end}}</strong></div>
|
||||
<div><span>备份方式</span><strong>另存为数据库文件</strong></div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button type="button" class="btn ghost js-export-db" data-export-url="/ui/system/db-backup" data-default-filename="app.db">备份数据库</button>
|
||||
</div>
|
||||
<form method="post" action="/ui/system/db-restore" enctype="multipart/form-data" style="margin-top:12px">
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>恢复数据库</span><input type="file" name="file" accept=".db,application/octet-stream" required /></label>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button type="submit" class="secondary">恢复数据库</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -1,35 +1,4 @@
|
||||
{{define "tasks"}}
|
||||
<div class="card">
|
||||
<h2>批量操作</h2>
|
||||
<div class="muted small">任务域负责批量下发、批量重启、批量回滚和执行历史。</div>
|
||||
<form method="post" action="/ui/tasks" style="margin-top:10px">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="muted small">任务类型</div>
|
||||
<select name="type">
|
||||
<option value="config_apply">下发识别配置</option>
|
||||
<option value="reload">重载识别服务</option>
|
||||
<option value="rollback">回滚识别配置</option>
|
||||
<option value="media_start">启动视频分析服务</option>
|
||||
<option value="media_restart">重启视频分析服务</option>
|
||||
<option value="media_stop">停止视频分析服务</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">目标节点标识(逗号分隔)</div>
|
||||
<input name="device_ids" value="{{.DeviceIDs}}" placeholder="id1,id2" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<div class="muted small">高级参数(JSON)</div>
|
||||
<textarea name="payload_json" spellcheck="false">{{if .RawJSON}}{{.RawJSON}}{{else}}
|
||||
{"config":{}}
|
||||
{{end}}</textarea>
|
||||
</div>
|
||||
<div style="margin-top:10px"><button type="submit">创建任务</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>执行历史</h2>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
@ -52,19 +21,13 @@
|
||||
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="muted">还没有批量任务。从设备页选择设备后发起操作,这里会展示执行记录。</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>常用动作</h2>
|
||||
<div class="actions" style="margin-top:8px">
|
||||
<span class="pill">批量下发</span>
|
||||
<span class="pill">批量重启</span>
|
||||
<span class="pill">批量回滚</span>
|
||||
<a class="btn ghost" href="/ui/api">高级调试</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -4,8 +4,11 @@ import (
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/service"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -129,9 +132,9 @@ func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"视觉识别运维平台",
|
||||
"配置管理",
|
||||
"操作审计",
|
||||
"系统",
|
||||
"总览",
|
||||
"任务",
|
||||
"诊断",
|
||||
"<h1>设备</h1>",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
@ -164,7 +167,12 @@ func TestUI_ConsoleTypographyStaysModerate(t *testing.T) {
|
||||
func newTestUI(t *testing.T) *UI {
|
||||
t.Helper()
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
reg := service.NewRegistryService(cfg, nil)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0"})
|
||||
tasks := service.NewTaskService(cfg, nil, reg)
|
||||
ui, err := NewUI(nil, reg, nil, tasks, nil)
|
||||
@ -203,7 +211,7 @@ func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, forbidden := range []string{"batch-toolbar", "已选", "批量配置", "重启服务", "启动服务", "停止服务", "重载服务", "清空选择"} {
|
||||
for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body)
|
||||
}
|
||||
@ -222,13 +230,15 @@ func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "批量配置", "清空选择"} {
|
||||
for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择", "将重载当前业务配置", "将回滚到上一版业务配置"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "重载服务") {
|
||||
t.Fatalf("device overview batch controls should not contain reload action")
|
||||
for _, forbidden := range []string{"批量配置", "重载服务"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device overview batch controls should not contain %q, got:\n%s", forbidden, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `href="/ui/devices/batch-config?selected=edge-01&selected=edge-02"`) {
|
||||
t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body)
|
||||
@ -250,24 +260,25 @@ func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"批量配置",
|
||||
"模板",
|
||||
"下发业务配置",
|
||||
"业务配置",
|
||||
"配置叠加项",
|
||||
"已选设备",
|
||||
"入口识别节点",
|
||||
"辅助节点",
|
||||
"预览摘要",
|
||||
"workshop_face_shoe_alarm",
|
||||
"业务配置摘要",
|
||||
"local_3588_test",
|
||||
"face_debug",
|
||||
"A厂区视觉识别",
|
||||
"workshop_face_shoe_alarm",
|
||||
"东门入口",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "已选 2 台设备") {
|
||||
t.Fatalf("batch config page should not contain explanatory selected-count copy")
|
||||
for _, forbidden := range []string{"config_id", "config_version", "完整 JSON"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("batch config page should not expose internal config fields %q, got:\n%s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -279,11 +290,7 @@ func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-02")
|
||||
form.Set("template", "workshop_face_shoe_alarm")
|
||||
form.Set("profile", "local_3588_test")
|
||||
form.Add("overlay", "face_debug")
|
||||
form.Set("config_id", "batch_edge")
|
||||
form.Set("config_version", "20260420.090000")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
@ -364,17 +371,17 @@ func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(t *testing.T)
|
||||
`name="device_id" value="edge-02"`,
|
||||
"入口识别节点",
|
||||
"辅助节点",
|
||||
`name="config_id" value=""`,
|
||||
`name="profile"`,
|
||||
`value="local_3588_test" selected`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `name="overlay" value="face_debug" checked`) {
|
||||
t.Fatalf("expected empty overlay selection to stay empty, got:\n%s", body)
|
||||
for _, forbidden := range []string{`name="config_id"`, `name="overlay"`, "完整 JSON"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("expected failure UI to avoid internal config field %q, got:\n%s", forbidden, body)
|
||||
}
|
||||
if strings.Contains(body, "完整 JSON 放在折叠区") {
|
||||
t.Fatalf("expected no JSON foldout hint on render failure, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@ -533,8 +540,28 @@ func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
|
||||
func createBatchConfigMediaRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{
|
||||
"name":"workshop_face_shoe_alarm",
|
||||
"description":"helmet and shoe alarm",
|
||||
"template":{"nodes":[],"edges":[]}
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
|
||||
"name":"local_3588_test",
|
||||
"business_name":"A厂区视觉识别",
|
||||
"description":"默认班次识别配置",
|
||||
"instances":[
|
||||
{
|
||||
"name":"cam1",
|
||||
"template":"workshop_face_shoe_alarm",
|
||||
"params":{
|
||||
"display_name":"东门入口",
|
||||
"site_name":"A厂区",
|
||||
"rtsp_url":"rtsp://10.0.0.1/live",
|
||||
"channel_no":"cam1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
|
||||
writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse
|
||||
import json
|
||||
@ -647,8 +674,12 @@ func withChiURLParam(req *http.Request, key string, value string) *http.Request
|
||||
func createBatchConfigBrokenMediaRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
|
||||
"name":"local_3588_test",
|
||||
"business_name":"A厂区视觉识别",
|
||||
"instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
|
||||
return root
|
||||
}
|
||||
@ -700,7 +731,7 @@ func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
|
||||
t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String())
|
||||
}
|
||||
body := rrTask.Body.String()
|
||||
for _, want := range []string{"任务详情", "返回任务列表", "设备结果表", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} {
|
||||
for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected task page to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
@ -891,7 +922,7 @@ func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceDetailIncludesTabs(t *testing.T) {
|
||||
func TestUI_DeviceDetailIncludesWorkspaceSections(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
routes, err := ui.Routes()
|
||||
if err != nil {
|
||||
@ -902,19 +933,19 @@ func TestUI_DeviceDetailIncludesTabs(t *testing.T) {
|
||||
routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"设备详情", "当前设备", "最近状态摘要"} {
|
||||
for _, want := range []string{"设备详情", "当前设备", "设备工作台", "概览", "运行与服务", "设备配置"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected device detail HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"device-tabs", "设备控制", "返回设备总览", "进入管理"} {
|
||||
for _, forbidden := range []string{"device-tabs", "返回设备总览", "进入管理"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device detail should not contain redundant nav %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceDetailIsReadOnly(t *testing.T) {
|
||||
func TestUI_DeviceDetailUsesUnifiedWorkspace(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
routes, err := ui.Routes()
|
||||
if err != nil {
|
||||
@ -925,9 +956,9 @@ func TestUI_DeviceDetailIsReadOnly(t *testing.T) {
|
||||
routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"设备详情", "设备状态", "当前运行配置", "最近状态摘要"} {
|
||||
for _, want := range []string{"设备详情", "设备工作台", "运行与服务", "设备配置", "模型与资源", "日志与指标"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected read-only device detail HTML to contain %q", want)
|
||||
t.Fatalf("expected unified device workspace HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} {
|
||||
@ -937,17 +968,9 @@ func TestUI_DeviceDetailIsReadOnly(t *testing.T) {
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
`action="/ui/devices/edge-01/alias"`,
|
||||
`type="file"`,
|
||||
"部署到设备",
|
||||
"启动视频分析",
|
||||
"重启视频分析",
|
||||
"停止视频分析",
|
||||
"重载识别服务",
|
||||
"回滚识别配置",
|
||||
"上传视频分析配置",
|
||||
} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device detail should be read-only and not contain %q", forbidden)
|
||||
t.Fatalf("device detail workspace should not contain obsolete entry %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1052,9 +1075,17 @@ func TestUI_DeviceDetailDoesNotUseChannelDisplayNameAsDeviceName(t *testing.T) {
|
||||
t.Fatalf("parse test server port: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000, DeviceAliases: map[string]string{"edge-01": "备用盒子-01"}}
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
agent := service.NewAgentClient(cfg)
|
||||
reg := service.NewRegistryService(cfg, agent)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
reg := service.NewRegistryService(cfg, agent, storage.NewDevicesRepo(store.DB()))
|
||||
if err := reg.SetDeviceAlias("edge-01", "备用盒子-01"); err != nil {
|
||||
t.Fatalf("SetDeviceAlias: %v", err)
|
||||
}
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"})
|
||||
ui, err := NewUI(nil, reg, agent, service.NewTaskService(cfg, agent, reg), nil)
|
||||
if err != nil {
|
||||
@ -1639,22 +1670,22 @@ func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) {
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"总览",
|
||||
"设备",
|
||||
"配置管理",
|
||||
"识别配置",
|
||||
"操作审计",
|
||||
"系统",
|
||||
"任务",
|
||||
"诊断",
|
||||
`href="/ui/dashboard"`,
|
||||
`href="/ui/devices"`,
|
||||
`href="/ui/device-config"`,
|
||||
`href="/ui/assets"`,
|
||||
`href="/ui/audit"`,
|
||||
`href="/ui/system"`,
|
||||
`href="/ui/tasks"`,
|
||||
`href="/ui/diagnostics"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected sidebar to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, old := range []string{"模型管理", "高级调试", "日志分析"} {
|
||||
for _, old := range []string{"配置管理", "系统状态", "操作审计"} {
|
||||
if strings.Contains(body, old) {
|
||||
t.Fatalf("sidebar should not contain old label %q", old)
|
||||
}
|
||||
@ -1672,7 +1703,7 @@ func TestUI_DeviceConfigPageShowsDeviceSelector(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"配置管理", "选择设备", "edge-01", "入口识别节点", `href="/ui/device-config/edge-01"`} {
|
||||
for _, want := range []string{"单设备配置已并入设备详情", "设备入口", "edge-01", "入口识别节点", `href="/ui/devices/edge-01#device-config"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
@ -1693,34 +1724,11 @@ func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/device-config/edge-01", nil))
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"配置管理",
|
||||
"单设备配置",
|
||||
"配置预览",
|
||||
"配置应用",
|
||||
"服务控制",
|
||||
"执行结果摘要",
|
||||
`action="/ui/devices/edge-01/action"`,
|
||||
`action="/ui/devices/edge-01/config-candidate/apply"`,
|
||||
`href="/ui/devices/edge-01/config-preview"`,
|
||||
"回滚到上一份",
|
||||
"打开预览器",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected device control HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "设备详情") {
|
||||
t.Fatalf("device config workspace should not cross-link back into device detail")
|
||||
}
|
||||
if strings.Contains(body, "重载服务") {
|
||||
t.Fatalf("device control page should not contain reload action")
|
||||
}
|
||||
for _, forbidden := range []string{"框架版", "结构样机", "待接入", `disabled>`, `value="workshop_face_shoe_alarm" disabled`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device control page should not contain placeholder marker %q", forbidden)
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Location"); got != "/ui/devices/edge-01#device-config" {
|
||||
t.Fatalf("expected device-config detail redirect to device workspace, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1776,7 +1784,7 @@ func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) {
|
||||
ui.actionDeviceAction(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"配置管理", "POST /v1/media-server/reload", "执行结果摘要"} {
|
||||
for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/media-server/reload", "执行结果摘要"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
@ -1840,7 +1848,7 @@ func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(t *testing.T) {
|
||||
t.Fatalf("expected status to be refreshed for control page, got %d calls", statusCalls)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"配置管理", "应用候选配置结果", "preview_edge-01", "local_3588_face_debug"} {
|
||||
for _, want := range []string{"设备工作台", "应用候选配置结果", "preview_edge-01", "local_3588_face_debug"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected control page apply result HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
@ -2033,7 +2041,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
|
||||
rrAudit := httptest.NewRecorder()
|
||||
ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil))
|
||||
for _, want := range []string{"操作审计", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发识别配置", "重启视频分析服务"} {
|
||||
for _, want := range []string{"诊断 / 审计记录", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发识别配置", "重启视频分析服务"} {
|
||||
if !strings.Contains(rrAudit.Body.String(), want) {
|
||||
t.Fatalf("expected audit HTML to contain %q", want)
|
||||
}
|
||||
@ -2051,12 +2059,12 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
|
||||
rrTasks := httptest.NewRecorder()
|
||||
ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil))
|
||||
for _, want := range []string{"任务列表", "批量配置", "批量服务", "目标设备数"} {
|
||||
for _, want := range []string{"执行历史", "目标设备数"} {
|
||||
if !strings.Contains(rrTasks.Body.String(), want) {
|
||||
t.Fatalf("expected tasks HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"任务中心", "节点执行情况"} {
|
||||
for _, forbidden := range []string{"任务中心", "节点执行情况", "创建任务", "<h2>目标设备</h2>", "高级参数(JSON)", `name="device_ids"`, `name="payload_json"`} {
|
||||
if strings.Contains(rrTasks.Body.String(), forbidden) {
|
||||
t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
@ -2068,7 +2076,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
rctx.URLParams.Add("id", taskConfig.ID)
|
||||
reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
|
||||
ui.pageTask(rrTaskConfig, reqTask)
|
||||
for _, want := range []string{"批量配置", "下发识别配置", "返回任务列表"} {
|
||||
for _, want := range []string{"任务概览", "下发识别配置", "返回任务列表"} {
|
||||
if !strings.Contains(rrTaskConfig.Body.String(), want) {
|
||||
t.Fatalf("expected task detail HTML to contain %q", want)
|
||||
}
|
||||
@ -2081,7 +2089,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
|
||||
rrSystem := httptest.NewRecorder()
|
||||
ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil))
|
||||
for _, want := range []string{"系统", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} {
|
||||
for _, want := range []string{"诊断 / 系统状态", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} {
|
||||
if !strings.Contains(rrSystem.Body.String(), want) {
|
||||
t.Fatalf("expected system HTML to contain %q", want)
|
||||
}
|
||||
@ -2124,7 +2132,7 @@ func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
html := renderPage(t, ui, "/ui/tasks")
|
||||
|
||||
for _, text := range []string{"批量下发", "批量重启", "批量回滚", "执行历史"} {
|
||||
for _, text := range []string{"执行历史", "目标设备数"} {
|
||||
if !strings.Contains(html, text) {
|
||||
t.Fatalf("expected tasks text %q in html: %s", text, html)
|
||||
}
|
||||
@ -2204,3 +2212,342 @@ func TestUI_DiagnosticsSecondaryPagesUseDiagnosticsCrumb(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_TasksPageDoesNotExposeTaskCreationForm(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
|
||||
html := renderPage(t, ui, "/ui/tasks?selected=edge-02")
|
||||
for _, forbidden := range []string{"<h2>目标设备</h2>", `name="device_id"`, `name="device_ids"`, `name="payload_json"`, "创建任务", "<option value=\"config_apply\">下发识别配置</option>"} {
|
||||
if strings.Contains(html, forbidden) {
|
||||
t.Fatalf("tasks page should not expose task-creation UI %q, got: %s", forbidden, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_TasksPageDoesNotShowTransitionQuickActions(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
html := renderPage(t, ui, "/ui/tasks")
|
||||
for _, forbidden := range []string{"常用动作", "<span class=\"pill\">批量下发</span>", "<span class=\"pill\">批量重启</span>", "<span class=\"pill\">批量回滚</span>"} {
|
||||
if strings.Contains(html, forbidden) {
|
||||
t.Fatalf("tasks page should not contain transition quick action block %q, got: %s", forbidden, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionCreateTaskUsesSelectedDeviceCheckboxes(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("type", "media_restart")
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-02")
|
||||
form.Set("payload_json", `{}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionCreateTask(rr, req)
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
taskID := strings.TrimPrefix(loc, "/ui/tasks/")
|
||||
var created *models.Task
|
||||
for _, item := range ui.tasks.ListTasks() {
|
||||
if item.ID == taskID {
|
||||
task := item
|
||||
created = &task
|
||||
break
|
||||
}
|
||||
}
|
||||
if created == nil {
|
||||
t.Fatalf("expected created task %q to exist", taskID)
|
||||
}
|
||||
if got := strings.Join(created.DeviceIDs, ","); got != "edge-01,edge-02" {
|
||||
t.Fatalf("expected selected devices preserved in order, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_TasksPageShowsPersistedHistory(t *testing.T) {
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
reg := service.NewRegistryService(cfg, nil)
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewTasksRepo(store.DB())
|
||||
|
||||
task := models.NewTask("task-persisted", "reload", []string{"edge-01"}, nil)
|
||||
task.Status = models.TaskSuccess
|
||||
task.Devices["edge-01"].Status = models.TaskSuccess
|
||||
task.Devices["edge-01"].Progress = 1
|
||||
if err := repo.Save(task); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
tasks := service.NewTaskService(cfg, nil, reg, repo)
|
||||
if err := tasks.LoadPersistedTasks(); err != nil {
|
||||
t.Fatalf("LoadPersistedTasks: %v", err)
|
||||
}
|
||||
|
||||
ui, err := NewUI(nil, reg, nil, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
html := renderPage(t, ui, "/ui/tasks")
|
||||
for _, want := range []string{"task-persisted", "重载识别服务", "1 台"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("expected persisted task history to contain %q, got: %s", want, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_AssetsOverviewShowsImportAction(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
html := renderPage(t, ui, "/ui/assets")
|
||||
if !strings.Contains(html, "导入现有 JSON") {
|
||||
t.Fatalf("expected assets overview to contain import action, got: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_AssetTemplateExportsJSON(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n"
|
||||
if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
|
||||
ui := newTestUI(t)
|
||||
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates/helmet/export", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("name", "helmet")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageAssetTemplateExport(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "helmet.json") {
|
||||
t.Fatalf("expected attachment filename, got %q", got)
|
||||
}
|
||||
if rr.Body.String() != raw {
|
||||
t.Fatalf("unexpected export body: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_AssetTemplateShowsSaveAsExportButton(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
|
||||
ui := newTestUI(t)
|
||||
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
|
||||
html := renderPage(t, ui, "/ui/assets/templates?name=helmet")
|
||||
|
||||
for _, want := range []string{"另存为 JSON", `data-export-url="/ui/assets/templates/helmet/export"`, `data-default-filename="helmet.json"`, "showSaveFilePicker", "当前浏览器不支持选择保存目录和文件名"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("expected asset template html to contain %q, got: %s", want, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
auditRepo := storage.NewAuditLogsRepo(store.DB())
|
||||
if err := auditRepo.Append(storage.AuditLogRecord{
|
||||
Actor: "system",
|
||||
Action: "config_apply",
|
||||
TargetType: "device",
|
||||
TargetID: "edge-01",
|
||||
DetailsJSON: `{"task_id":"task-99","profile":"gate_a","status":"success"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
ui.auditRepo = auditRepo
|
||||
|
||||
html := renderPage(t, ui, "/ui/audit")
|
||||
for _, want := range []string{"task-99", "gate_a", "下发业务配置", "成功", "edge-01"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("expected audit page to contain %q, got: %s", want, html)
|
||||
}
|
||||
}
|
||||
if strings.Contains(html, "暂无审计记录") {
|
||||
t.Fatalf("expected persisted audit logs to replace empty state, got: %s", html)
|
||||
}
|
||||
if strings.Contains(html, "config_apply") || strings.Contains(html, "success") {
|
||||
t.Fatalf("expected audit page to avoid raw enums, got: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceDetailFallsBackToPersistedConfigState(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
|
||||
if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{
|
||||
DeviceID: "edge-01",
|
||||
TemplateName: "helmet",
|
||||
ProfileName: "gate_a",
|
||||
OverlaysJSON: `["night_relaxed"]`,
|
||||
ConfigID: "cfg-001",
|
||||
ConfigVersion: "20260427.1",
|
||||
LastAppliedTaskID: "task-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
ui.stateRepo = stateRepo
|
||||
|
||||
html := renderPage(t, ui, "/ui/devices/edge-01")
|
||||
for _, want := range []string{"cfg-001", "20260427.1", "helmet", "gate_a", "night_relaxed"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("expected device detail to contain %q, got: %s", want, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_SystemPageShowsDatabaseBackupAction(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.dbPath = filepath.Join(t.TempDir(), "app.db")
|
||||
|
||||
html := renderPage(t, ui, "/ui/system")
|
||||
for _, want := range []string{
|
||||
"数据备份 / 恢复",
|
||||
"恢复数据库",
|
||||
`class="btn ghost js-export-db"`,
|
||||
`data-export-url="/ui/system/db-backup"`,
|
||||
`data-default-filename="app.db"`,
|
||||
"showSaveFilePicker",
|
||||
"当前浏览器不支持选择保存目录和文件名",
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("expected system page to contain %q, got: %s", want, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_SystemPageShowsFlashMessageFromQuery(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.dbPath = filepath.Join(t.TempDir(), "app.db")
|
||||
|
||||
html := renderPage(t, ui, "/ui/system?msg=%E6%95%B0%E6%8D%AE%E5%BA%93%E6%81%A2%E5%A4%8D%E5%AE%8C%E6%88%90")
|
||||
if !strings.Contains(html, "数据库恢复完成") {
|
||||
t.Fatalf("expected system page to show success message, got: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_SystemDBBackupUsesTimestampedFilename(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
dir := t.TempDir()
|
||||
ui.dbPath = filepath.Join(dir, "app.db")
|
||||
if err := os.WriteFile(ui.dbPath, []byte("sqlite-bytes"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/system/db-backup", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.pageSystemDBBackup(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got := rr.Header().Get("Content-Disposition")
|
||||
if !strings.Contains(got, "attachment;") || !strings.Contains(got, "app-") || !strings.Contains(got, ".db") {
|
||||
t.Fatalf("expected timestamped filename, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_SystemDBRestoreReplacesDatabaseFile(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
dir := t.TempDir()
|
||||
ui.dbPath = filepath.Join(dir, "app.db")
|
||||
if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "restore.db")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte("new-db")); err != nil {
|
||||
t.Fatalf("Write part: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rr := httptest.NewRecorder()
|
||||
ui.actionSystemDBRestore(rr, req)
|
||||
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
restored, err := os.ReadFile(ui.dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(restored) != "new-db" {
|
||||
t.Fatalf("expected restored db contents, got %q", string(restored))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_SystemDBRestoreRequiresSelectedFile(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
dir := t.TempDir()
|
||||
ui.dbPath = filepath.Join(dir, "app.db")
|
||||
if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rr := httptest.NewRecorder()
|
||||
ui.actionSystemDBRestore(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
html := rr.Body.String()
|
||||
if !strings.Contains(html, "请先选择数据库备份文件") {
|
||||
t.Fatalf("expected friendly error message, got: %s", html)
|
||||
}
|
||||
if strings.Contains(html, "http: no such file") {
|
||||
t.Fatalf("expected raw form-file error to stay hidden, got: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
"offline_after_ms": 10000,
|
||||
"agent_token": "4fe2d69fda23d0d5d04a1486d4920e68",
|
||||
"concurrency": 5,
|
||||
"data_dir": "data",
|
||||
"db_path": "data/app.db",
|
||||
"log_dir": "data/logs",
|
||||
"device_aliases": {
|
||||
"d12a4719c91641df91b76ab271280797": "盒子A"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user