Add config preview page
This commit is contained in:
parent
c2729d90c9
commit
c9e3698008
@ -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) {
|
||||
|
||||
239
internal/service/config_preview.go
Normal file
239
internal/service/config_preview.go
Normal 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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
69
internal/service/config_preview_test.go
Normal file
69
internal/service/config_preview_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
82
internal/web/ui/templates/config_preview.html
Normal file
82
internal/web/ui/templates/config_preview.html
Normal 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}}
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user