From ec419e87c35888daa4332fcb7ee15689fab57456 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sun, 19 Apr 2026 12:33:17 +0800 Subject: [PATCH] Keep candidate apply action visible --- internal/web/ui.go | 28 ++++++ internal/web/ui/templates/config_preview.html | 6 ++ internal/web/ui_test.go | 98 +++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/internal/web/ui.go b/internal/web/ui.go index 35f4992..759b9d8 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -73,6 +73,7 @@ type ConfigStatusView struct { Sha256 string `json:"sha256"` Size int64 `json:"size"` Metadata ConfigStatusMetadata `json:"metadata"` + Candidate *ConfigStatusLastGoodFile `json:"candidate"` MediaServer ConfigStatusMediaServer `json:"media_server"` LastGood *ConfigStatusLastGoodFile `json:"last_good"` } @@ -898,6 +899,7 @@ func (u *UI) actionDeviceConfigCandidate(w http.ResponseWriter, r *http.Request) u.render(w, r, "config_preview", data) return } + data.ConfigPreview = previewResultFromJSON(raw) body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config/candidate", []byte(raw)) data.Message = fmt.Sprintf("PUT /v1/config/candidate -> %d", code) data.RawText = prettyJSON(body) @@ -915,6 +917,10 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req return } data := u.configPreviewPageData(dev) + raw := strings.TrimSpace(r.FormValue("json")) + if raw != "" { + data.ConfigPreview = previewResultFromJSON(raw) + } body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`)) data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code) data.RawText = prettyJSON(body) @@ -930,6 +936,11 @@ func (u *UI) configPreviewPageData(dev *models.Device) PageData { if err != nil { data.Error = err.Error() } + status, _, statusErr := u.loadConfigStatus(dev) + data.ConfigStatus = status + if statusErr != nil { + data.ConfigStatusErr = statusErr.Error() + } return data } @@ -944,6 +955,23 @@ func cleanFormList(values []string) []string { return out } +func previewResultFromJSON(raw string) *service.ConfigPreviewResult { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var doc map[string]any + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return nil + } + metadata, _ := doc["metadata"].(map[string]any) + return &service.ConfigPreviewResult{ + JSON: raw, + Metadata: metadata, + Size: len([]byte(raw)), + } +} + 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 index 404aa19..97b8daf 100644 --- a/internal/web/ui/templates/config_preview.html +++ b/internal/web/ui/templates/config_preview.html @@ -54,7 +54,13 @@
+ {{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}} +
+ +
+ {{else}} + {{end}} 查看当前运行配置
diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index 6862c64..b8bb755 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -4,6 +4,7 @@ import ( "3588AdminBackend/internal/config" "3588AdminBackend/internal/models" "3588AdminBackend/internal/service" + "context" "net" "net/http" "net/http/httptest" @@ -12,6 +13,8 @@ import ( "strconv" "strings" "testing" + + "github.com/go-chi/chi/v5" ) func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) { @@ -340,6 +343,101 @@ func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) { } } +func TestUI_ConfigPreviewPageKeepsApplyActionAfterUploadResult(t *testing.T) { + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) + rr := httptest.NewRecorder() + + ui.render(rr, req, "config_preview", PageData{ + Title: "配置预览", + Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, + ConfigStatus: &ConfigStatusView{ + Candidate: &ConfigStatusLastGoodFile{Exists: true, Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, + }, + ConfigPreview: &service.ConfigPreviewResult{ + JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1"}}`, + Metadata: map[string]any{ + "config_id": "preview_edge-01", + "config_version": "v1", + "template": "workshop_face_shoe_alarm", + "profile": "local_3588_test", + }, + Size: 123, + }, + RawText: `{"ok":true}`, + }) + + body := rr.Body.String() + for _, want := range []string{ + "上传结果", + "上传为候选配置", + "应用候选配置", + `formaction="/ui/devices/edge-01/config-candidate/apply"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected config preview upload result HTML to contain %q, got:\n%s", want, body) + } + } +} + +func TestUI_ActionDeviceConfigCandidateKeepsPreviewApplyAction(t *testing.T) { + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v1/config/status" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"candidate":{"exists":true,"path":"/opt/rk3588-media-server/etc/media-server.json.candidate.json"}}`)) + return + } + if r.Method != http.MethodPut || r.URL.Path != "/v1/config/candidate" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer agentServer.Close() + + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + + form := url.Values{} + form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test"}}`) + req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetPathValue("id", "edge-01") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "edge-01") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + + ui.actionDeviceConfigCandidate(rr, req) + + body := rr.Body.String() + for _, want := range []string{ + "上传结果", + "应用候选配置", + `formaction="/ui/devices/edge-01/config-candidate/apply"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected actionDeviceConfigCandidate HTML 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)