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)