From c9e369800833320e384403a266143702deeae0fd Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sun, 19 Apr 2026 11:14:29 +0800 Subject: [PATCH] Add config preview page --- internal/config/config.go | 1 + internal/service/config_preview.go | 239 ++++++++++++++++++ internal/service/config_preview_test.go | 69 +++++ internal/web/ui.go | 67 +++++ internal/web/ui/templates/config_preview.html | 82 ++++++ internal/web/ui/templates/device_nav.html | 1 + internal/web/ui_test.go | 32 +++ 7 files changed, 491 insertions(+) create mode 100644 internal/service/config_preview.go create mode 100644 internal/service/config_preview_test.go create mode 100644 internal/web/ui/templates/config_preview.html diff --git a/internal/config/config.go b/internal/config/config.go index d42b410..6f8eb70 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { OfflineAfterMs int `json:"offline_after_ms"` AgentToken string `json:"agent_token"` Concurrency int `json:"concurrency"` + MediaRepoPath string `json:"media_repo_path"` } func LoadConfig(path string) (*Config, error) { diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go new file mode 100644 index 0000000..2840229 --- /dev/null +++ b/internal/service/config_preview.go @@ -0,0 +1,239 @@ +package service + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "3588AdminBackend/internal/config" +) + +var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +type ConfigPreviewService struct { + cfg *config.Config +} + +type ConfigSource struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type ConfigPreviewSources struct { + Root string `json:"root"` + Templates []ConfigSource `json:"templates"` + Profiles []ConfigSource `json:"profiles"` + Overlays []ConfigSource `json:"overlays"` +} + +type ConfigPreviewRequest struct { + Template string + Profile string + Overlays []string + ConfigID string + ConfigVersion string +} + +type ConfigPreviewResult struct { + Request ConfigPreviewRequest `json:"request"` + Root string `json:"root"` + Sha256 string `json:"sha256"` + Size int `json:"size"` + Metadata map[string]any `json:"metadata"` + JSON string `json:"json"` +} + +func NewConfigPreviewService(cfg *config.Config) *ConfigPreviewService { + return &ConfigPreviewService{cfg: cfg} +} + +func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) { + root := s.mediaRepoRoot() + if root == "" { + return defaultConfigPreviewSources(""), nil + } + + out := ConfigPreviewSources{Root: root} + var err error + out.Templates, err = listConfigSources(filepath.Join(root, "configs", "templates")) + if err != nil { + if s.hasExplicitRoot() { + return out, err + } + return defaultConfigPreviewSources(root), nil + } + out.Profiles, err = listConfigSources(filepath.Join(root, "configs", "profiles")) + if err != nil { + if s.hasExplicitRoot() { + return out, err + } + return defaultConfigPreviewSources(root), nil + } + out.Overlays, err = listConfigSources(filepath.Join(root, "configs", "overlays")) + if err != nil { + if s.hasExplicitRoot() { + return out, err + } + return defaultConfigPreviewSources(root), nil + } + return out, nil +} + +func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) { + root := s.mediaRepoRoot() + if root == "" { + return nil, fmt.Errorf("media repo path is not configured") + } + if err := validateConfigName(req.Template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + if err := validateConfigName(req.Profile); err != nil { + return nil, fmt.Errorf("invalid profile: %w", err) + } + for _, overlay := range req.Overlays { + if err := validateConfigName(overlay); err != nil { + return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) + } + } + if req.ConfigID == "" { + req.ConfigID = "preview_" + time.Now().Format("20060102.150405") + } + if req.ConfigVersion == "" { + req.ConfigVersion = time.Now().Format("20060102.150405") + } + + out, err := os.CreateTemp("", "rk3588-config-preview-*.json") + if err != nil { + return nil, err + } + outPath := out.Name() + _ = out.Close() + defer os.Remove(outPath) + + args := []string{ + filepath.Join(root, "tools", "render_config.py"), + "--template", filepath.Join(root, "configs", "templates", req.Template+".json"), + "--profile", filepath.Join(root, "configs", "profiles", req.Profile+".json"), + "--out", outPath, + "--config-id", req.ConfigID, + "--config-version", req.ConfigVersion, + "--rendered-at", time.Now().Format(time.RFC3339), + } + for _, overlay := range req.Overlays { + args = append(args, "--overlay", filepath.Join(root, "configs", "overlays", overlay+".json")) + } + + cmd := exec.Command("python", args...) + cmd.Dir = root + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("render config preview: %s", msg) + } + + body, err := os.ReadFile(outPath) + if err != nil { + return nil, err + } + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + return nil, err + } + metadata, _ := doc["metadata"].(map[string]any) + sum := sha256.Sum256(body) + return &ConfigPreviewResult{ + Request: req, + Root: root, + Sha256: hex.EncodeToString(sum[:]), + Size: len(body), + Metadata: metadata, + JSON: string(body), + }, nil +} + +func (s *ConfigPreviewService) mediaRepoRoot() string { + if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" { + return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath)) + } + if env := strings.TrimSpace(os.Getenv("ORANGEPI_MEDIA_REPO")); env != "" { + return filepath.Clean(env) + } + wd, err := os.Getwd() + if err != nil { + return "" + } + candidates := []string{ + filepath.Join(wd, "..", "OrangePi3588Media"), + filepath.Join(wd, "..", "..", "OrangePi3588Media"), + filepath.Join(filepath.Dir(wd), "OrangePi3588Media"), + } + for _, candidate := range candidates { + if _, err := os.Stat(filepath.Join(candidate, "tools", "render_config.py")); err == nil { + return filepath.Clean(candidate) + } + } + return "" +} + +func (s *ConfigPreviewService) hasExplicitRoot() bool { + return s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" +} + +func listConfigSources(dir string) ([]ConfigSource, error) { + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + out := make([]ConfigSource, 0) + for _, file := range files { + if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { + continue + } + name := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) + out = append(out, ConfigSource{Name: name, Path: filepath.Join(dir, file.Name())}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func validateConfigName(name string) error { + if !safeConfigName.MatchString(strings.TrimSpace(name)) { + return fmt.Errorf("must contain only letters, numbers, dot, underscore, or dash") + } + if strings.Contains(name, "..") { + return fmt.Errorf("must not contain '..'") + } + return nil +} + +func defaultConfigPreviewSources(root string) ConfigPreviewSources { + return ConfigPreviewSources{ + Root: root, + Templates: []ConfigSource{ + {Name: "workshop_face_shoe_alarm"}, + }, + Profiles: []ConfigSource{ + {Name: "local_3588_test"}, + }, + Overlays: []ConfigSource{ + {Name: "face_debug"}, + {Name: "face_test_sensitive"}, + {Name: "production_quiet"}, + {Name: "shoe_debug"}, + {Name: "shoe_test_sensitive"}, + }, + } +} diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go new file mode 100644 index 0000000..bd4c177 --- /dev/null +++ b/internal/service/config_preview_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "3588AdminBackend/internal/config" +) + +func TestConfigPreviewServiceListsSources(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) + sources, err := svc.ListSources() + if err != nil { + t.Fatalf("ListSources: %v", err) + } + + if sources.Root != root { + t.Fatalf("expected root %q, got %q", root, sources.Root) + } + if got := sourceNames(sources.Templates); strings.Join(got, ",") != "workshop_face_shoe_alarm" { + t.Fatalf("unexpected templates: %v", got) + } + if got := sourceNames(sources.Profiles); strings.Join(got, ",") != "local_3588_test" { + t.Fatalf("unexpected profiles: %v", got) + } + if got := sourceNames(sources.Overlays); strings.Join(got, ",") != "face_debug" { + t.Fatalf("unexpected overlays: %v", got) + } +} + +func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) { + root := t.TempDir() + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) + + _, err := svc.Render(ConfigPreviewRequest{ + Template: "../secret", + Profile: "local_3588_test", + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err == nil { + t.Fatal("expected unsafe template name to be rejected") + } +} + +func sourceNames(items []ConfigSource) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, item.Name) + } + return out +} + +func mustWrite(t *testing.T, path string, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 3235de5..a37fd28 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -24,6 +24,7 @@ type UI struct { agent *service.AgentClient tasks *service.TaskService templates *service.TemplateService + preview *service.ConfigPreviewService tpl *template.Template } @@ -46,6 +47,8 @@ type PageData struct { ConfigStatus *ConfigStatusView ConfigStatusText string ConfigStatusErr string + ConfigSources service.ConfigPreviewSources + ConfigPreview *service.ConfigPreviewResult Tasks []models.Task Task *models.Task Templates []service.Template @@ -134,6 +137,7 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic agent: agent, tasks: tasks, templates: templates, + preview: service.NewConfigPreviewService(nil), tpl: tpl, }, nil } @@ -175,6 +179,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply) r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI) r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly) + r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview) + r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview) r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan) r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply) r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload) @@ -829,6 +835,67 @@ func (u *UI) pageDeviceConfigFriendly(w http.ResponseWriter, r *http.Request) { u.render(w, r, "config_friendly", PageData{Title: "识别方案配置", Device: dev}) } +func (u *UI) pageDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + data := u.configPreviewPageData(dev) + u.render(w, r, "config_preview", data) +} + +func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + 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")), + } + if req.Template == "" { + req.Template = "workshop_face_shoe_alarm" + } + if req.Profile == "" { + req.Profile = "local_3588_test" + } + preview, err := u.preview.Render(req) + data := u.configPreviewPageData(dev) + data.ConfigPreview = preview + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "config_preview", data) +} + +func (u *UI) configPreviewPageData(dev *models.Device) PageData { + sources, err := u.preview.ListSources() + data := PageData{Title: "配置预览", Device: dev, ConfigSources: sources} + if err != nil { + data.Error = err.Error() + } + return data +} + +func cleanFormList(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + out = append(out, value) + } + } + return out +} + func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) diff --git a/internal/web/ui/templates/config_preview.html b/internal/web/ui/templates/config_preview.html new file mode 100644 index 0000000..9ca847d --- /dev/null +++ b/internal/web/ui/templates/config_preview.html @@ -0,0 +1,82 @@ +{{define "config_preview"}} +{{template "device_nav" .}} +
+
+
+

配置预览

+
基于模板、Profile 和 Overlay 生成完整配置预览;此页面不会下发到设备。
+
+ 返回节点详情 +
+
+ +
+

生成配置预览

+ {{if .ConfigSources.Root}}
Media 仓库:{{.ConfigSources.Root}}
{{end}} +
+
+
+
模板
+ +
+
+
Profile
+ +
+
+
config_id
+ +
+
+
config_version
+ +
+
+
+
Overlay
+
+ {{range .ConfigSources.Overlays}} + + {{end}} +
+
+
+ + 查看当前运行配置 +
+
+
+ +{{if .ConfigPreview}} +
+

预览摘要

+
+
配置 ID
{{index .ConfigPreview.Metadata "config_id"}}
+
版本
{{index .ConfigPreview.Metadata "config_version"}}
+
模板
{{index .ConfigPreview.Metadata "template"}}
+
Profile
{{index .ConfigPreview.Metadata "profile"}}
+
大小
{{.ConfigPreview.Size}} bytes
+
+
+
SHA256
+
{{.ConfigPreview.Sha256}}
+
+
+ +
+

完整 JSON

+
{{.ConfigPreview.JSON}}
+
+{{end}} +{{end}} diff --git a/internal/web/ui/templates/device_nav.html b/internal/web/ui/templates/device_nav.html index a9c9e29..d7e7a78 100644 --- a/internal/web/ui/templates/device_nav.html +++ b/internal/web/ui/templates/device_nav.html @@ -15,6 +15,7 @@ 概览 配置管理 模型管理 + 配置预览 诊断日志 运行指标 技术信息 diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index a12eec2..7e66d43 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -306,6 +306,38 @@ func TestUI_ConfigFriendlyIncludesWizard(t *testing.T) { } } +func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) { + ui := newTestUI(t) + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() + + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-preview", nil)) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "配置预览", + "模板", + "Profile", + "Overlay", + "config_id", + "config_version", + "生成预览", + "workshop_face_shoe_alarm", + "local_3588_test", + "face_debug", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected config preview page to contain %q, got:\n%s", want, body) + } + } +} + func TestUI_ModelDeploymentPageRendersDeviceActions(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)