Compare commits

...

7 Commits

5 changed files with 228 additions and 0 deletions

View File

@ -0,0 +1,173 @@
package httpapi
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"rk3588sys/agent/internal/files"
)
func (s *Server) alarmsPath() string {
return filepath.Join(s.baseDir, "logs", "alarms.jsonl")
}
// handleAlarmReport receives alarm notifications from media-server.
// POST /v1/alarms/report
func (s *Server) handleAlarmReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Allow localhost requests without token (media-server alarm callback)
if !isLocalhost(r) && !s.authorize(r, true) {
errorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
maxBytes := int64(1 << 20) // 1MB max
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
var alarm map[string]any
if err := json.NewDecoder(r.Body).Decode(&alarm); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
// Normalize: extract channel and rule_name from whatever format media-server sends
id, _ := alarm["id"].(string)
if id == "" {
id = fmt.Sprintf("alarm_%s_%s", time.Now().Format("20060102_150405"), randomHex(6))
}
if _, ok := alarm["timestamp"]; !ok {
alarm["timestamp"] = time.Now().Format(time.RFC3339)
}
alarm["id"] = id
alarm["received_at"] = time.Now().Format(time.RFC3339)
path := s.alarmsPath()
dir := filepath.Dir(path)
if err := files.EnsureDir(dir, 0o755); err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
line, err := json.Marshal(alarm)
if err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%s\n", string(line)); err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
s.recordAudit(r, "alarm.report", true, id)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
}
// handleAlarmsRecent returns recent alarm records.
// GET /v1/alarms/recent?limit=50&since=2026-01-01T00:00:00Z
func (s *Server) handleAlarmsRecent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !s.authorize(r, false) {
errorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
limit := 50
if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
limit = n
}
}
alarms, err := s.readRecentAlarms(limit)
if err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
if alarms == nil {
alarms = make([]map[string]any, 0)
}
writeJSON(w, http.StatusOK, map[string]any{"alarms": alarms})
}
func (s *Server) readRecentAlarms(limit int) ([]map[string]any, error) {
path := s.alarmsPath()
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()
// Read all lines and keep last N
var lines []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1<<20), 1<<20) // 1MB max line
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
lines = append(lines, line)
// Keep only last 2*limit lines in memory
if len(lines) > 2*limit {
lines = lines[limit:]
}
}
if err := scanner.Err(); err != nil && err != io.EOF {
return nil, err
}
// Take last `limit` lines
start := 0
if len(lines) > limit {
start = len(lines) - limit
}
alarms := make([]map[string]any, 0, limit)
for i := start; i < len(lines); i++ {
var alarm map[string]any
if err := json.Unmarshal([]byte(lines[i]), &alarm); err != nil {
continue
}
alarms = append(alarms, alarm)
}
return alarms, nil
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)[:n]
}
func isLocalhost(r *http.Request) bool {
ip := remoteIP(r)
return ip == "127.0.0.1" || ip == "::1" || ip == "localhost"
}

View File

@ -132,6 +132,8 @@ func New(agentCfg config.AgentConfig, baseDir string, ms *mediaserver.Client, st
mux.HandleFunc("/v1/config/ui/apply", s.handleConfigUIApply)
mux.HandleFunc("/v1/face-gallery", s.handleFaceGallery)
mux.HandleFunc("/v1/face-gallery/reload", s.handleFaceGalleryReload)
mux.HandleFunc("/v1/alarms/report", s.handleAlarmReport)
mux.HandleFunc("/v1/alarms/recent", s.handleAlarmsRecent)
mux.HandleFunc("/v1/resources/status", s.handleResourcesStatus)
mux.HandleFunc("/v1/resources/", s.handleResourceUpload)
mux.HandleFunc("/v1/models", s.handleModelsList)

Binary file not shown.

View File

@ -0,0 +1,47 @@
{
"description": "降低告警阈值用于测试:缩短冷却时间、降低最小持续时间和命中数。",
"instance_overrides": {
"*": {
"override": {
"nodes": {
"alarm_violation": {
"rules": [
{
"name": "non_compliant_workshoe",
"class_ids": [2],
"min_score": 0.1,
"min_duration_ms": 100,
"min_hits": 1,
"hit_window_ms": 5000,
"cooldown_ms": 2000
}
],
"face_rules": [
{
"name": "unknown_face",
"type": "unknown",
"cooldown_ms": 2000,
"min_hits": 1,
"hit_window_ms": 5000,
"min_face_area_ratio": 0.0001,
"min_face_aspect": 0.3,
"max_face_aspect": 3.0
},
{
"name": "known_person",
"type": "person",
"cooldown_ms": 2000,
"min_sim": 0.3,
"min_hits": 1,
"hit_window_ms": 5000,
"min_face_area_ratio": 0.0001,
"min_face_aspect": 0.3,
"max_face_aspect": 3.0
}
]
}
}
}
}
}
}

View File

@ -407,6 +407,12 @@
}
],
"actions": {
"http": {
"enable": true,
"url": "http://127.0.0.1:9100/v1/alarms/report",
"timeout_ms": 2000,
"include_media_url": true
},
"log": {
"enable": true,
"level": "info",