Compare commits
4 Commits
4c9119b2bf
...
2304516dbc
| Author | SHA1 | Date | |
|---|---|---|---|
| 2304516dbc | |||
| 1c5ce424e1 | |||
| 28764a1a6d | |||
| 7ae66b2569 |
23
Readme.md
23
Readme.md
@ -25,6 +25,29 @@ managerd
|
||||
managerd path/to/managerd.json
|
||||
```
|
||||
|
||||
在当前仓库的 Windows 开发环境中,推荐使用统一脚本入口:
|
||||
|
||||
```bat
|
||||
scripts\managerd.bat build
|
||||
scripts\managerd.bat start
|
||||
scripts\managerd.bat stop
|
||||
scripts\managerd.bat restart
|
||||
scripts\managerd.bat status
|
||||
```
|
||||
|
||||
各动作说明:
|
||||
|
||||
- `build`: 编译 `.\cmd\managerd`,生成根目录下的 `managerd.exe`
|
||||
- `start`: 启动当前仓库中的 `managerd.exe`,并检查 `/health`
|
||||
- `stop`: 停止当前仓库对应的 `managerd.exe`
|
||||
- `restart`: 先停止再启动
|
||||
- `status`: 查看进程状态与健康检查结果
|
||||
|
||||
脚本实际入口文件:
|
||||
|
||||
- `scripts/managerd.bat`
|
||||
- `scripts/managerd.ps1`
|
||||
|
||||
程序启动后:
|
||||
|
||||
- `GET /` 会重定向到 `/ui`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -241,6 +241,79 @@ func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
|
||||
"name":"std_workshop_face_recognition_shoe_alarm",
|
||||
"source":"standard",
|
||||
"slots":{
|
||||
"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],
|
||||
"services":[{"name":"token_service_main","type":"token_service","required":false,"description":"认证服务"}],
|
||||
"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]
|
||||
},
|
||||
"template":{
|
||||
"nodes":[
|
||||
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
|
||||
{"id":"alarm_violation","type":"alarm","outputs":[{"external_api":{"getTokenUrl":"${slot:token_service_main.get_token_url}"}}]}
|
||||
],
|
||||
"edges":[]
|
||||
}
|
||||
}`)
|
||||
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
mustImportAssetsFromMediaRepo(t, svc)
|
||||
editor := ConfigProfileEditor{
|
||||
Name: "line_a",
|
||||
Instances: []ConfigProfileInstanceEditor{{
|
||||
Name: "cam1",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
PublishRTSPPort: "8555",
|
||||
ChannelNo: "cam1",
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
ConfigID: "preview",
|
||||
ConfigVersion: "v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderProfileEditor: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
|
||||
t.Fatalf("unmarshal render result: %v", err)
|
||||
}
|
||||
templates, _ := doc["templates"].(map[string]any)
|
||||
renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
|
||||
nodes, _ := renderedTemplate["nodes"].([]any)
|
||||
alarmNode, _ := nodes[1].(map[string]any)
|
||||
outputs, _ := alarmNode["outputs"].([]any)
|
||||
output, _ := outputs[0].(map[string]any)
|
||||
externalAPI, _ := output["external_api"].(map[string]any)
|
||||
if got := stringValue(externalAPI["getTokenUrl"]); got != "" {
|
||||
t.Fatalf("expected empty getTokenUrl for unbound optional slot, got %#v", externalAPI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
|
||||
|
||||
@ -169,7 +169,11 @@ func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string,
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context)
|
||||
slotRequirements, err := runtimeSlotRequirements(templateRaw)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context, slotRequirements)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
@ -187,6 +191,24 @@ func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string,
|
||||
return boundName, renderedTemplate, renderedInstance, nil
|
||||
}
|
||||
|
||||
func runtimeSlotRequirements(templateRaw map[string]any) (map[string]bool, error) {
|
||||
group, err := parseTemplateSlots(templateRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, slot := range group.Inputs {
|
||||
out[slot.Name] = slot.Required
|
||||
}
|
||||
for _, slot := range group.Services {
|
||||
out[slot.Name] = slot.Required
|
||||
}
|
||||
for _, slot := range group.Outputs {
|
||||
out[slot.Name] = slot.Required
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) {
|
||||
context := map[string]any{}
|
||||
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
|
||||
@ -212,12 +234,12 @@ func resolvedRuntimeBindingValue(entry map[string]any) map[string]any {
|
||||
return deepCopyMap(entry)
|
||||
}
|
||||
|
||||
func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
|
||||
func expandRuntimeSlotTokens(value any, context map[string]any, slotRequirements map[string]bool) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
expanded, err := expandRuntimeSlotTokens(item, context)
|
||||
expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -227,7 +249,7 @@ func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
|
||||
case []any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
expanded, err := expandRuntimeSlotTokens(item, context)
|
||||
expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -239,12 +261,22 @@ func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
|
||||
if len(match) != 3 {
|
||||
return typed, nil
|
||||
}
|
||||
required, known := slotRequirements[match[1]]
|
||||
if !known {
|
||||
required = true
|
||||
}
|
||||
slotValues, _ := context[match[1]].(map[string]any)
|
||||
if slotValues == nil {
|
||||
if !required {
|
||||
return "", nil
|
||||
}
|
||||
return nil, fmt.Errorf("required slot '%s' is not bound", match[1])
|
||||
}
|
||||
fieldValue, ok := slotValues[match[2]]
|
||||
if !ok {
|
||||
if !required {
|
||||
return "", nil
|
||||
}
|
||||
return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2])
|
||||
}
|
||||
return deepCopyAny(fieldValue), nil
|
||||
|
||||
231
internal/service/model_management.go
Normal file
231
internal/service/model_management.go
Normal file
@ -0,0 +1,231 @@
|
||||
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"`
|
||||
ExtraModelCount int `json:"extra_model_count"`
|
||||
ExtraModels []InstalledModelStatus `json:"extra_models"`
|
||||
}
|
||||
|
||||
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,
|
||||
ModelType: inferModelType(entry.Name()),
|
||||
}
|
||||
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 inferModelType(fileName string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(fileName))
|
||||
switch {
|
||||
case strings.Contains(name, "face_det"), strings.Contains(name, "retinaface"), strings.Contains(name, "scrfd"):
|
||||
return "face_detection"
|
||||
case strings.Contains(name, "face_recog"), strings.Contains(name, "mobilefacenet"), strings.Contains(name, "arcface"):
|
||||
return "face_recognition"
|
||||
case strings.Contains(name, "ppe"):
|
||||
return "ppe_detection"
|
||||
case strings.Contains(name, "shoe"):
|
||||
return "shoe_detection"
|
||||
case strings.Contains(name, "object_det"), strings.Contains(name, "yolo"):
|
||||
return "object_detection"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
standardIndex := make(map[string]struct{}, len(standardModels))
|
||||
for _, model := range standardModels {
|
||||
standardIndex[model.Name] = struct{}{}
|
||||
}
|
||||
row := ModelStatusRow{
|
||||
DeviceID: device.DeviceID,
|
||||
DeviceName: device.DisplayName(),
|
||||
Online: device.Online,
|
||||
Cells: make([]ModelStatusCell, 0, len(standardModels)),
|
||||
ExtraModels: make([]InstalledModelStatus, 0),
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, item := range installed[device.DeviceID] {
|
||||
if _, ok := standardIndex[item.Name]; ok {
|
||||
continue
|
||||
}
|
||||
row.ExtraModels = append(row.ExtraModels, item)
|
||||
}
|
||||
sort.Slice(row.ExtraModels, func(i, j int) bool {
|
||||
return row.ExtraModels[i].Name < row.ExtraModels[j].Name
|
||||
})
|
||||
row.ExtraModelCount = len(row.ExtraModels)
|
||||
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
|
||||
}
|
||||
103
internal/service/model_management_test.go
Normal file
103
internal/service/model_management_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
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])
|
||||
}
|
||||
if items[0].ModelType != "face_detection" {
|
||||
t.Fatalf("expected inferred model type face_detection, got %#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])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelStatusBoardSeparatesExtraModels(t *testing.T) {
|
||||
modelsList := []storage.StandardModelRecord{
|
||||
{Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"},
|
||||
}
|
||||
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"},
|
||||
{Name: "best-640", FileName: "best-640.rknn", SHA256: "sha-x"},
|
||||
{Name: "mobilefacenet_arcface", FileName: "mobilefacenet_arcface.rknn", SHA256: "sha-y"},
|
||||
},
|
||||
}
|
||||
|
||||
board := BuildModelStatusBoard(modelsList, devices, installed)
|
||||
|
||||
if len(board.Rows) != 1 {
|
||||
t.Fatalf("unexpected board rows: %#v", board.Rows)
|
||||
}
|
||||
if got := board.Rows[0].ExtraModelCount; got != 2 {
|
||||
t.Fatalf("expected 2 extra models, got %#v", board.Rows[0])
|
||||
}
|
||||
if len(board.Rows[0].ExtraModels) != 2 {
|
||||
t.Fatalf("expected extra model details to be preserved, got %#v", board.Rows[0].ExtraModels)
|
||||
}
|
||||
if board.Rows[0].ExtraModels[0].Name != "best-640" || board.Rows[0].ExtraModels[1].Name != "mobilefacenet_arcface" {
|
||||
t.Fatalf("expected sorted extra models, got %#v", board.Rows[0].ExtraModels)
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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[:])
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
89
internal/storage/models_repo.go
Normal file
89
internal/storage/models_repo.go
Normal 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()
|
||||
}
|
||||
36
internal/storage/models_repo_test.go
Normal file
36
internal/storage/models_repo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -209,6 +211,24 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
||||
}
|
||||
return v
|
||||
},
|
||||
"modelTypeLabel": func(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "face_detection":
|
||||
return "人脸检测"
|
||||
case "face_recognition":
|
||||
return "人脸识别"
|
||||
case "object_detection":
|
||||
return "通用检测"
|
||||
case "ppe_detection":
|
||||
return "PPE检测"
|
||||
case "shoe_detection":
|
||||
return "工鞋检测"
|
||||
case "other":
|
||||
return "其他"
|
||||
default:
|
||||
return "-"
|
||||
}
|
||||
},
|
||||
"displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string {
|
||||
if dev == nil {
|
||||
return "-"
|
||||
@ -240,6 +260,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 +282,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 +618,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)
|
||||
@ -1163,7 +1190,7 @@ func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
|
||||
devices := u.registry.GetDevices()
|
||||
selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"]))
|
||||
data := PageData{
|
||||
Title: "任务",
|
||||
Title: "任务中心",
|
||||
Tasks: u.tasks.ListTasks(),
|
||||
Devices: devices,
|
||||
SelectedDeviceIDs: selectedIDs,
|
||||
@ -1174,7 +1201,7 @@ func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) taskPageData(task *models.Task) PageData {
|
||||
data := PageData{Title: "任务详情", Task: task}
|
||||
data := PageData{Title: "任务中心", Task: task}
|
||||
if task == nil {
|
||||
return data
|
||||
}
|
||||
@ -1233,13 +1260,13 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
return
|
||||
}
|
||||
|
||||
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
|
||||
if err != nil {
|
||||
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
|
||||
@ -1274,7 +1301,75 @@ 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()})
|
||||
u.ensureDevicesLoaded()
|
||||
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) {
|
||||
@ -1282,7 +1377,7 @@ func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "resources", PageData{Title: "资源管理", Devices: u.registry.GetDevices()})
|
||||
u.render(w, r, "resources", PageData{Title: "资源状态", Devices: u.registry.GetDevices()})
|
||||
}
|
||||
|
||||
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
|
||||
@ -1476,7 +1571,7 @@ func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
}
|
||||
if u.preview == nil {
|
||||
data.Error = "基础配置服务未初始化"
|
||||
data.Error = "配置中心服务未初始化"
|
||||
u.render(w, r, "asset_templates", data)
|
||||
return
|
||||
}
|
||||
@ -1666,7 +1761,7 @@ func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("")
|
||||
data.Title = "场景模板"
|
||||
data.Title = "场景"
|
||||
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
|
||||
if data.Error == "" {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
@ -1727,7 +1822,7 @@ func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data.Title = "场景模板"
|
||||
data.Title = "场景"
|
||||
data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
|
||||
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
|
||||
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
|
||||
@ -1762,7 +1857,7 @@ func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name
|
||||
)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
data = u.assetPageData("")
|
||||
data.Title = "场景模板"
|
||||
data.Title = "场景"
|
||||
_ = r.ParseForm()
|
||||
editor = service.ConfigProfileEditor{
|
||||
Name: strings.TrimSpace(r.FormValue("profile_name")),
|
||||
@ -1789,14 +1884,14 @@ func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name
|
||||
}
|
||||
if err := u.preview.SaveProfileEditor(editor); err != nil {
|
||||
data.Error = err.Error()
|
||||
data.Title = "场景模板"
|
||||
data.Title = "场景"
|
||||
data.AssetProfileEditing = true
|
||||
u.render(w, r, "scene_templates", data)
|
||||
return
|
||||
}
|
||||
msg := "场景模板已保存"
|
||||
msg := "场景已保存"
|
||||
if strings.TrimSpace(name) != "" && editor.Name != name {
|
||||
msg = "场景模板已保存,名称已更新"
|
||||
msg = "场景已保存,名称已更新"
|
||||
}
|
||||
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound)
|
||||
}
|
||||
@ -1818,7 +1913,7 @@ func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
|
||||
for _, item := range items {
|
||||
deviceIDs = append(deviceIDs, item.DeviceID)
|
||||
}
|
||||
msg := fmt.Sprintf("场景模板 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、"))
|
||||
msg := fmt.Sprintf("场景 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、"))
|
||||
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound)
|
||||
return
|
||||
}
|
||||
@ -1827,7 +1922,7 @@ func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景模板已删除"), http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
|
||||
@ -1864,7 +1959,7 @@ func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("")
|
||||
data.Title = "识别单元"
|
||||
data.Title = "视频通道"
|
||||
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
|
||||
if data.Error == "" {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
@ -1912,7 +2007,7 @@ func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) {
|
||||
originalRef := strings.TrimSpace(r.FormValue("original_ref"))
|
||||
if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil {
|
||||
data := u.assetPageData("")
|
||||
data.Title = "识别单元"
|
||||
data.Title = "视频通道"
|
||||
data.Error = err.Error()
|
||||
data.RecognitionUnit = &asset
|
||||
data.RecognitionUnitEditing = true
|
||||
@ -1921,7 +2016,7 @@ func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name)
|
||||
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("识别单元已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound)
|
||||
}
|
||||
|
||||
func serviceRecognitionUnitRef(profileName string, unitName string) string {
|
||||
@ -1934,12 +2029,12 @@ func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request)
|
||||
http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("识别单元已删除"), http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("")
|
||||
data.Title = "设备分配"
|
||||
data.Title = "通道部署"
|
||||
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
|
||||
if data.Error == "" {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
@ -1969,7 +2064,7 @@ func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request)
|
||||
assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json")))
|
||||
if err != nil {
|
||||
data := u.assetPageData("")
|
||||
data.Title = "设备分配"
|
||||
data.Title = "通道部署"
|
||||
u.ensureDevicesLoaded()
|
||||
data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices())
|
||||
data.Error = err.Error()
|
||||
@ -1993,7 +2088,7 @@ func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request)
|
||||
http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("设备分配已保存"), http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound)
|
||||
}
|
||||
|
||||
func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device {
|
||||
@ -2042,12 +2137,12 @@ func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request
|
||||
http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("设备分配已删除"), http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("video-sources")
|
||||
data.Title = "基础配置"
|
||||
data.Title = "配置中心"
|
||||
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
|
||||
if data.Error == "" {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
@ -2102,7 +2197,7 @@ func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
|
||||
data := u.assetPageData("video-sources")
|
||||
data.Title = "基础配置"
|
||||
data.Title = "配置中心"
|
||||
data.Error = err.Error()
|
||||
data.AssetVideoSource = &asset
|
||||
data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType)
|
||||
@ -2128,7 +2223,7 @@ func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("integrations")
|
||||
data.Title = "基础配置"
|
||||
data.Title = "配置中心"
|
||||
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
|
||||
if data.Error == "" {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
@ -2287,7 +2382,7 @@ func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) {
|
||||
raw := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(rawText), &raw); err != nil {
|
||||
data := u.assetPageData("overlays")
|
||||
data.Title = "基础配置"
|
||||
data.Title = "配置中心"
|
||||
data.Error = "调试参数 JSON 格式不正确:" + err.Error()
|
||||
data.AssetOverlayEditing = true
|
||||
data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
|
||||
@ -2298,7 +2393,7 @@ func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) {
|
||||
asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
|
||||
if err := u.preview.SaveOverlayAsset(asset, raw); err != nil {
|
||||
data := u.assetPageData("overlays")
|
||||
data.Title = "基础配置"
|
||||
data.Title = "配置中心"
|
||||
data.Error = err.Error()
|
||||
data.AssetOverlayEditing = true
|
||||
data.AssetOverlay = &asset
|
||||
@ -2324,11 +2419,11 @@ func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (u *UI) assetPageData(tab string) PageData {
|
||||
data := PageData{
|
||||
Title: "基础配置",
|
||||
Title: "配置中心",
|
||||
AssetTab: tab,
|
||||
}
|
||||
if u.preview == nil {
|
||||
data.Error = "基础配置服务未初始化"
|
||||
data.Error = "配置中心服务未初始化"
|
||||
return data
|
||||
}
|
||||
sources, err := u.preview.ListSources()
|
||||
@ -2961,7 +3056,7 @@ func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
data := u.deviceDetailPageData(dev)
|
||||
if data.DeviceAssignment == nil {
|
||||
data.Error = "请先到设备分配中为该设备指定识别单元"
|
||||
data.Error = "请先到通道部署中为该设备指定视频通道"
|
||||
u.render(w, r, "device", data)
|
||||
return
|
||||
}
|
||||
|
||||
@ -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}
|
||||
@ -273,6 +275,16 @@ tbody tr[data-nav-row]{cursor:pointer}
|
||||
.scene-summary-details[open] summary::before{transform:rotate(45deg)}
|
||||
.scene-summary-details[open] summary{margin-bottom:10px;color:var(--text)}
|
||||
.scene-summary-details .info-list{margin-top:0}
|
||||
.mini-details summary{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--table-link);list-style:none}
|
||||
.mini-details summary::before{content:"";width:7px;height:7px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .16s ease}
|
||||
.mini-details summary::-webkit-details-marker{display:none}
|
||||
.mini-details[open] summary::before{transform:rotate(45deg)}
|
||||
.mini-details-body{margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.mini-details-item{font-size:11px;line-height:1.5;color:var(--text)}
|
||||
.mini-details-item+.mini-details-item{margin-top:4px}
|
||||
.models-status-table .model-status-col{min-width:92px;max-width:124px;vertical-align:bottom}
|
||||
.models-status-table .model-status-label{display:block;font-size:11px;line-height:1.25;white-space:normal;word-break:break-word;overflow-wrap:anywhere}
|
||||
.models-status-table .model-extra-col{min-width:150px}
|
||||
.scene-actions-row{margin-top:12px}
|
||||
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
|
||||
.field-grid label>span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
|
||||
|
||||
@ -2,14 +2,14 @@
|
||||
<div class="card assignment-board-page">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "devices"}}<span>设备分配</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "devices"}}<span>通道部署</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .DeviceAssignmentBoard}}
|
||||
<div class="assignment-kpis">
|
||||
<div class="assignment-kpi">
|
||||
<span>识别单元</span>
|
||||
<span>视频通道</span>
|
||||
<strong>{{.DeviceAssignmentBoard.Stats.TotalUnits}}</strong>
|
||||
</div>
|
||||
<div class="assignment-kpi">
|
||||
@ -45,7 +45,7 @@
|
||||
<div class="actions compact">
|
||||
<button type="button" class="btn secondary" id="auto-assign-btn">自动平均分配</button>
|
||||
<button type="button" class="btn secondary" id="clear-assign-btn">清空分配</button>
|
||||
<button type="submit">保存设备分配</button>
|
||||
<button type="submit">保存通道部署</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
{{if lt .AssignedCount .MaxUnits}}
|
||||
<div class="assignment-device-add">
|
||||
<select data-add-select="{{.DeviceID}}">
|
||||
<option value="">添加识别单元</option>
|
||||
<option value="">添加视频通道</option>
|
||||
</select>
|
||||
<button type="button" class="btn secondary" data-add-button="{{.DeviceID}}">加入</button>
|
||||
</div>
|
||||
@ -87,7 +87,7 @@
|
||||
<section class="assignment-unassigned">
|
||||
<div class="section-title compact">
|
||||
<div>
|
||||
<h3>未分配识别单元</h3>
|
||||
<h3>未部署视频通道</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assignment-chip-list" id="assignment-unassigned-list">
|
||||
@ -273,7 +273,7 @@
|
||||
const addControls = card.refs.length < state.max ? `
|
||||
<div class="assignment-device-add">
|
||||
<select data-add-select="${card.deviceID}">
|
||||
<option value="">添加识别单元</option>
|
||||
<option value="">添加视频通道</option>
|
||||
${availableRefs.map(ref => `<option value="${ref}">${unitLabel(state.units[ref])}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn secondary" data-add-button="${card.deviceID}">加入</button>
|
||||
@ -358,7 +358,7 @@
|
||||
document.getElementById('auto-assign-btn').addEventListener('click', autoAssign);
|
||||
document.getElementById('clear-assign-btn').addEventListener('click', () => {
|
||||
clearAssignments();
|
||||
feedback.textContent = '已清空当前页面中的设备分配。';
|
||||
feedback.textContent = '已清空当前页面中的通道部署。';
|
||||
render();
|
||||
});
|
||||
form.addEventListener('submit', syncHiddenState);
|
||||
@ -366,7 +366,7 @@
|
||||
})();
|
||||
</script>
|
||||
{{else}}
|
||||
<div class="empty-state compact"><div class="empty-title">暂无可用的设备分配数据</div></div>
|
||||
<div class="empty-state compact"><div class="empty-title">暂无可用的通道部署数据</div></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -22,11 +22,11 @@
|
||||
<div class="nav-section">主模块</div>
|
||||
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
|
||||
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
|
||||
<a href="/ui/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景模板</span></a>
|
||||
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>识别单元</span></a>
|
||||
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>设备分配</span></a>
|
||||
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>基础配置</span></a>
|
||||
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a>
|
||||
<a href="/ui/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景</span></a>
|
||||
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>视频通道</span></a>
|
||||
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>通道部署</span></a>
|
||||
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>配置中心</span></a>
|
||||
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务中心</span></a>
|
||||
<details class="nav-group" id="system-nav-group">
|
||||
<summary>
|
||||
<span class="nav-icon">{{icon "system"}}</span>
|
||||
@ -34,7 +34,7 @@
|
||||
</summary>
|
||||
<div class="nav-group-items">
|
||||
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源状态</span></a>
|
||||
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
|
||||
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
|
||||
</div>
|
||||
|
||||
@ -1,40 +1,123 @@
|
||||
{{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><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>{{modelTypeLabel .ModelType}}</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="6" 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 class="models-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备</th>
|
||||
{{range .StandardModels}}<th class="model-status-col" title="{{.Name}}"><span class="model-status-label">{{modelTypeLabel .ModelType}}</span></th>{{end}}
|
||||
<th class="model-extra-col">非标准模型</th>
|
||||
</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}}
|
||||
<td>
|
||||
{{if gt .ExtraModelCount 0}}
|
||||
<details class="mini-details">
|
||||
<summary>{{.ExtraModelCount}} 个 · 更多</summary>
|
||||
<div class="mini-details-body">
|
||||
{{range .ExtraModels}}
|
||||
<div class="mini-details-item mono">{{.FileName}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{else}}
|
||||
<span class="muted">0</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="99" class="muted">暂无设备模型状态。</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>识别单元列表</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/recognition-units?new=1">{{icon "apply"}}<span>新增识别单元</span></a>
|
||||
<a class="btn secondary" href="/ui/recognition-units?new=1">{{icon "apply"}}<span>新增视频通道</span></a>
|
||||
{{if .SelectedRecognitionUnit}}
|
||||
<a class="btn secondary" href="/ui/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/recognition-units/delete" onsubmit="return confirm('确认删除这个识别单元吗?');">
|
||||
<form method="post" action="/ui/recognition-units/delete" onsubmit="return confirm('确认删除这个视频通道吗?');">
|
||||
<input type="hidden" name="ref" value="{{.SelectedRecognitionUnit}}" />
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
@ -19,8 +19,8 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>识别单元</th>
|
||||
<th>场景模板</th>
|
||||
<th>视频通道</th>
|
||||
<th>场景</th>
|
||||
<th>视频源</th>
|
||||
<th>输出频道号</th>
|
||||
</tr>
|
||||
@ -34,7 +34,7 @@
|
||||
<td class="mono">{{if .OutputChannel}}{{.OutputChannel}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有识别单元</div></div></td></tr>
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有视频通道</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -47,11 +47,11 @@
|
||||
<div class="card editor-state {{if .RecognitionUnitEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>识别单元{{if .RecognitionUnit.Name}} · {{.RecognitionUnit.Name}}{{end}}</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道{{if .RecognitionUnit.Name}} · {{.RecognitionUnit.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>一路视频对应一个识别单元,由设备分配决定最终在哪台设备上运行。</span>
|
||||
<span>一路视频对应一个视频通道,由通道部署决定最终在哪台设备上运行。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
|
||||
@ -60,16 +60,16 @@
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存识别单元</span></button>
|
||||
<button type="submit">{{icon "apply"}}<span>保存视频通道</span></button>
|
||||
<a class="btn secondary" href="/ui/recognition-units{{if .SelectedRecognitionUnit}}?ref={{.SelectedRecognitionUnit}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>识别单元名称<span class="required-mark">*</span></span><input name="name" value="{{.RecognitionUnit.Name}}" {{if not .SelectedRecognitionUnit}}autofocus{{end}} /></label>
|
||||
<label><span>视频通道名称<span class="required-mark">*</span></span><input name="name" value="{{.RecognitionUnit.Name}}" {{if not .SelectedRecognitionUnit}}autofocus{{end}} /></label>
|
||||
<label>
|
||||
<span>场景模板<span class="required-mark">*</span></span>
|
||||
<span>场景<span class="required-mark">*</span></span>
|
||||
<select name="scene_template_name">
|
||||
{{range .AssetProfiles}}
|
||||
<option value="{{.Name}}" {{if eq $.RecognitionUnit.SceneTemplateName .Name}}selected{{end}}>{{.Name}}</option>
|
||||
@ -92,8 +92,8 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>识别单元名称</span><strong class="mono">{{if .RecognitionUnit.Name}}{{.RecognitionUnit.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>场景模板</span><strong class="mono">{{if .RecognitionUnit.SceneTemplateName}}{{.RecognitionUnit.SceneTemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>视频通道名称</span><strong class="mono">{{if .RecognitionUnit.Name}}{{.RecognitionUnit.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>场景</span><strong class="mono">{{if .RecognitionUnit.SceneTemplateName}}{{.RecognitionUnit.SceneTemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>通道显示名</span><strong>{{if .RecognitionUnit.DisplayName}}{{.RecognitionUnit.DisplayName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>站点名</span><strong>{{if .RecognitionUnit.SiteName}}{{.RecognitionUnit.SiteName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>视频源</span><strong class="mono">{{if .RecognitionUnit.VideoSourceRef}}{{.RecognitionUnit.VideoSourceRef}}{{else}}-{{end}}</strong></div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>资源管理</h2>
|
||||
<h2>资源状态</h2>
|
||||
<div class="muted small">统一维护人脸库与通用资源,设备侧只显示当前版本与同步状态。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板列表</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景模板</span></a>
|
||||
<a class="btn secondary" href="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景</span></a>
|
||||
{{if .SelectedProfile}}
|
||||
<a class="btn secondary" href="/ui/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景模板吗?');">
|
||||
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景吗?');">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@ -18,10 +18,10 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>场景模板</th>
|
||||
<th>场景</th>
|
||||
<th>识别模板</th>
|
||||
<th>调试参数</th>
|
||||
<th>识别单元</th>
|
||||
<th>视频通道</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -33,7 +33,7 @@
|
||||
<td>{{len .Instances}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景模板</div></div></td></tr>
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -45,11 +45,11 @@
|
||||
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetProfileEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前内容只包含模板级信息,识别单元请到“识别单元”页面维护。</span>
|
||||
<span>当前内容只包含场景级信息,视频通道请到“视频通道”页面维护。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
|
||||
@ -58,7 +58,7 @@
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
{{if .AssetProfileEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存场景模板</span></button>
|
||||
<button type="submit">{{icon "apply"}}<span>保存场景</span></button>
|
||||
<a class="btn secondary" href="/ui/scene-templates{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
<button type="button" class="btn secondary js-export-json" data-export-url="/ui/scene-templates/{{.AssetProfileEditor.Name}}/export" data-default-filename="{{.AssetProfileEditor.Name}}.json">{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
@ -67,7 +67,7 @@
|
||||
|
||||
{{if .AssetProfileEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>场景模板名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if not .SelectedProfile}}autofocus{{end}} /></label>
|
||||
<label><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if not .SelectedProfile}}autofocus{{end}} /></label>
|
||||
<label><span>识别模板<span class="required-mark">*</span></span>
|
||||
<select name="primary_template_name">
|
||||
{{range .AssetTemplates}}
|
||||
@ -90,12 +90,12 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>场景模板名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>场景名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>识别模板</span><strong class="mono">{{if .AssetProfileEditor.PrimaryTemplateName}}{{.AssetProfileEditor.PrimaryTemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>业务名称</span><strong>{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>调试参数</span><strong>{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}</strong></div>
|
||||
<div class="detail-item"><span>站点名</span><strong>{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>识别单元</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
|
||||
<div class="detail-item"><span>视频通道</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
|
||||
<div class="detail-item full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
@ -18,6 +19,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@ -133,7 +135,11 @@ func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"视觉识别运维平台",
|
||||
"总览",
|
||||
"任务",
|
||||
"场景",
|
||||
"视频通道",
|
||||
"通道部署",
|
||||
"配置中心",
|
||||
"任务中心",
|
||||
"系统管理",
|
||||
"<h1>设备</h1>",
|
||||
} {
|
||||
@ -1842,8 +1848,52 @@ func TestUI_LoadConfigStatusPrefersPreviousConfigFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ModelManagementPageShowsUnifiedCatalog(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models/status":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123},{"name":"best-640","file_name":"best-640.rknn","sha256":"sha-x","size_bytes":456}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.Listener.Addr().String(), "[::]"))
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
dbPath := filepath.Join(t.TempDir(), "models.db")
|
||||
store, err := storage.OpenSQLite(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
|
||||
agent := service.NewAgentClient(cfg)
|
||||
tasks := service.NewTaskService(cfg, agent, reg)
|
||||
ui, err := NewUI(nil, reg, agent, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
modelsRepo := storage.NewModelsRepo(store.DB())
|
||||
if err := modelsRepo.Save(storage.StandardModelRecord{
|
||||
Name: "face_det_scrfd_500m_640_rk3588",
|
||||
FileName: "face_det_scrfd_500m_640_rk3588.rknn",
|
||||
Version: "v1",
|
||||
SHA256: "sha-1",
|
||||
SizeBytes: 123,
|
||||
ModelType: "face_detection",
|
||||
}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
ui.SetDBPath(dbPath)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@ -1853,23 +1903,149 @@ 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 _, want := range []string{"1 个 · 更多", "best-640.rknn"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected model management HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"统一模型目录", "人脸库", "查看设备模型"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("model management page should not contain legacy text %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ModelsPageLoadsDevicesBeforeBuildingStatusBoard(t *testing.T) {
|
||||
modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models/status":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer modelServer.Close()
|
||||
host, portText, err := net.SplitHostPort(strings.TrimPrefix(modelServer.Listener.Addr().String(), "[::]"))
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
agentPort, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
|
||||
discoveryConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 35689})
|
||||
if err != nil {
|
||||
t.Fatalf("ListenUDP: %v", err)
|
||||
}
|
||||
defer discoveryConn.Close()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
buf := make([]byte, 2048)
|
||||
_ = discoveryConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, addr, err := discoveryConn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(string(buf[:n]))
|
||||
lines := strings.SplitN(text, "\n", 3)
|
||||
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "RK3588SYS_DISCOVERY_V1" {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ReqID string `json:"req_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &req); err != nil {
|
||||
return
|
||||
}
|
||||
reply := fmt.Sprintf("RK3588SYS_DISCOVERY_V1\n{\"type\":\"discover_reply\",\"req_id\":%q,\"device_id\":\"edge-01\",\"device_name\":\"入口识别节点\",\"ip\":%q,\"agent_port\":%d,\"media_port\":9000}\n", req.ReqID, host, agentPort)
|
||||
_, _ = discoveryConn.WriteToUDP([]byte(reply), addr)
|
||||
}()
|
||||
|
||||
cfg := &config.Config{
|
||||
Concurrency: 1,
|
||||
OfflineAfterMs: 1000000,
|
||||
DiscoveryTimeoutMs: 300,
|
||||
DiscoveryPort: 35689,
|
||||
}
|
||||
dbPath := filepath.Join(t.TempDir(), "models.db")
|
||||
store, err := storage.OpenSQLite(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
|
||||
discovery := service.NewDiscoveryService(cfg, reg)
|
||||
agent := service.NewAgentClient(cfg)
|
||||
tasks := service.NewTaskService(cfg, agent, reg)
|
||||
ui, err := NewUI(discovery, reg, agent, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
modelsRepo := storage.NewModelsRepo(store.DB())
|
||||
if err := modelsRepo.Save(storage.StandardModelRecord{
|
||||
Name: "face_det_scrfd_500m_640_rk3588",
|
||||
FileName: "face_det_scrfd_500m_640_rk3588.rknn",
|
||||
Version: "v1",
|
||||
SHA256: "sha-1",
|
||||
SizeBytes: 123,
|
||||
ModelType: "face_detection",
|
||||
}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
ui.SetDBPath(dbPath)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.pageModels(rr, req)
|
||||
|
||||
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{"入口识别节点", "设备模型状态", "一致"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected model page to contain %q after lazy device load, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "暂无设备模型状态") {
|
||||
t.Fatalf("expected model page to build status board after lazy device load, got:\n%s", body)
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
for _, want := range []string{"资源管理", "人脸库版本", "设备资源状态", "入口识别节点"} {
|
||||
for _, want := range []string{"资源状态", "人脸库版本", "设备资源状态", "入口识别节点"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("expected resources HTML to contain %q", want)
|
||||
}
|
||||
@ -2128,7 +2304,7 @@ func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
|
||||
ui.pageAssets(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"基础配置", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
|
||||
for _, want := range []string{"配置中心", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected assets HTML to contain %q", want)
|
||||
}
|
||||
@ -3195,7 +3371,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
t.Fatalf("expected audit HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"框架版", "后续", "任务中心", "节点执行情况", `disabled`} {
|
||||
for _, forbidden := range []string{"框架版", "后续", "节点执行情况", `disabled`} {
|
||||
if strings.Contains(rrAudit.Body.String(), forbidden) {
|
||||
t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
@ -3213,7 +3389,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
t.Fatalf("expected tasks HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"任务中心", "节点执行情况", "创建任务", "<h2>目标设备</h2>", "高级参数(JSON)", `name="device_ids"`, `name="payload_json"`} {
|
||||
for _, forbidden := range []string{"节点执行情况", "创建任务", "<h2>目标设备</h2>", "高级参数(JSON)", `name="device_ids"`, `name="payload_json"`} {
|
||||
if strings.Contains(rrTasks.Body.String(), forbidden) {
|
||||
t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
@ -3230,7 +3406,7 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
t.Fatalf("expected task detail HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"任务中心", "返回操作审计"} {
|
||||
for _, forbidden := range []string{"返回操作审计"} {
|
||||
if strings.Contains(rrTaskConfig.Body.String(), forbidden) {
|
||||
t.Fatalf("task detail HTML should not contain %q", forbidden)
|
||||
}
|
||||
@ -3294,7 +3470,7 @@ func TestUI_DeviceAssignmentsPageShowsBoard(t *testing.T) {
|
||||
ui.pageDeviceAssignments(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"设备分配", "识别单元", "未分配", "每台最多", "自动平均分配", "保存设备分配", "东门入口", "西门入口"} {
|
||||
for _, want := range []string{"通道部署", "视频通道", "未分配", "每台最多", "自动平均分配", "保存通道部署", "东门入口", "西门入口"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected device assignment board to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
|
||||
BIN
models/standard_models/face_det_scrfd_500m_640_rk3588.rknn
Normal file
BIN
models/standard_models/face_det_scrfd_500m_640_rk3588.rknn
Normal file
Binary file not shown.
Binary file not shown.
BIN
models/standard_models/object_det_yolov8n_coco_640_rk3588.rknn
Normal file
BIN
models/standard_models/object_det_yolov8n_coco_640_rk3588.rknn
Normal file
Binary file not shown.
BIN
models/standard_models/ppe_det_yolov8_ppe11_768_rk3588.rknn
Normal file
BIN
models/standard_models/ppe_det_yolov8_ppe11_768_rk3588.rknn
Normal file
Binary file not shown.
BIN
models/standard_models/shoe_det_yolov8s_workshoe_640_rk3588.rknn
Normal file
BIN
models/standard_models/shoe_det_yolov8s_workshoe_640_rk3588.rknn
Normal file
Binary file not shown.
5
scripts/managerd.bat
Normal file
5
scripts/managerd.bat
Normal file
@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
chcp 65001 >nul
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0managerd.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
197
scripts/managerd.ps1
Normal file
197
scripts/managerd.ps1
Normal file
@ -0,0 +1,197 @@
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$Action = "help"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$rootDir = (Resolve-Path (Join-Path $scriptDir "..")).Path
|
||||
$exePath = Join-Path $rootDir "managerd.exe"
|
||||
$configPath = Join-Path $rootDir "managerd.json"
|
||||
$logPath = Join-Path $rootDir "managerd.local.log"
|
||||
$errLogPath = Join-Path $rootDir "managerd.local.err.log"
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[managerd] $Message"
|
||||
}
|
||||
|
||||
function Get-ManagerdProcess {
|
||||
$all = Get-CimInstance Win32_Process -Filter "name = 'managerd.exe'"
|
||||
$matched = $all | Where-Object { $_.ExecutablePath -eq $exePath }
|
||||
return $matched | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Get-HealthInfo {
|
||||
$listen = "127.0.0.1:18080"
|
||||
if (Test-Path $configPath) {
|
||||
$cfg = Get-Content -Raw $configPath | ConvertFrom-Json
|
||||
if ($cfg.listen -and -not [string]::IsNullOrWhiteSpace([string]$cfg.listen)) {
|
||||
$listen = [string]$cfg.listen
|
||||
}
|
||||
}
|
||||
|
||||
$parts = $listen.Split(":")
|
||||
$hostName = $parts[0]
|
||||
$port = $parts[-1]
|
||||
if ([string]::IsNullOrWhiteSpace($hostName) -or $hostName -in @("0.0.0.0", "::")) {
|
||||
$hostName = "127.0.0.1"
|
||||
}
|
||||
|
||||
$url = "http://{0}:{1}/health" -f $hostName, $port
|
||||
try {
|
||||
$resp = Invoke-WebRequest -UseBasicParsing $url -TimeoutSec 3
|
||||
if ($resp.StatusCode -eq 200 -and $resp.Content -match "ok") {
|
||||
return [pscustomobject]@{ State = "ok"; Url = $url }
|
||||
}
|
||||
return [pscustomobject]@{ State = "bad"; Url = $url }
|
||||
} catch {
|
||||
return [pscustomobject]@{ State = "down"; Url = $url }
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForHealthOk {
|
||||
param([int]$Retries = 8)
|
||||
for ($i = 0; $i -lt $Retries; $i++) {
|
||||
$health = Get-HealthInfo
|
||||
if ($health.State -eq "ok") {
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Wait-ForStop {
|
||||
param([int]$Retries = 8)
|
||||
for ($i = 0; $i -lt $Retries; $i++) {
|
||||
if (-not (Get-ManagerdProcess)) {
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Show-Health {
|
||||
$health = Get-HealthInfo
|
||||
switch ($health.State) {
|
||||
"ok" { Write-Info ("健康检查: ok ({0})" -f $health.Url) }
|
||||
"bad" { Write-Info ("健康检查: 响应异常 ({0})" -f $health.Url) }
|
||||
default { Write-Info ("健康检查: down ({0})" -f $health.Url) }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Build {
|
||||
Write-Info "编译 managerd.exe ..."
|
||||
Push-Location $rootDir
|
||||
try {
|
||||
& go build -o $exePath .\cmd\managerd
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "go build failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Info "编译完成: $exePath"
|
||||
}
|
||||
|
||||
function Invoke-Start {
|
||||
if (-not (Test-Path $exePath)) {
|
||||
throw "未找到 managerd.exe,请先执行 build"
|
||||
}
|
||||
|
||||
$proc = Get-ManagerdProcess
|
||||
if ($proc) {
|
||||
Write-Info ("已在运行,PID={0}" -f $proc.ProcessId)
|
||||
Show-Health
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "启动 managerd.exe ..."
|
||||
Start-Process -FilePath $exePath `
|
||||
-ArgumentList $configPath `
|
||||
-WorkingDirectory $rootDir `
|
||||
-RedirectStandardOutput $logPath `
|
||||
-RedirectStandardError $errLogPath `
|
||||
-WindowStyle Hidden
|
||||
|
||||
$null = Wait-ForHealthOk
|
||||
$proc = Get-ManagerdProcess
|
||||
if (-not $proc) {
|
||||
throw "进程未启动成功"
|
||||
}
|
||||
|
||||
Write-Info ("已启动,PID={0}" -f $proc.ProcessId)
|
||||
Show-Health
|
||||
}
|
||||
|
||||
function Invoke-Stop {
|
||||
$proc = Get-ManagerdProcess
|
||||
if (-not $proc) {
|
||||
Write-Info "当前未运行"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info ("停止 managerd.exe,PID={0} ..." -f $proc.ProcessId)
|
||||
Stop-Process -Id $proc.ProcessId -Force
|
||||
if (-not (Wait-ForStop)) {
|
||||
throw "进程停止超时"
|
||||
}
|
||||
Write-Info "已停止"
|
||||
}
|
||||
|
||||
function Invoke-Restart {
|
||||
Invoke-Stop
|
||||
Invoke-Start
|
||||
}
|
||||
|
||||
function Show-Status {
|
||||
$proc = Get-ManagerdProcess
|
||||
if ($proc) {
|
||||
Write-Info ("进程状态: running (PID={0})" -f $proc.ProcessId)
|
||||
} else {
|
||||
Write-Info "进程状态: stopped"
|
||||
}
|
||||
Show-Health
|
||||
}
|
||||
|
||||
function Show-Usage {
|
||||
@(
|
||||
"用法:"
|
||||
" managerd.bat build"
|
||||
" managerd.bat start"
|
||||
" managerd.bat stop"
|
||||
" managerd.bat restart"
|
||||
" managerd.bat status"
|
||||
" managerd.bat help"
|
||||
""
|
||||
"说明:"
|
||||
" build 编译 .\cmd\managerd 到 managerd.exe"
|
||||
" start 启动现有 managerd.exe,并做健康检查"
|
||||
" stop 停止当前仓库对应的 managerd.exe"
|
||||
" restart 先 stop 再 start"
|
||||
" status 查看进程状态与 /health"
|
||||
" help 显示帮助"
|
||||
) | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($Action.ToLowerInvariant()) {
|
||||
"build" { Invoke-Build }
|
||||
"start" { Invoke-Start }
|
||||
"stop" { Invoke-Stop }
|
||||
"restart" { Invoke-Restart }
|
||||
"status" { Show-Status }
|
||||
"help" { Show-Usage }
|
||||
default {
|
||||
Write-Host "未知动作: $Action"
|
||||
Show-Usage
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Error $_
|
||||
exit 1
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ^> 编译 managerd.exe ...
|
||||
go build -o managerd.exe ./cmd/managerd
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo 编译失败,请检查代码错误
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ^> 停止正在运行的 managerd.exe ...
|
||||
taskkill /f /im managerd.exe 2>nul
|
||||
|
||||
echo ^> 启动 managerd.exe ...
|
||||
start /b "" managerd.exe
|
||||
|
||||
echo ^> 等待启动 ...
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
echo ^> 检查进程状态 ...
|
||||
tasklist /fi "imagename eq managerd.exe" 2>nul | findstr /i managerd >nul
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo managerd 已启动
|
||||
) else (
|
||||
echo 启动失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user