diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index 1dbec70..f4b82b3 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -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 { diff --git a/internal/service/model_management.go b/internal/service/model_management.go new file mode 100644 index 0000000..dc0f0bc --- /dev/null +++ b/internal/service/model_management.go @@ -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 +} diff --git a/internal/service/model_management_test.go b/internal/service/model_management_test.go new file mode 100644 index 0000000..e6b2b1d --- /dev/null +++ b/internal/service/model_management_test.go @@ -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]) + } +} diff --git a/internal/service/task.go b/internal/service/task.go index 8ebc69c..bd0d053 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -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] diff --git a/internal/service/task_test.go b/internal/service/task_test.go index d8ad23e..4463016 100644 --- a/internal/service/task_test.go +++ b/internal/service/task_test.go @@ -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[:]) +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index 51b1056..223c6f6 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -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, diff --git a/internal/storage/models_repo.go b/internal/storage/models_repo.go new file mode 100644 index 0000000..e11d2c0 --- /dev/null +++ b/internal/storage/models_repo.go @@ -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() +} diff --git a/internal/storage/models_repo_test.go b/internal/storage/models_repo_test.go new file mode 100644 index 0000000..57d6d94 --- /dev/null +++ b/internal/storage/models_repo_test.go @@ -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) + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 1055b10..c2ca1af 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -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) { diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index f2831d2..992ac53 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -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} diff --git a/internal/web/ui/templates/models.html b/internal/web/ui/templates/models.html index 9691099..29cfb22 100644 --- a/internal/web/ui/templates/models.html +++ b/internal/web/ui/templates/models.html @@ -1,40 +1,107 @@ {{define "models"}} +{{$board := .ModelStatusBoard}}
| 节点 | 状态 | 管理地址 | 模型版本 | 人脸库 | |||||
|---|---|---|---|---|---|---|---|---|---|
| 模型名 | 文件名 | 版本 | 哈希 | 大小 | |||||
|
- {{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}
- {{.DeviceID}}
- |
- {{if .Online}}在线{{else}}离线{{end}} | -{{.IP}}:{{.AgentPort}} | -待上报 | -待上报 | +{{.Name}} | +{{.FileName}} | +{{if .Version}}{{.Version}}{{else}}auto{{end}} | +{{shortHash .SHA256}} | +{{.SizeBytes}} |
| 暂无设备。请先在“设备”页扫描或手动添加。 | |||||||||
| 标准模型目录为空。 | |||||||||
| 设备 | + {{range .StandardModels}}{{.Name}} | {{end}} +
|---|---|
|
+ {{.DeviceName}}
+ {{.DeviceID}}
+ |
+ {{range .Cells}}
+ + {{if eq .Status "ok"}}一致 + {{else if eq .Status "mismatch"}}不一致 + {{else}}缺失{{end}} + | + {{end}} +
| 暂无设备模型状态。 | |