Keep candidate apply action visible

This commit is contained in:
tian 2026-04-19 12:33:17 +08:00
parent 5d3948250d
commit ec419e87c3
3 changed files with 132 additions and 0 deletions

View File

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

View File

@ -54,7 +54,13 @@
<div class="actions" style="margin-top:12px">
<button type="submit">生成预览</button>
<button type="button" disabled>上传为候选配置</button>
{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply" style="display:inline">
<button type="submit">应用候选配置</button>
</form>
{{else}}
<button type="button" disabled>应用候选配置</button>
{{end}}
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">查看当前运行配置</a>
</div>
</form>

View File

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