feat: persist device features to DB as source of truth
- device_features 表:持久化设备检测功能选择 - pageConsole 从 DB 读取功能选择(非从模板反推) - actionConsoleSave 先存 DB 再从 DB 读取运行流水线 - 模板选择改为最精确匹配(单功能只用该功能模板) - 修复内存指标解析(补充 memory 字段)
This commit is contained in:
parent
25cf456047
commit
286dcaa945
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,3 +38,4 @@ managerd~
|
||||
nul
|
||||
dataset/
|
||||
__pycache__/
|
||||
managerd.db
|
||||
|
||||
@ -142,9 +142,19 @@ func (r *FeatureRegistry) ResolveTemplate(featureKeys []string) (*ResolveResult,
|
||||
// Find intersection.
|
||||
intersection := intersectTemplateLists(lists)
|
||||
if len(intersection) > 0 {
|
||||
// Pick the one with the broadest coverage (first in the list).
|
||||
// Pick the most specific template: the one covering the fewest features
|
||||
// (i.e. listed by the fewest FeatureDefs), not the broadest.
|
||||
// This ensures selecting only "face" doesn't pull in a face+shoe template.
|
||||
best := intersection[0]
|
||||
bestScore := r.templateFeatureCount(best)
|
||||
for _, tpl := range intersection[1:] {
|
||||
if score := r.templateFeatureCount(tpl); score < bestScore {
|
||||
best = tpl
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &ResolveResult{
|
||||
TemplateName: intersection[0],
|
||||
TemplateName: best,
|
||||
Composed: false,
|
||||
}, nil
|
||||
}
|
||||
@ -204,6 +214,21 @@ func pickBroadestTemplate(lists [][]string) string {
|
||||
return lists[0][0]
|
||||
}
|
||||
|
||||
// templateFeatureCount returns how many features list this template.
|
||||
// Lower = more specific template.
|
||||
func (r *FeatureRegistry) templateFeatureCount(templateName string) int {
|
||||
count := 0
|
||||
for _, def := range r.features {
|
||||
for _, t := range def.Templates {
|
||||
if t == templateName {
|
||||
count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (r *FeatureRegistry) ValidateFeatureKeys(keys []string) error {
|
||||
seen := map[string]struct{}{}
|
||||
for _, key := range keys {
|
||||
@ -459,6 +484,39 @@ func (s *AutoConfigService) composeFromFeatureTemplates(featureKeys []string, pr
|
||||
return ComposeTemplates(templates)
|
||||
}
|
||||
|
||||
// ---- Device feature persistence ----
|
||||
|
||||
// GetDeviceFeatures returns the persisted feature keys for a device.
|
||||
func (s *ConfigPreviewService) GetDeviceFeatures(deviceID string) ([]string, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rec, err := s.assets.GetDeviceFeatures(deviceID)
|
||||
if err != nil || rec == nil {
|
||||
return nil, err
|
||||
}
|
||||
var features []string
|
||||
if err := json.Unmarshal([]byte(rec.FeaturesJSON), &features); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return features, nil
|
||||
}
|
||||
|
||||
// SaveDeviceFeatures persists the feature keys for a device.
|
||||
func (s *ConfigPreviewService) SaveDeviceFeatures(deviceID string, features []string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
if len(features) == 0 {
|
||||
features = []string{}
|
||||
}
|
||||
body, err := json.Marshal(features)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.assets.SaveDeviceFeatures(deviceID, string(body))
|
||||
}
|
||||
|
||||
func cleanSourceNames(names []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(names))
|
||||
|
||||
@ -15,9 +15,10 @@ func TestFeatureRegistry_ResolveTemplate_SingleFeature(t *testing.T) {
|
||||
if result.Composed {
|
||||
t.Errorf("expected single template, got composed=true")
|
||||
}
|
||||
// std_workshop_face_recognition_shoe_alarm is first (broadest coverage)
|
||||
if result.TemplateName != "std_workshop_face_recognition_shoe_alarm" {
|
||||
t.Errorf("expected std_workshop_face_recognition_shoe_alarm, got %s", result.TemplateName)
|
||||
// Most specific template for face-only: std_face_recognition_stream (covers 1 feature)
|
||||
// std_workshop_face_recognition_shoe_alarm covers 2 features, less specific.
|
||||
if result.TemplateName != "std_face_recognition_stream" {
|
||||
t.Errorf("expected std_face_recognition_stream (most specific), got %s", result.TemplateName)
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +62,7 @@ func TestFeatureRegistry_ResolveTemplate_ComposedNeeded(t *testing.T) {
|
||||
if !strings.HasPrefix(result.TemplateName, "auto_") {
|
||||
t.Errorf("composed template should start with auto_, got %s", result.TemplateName)
|
||||
}
|
||||
// composePrimary is the first template from the first feature (face)
|
||||
if result.composePrimary != "std_workshop_face_recognition_shoe_alarm" {
|
||||
t.Errorf("expected compose primary std_workshop_face_recognition_shoe_alarm, got %s", result.composePrimary)
|
||||
}
|
||||
|
||||
@ -757,3 +757,44 @@ func valueString(v any) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- device_features ----
|
||||
|
||||
// DeviceFeatureRecord holds the persisted feature selection for a device.
|
||||
type DeviceFeatureRecord struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
FeaturesJSON string `json:"features_json"` // JSON array of feature keys, e.g. ["face","shoe"]
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SaveDeviceFeatures upserts the feature selection for a device.
|
||||
func (r *AssetsRepo) SaveDeviceFeatures(deviceID string, featuresJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO device_features(device_id, features_json, updated_at)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
features_json=excluded.features_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, deviceID, featuresJSON, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDeviceFeatures returns the persisted features for a device, or nil.
|
||||
func (r *AssetsRepo) GetDeviceFeatures(deviceID string) (*DeviceFeatureRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var rec DeviceFeatureRecord
|
||||
err := r.db.QueryRow(
|
||||
"SELECT device_id, features_json, updated_at FROM device_features WHERE device_id = ?",
|
||||
deviceID,
|
||||
).Scan(&rec.DeviceID, &rec.FeaturesJSON, &rec.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, nil // not found is not an error
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
@ -150,6 +150,11 @@ CREATE TABLE IF NOT EXISTS device_config_state (
|
||||
last_applied_task_id TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS device_features (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
features_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
|
||||
@ -918,40 +918,21 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) {
|
||||
{Key: "intrusion", Label: "区域入侵"},
|
||||
}
|
||||
|
||||
// Template-to-feature mapping (which templates enable which features)
|
||||
templateFeatures := map[string][]string{
|
||||
"std_face_recognition_stream": {"face"},
|
||||
"std_workshoe_detection_stream": {"shoe"},
|
||||
"std_workshop_face_recognition_shoe_alarm": {"face", "shoe"},
|
||||
"std_service_test_stream": {},
|
||||
}
|
||||
|
||||
// Collect assigned template names per device.
|
||||
// First collect scene template (profile) names from recognition units,
|
||||
// then resolve each profile to its primary_template_name for feature detection.
|
||||
deviceProfileNames := map[string]map[string]bool{}
|
||||
for _, a := range assignments {
|
||||
if deviceProfileNames[a.DeviceID] == nil {
|
||||
deviceProfileNames[a.DeviceID] = map[string]bool{}
|
||||
}
|
||||
for _, ref := range a.RecognitionUnits {
|
||||
for _, u := range units {
|
||||
if u.Ref == ref && u.SceneTemplateName != "" {
|
||||
deviceProfileNames[a.DeviceID][u.SceneTemplateName] = true
|
||||
break
|
||||
}
|
||||
// Load persisted device features as the source of truth.
|
||||
// This represents what capabilities the device is configured for,
|
||||
// not just what the last deployment used.
|
||||
persistedFeatures := map[string]map[string]bool{}
|
||||
if u.preview != nil {
|
||||
for _, dev := range devices {
|
||||
if dev == nil || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve profile names → actual template names.
|
||||
deviceTemplates := map[string]map[string]bool{}
|
||||
for deviceID, profiles := range deviceProfileNames {
|
||||
deviceTemplates[deviceID] = map[string]bool{}
|
||||
for profileName := range profiles {
|
||||
if editor, err := u.preview.GetProfileEditor(profileName); err == nil {
|
||||
if tpl := editor.RawTemplateName(); tpl != "" {
|
||||
deviceTemplates[deviceID][tpl] = true
|
||||
if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 {
|
||||
m := map[string]bool{}
|
||||
for _, f := range feats {
|
||||
m[f] = true
|
||||
}
|
||||
persistedFeatures[dev.DeviceID] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1028,14 +1009,10 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which features are enabled
|
||||
activeKeys := map[string]bool{}
|
||||
for tpl := range deviceTemplates[dev.DeviceID] {
|
||||
if keys, ok := templateFeatures[tpl]; ok {
|
||||
for _, k := range keys {
|
||||
activeKeys[k] = true
|
||||
}
|
||||
}
|
||||
// Determine which features are enabled from persisted device features.
|
||||
activeKeys := persistedFeatures[dev.DeviceID]
|
||||
if activeKeys == nil {
|
||||
activeKeys = map[string]bool{}
|
||||
}
|
||||
|
||||
var features []ConsoleFeature
|
||||
@ -1086,7 +1063,7 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if u.autoConfig == nil {
|
||||
if u.autoConfig == nil || u.preview == nil {
|
||||
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
@ -1094,17 +1071,40 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
|
||||
u.ensureDevicesLoaded()
|
||||
devices := u.registry.GetDevices()
|
||||
|
||||
// Build request per device from form data.
|
||||
// Form fields:
|
||||
// device_{id}_feature = face (one per checked feature)
|
||||
// device_{id}_source = cam1 (one per assigned video source)
|
||||
// Step 1: persist feature selections to DB (source of truth).
|
||||
for _, dev := range devices {
|
||||
if dev == nil || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"])
|
||||
if err := u.preview.SaveDeviceFeatures(dev.DeviceID, features); err != nil {
|
||||
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("保存功能配置失败: "+err.Error()), http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: build requests from DB only.
|
||||
// Features come from device_features table.
|
||||
// Video sources come from existing device assignments and recognition units.
|
||||
var requests []service.AutoConfigRequest
|
||||
for _, dev := range devices {
|
||||
if dev == nil || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
features := r.Form["device_"+dev.DeviceID+"_feature"]
|
||||
sources := r.Form["device_"+dev.DeviceID+"_source"]
|
||||
features, err := u.preview.GetDeviceFeatures(dev.DeviceID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var sources []string
|
||||
if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil {
|
||||
for _, ref := range assignment.RecognitionUnits {
|
||||
if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil {
|
||||
if unit.VideoSourceRef != "" {
|
||||
sources = append(sources, unit.VideoSourceRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(features) == 0 && len(sources) == 0 {
|
||||
continue
|
||||
}
|
||||
@ -1116,7 +1116,7 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(requests) == 0 {
|
||||
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("未选择任何检测功能或视频源"), http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("未选择检测功能或未分配视频源"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user