Add config preview page

This commit is contained in:
tian 2026-04-19 11:14:29 +08:00
parent c2729d90c9
commit c9e3698008
7 changed files with 491 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,82 @@
{{define "config_preview"}}
{{template "device_nav" .}}
<div class="card">
<div class="section-title">
<div>
<h2>配置预览</h2>
<div class="muted small">基于模板、Profile 和 Overlay 生成完整配置预览;此页面不会下发到设备。</div>
</div>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">返回节点详情</a>
</div>
</div>
<div class="card">
<h2>生成配置预览</h2>
{{if .ConfigSources.Root}}<div class="muted small">Media 仓库:<span class="mono">{{.ConfigSources.Root}}</span></div>{{end}}
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview" style="margin-top:12px">
<div class="row">
<div>
<div class="muted small">模板</div>
<select name="template">
{{range .ConfigSources.Templates}}
<option value="{{.Name}}" {{if eq .Name "workshop_face_shoe_alarm"}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div>
<div class="muted small">Profile</div>
<select name="profile">
{{range .ConfigSources.Profiles}}
<option value="{{.Name}}" {{if eq .Name "local_3588_test"}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div>
<div class="muted small">config_id</div>
<input name="config_id" value="preview_{{.Device.DeviceID}}" />
</div>
<div>
<div class="muted small">config_version</div>
<input name="config_version" placeholder="留空自动生成" />
</div>
</div>
<div style="margin-top:12px">
<div class="muted small">Overlay</div>
<div class="actions" style="margin-top:6px">
{{range .ConfigSources.Overlays}}
<label class="btn ghost">
<input type="checkbox" name="overlay" value="{{.Name}}" {{if eq .Name "face_debug"}}checked{{end}} />
{{.Name}}
</label>
{{end}}
</div>
</div>
<div class="actions" style="margin-top:12px">
<button type="submit">生成预览</button>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">查看当前运行配置</a>
</div>
</form>
</div>
{{if .ConfigPreview}}
<div class="card">
<h2>预览摘要</h2>
<div class="row" style="margin-top:10px">
<div><div class="muted small">配置 ID</div><div class="mono">{{index .ConfigPreview.Metadata "config_id"}}</div></div>
<div><div class="muted small">版本</div><div class="mono">{{index .ConfigPreview.Metadata "config_version"}}</div></div>
<div><div class="muted small">模板</div><div class="mono">{{index .ConfigPreview.Metadata "template"}}</div></div>
<div><div class="muted small">Profile</div><div class="mono">{{index .ConfigPreview.Metadata "profile"}}</div></div>
<div><div class="muted small">大小</div><div class="mono">{{.ConfigPreview.Size}} bytes</div></div>
</div>
<div style="margin-top:10px">
<div class="muted small">SHA256</div>
<div class="mono small">{{.ConfigPreview.Sha256}}</div>
</div>
</div>
<div class="card">
<h2>完整 JSON</h2>
<pre>{{.ConfigPreview.JSON}}</pre>
</div>
{{end}}
{{end}}

View File

@ -15,6 +15,7 @@
<a href="/ui/devices/{{.Device.DeviceID}}">概览</a>
<a href="/ui/device-config">配置管理</a>
<a href="/ui/models">模型管理</a>
<a href="/ui/devices/{{.Device.DeviceID}}/config-preview">配置预览</a>
<a href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
<a href="/ui/devices/{{.Device.DeviceID}}/graphs">运行指标</a>
<a href="/ui/devices/{{.Device.DeviceID}}#technical">技术信息</a>

View File

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