feat: persist device features to DB as source of truth

- device_features 表:持久化设备检测功能选择
- pageConsole 从 DB 读取功能选择(非从模板反推)
- actionConsoleSave 先存 DB 再从 DB 读取运行流水线
- 模板选择改为最精确匹配(单功能只用该功能模板)
- 修复内存指标解析(补充 memory 字段)
This commit is contained in:
tian 2026-05-12 13:40:37 +08:00
parent 25cf456047
commit 286dcaa945
6 changed files with 160 additions and 53 deletions

1
.gitignore vendored
View File

@ -38,3 +38,4 @@ managerd~
nul
dataset/
__pycache__/
managerd.db

View File

@ -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))

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,

View File

@ -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
}