feat: add local sqlite storage foundation

This commit is contained in:
tian 2026-04-28 15:21:16 +08:00
parent fdd7a03378
commit 43d18d0c7b
48 changed files with 2925 additions and 294 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ tmp/
# Local runtime files
logs/
data/
*.log
managerd.local.json

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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")
}

View 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)
}
}

View File

@ -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{}

View File

@ -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)
}
}

View File

@ -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"])
}

View File

@ -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 {

View File

@ -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 ""
}

View File

@ -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)
}

View File

@ -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

View File

@ -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()

View File

@ -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)
}
}

View 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
}

View 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)
}
}

View 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()
}

View 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])
}
}

View 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
}

View 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)
}
}

View 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
}

View 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])
}
}

View 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
View 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"),
}
}

View 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
}

View 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)
}
}
}

View 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()
}

View 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"])
}
}

View File

@ -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)

View File

@ -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}
}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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">

View File

@ -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}}

View File

@ -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">

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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>

View File

@ -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>

View File

@ -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}}

View File

@ -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>

View File

@ -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}}

View File

@ -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}}

View File

@ -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&amp;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)
}
}

View File

@ -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"
}