Add model management workflow to admin backend

This commit is contained in:
tian 2026-05-05 11:14:57 +08:00
parent 4c9119b2bf
commit 7ae66b2569
17 changed files with 752 additions and 24 deletions

View File

@ -40,14 +40,21 @@ func main() {
defer store.Close()
taskRepo := storage.NewTasksRepo(store.DB())
assetsRepo := storage.NewAssetsRepo(store.DB())
modelsRepo := storage.NewModelsRepo(store.DB())
if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil {
log.Fatalf("import standard templates: %v", err)
} else if imported > 0 {
log.Printf("imported %d standard templates", imported)
}
standardModelsDir := filepath.Join("models", "standard_models")
modelSvc := service.NewModelManagementService(modelsRepo)
if err := modelSvc.SyncStandardModelsFromDirectory(standardModelsDir); err != nil {
log.Fatalf("sync standard models: %v", err)
}
stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
auditRepo := storage.NewAuditLogsRepo(store.DB())
taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo)
taskSvc.SetStandardModels(modelsRepo, standardModelsDir)
taskSvc.SetDeviceConfigStateRepo(stateRepo)
taskSvc.SetAuditLogRepo(auditRepo)
if err := taskSvc.LoadPersistedTasks(); err != nil {

View File

@ -0,0 +1,195 @@
package service
import (
"crypto/sha256"
"encoding/json"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
type ModelManagementService struct {
models *storage.ModelsRepo
}
type InstalledModelStatus struct {
Name string `json:"name"`
FileName string `json:"file_name"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
UpdatedAt int64 `json:"updated_at"`
}
type ModelStatusCell struct {
ModelName string `json:"model_name"`
FileName string `json:"file_name"`
Status string `json:"status"`
Version string `json:"version"`
}
type ModelStatusRow struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
Online bool `json:"online"`
Cells []ModelStatusCell `json:"cells"`
}
type ModelStatusSummary struct {
StandardModels int `json:"standard_models"`
Devices int `json:"devices"`
CompleteDevices int `json:"complete_devices"`
MissingDevices int `json:"missing_devices"`
MismatchDevices int `json:"mismatch_devices"`
}
type ModelStatusBoard struct {
Summary ModelStatusSummary `json:"summary"`
Rows []ModelStatusRow `json:"rows"`
}
func NewModelManagementService(models *storage.ModelsRepo) *ModelManagementService {
return &ModelManagementService{models: models}
}
func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) error {
if s == nil || s.models == nil {
return fmt.Errorf("models repo is not configured")
}
dir = filepath.Clean(strings.TrimSpace(dir))
if dir == "" {
return fmt.Errorf("standard models dir is empty")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".rknn" {
continue
}
fullPath := filepath.Join(dir, entry.Name())
sum, size, err := hashFile(fullPath)
if err != nil {
return err
}
record := storage.StandardModelRecord{
Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())),
FileName: entry.Name(),
Version: "auto",
SHA256: sum,
SizeBytes: size,
}
if err := s.models.Save(record); err != nil {
return err
}
}
return nil
}
func hashFile(path string) (string, int64, error) {
file, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer file.Close()
hasher := sha256.New()
size, err := io.Copy(hasher, file)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard {
board := ModelStatusBoard{
Summary: ModelStatusSummary{
StandardModels: len(standardModels),
Devices: len(devices),
},
Rows: make([]ModelStatusRow, 0, len(devices)),
}
for _, device := range devices {
if device == nil {
continue
}
index := make(map[string]InstalledModelStatus, len(installed[device.DeviceID]))
for _, item := range installed[device.DeviceID] {
index[item.Name] = item
}
row := ModelStatusRow{
DeviceID: device.DeviceID,
DeviceName: device.DisplayName(),
Online: device.Online,
Cells: make([]ModelStatusCell, 0, len(standardModels)),
}
hasMissing := false
hasMismatch := false
for _, model := range standardModels {
cell := ModelStatusCell{
ModelName: model.Name,
FileName: model.FileName,
Version: model.Version,
Status: "missing",
}
if item, ok := index[model.Name]; ok {
cell.FileName = item.FileName
if strings.EqualFold(strings.TrimSpace(item.SHA256), strings.TrimSpace(model.SHA256)) {
cell.Status = "ok"
} else {
cell.Status = "mismatch"
hasMismatch = true
}
} else {
hasMissing = true
}
if cell.Status == "missing" {
hasMissing = true
}
row.Cells = append(row.Cells, cell)
}
switch {
case hasMismatch:
board.Summary.MismatchDevices++
case hasMissing:
board.Summary.MissingDevices++
default:
board.Summary.CompleteDevices++
}
board.Rows = append(board.Rows, row)
}
sort.SliceStable(board.Rows, func(i, j int) bool {
return board.Rows[i].DeviceName < board.Rows[j].DeviceName
})
return board
}
func FetchInstalledModelStatuses(agent *AgentClient, device *models.Device) ([]InstalledModelStatus, error) {
if agent == nil || device == nil || strings.TrimSpace(device.IP) == "" || device.AgentPort <= 0 {
return nil, nil
}
body, status, err := agent.Do("GET", device.IP, device.AgentPort, "/v1/models/status", nil)
if err != nil {
return nil, err
}
if status != 200 {
return nil, fmt.Errorf("agent returned status %d", status)
}
var resp struct {
Models []InstalledModelStatus `json:"models"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
return resp.Models, nil
}

View File

@ -0,0 +1,69 @@
package service
import (
"os"
"path/filepath"
"testing"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
func TestSyncStandardModelsFromDirectory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "face_det_scrfd_500m_640_rk3588.rknn")
if err := os.WriteFile(path, []byte("model-bytes"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
modelsRepo := storage.NewModelsRepo(store.DB())
svc := NewModelManagementService(modelsRepo)
if err := svc.SyncStandardModelsFromDirectory(dir); err != nil {
t.Fatalf("SyncStandardModelsFromDirectory: %v", err)
}
items, err := modelsRepo.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 1 || items[0].FileName != "face_det_scrfd_500m_640_rk3588.rknn" {
t.Fatalf("unexpected synced models: %#v", items)
}
if items[0].SHA256 == "" {
t.Fatalf("expected sha256 to be populated: %#v", items[0])
}
}
func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) {
modelsList := []storage.StandardModelRecord{
{Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"},
{Name: "face_recog", FileName: "face_recog.rknn", SHA256: "sha-2"},
}
devices := []*models.Device{
{DeviceID: "edge-01", DeviceName: "设备一", Online: true},
}
installed := map[string][]InstalledModelStatus{
"edge-01": {
{Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"},
},
}
board := BuildModelStatusBoard(modelsList, devices, installed)
if board.Summary.MissingDevices != 1 {
t.Fatalf("expected one device with missing models, got %#v", board.Summary)
}
if len(board.Rows) != 1 || len(board.Rows[0].Cells) != 2 {
t.Fatalf("unexpected board rows: %#v", board.Rows)
}
if board.Rows[0].Cells[1].Status != "missing" {
t.Fatalf("expected second model to be missing, got %#v", board.Rows[0].Cells[1])
}
}

View File

@ -5,10 +5,14 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
"github.com/google/uuid"
)
@ -30,6 +34,8 @@ type TaskService struct {
agent *AgentClient
registry *RegistryService
repo TaskRepository
models *storage.ModelsRepo
modelsDir string
stateRepo DeviceConfigStateRepository
auditRepo AuditLogRepository
tasks map[string]*models.Task
@ -52,6 +58,14 @@ func (s *TaskService) SetAuditLogRepo(repo AuditLogRepository) {
s.auditRepo = repo
}
func (s *TaskService) SetStandardModels(models *storage.ModelsRepo, dir string) {
if s == nil {
return
}
s.models = models
s.modelsDir = filepath.Clean(dir)
}
func NewTaskService(cfg *config.Config, agent *AgentClient, registry *RegistryService, repo ...TaskRepository) *TaskService {
var taskRepo TaskRepository
if len(repo) > 0 {
@ -369,11 +383,83 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
s.appendAuditLog(task, did, models.TaskSuccess, "")
case "model_sync_one":
if err := s.syncModelToDevice(task, dev, did, false); err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
s.appendAuditLog(task, did, models.TaskSuccess, "")
case "model_sync_all":
if err := s.syncModelToDevice(task, dev, did, true); err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
}
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
s.appendAuditLog(task, did, models.TaskSuccess, "")
default:
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "unsupported task type")
}
}
func (s *TaskService) syncModelToDevice(task *models.Task, dev *models.Device, did string, syncAll bool) error {
if s.models == nil {
return fmt.Errorf("standard models repo is not configured")
}
if strings.TrimSpace(s.modelsDir) == "" {
return fmt.Errorf("standard models directory is not configured")
}
items, err := s.models.List()
if err != nil {
return err
}
if len(items) == 0 {
return fmt.Errorf("no standard models configured")
}
targets := items[:0]
if syncAll {
targets = append(targets, items...)
} else {
payload, _ := task.Payload.(map[string]any)
targetName := strings.TrimSpace(stringAny(payload["model_name"]))
if targetName == "" {
return fmt.Errorf("model_name is required")
}
for _, item := range items {
if strings.TrimSpace(item.Name) == targetName {
targets = append(targets, item)
break
}
}
if len(targets) == 0 {
return fmt.Errorf("standard model %q not found", targetName)
}
}
for _, item := range targets {
fullPath := filepath.Join(s.modelsDir, item.FileName)
file, err := os.Open(fullPath)
if err != nil {
return err
}
stat, err := file.Stat()
if err != nil {
file.Close()
return err
}
resp, code, err := s.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/models/"+item.Name, file, "application/octet-stream", stat.Size())
file.Close()
if err != nil {
return err
}
if code >= 400 {
return fmt.Errorf("agent error: %d %s", code, strings.TrimSpace(string(resp)))
}
}
return nil
}
func (s *TaskService) updateDeviceStatus(taskID, did string, status models.TaskStatus, progress float64, errStr string) {
s.mu.RLock()
task, ok := s.tasks[taskID]

View File

@ -4,14 +4,18 @@ import (
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
@ -243,3 +247,69 @@ func TestTaskService_ConfigApplyPersistsDeviceConfigStateAndAudit(t *testing.T)
t.Fatalf("unexpected audit logs: %#v", logs)
}
}
func TestTaskService_ModelSyncAllUploadsStandardModels(t *testing.T) {
var uploads []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Fatalf("expected PUT, got %s", r.Method)
}
uploads = append(uploads, r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
u, _ := url.Parse(server.URL)
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("SplitHostPort(%q): %v", u.Host, err)
}
port, _ := strconv.Atoi(portStr)
cfg := &config.Config{Concurrency: 1}
agent := NewAgentClient(cfg)
reg := NewRegistryService(cfg, agent)
reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewModelsRepo(store.DB())
modelDir := t.TempDir()
body := []byte("model-a")
sum := sha256SumHex(body)
fileName := "face_det_scrfd_500m_640_rk3588.rknn"
if err := os.WriteFile(filepath.Join(modelDir, fileName), body, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := repo.Save(storage.StandardModelRecord{
Name: "face_det_scrfd_500m_640_rk3588",
FileName: fileName,
Version: "v1.0.0",
SHA256: sum,
}); err != nil {
t.Fatalf("Save model: %v", err)
}
svc := NewTaskService(cfg, agent, reg)
svc.SetStandardModels(repo, modelDir)
task, err := svc.CreateTask("model_sync_all", []string{"dev1"}, map[string]any{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
if st := waitForTaskDone(t, task, 2*time.Second); st != models.TaskSuccess {
t.Fatalf("expected task success, got %s", st)
}
if len(uploads) != 1 || !strings.Contains(uploads[0], "/v1/models/face_det_scrfd_500m_640_rk3588") {
t.Fatalf("unexpected uploads: %#v", uploads)
}
}
func sha256SumHex(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}

View File

@ -55,6 +55,17 @@ CREATE TABLE IF NOT EXISTS video_sources (
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS standard_models (
name TEXT PRIMARY KEY,
file_name TEXT NOT NULL,
version TEXT NOT NULL,
sha256 TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
model_type TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scene_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,

View File

@ -0,0 +1,89 @@
package storage
import (
"database/sql"
"time"
)
type StandardModelRecord struct {
Name string
FileName string
Version string
SHA256 string
SizeBytes int64
ModelType string
Description string
CreatedAt string
UpdatedAt string
}
type ModelsRepo struct {
db *sql.DB
}
func NewModelsRepo(db *sql.DB) *ModelsRepo {
return &ModelsRepo{db: db}
}
func (r *ModelsRepo) Save(item StandardModelRecord) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
createdAt := item.CreatedAt
if createdAt == "" {
createdAt = now
}
updatedAt := item.UpdatedAt
if updatedAt == "" {
updatedAt = now
}
_, err := r.db.Exec(`
INSERT INTO standard_models(name, file_name, version, sha256, size_bytes, model_type, description, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM standard_models WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
file_name=excluded.file_name,
version=excluded.version,
sha256=excluded.sha256,
size_bytes=excluded.size_bytes,
model_type=excluded.model_type,
description=excluded.description,
updated_at=excluded.updated_at
`, item.Name, item.FileName, item.Version, item.SHA256, item.SizeBytes, item.ModelType, item.Description, item.Name, createdAt, updatedAt)
return err
}
func (r *ModelsRepo) List() ([]StandardModelRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT name, file_name, version, sha256, size_bytes, model_type, description, created_at, updated_at
FROM standard_models
ORDER BY name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]StandardModelRecord, 0)
for rows.Next() {
var item StandardModelRecord
if err := rows.Scan(
&item.Name,
&item.FileName,
&item.Version,
&item.SHA256,
&item.SizeBytes,
&item.ModelType,
&item.Description,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}

View File

@ -0,0 +1,36 @@
package storage
import (
"path/filepath"
"testing"
)
func TestModelsRepo_SaveAndList(t *testing.T) {
store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := NewModelsRepo(store.DB())
err = repo.Save(StandardModelRecord{
Name: "face_det_scrfd_500m_640",
FileName: "face_det_scrfd_500m_640_rk3588.rknn",
Version: "v1.0.0",
SHA256: "abc123",
SizeBytes: 1024,
ModelType: "face_det",
Description: "人脸检测模型",
})
if err != nil {
t.Fatalf("Save: %v", err)
}
items, err := repo.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 1 || items[0].Version != "v1.0.0" {
t.Fatalf("unexpected models: %#v", items)
}
}

View File

@ -73,6 +73,8 @@ type PageData struct {
Tasks []models.Task
Task *models.Task
TaskDeviceRows []TaskDeviceRow
StandardModels []storage.StandardModelRecord
ModelStatusBoard *service.ModelStatusBoard
Templates []service.Template
Template *service.Template
AssetTab string
@ -240,6 +242,10 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
switch fmt.Sprint(v) {
case "config_apply":
return "下发设备分配"
case "model_sync_one":
return "更新单个模型"
case "model_sync_all":
return "更新全部模型"
case "reload":
return "重载识别服务"
case "rollback":
@ -258,6 +264,8 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
switch fmt.Sprint(v) {
case "config_apply":
return "pill run"
case "model_sync_one", "model_sync_all":
return "pill warn"
case "media_start", "media_restart", "media_stop":
return "pill ok"
case "reload", "rollback":
@ -592,6 +600,7 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/templates", u.pageTemplates)
r.Get("/templates/{name}", u.pageTemplate)
r.Get("/models", u.pageModels)
r.Post("/models/sync", u.actionModelSync)
r.Get("/diagnostics", u.pageDiagnostics)
r.Get("/recognition", u.pageRecognition)
r.Get("/logs", u.pageLogs)
@ -1274,7 +1283,74 @@ func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "models", PageData{Title: "模型管理", Devices: u.registry.GetDevices()})
data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()}
for _, dev := range data.Devices {
if dev == nil {
continue
}
if dev.Online {
data.OnlineCount++
}
}
board := service.ModelStatusBoard{}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
modelsRepo := storage.NewModelsRepo(store.DB())
if items, err := modelsRepo.List(); err == nil {
data.StandardModels = items
installed := map[string][]service.InstalledModelStatus{}
for _, device := range data.Devices {
if device == nil || !device.Online {
continue
}
items, err := service.FetchInstalledModelStatuses(u.agent, device)
if err == nil {
installed[device.DeviceID] = items
}
}
board = service.BuildModelStatusBoard(items, data.Devices, installed)
}
_ = store.Close()
}
}
data.ModelStatusBoard = &board
u.render(w, r, "models", data)
}
func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
action := strings.TrimSpace(r.FormValue("action"))
if action == "" {
action = "model_sync_all"
}
if action != "model_sync_one" && action != "model_sync_all" {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
deviceIDs := make([]string, 0)
for _, id := range r.Form["device_id"] {
id = strings.TrimSpace(id)
if id != "" {
deviceIDs = append(deviceIDs, id)
}
}
if len(deviceIDs) == 0 {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
payload := map[string]any{}
if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" {
payload["model_name"] = modelName
}
task, err := u.tasks.CreateTask(action, deviceIDs, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {

View File

@ -204,10 +204,12 @@ th,td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;ver
th{background:var(--surface-soft);font-size:12px;font-weight:600;color:var(--muted)}
.table-wrap td a{color:var(--table-link)}
.table-wrap td a:hover{color:var(--text)}
.table-wrap td .table-key{color:var(--table-link)}
tbody tr:hover{background:var(--surface-soft)}
tbody tr.selected{background:var(--selected-row)}
tbody tr.selected td{color:var(--text)}
tbody tr.selected .mono,
tbody tr.selected .table-key,
tbody tr.selected a,
tbody tr.selected strong{color:var(--text)}
.checkbox-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}

View File

@ -1,40 +1,107 @@
{{define "models"}}
{{$board := .ModelStatusBoard}}
<div class="card">
<div class="section-title">
<div>
<h2>统一模型目录</h2>
<div class="muted small">平台统一维护识别模型版本,设备页只查看已生效版本与同步状态。</div>
<h2 class="title-with-icon">{{icon "assets"}}<span>模型概览</span></h2>
<div class="form-hint">统一维护标准模型目录,设备页只看当前生效状态,更新动作统一走任务。</div>
</div>
<div class="actions compact">
<button class="btn secondary" type="button">上传新模型</button>
<form method="post" action="/ui/models/sync">
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
<input type="hidden" name="action" value="model_sync_all">
<button class="btn" type="submit">更新全部模型</button>
</form>
</div>
</div>
<div class="model-summary">
<div class="summary-item"><div class="summary-label">模型目录</div><div class="summary-value">统一管理</div><div class="summary-hint">检测 / 识别模型统一发布</div></div>
<div class="summary-item"><div class="summary-label">发布版本</div><div class="summary-value">当前版本</div><div class="summary-hint">按场景模板与识别单元引用生效</div></div>
<div class="summary-item"><div class="summary-label">设备版本状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备版本覆盖</div></div>
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">统一管理</div><div class="summary-hint">在人脸库资源中维护</div></div>
<div class="stats">
<div class="stat accent-teal">
<div class="k metric-label">{{icon "template"}}<span>标准模型总数</span></div>
<div class="v">{{if $board}}{{$board.Summary.StandardModels}}{{else}}0{{end}}</div>
</div>
<div class="stat accent-green">
<div class="k metric-label">{{icon "devices"}}<span>在线设备数</span></div>
<div class="v">{{.OnlineCount}}</div>
</div>
<div class="stat accent-teal">
<div class="k metric-label">{{icon "assets"}}<span>完整设备数</span></div>
<div class="v">{{if $board}}{{$board.Summary.CompleteDevices}}{{else}}0{{end}}</div>
</div>
<div class="stat accent-amber">
<div class="k metric-label">{{icon "warn"}}<span>缺失设备数</span></div>
<div class="v">{{if $board}}{{$board.Summary.MissingDevices}}{{else}}0{{end}}</div>
</div>
<div class="stat accent-amber">
<div class="k metric-label">{{icon "service"}}<span>版本不一致设备数</span></div>
<div class="v">{{if $board}}{{$board.Summary.MismatchDevices}}{{else}}0{{end}}</div>
</div>
</div>
</div>
<div class="card">
<h2>设备版本状态</h2>
<div class="table-wrap" style="margin-top:10px">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>标准模型</span></h2>
<div class="form-hint">版本号用于展示,设备一致性以模型哈希校验。</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>节点</th><th>状态</th><th>管理地址</th><th>模型版本</th><th>人脸库</th></tr>
<tr><th>模型名</th><th>文件名</th><th>版本</th><th>哈希</th><th>大小</th></tr>
</thead>
<tbody>
{{range .Devices}}
{{range .StandardModels}}
<tr>
<td>
<a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
<td class="mono">待上报</td>
<td class="mono">待上报</td>
<td><span class="mono table-key">{{.Name}}</span></td>
<td class="mono">{{.FileName}}</td>
<td>{{if .Version}}{{.Version}}{{else}}auto{{end}}</td>
<td class="mono">{{shortHash .SHA256}}</td>
<td>{{.SizeBytes}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">暂无设备。请先在“设备”页扫描或手动添加。</td></tr>
<tr><td colspan="5" class="muted">标准模型目录为空。</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>设备模型状态</span></h2>
<div class="form-hint">按标准模型逐项比对设备已安装状态,缺失和不一致会直接标出。</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>设备</th>
{{range .StandardModels}}<th>{{.Name}}</th>{{end}}
</tr>
</thead>
<tbody>
{{if and $board (gt (len $board.Rows) 0)}}
{{range $board.Rows}}
<tr>
<td>
<div class="table-key">{{.DeviceName}}</div>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
{{range .Cells}}
<td>
{{if eq .Status "ok"}}<span class="pill ok">一致</span>
{{else if eq .Status "mismatch"}}<span class="pill warn">不一致</span>
{{else}}<span class="pill bad">缺失</span>{{end}}
</td>
{{end}}
</tr>
{{end}}
{{else}}
<tr><td colspan="2" class="muted">暂无设备模型状态。</td></tr>
{{end}}
</tbody>
</table>

View File

@ -1842,7 +1842,7 @@ func TestUI_LoadConfigStatusPrefersPreviousConfigFields(t *testing.T) {
}
}
func TestUI_ModelManagementPageShowsUnifiedCatalog(t *testing.T) {
func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
rr := httptest.NewRecorder()
@ -1853,18 +1853,38 @@ func TestUI_ModelManagementPageShowsUnifiedCatalog(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"模型管理", "统一模型目录", "设备版本状态", "模型版本", "入口识别节点", "人脸库"} {
for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型"} {
if !strings.Contains(body, want) {
t.Fatalf("expected model management HTML to contain %q", want)
}
}
for _, forbidden := range []string{"上传模型", "单节点", "查看设备模型"} {
for _, forbidden := range []string{"统一模型目录", "人脸库", "查看设备模型"} {
if strings.Contains(body, forbidden) {
t.Fatalf("model management page should not contain legacy text %q", forbidden)
}
}
}
func TestUI_ActionModelSyncCreatesTask(t *testing.T) {
ui := newTestUI(t)
form := url.Values{}
form.Set("action", "model_sync_all")
form.Add("device_id", "edge-01")
req := httptest.NewRequest(http.MethodPost, "/ui/models/sync", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionModelSync(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
tasks := ui.tasks.ListTasks()
if len(tasks) != 1 || tasks[0].Type != "model_sync_all" {
t.Fatalf("unexpected tasks: %#v", tasks)
}
}
func TestUI_ResourcesPageShowsUnifiedResourceStatus(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/resources")