From 7db707e596eef74dd77b31849e5dc3706b03baad Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 11 Aug 2026 14:38:22 +0800 Subject: [PATCH] feat(sense): add loopback NVR management console --- Sense/README.md | 20 +- Sense/cmd/sense-api/main.go | 13 + Sense/internal/config/config.go | 29 ++ Sense/internal/config/config_test.go | 36 ++ Sense/internal/console/assets/app.css | 227 +++++++++ Sense/internal/console/assets/app.js | 594 +++++++++++++++++++++++ Sense/internal/console/assets/index.html | 158 ++++++ Sense/internal/console/handler.go | 100 ++++ Sense/internal/console/handler_test.go | 95 ++++ docs/00-ai-start-here.md | 4 +- docs/03-tech-stack.md | 8 +- docs/04-architecture.md | 4 +- docs/current-state.md | 3 +- docs/tasks/T-018.md | 27 +- tests/test_sense_console_contract.py | 65 +++ 15 files changed, 1370 insertions(+), 13 deletions(-) create mode 100644 Sense/internal/console/assets/app.css create mode 100644 Sense/internal/console/assets/app.js create mode 100644 Sense/internal/console/assets/index.html create mode 100644 Sense/internal/console/handler.go create mode 100644 Sense/internal/console/handler_test.go create mode 100644 tests/test_sense_console_contract.py diff --git a/Sense/README.md b/Sense/README.md index b7f36c3..d1210b1 100644 --- a/Sense/README.md +++ b/Sense/README.md @@ -1,6 +1,6 @@ # Sense M1/M2 接入骨架 -本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。默认关闭真实 ONVIF、公共业务路由和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。 +本目录是 YoVision Sense 的接入与 NVR 管理面。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。T-018 增加默认关闭的回环工程控制台,用于展示设备、配额、收敛状态和最多 4 路按需 MediaMTX WebRTC 预览;它不包含录像/回放,也不是生产公网入口。默认关闭真实 ONVIF、公共业务路由、控制台和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。 ## 常用命令 @@ -14,7 +14,7 @@ go build -o bin/sense-api.exe ./cmd/sense-api go run ./cmd/sense-api ``` -Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由。 +Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由,`/sense-console/` 还需独立显式开启。 常用环境变量: @@ -42,6 +42,8 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080` | `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 | | `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url | | `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS | +| `SENSE_CONSOLE_ENABLED` | `false` | 显式开启 `/sense-console/`;T-018 只允许与回环 Control API 一起使用 | +| `SENSE_CONSOLE_WEBRTC_BASE_URL` | `http://127.0.0.1:8889` | MediaMTX WebRTC 浏览器入口基地址;必须为无 userinfo/path/query/fragment 的显式回环 HTTP(S) URL | | `SENSE_AUDIT_RELAY_ENABLED` | `false` | 显式开启 PostgreSQL Outbox → Bell relay;SQLite 不支持 | | `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS | | `SENSE_AUDIT_RELAY_KEY_FILE` | 空 | 仓库外绝对路径 version 1 JSON key 文件,secret 至少 32 字节 | @@ -122,6 +124,20 @@ go run ./cmd/sense-api 业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。 +### 开启 T-018 回环控制台 + +先按上一节完成 PostgreSQL、Control API、仓库外 auth/cursor 文件和 MediaMTX 启动,再增加: + +```powershell +$env:SENSE_CONSOLE_ENABLED = 'true' +$env:SENSE_CONSOLE_WEBRTC_BASE_URL = 'http://127.0.0.1:8889' +go run ./cmd/sense-api +``` + +浏览器打开 `http://127.0.0.1:8080/sense-console/`,输入已授权的 Site ID 和原始 Bearer token。token 只保留在当前页面 JavaScript 内存,输入框随即清空,刷新页面后必须重新输入;不得把 token 放进 URL、截图、命令历史或文档。设备列表默认每页 16 项,只有 `video_capture + enabled + online + converged` 的设备可选择,最多同时启动 4 路预览。 + +播放使用 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,Sense 不代理媒体字节,也不复制播放器源码。浏览器是否能解码取决于摄像头编码;首选已验收的低码率 H.264 子码流,H.265 或带 B-frame 的 H.264 不能因“Path 在线”就宣称浏览器可播放。本版 Sense 和播放端都必须显式回环;非回环 HTTPS、正式会话认证和 MediaMTX 外部鉴权需另立任务。控制台不会实现或暗示常态录像、录像计划和回放。 + Windows 本地准备 MediaMTX(从仓库根目录执行): ```powershell diff --git a/Sense/cmd/sense-api/main.go b/Sense/cmd/sense-api/main.go index 7366e88..7ef55a1 100644 --- a/Sense/cmd/sense-api/main.go +++ b/Sense/cmd/sense-api/main.go @@ -15,6 +15,7 @@ import ( "yovision/sense/internal/auditrelay" "yovision/sense/internal/auth" "yovision/sense/internal/config" + "yovision/sense/internal/console" "yovision/sense/internal/controlapi" "yovision/sense/internal/metrics" "yovision/sense/internal/mtx" @@ -160,6 +161,17 @@ func run(logger *slog.Logger) error { if cfg.ControlAPIEnabled { mux.Handle("/api/v1/", controlHandler) } + if cfg.ConsoleEnabled { + consoleHandler, err := console.NewHandler(cfg.ConsoleWebRTCBaseURL) + if err != nil { + return err + } + mux.Handle("/sense-console/", consoleHandler) + mux.HandleFunc("GET /sense-console", func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + http.Redirect(writer, request, "/sense-console/", http.StatusTemporaryRedirect) + }) + } server := &http.Server{ Addr: cfg.HTTPAddress, Handler: mux, @@ -173,6 +185,7 @@ func run(logger *slog.Logger) error { logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version, "instance_id", instanceID, "control_api_enabled", cfg.ControlAPIEnabled, + "console_enabled", cfg.ConsoleEnabled, "audit_relay_enabled", cfg.AuditRelayEnabled) serverErrors <- server.ListenAndServe() }() diff --git a/Sense/internal/config/config.go b/Sense/internal/config/config.go index 9c9ecc8..fc5879c 100644 --- a/Sense/internal/config/config.go +++ b/Sense/internal/config/config.go @@ -27,6 +27,7 @@ const ( defaultONVIFMode = "disabled" defaultControlAuthMode = "static-sha256" defaultAuditRelayPeriod = time.Second + defaultConsoleWebRTCURL = "http://127.0.0.1:8889" ) var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) @@ -54,6 +55,8 @@ type Config struct { ControlAuthFile string ControlCursorKeyFile string ControlAllowInsecureHTTP bool + ConsoleEnabled bool + ConsoleWebRTCBaseURL string AuditRelayEnabled bool AuditRelayURL string AuditRelayKeyFile string @@ -98,6 +101,10 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + consoleEnabled, err := boolEnv("SENSE_CONSOLE_ENABLED", false) + if err != nil { + return Config{}, err + } auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false) if err != nil { return Config{}, err @@ -144,6 +151,8 @@ func Load() (Config, error) { ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""), ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""), ControlAllowInsecureHTTP: controlAllowInsecure, + ConsoleEnabled: consoleEnabled, + ConsoleWebRTCBaseURL: stringEnv("SENSE_CONSOLE_WEBRTC_BASE_URL", defaultConsoleWebRTCURL), AuditRelayEnabled: auditRelayEnabled, AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""), AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""), @@ -247,6 +256,26 @@ func (c Config) Validate() error { return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true") } } + if c.ConsoleEnabled { + if !c.ControlAPIEnabled { + return fmt.Errorf("Sense console requires SENSE_CONTROL_API_ENABLED=true") + } + if !isLoopback { + return fmt.Errorf("Sense console requires an explicit loopback SENSE_HTTP_ADDR") + } + previewURL, err := url.Parse(c.ConsoleWebRTCBaseURL) + if err != nil || previewURL.Host == "" || + (previewURL.Scheme != "http" && previewURL.Scheme != "https") || + previewURL.User != nil || previewURL.RawQuery != "" || previewURL.Fragment != "" || + (previewURL.Path != "" && previewURL.Path != "/") { + return fmt.Errorf("invalid SENSE_CONSOLE_WEBRTC_BASE_URL") + } + previewHost := previewURL.Hostname() + previewIP := net.ParseIP(previewHost) + if previewHost != "localhost" && (previewIP == nil || !previewIP.IsLoopback()) { + return fmt.Errorf("SENSE_CONSOLE_WEBRTC_BASE_URL must use an explicit loopback host") + } + } if c.AuditRelayEnabled { if databaseDriver != postgresDatabaseDriver { return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres") diff --git a/Sense/internal/config/config_test.go b/Sense/internal/config/config_test.go index bcc5425..5cb3faf 100644 --- a/Sense/internal/config/config_test.go +++ b/Sense/internal/config/config_test.go @@ -167,6 +167,42 @@ func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T) } } +func TestValidateSenseConsoleSecurityBoundary(t *testing.T) { + base := Config{ + HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres", + DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable", + MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second, ProbeInterval: time.Second, + ControlAPIEnabled: true, ControlAuthMode: "static-sha256", + ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"), + ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"), + ConsoleEnabled: true, ConsoleWebRTCBaseURL: "http://127.0.0.1:8889", + } + if err := base.Validate(); err != nil { + t.Fatalf("valid loopback console rejected: %v", err) + } + withoutControl := base + withoutControl.ControlAPIEnabled = false + if err := withoutControl.Validate(); err == nil { + t.Fatal("console without Control API was accepted") + } + remoteBind := base + remoteBind.HTTPAddress, remoteBind.AllowNonLoopback = "0.0.0.0:8080", true + remoteBind.ControlAllowInsecureHTTP = true + if err := remoteBind.Validate(); err == nil { + t.Fatal("console on non-loopback Sense listener was accepted") + } + for _, invalidURL := range []string{ + "http://media.example:8889", "ftp://127.0.0.1:8889", "http://127.0.0.1:8889/path", + "http://127.0.0.1:8889?token=hidden", "http://user@127.0.0.1:8889", + } { + candidate := base + candidate.ConsoleWebRTCBaseURL = invalidURL + if err := candidate.Validate(); err == nil { + t.Fatalf("invalid console WebRTC URL was accepted: %s", invalidURL) + } + } +} + func TestValidateAuditRelaySecurityBoundary(t *testing.T) { base := Config{ HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres", diff --git a/Sense/internal/console/assets/app.css b/Sense/internal/console/assets/app.css new file mode 100644 index 0000000..6beca55 --- /dev/null +++ b/Sense/internal/console/assets/app.css @@ -0,0 +1,227 @@ +:root { + color-scheme: dark; + --bg: #06111f; + --sidebar: #071522; + --surface: #0d2033; + --surface-2: #112a40; + --surface-3: #16344d; + --border: #294762; + --text: #f5f9fc; + --muted: #a8bbcb; + --accent: #55dcc7; + --accent-strong: #2bbca9; + --accent-ink: #03231f; + --info: #6fc6ff; + --success: #71e19c; + --warning: #ffd16a; + --danger: #ff8a95; + --focus: #8bdcff; + --shadow: 0 18px 48px rgba(0, 0, 0, .26); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; + line-height: 1.5; +} + +* { box-sizing: border-box; } +html { background: var(--bg); } +body { margin: 0; min-width: 320px; min-height: 100dvh; background: radial-gradient(circle at 75% -10%, #103150 0, transparent 32rem), var(--bg); color: var(--text); } +button, input, select { font: inherit; } +button, a, input, select { touch-action: manipulation; } +button, a { -webkit-tap-highlight-color: transparent; } +button { color: inherit; } +a { color: var(--info); } +[hidden] { display: none !important; } +:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } + +.skip-link { position: fixed; left: 12px; top: -80px; z-index: 1000; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--bg); font-weight: 700; } +.skip-link:focus { top: 8px; } +.engineering-banner { min-height: 30px; display: grid; place-items: center; padding: 4px 16px; background: #f5c95b; color: #1e1705; font-size: 12px; font-weight: 800; letter-spacing: .04em; text-align: center; } +.shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: calc(100dvh - 30px); } + +.sidebar { position: sticky; top: 0; height: calc(100dvh - 30px); display: flex; flex-direction: column; padding: 28px 14px 18px; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--sidebar) 95%, transparent); } +.brand { display: flex; align-items: center; gap: 12px; padding: 8px 8px 28px; } +.brand strong, .brand small { display: block; } +.brand strong { font-size: 17px; } +.brand small { color: var(--muted); font-size: 12px; } +.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), #58a9ff); color: #06222a; font-weight: 900; box-shadow: 0 9px 24px rgba(85, 220, 199, .2); } +.primary-nav { display: grid; gap: 6px; } +.primary-nav button, .mobile-nav button { border: 0; cursor: pointer; } +.primary-nav button { min-height: 48px; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 8px; padding: 0 12px; border-radius: 12px; background: transparent; color: var(--muted); text-align: left; transition: background-color .18s ease, color .18s ease; } +.primary-nav button:hover { background: var(--surface); color: var(--text); } +.primary-nav button[aria-current="page"] { background: #123d3a; color: var(--text); box-shadow: inset 3px 0 var(--accent); } +.nav-count { min-width: 24px; padding: 2px 7px; border-radius: 999px; background: var(--surface-3); color: #d6e5ef; font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; } +.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 9px; padding: 13px; border: 1px solid var(--border); border-radius: 12px; color: var(--muted); font-size: 12px; } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #73879a; box-shadow: 0 0 0 4px rgba(115, 135, 154, .13); } +.sidebar-foot.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px rgba(113, 225, 156, .13); } + +.workspace { min-width: 0; } +.topbar { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 28px; border-bottom: 1px solid var(--border); background: rgba(8, 24, 39, .78); backdrop-filter: blur(18px); } +.topbar strong, .topbar .eyebrow { display: block; } +.topbar strong { margin-top: 2px; } +.topbar-actions, .heading-actions { display: flex; align-items: center; gap: 10px; } +.eyebrow { color: var(--info); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.connection-pill { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 13px; } +.connection-pill > .connection-dot { width: 7px; height: 7px; border-radius: 50%; background: #718497; } +.connection-pill.connected { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); } +.connection-pill.connected > .connection-dot { background: var(--success); } + +.degraded-banner { display: flex; align-items: center; gap: 12px; padding: 12px 28px; border-bottom: 1px solid rgba(255, 138, 149, .38); background: rgba(96, 26, 39, .82); color: #ffe7ea; } +.degraded-banner span { flex: 1; color: #ffc4ca; } +main { padding: 32px clamp(18px, 3vw, 42px) 80px; } +.view { max-width: 1440px; margin: 0 auto; } +.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; } +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 4px; font-size: clamp(26px, 3vw, 36px); line-height: 1.2; letter-spacing: -.025em; } +h2 { margin-bottom: 4px; font-size: 17px; } +.page-heading p, .panel-heading p { margin-bottom: 0; color: var(--muted); } + +.button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; text-decoration: none; font-weight: 700; font-size: 13px; transition: background-color .18s ease, border-color .18s ease, opacity .18s ease; } +.button.primary { background: var(--accent); color: var(--accent-ink); } +.button.primary:hover { background: #7ae7d5; } +.button.secondary { border-color: var(--border); background: var(--surface); color: var(--text); } +.button.secondary:hover, .button.ghost:hover { border-color: #48708f; background: var(--surface-2); } +.button.ghost { border-color: transparent; background: transparent; color: var(--info); } +.button:disabled { cursor: not-allowed; opacity: .42; } + +.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } +.metric-card, .panel { border: 1px solid var(--border); border-radius: 15px; background: linear-gradient(145deg, rgba(17, 42, 64, .82), rgba(10, 29, 47, .9)); box-shadow: var(--shadow); } +.metric-card { min-height: 140px; display: flex; flex-direction: column; justify-content: center; padding: 20px; } +.metric-card > span { color: var(--muted); font-size: 13px; } +.metric-card strong { margin: 7px 0 2px; font-size: 30px; font-variant-numeric: tabular-nums; } +.metric-card small { color: var(--muted); } +.metric-card.warning strong { color: var(--warning); } +.content-grid, .operations-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); gap: 18px; } +.panel { padding: 18px; } +.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; } +.health-list { display: grid; gap: 8px; } +.health-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 13px; border: 1px solid rgba(74, 111, 140, .56); border-radius: 11px; background: rgba(4, 17, 29, .35); } +.health-item strong, .health-item small { display: block; } +.health-item small { margin-top: 2px; color: var(--muted); } +.health-item .value { color: var(--warning); font-weight: 800; font-variant-numeric: tabular-nums; } +.empty-compact { min-height: 100px; display: grid; place-items: center; padding: 18px; border: 1px dashed var(--border); border-radius: 11px; color: var(--muted); text-align: center; } +.boundary-card { position: relative; overflow: hidden; } +.boundary-card::after { content: ""; position: absolute; right: -45px; top: -45px; width: 130px; height: 130px; border-radius: 50%; background: rgba(85, 220, 199, .09); } +.boundary-label, .feature-state { display: inline-flex; padding: 4px 8px; border-radius: 999px; background: rgba(85, 220, 199, .12); color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .05em; } +.boundary-card h2 { margin-top: 14px; } +.boundary-card p, .boundary-card li { color: var(--muted); } +.boundary-card ul { padding-left: 18px; margin-bottom: 0; } + +.monitor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; } +.monitor-toolbar strong, .monitor-toolbar span { display: block; } +.monitor-toolbar span { color: var(--muted); font-size: 13px; } +.preview-grid { min-height: 500px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.preview-empty { grid-column: 1 / -1; min-height: 500px; display: grid; align-content: center; justify-items: center; padding: 30px; border: 1px dashed var(--border); border-radius: 15px; background: rgba(7, 20, 33, .55); color: var(--muted); text-align: center; } +.preview-empty > span { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--accent); font-size: 24px; } +.preview-empty strong { color: var(--text); font-size: 18px; } +.preview-empty p { margin: 4px 0 0; } +.preview-card { overflow: hidden; border: 1px solid var(--border); border-radius: 15px; background: #02070b; box-shadow: var(--shadow); } +.preview-frame { position: relative; aspect-ratio: 16 / 9; background: #02070b; } +.preview-frame iframe { width: 100%; height: 100%; display: block; border: 0; } +.preview-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--surface); } +.preview-meta strong, .preview-meta small { display: block; } +.preview-meta small { color: var(--muted); } + +.filter-panel { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(140px, .7fr)) auto; align-items: end; gap: 12px; margin-bottom: 14px; } +label > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; font-weight: 700; } +input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: #081927; color: var(--text); } +input::placeholder { color: #72899c; } +select { cursor: pointer; } +.table-panel { padding: 0; overflow: hidden; } +.table-status { padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; } +.device-table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; min-width: 900px; } +th, td { padding: 13px 14px; border-bottom: 1px solid rgba(41, 71, 98, .75); text-align: left; vertical-align: middle; } +th { color: var(--muted); font-size: 11px; letter-spacing: .04em; } +td { font-size: 13px; } +td strong, td small { display: block; } +td small { margin-top: 2px; color: var(--muted); } +tbody tr:hover { background: rgba(34, 66, 92, .34); } +.select-preview { width: 44px; height: 44px; display: grid; place-items: center; border: 0; } +.select-preview input { width: 18px; min-height: auto; height: 18px; accent-color: var(--accent); } +.status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 800; white-space: nowrap; } +.status-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.status-badge.success { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); } +.status-badge.warning { color: var(--warning); border-color: rgba(255, 209, 106, .35); background: rgba(255, 209, 106, .08); } +.status-badge.danger { color: var(--danger); border-color: rgba(255, 138, 149, .35); background: rgba(255, 138, 149, .08); } +.device-cards { display: none; } +.pagination { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 14px; } +.pagination > span { color: var(--muted); font-size: 13px; } +.pagination > div { display: flex; gap: 8px; } + +.honest-empty { max-width: 820px; min-height: 340px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: clamp(28px, 5vw, 60px); } +.honest-empty h2 { margin: 18px 0 8px; font-size: 24px; } +.honest-empty > p { max-width: 70ch; color: var(--muted); } +.next-list { width: 100%; display: grid; gap: 8px; margin-top: 14px; } +.next-list span { padding: 12px; border: 1px solid var(--border); border-radius: 10px; color: var(--muted); } +.next-list strong { margin-right: 10px; color: var(--text); } + +dialog { width: min(520px, calc(100% - 28px)); padding: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 30px 90px rgba(0, 0, 0, .6); } +dialog::backdrop { background: rgba(0, 7, 13, .72); backdrop-filter: blur(4px); } +dialog form { padding: 22px; } +.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } +.dialog-heading h2 { margin-top: 5px; font-size: 22px; } +.icon-button { min-width: 44px; min-height: 44px; border: 1px solid var(--border); border-radius: 10px; background: transparent; cursor: pointer; font-size: 20px; } +dialog p { color: var(--muted); } +dialog label { display: block; margin-top: 14px; } +.form-error { margin-top: 12px; padding: 10px 12px; border: 1px solid rgba(255, 138, 149, .5); border-radius: 9px; background: rgba(120, 28, 43, .28); color: #ffdce0; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } +.toast { position: fixed; right: 20px; bottom: 24px; z-index: 200; max-width: min(420px, calc(100% - 40px)); padding: 13px 16px; border: 1px solid var(--border); border-radius: 11px; background: #173148; color: var(--text); box-shadow: var(--shadow); } +.mobile-nav { display: none; } + +@media (max-width: 1080px) { + .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .content-grid, .operations-grid { grid-template-columns: 1fr; } + .filter-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 760px) { + .engineering-banner { min-height: 38px; } + .shell { display: block; min-height: calc(100dvh - 38px); } + .sidebar { display: none; } + .topbar { min-height: 72px; padding: 12px 16px; } + .connection-pill { display: none; } + .topbar-actions .button { min-height: 44px; } + .degraded-banner { align-items: flex-start; flex-wrap: wrap; padding: 12px 16px; } + .degraded-banner span { flex-basis: calc(100% - 10px); } + main { padding: 24px 14px 92px; } + .page-heading { align-items: stretch; flex-direction: column; } + .page-heading .button, .heading-actions { width: 100%; } + .heading-actions .button { flex: 1; } + .metric-grid { grid-template-columns: 1fr 1fr; gap: 9px; } + .metric-card { min-height: 126px; padding: 15px; } + .metric-card strong { font-size: 25px; } + .panel { padding: 15px; } + .panel-heading, .monitor-toolbar { align-items: stretch; flex-direction: column; } + .preview-grid { grid-template-columns: 1fr; min-height: 420px; } + .preview-empty { min-height: 420px; } + .filter-panel { grid-template-columns: 1fr; } + input, select { min-height: 46px; font-size: 16px; } + .device-table-wrap { display: none; } + .device-cards { display: grid; gap: 10px; padding: 12px; } + .device-card { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: rgba(5, 18, 30, .45); } + .device-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } + .device-card strong, .device-card small { display: block; } + .device-card small { color: var(--muted); } + .device-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0 0; } + .device-card dt { color: var(--muted); font-size: 11px; } + .device-card dd { margin: 2px 0 0; font-size: 13px; } + .pagination { align-items: stretch; flex-direction: column; } + .pagination > div { display: grid; grid-template-columns: 1fr 1fr; } + .pagination .button { min-height: 44px; } + .mobile-nav { position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 100; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; border: 1px solid var(--border); border-radius: 15px; background: rgba(8, 24, 39, .96); box-shadow: 0 15px 40px rgba(0, 0, 0, .5); backdrop-filter: blur(18px); } + .mobile-nav button { min-height: 48px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 12px; } + .mobile-nav button[aria-current="page"] { background: #153c3a; color: var(--accent); font-weight: 800; } + dialog form { padding: 18px; } + .dialog-actions { display: grid; grid-template-columns: 1fr 1fr; } + .dialog-actions .button { min-height: 46px; } + .toast { left: 14px; right: 14px; bottom: 78px; max-width: none; } +} + +@media (max-width: 420px) { + .metric-grid { grid-template-columns: 1fr; } + .topbar { gap: 8px; } + .topbar strong { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/Sense/internal/console/assets/app.js b/Sense/internal/console/assets/app.js new file mode 100644 index 0000000..84cccfd --- /dev/null +++ b/Sense/internal/console/assets/app.js @@ -0,0 +1,594 @@ +"use strict"; + +const state = { + token: "", + siteId: "", + config: { page_size: 16, max_active_previews: 4, webrtc_base_url: "" }, + items: [], + quota: null, + cursors: [null], + pageIndex: 0, + nextCursor: null, + selected: new Map(), + previewing: new Set(), + loading: false, +}; + +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => Array.from(document.querySelectorAll(selector)); + +const labels = { + modality: { video: "视频", radar: "雷达", contact: "门磁", button: "按钮", wearable: "可穿戴", other: "其他" }, + desired: { enabled: "启用", disabled: "停用" }, + actual: { online: "在线", offline: "离线", failed: "失败", pending: "收敛中" }, + adapter: { ready: "适配器就绪", pending: "适配器处理中", adapter_not_ready: "适配器未交付", authentication_failed: "认证失败", unavailable: "适配器不可用" }, + capability: { video_capture: "成像", audio_capture: "音频", spatial_rule: "空间配置", telemetry: "遥测" }, +}; + +function showToast(message) { + const toast = $("#toast"); + toast.textContent = message; + toast.hidden = false; + window.clearTimeout(showToast.timer); + showToast.timer = window.setTimeout(() => { toast.hidden = true; }, 4200); +} + +function setText(selector, value) { + const target = $(selector); + if (target) target.textContent = String(value); +} + +function formatTime(value) { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", + hour12: false, + }).format(date); +} + +function isPreviewEligible(device) { + return Array.isArray(device.capabilities) && device.capabilities.includes("video_capture") && + device.desired_state === "enabled" && device.actual_state === "online" && device.converged === true; +} + +function attentionNeeded(device) { + return device.converged !== true || device.actual_state === "offline" || device.actual_state === "failed" || + device.adapter_status === "authentication_failed" || device.adapter_status === "unavailable"; +} + +function statusFor(device) { + if (device.actual_state === "online" && device.converged) return { text: "在线 · 已收敛", tone: "success" }; + if (device.actual_state === "failed" || device.adapter_status === "authentication_failed") return { text: labels.adapter[device.adapter_status] || "失败", tone: "danger" }; + if (device.actual_state === "offline") return { text: "离线", tone: "danger" }; + return { text: device.converged ? (labels.actual[device.actual_state] || "未知") : "等待收敛", tone: "warning" }; +} + +function createStatusBadge(device) { + const value = statusFor(device); + const badge = document.createElement("span"); + badge.className = `status-badge ${value.tone}`; + badge.textContent = value.text; + return badge; +} + +function setConnected(connected) { + const pill = $("#connectionPill"); + const foot = $(".sidebar-foot"); + pill.classList.toggle("connected", connected); + foot.classList.toggle("connected", connected); + setText("#connectionText", connected ? "已连接" : "未连接"); + setText("#sessionState", connected ? "会话仅驻留当前页面" : "尚未建立会话"); +} + +function setLoading(loading) { + state.loading = loading; + $("#refreshDevices").disabled = loading || !state.token; + $("#refreshOperations").disabled = loading || !state.token; + $("#retryLoad").disabled = loading || !state.token; + $("#prevPage").disabled = loading || state.pageIndex === 0; + $("#nextPage").disabled = loading || !state.nextCursor; + setText("#deviceTableStatus", loading ? "正在读取最新状态…" : state.token ? "已读取当前页真实设备状态。" : "请先建立会话。"); +} + +function showDegraded(title, message) { + setText("#degradedTitle", title); + setText("#degradedMessage", message); + $("#degradedBanner").hidden = false; +} + +function clearDegraded() { + $("#degradedBanner").hidden = true; +} + +async function apiFetch(path) { + const response = await fetch(path, { + headers: { Authorization: `Bearer ${state.token}`, Accept: "application/json" }, + cache: "no-store", + }); + let payload = null; + try { payload = await response.json(); } catch (_) { payload = null; } + if (!response.ok) { + const error = new Error(payload && payload.code ? payload.code : `http_${response.status}`); + error.status = response.status; + error.payload = payload; + throw error; + } + return payload; +} + +function buildDevicePath(cursor) { + const params = new URLSearchParams(); + params.set("limit", String(state.config.page_size)); + const modality = $("#modalityFilter").value; + const desired = $("#desiredFilter").value; + const actual = $("#actualFilter").value; + if (modality) params.set("modality", modality); + if (desired) params.set("desired_state", desired); + if (actual) params.set("actual_state", actual); + if (cursor) params.set("cursor", cursor); + return `/api/v1/sites/${encodeURIComponent(state.siteId)}/devices?${params.toString()}`; +} + +async function loadDevices(options = {}) { + if (!state.token || state.loading) return; + const cursor = state.cursors[state.pageIndex] || null; + setLoading(true); + clearDegraded(); + try { + const payload = await apiFetch(buildDevicePath(cursor)); + state.items = Array.isArray(payload.items) ? payload.items : []; + state.quota = payload.quota || null; + state.nextCursor = payload.page && payload.page.has_more ? payload.page.next_cursor : null; + for (const device of state.items) { + if (state.selected.has(device.id)) { + if (isPreviewEligible(device)) state.selected.set(device.id, device); + else state.selected.delete(device.id); + } + } + setConnected(true); + renderAll(); + if (options.announce !== false) showToast(`已刷新第 ${state.pageIndex + 1} 页,共 ${state.items.length} 台设备`); + return true; + } catch (error) { + handleLoadError(error); + throw error; + } finally { + setLoading(false); + } +} + +function handleLoadError(error) { + let title = "无法读取最新状态"; + let message = "未知不会显示为在线。请检查 Sense 进程、网络和依赖后重试。"; + if (error.status === 401) { + state.token = ""; + setConnected(false); + title = "会话无效或已过期"; + message = "Bearer token 已从页面内存清除,请重新建立会话。"; + } else if (error.status === 403) { + title = "当前角色没有设备读取权限"; + message = "页面不会尝试绕过权限;请使用具备 devices:read 的会话。"; + } else if (error.status === 404) { + title = "无法访问该站点"; + message = "目标不存在或当前会话无权访问,页面不会区分这两种情况。"; + } else if (error.payload && ["quota_projection_unavailable", "area_policy_unavailable"].includes(error.payload.code)) { + title = "策略投影暂时不可用"; + message = "已有链路不会因此被静默停用;相关新写入应保持禁用。"; + } + showDegraded(title, message); + state.items = []; + state.quota = null; + renderAll(); +} + +function visibleItems() { + const query = $("#deviceSearch").value.trim().toLocaleLowerCase("zh-CN"); + if (!query) return state.items; + return state.items.filter((device) => `${device.name} ${device.serial_number}`.toLocaleLowerCase("zh-CN").includes(query)); +} + +function renderAll() { + renderDevices(); + renderMetrics(); + renderHealth(); + renderOperations(); + renderSelection(); +} + +function renderMetrics() { + const used = state.quota && Number.isInteger(state.quota.used_video_channels) ? state.quota.used_video_channels : null; + const max = state.quota && Number.isInteger(state.quota.max_video_channels) ? state.quota.max_video_channels : null; + setText("#quotaMetric", used === null || max === null ? "—" : `${used} / ${max}`); + setText("#quotaHint", state.quota ? `投影状态:${state.quota.status || "未知"}` : "连接后读取 Bell 投影"); + setText("#pageMetric", state.items.length); + setText("#convergedMetric", state.items.filter((device) => device.converged === true).length); + const attention = state.items.filter(attentionNeeded).length; + setText("#attentionMetric", attention); + setText("#deviceCount", state.items.length); + setText("#issueCount", attention); +} + +function addTextCell(row, primary, secondary) { + const cell = document.createElement("td"); + const strong = document.createElement("strong"); + strong.textContent = primary || "—"; + cell.appendChild(strong); + if (secondary) { + const small = document.createElement("small"); + small.textContent = secondary; + cell.appendChild(small); + } + row.appendChild(cell); +} + +function createPreviewCheckbox(device) { + const label = document.createElement("label"); + label.className = "select-preview"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = state.selected.has(device.id); + checkbox.disabled = !isPreviewEligible(device); + checkbox.setAttribute("aria-label", checkbox.disabled ? `${device.name} 当前不可预览` : `选择 ${device.name} 进行预览`); + checkbox.addEventListener("change", () => toggleSelection(device, checkbox)); + label.appendChild(checkbox); + return label; +} + +function renderDevices() { + const rows = $("#deviceRows"); + const cards = $("#deviceCards"); + rows.replaceChildren(); + cards.replaceChildren(); + const items = visibleItems(); + for (const device of items) { + const row = document.createElement("tr"); + const selectCell = document.createElement("td"); + selectCell.appendChild(createPreviewCheckbox(device)); + row.appendChild(selectCell); + addTextCell(row, device.name, device.serial_number); + addTextCell(row, labels.modality[device.modality] || device.modality, (device.capabilities || []).map((value) => labels.capability[value] || value).join(" / ")); + const statusCell = document.createElement("td"); + statusCell.appendChild(createStatusBadge(device)); + row.appendChild(statusCell); + addTextCell(row, labels.desired[device.desired_state] || device.desired_state, labels.actual[device.actual_state] || device.actual_state); + addTextCell(row, device.area_id || "—", device.projection_versions && device.projection_versions.area_policy_source_version ? `策略 v${device.projection_versions.area_policy_source_version}` : "策略版本未知"); + addTextCell(row, formatTime(device.updated_at), device.next_attempt_at ? `重试 ${formatTime(device.next_attempt_at)}` : ""); + rows.appendChild(row); + + const card = document.createElement("article"); + card.className = "device-card"; + const cardHead = document.createElement("div"); + cardHead.className = "device-card-head"; + const identity = document.createElement("div"); + const name = document.createElement("strong"); + const serial = document.createElement("small"); + name.textContent = device.name; + serial.textContent = device.serial_number; + identity.append(name, serial); + cardHead.append(identity, createPreviewCheckbox(device)); + const status = createStatusBadge(device); + const details = document.createElement("dl"); + const pairs = [ + ["状态", "", status], + ["模态", labels.modality[device.modality] || device.modality], + ["期望 / 实际", `${labels.desired[device.desired_state] || device.desired_state} / ${labels.actual[device.actual_state] || device.actual_state}`], + ["Area", device.area_id || "—"], + ]; + for (const [term, value, node] of pairs) { + const wrapper = document.createElement("div"); + const dt = document.createElement("dt"); + const dd = document.createElement("dd"); + dt.textContent = term; + if (node) dd.appendChild(node); else dd.textContent = value; + wrapper.append(dt, dd); + details.appendChild(wrapper); + } + card.append(cardHead, details); + cards.appendChild(card); + } + if (items.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty-compact"; + empty.textContent = state.token ? "当前页没有符合条件的设备。" : "请先建立会话。"; + cards.appendChild(empty.cloneNode(true)); + const cell = document.createElement("td"); + cell.colSpan = 7; + cell.appendChild(empty); + const row = document.createElement("tr"); + row.appendChild(cell); + rows.appendChild(row); + } + setText("#pageLabel", `第 ${state.pageIndex + 1} 页 · 每页 ${state.config.page_size} · 当前显示 ${items.length} 项`); + $("#prevPage").disabled = state.loading || state.pageIndex === 0; + $("#nextPage").disabled = state.loading || !state.nextCursor; +} + +function toggleSelection(device, checkbox) { + if (checkbox.checked) { + if (!isPreviewEligible(device)) { + checkbox.checked = false; + showToast("只有在线、已启用且已收敛的视频设备可以预览"); + return; + } + if (!state.selected.has(device.id) && state.selected.size >= state.config.max_active_previews) { + checkbox.checked = false; + showToast(`最多同时选择 ${state.config.max_active_previews} 路预览`); + return; + } + state.selected.set(device.id, device); + } else { + state.selected.delete(device.id); + if (state.previewing.has(device.id)) stopPreview(device.id); + } + renderSelection(); + renderDevices(); +} + +function renderSelection() { + setText("#selectionCount", state.selected.size); + setText("#previewCount", `${state.previewing.size}/${state.config.max_active_previews}`); + $("#startPreview").disabled = state.selected.size === 0; + $("#stopAll").disabled = state.previewing.size === 0; +} + +function previewURL(deviceID) { + const url = new URL(`${state.config.webrtc_base_url}/devices/${encodeURIComponent(deviceID)}`); + url.searchParams.set("controls", "true"); + url.searchParams.set("muted", "true"); + url.searchParams.set("autoplay", "true"); + url.searchParams.set("playsInline", "true"); + return url.toString(); +} + +function startSelectedPreviews() { + if (state.selected.size === 0) return; + state.previewing = new Set(state.selected.keys()); + const grid = $("#previewGrid"); + grid.replaceChildren(); + for (const device of state.selected.values()) { + const card = document.createElement("article"); + card.className = "preview-card"; + card.dataset.deviceId = device.id; + const frame = document.createElement("div"); + frame.className = "preview-frame"; + const iframe = document.createElement("iframe"); + iframe.title = `${device.name} 实时预览`; + iframe.loading = "eager"; + iframe.allow = "autoplay; fullscreen"; + iframe.sandbox = "allow-scripts allow-same-origin"; + iframe.referrerPolicy = "no-referrer"; + iframe.src = previewURL(device.id); + frame.appendChild(iframe); + const meta = document.createElement("div"); + meta.className = "preview-meta"; + const identity = document.createElement("div"); + const name = document.createElement("strong"); + const note = document.createElement("small"); + name.textContent = device.name; + note.textContent = "MediaMTX WebRTC · 按需读取"; + identity.append(name, note); + const stop = document.createElement("button"); + stop.type = "button"; + stop.className = "button secondary"; + stop.textContent = "停止"; + stop.addEventListener("click", () => stopPreview(device.id)); + meta.append(identity, stop); + card.append(frame, meta); + grid.appendChild(card); + } + switchView("monitor"); + renderSelection(); + showToast(`已按需启动 ${state.previewing.size} 路预览`); +} + +function stopPreview(deviceID) { + state.previewing.delete(deviceID); + const card = $(`[data-device-id="${CSS.escape(deviceID)}"]`); + if (card) { + const iframe = card.querySelector("iframe"); + if (iframe) iframe.removeAttribute("src"); + card.remove(); + } + if (state.previewing.size === 0) renderPreviewEmpty(); + renderSelection(); +} + +function stopAllPreviews() { + for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src"); + state.previewing.clear(); + renderPreviewEmpty(); + renderSelection(); + showToast("全部预览已停止"); +} + +function renderPreviewEmpty() { + const grid = $("#previewGrid"); + grid.replaceChildren(); + const empty = document.createElement("div"); + empty.className = "preview-empty"; + const mark = document.createElement("span"); + mark.setAttribute("aria-hidden", "true"); + mark.textContent = "▷"; + const title = document.createElement("strong"); + title.textContent = "尚未启动预览"; + const note = document.createElement("p"); + note.textContent = "前往设备页选择最多 4 路运行中的视频设备。"; + empty.append(mark, title, note); + grid.appendChild(empty); +} + +function renderHealth() { + const container = $("#overviewHealth"); + container.replaceChildren(); + if (!state.token || state.items.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty-compact"; + empty.textContent = state.token ? "当前页没有设备事实。" : "建立会话后显示真实状态。"; + container.appendChild(empty); + return; + } + const groups = [ + ["在线且已收敛", state.items.filter((device) => device.actual_state === "online" && device.converged).length, "设备可用于按需预览"], + ["等待收敛", state.items.filter((device) => !device.converged).length, "期望 generation 尚未被完整观察"], + ["离线或失败", state.items.filter((device) => ["offline", "failed"].includes(device.actual_state)).length, "查看重试时间和稳定错误码"], + ]; + for (const [title, count, note] of groups) container.appendChild(createHealthItem(title, count, note)); +} + +function createHealthItem(title, count, note) { + const item = document.createElement("div"); + item.className = "health-item"; + const text = document.createElement("div"); + const strong = document.createElement("strong"); + const small = document.createElement("small"); + strong.textContent = title; + small.textContent = note; + text.append(strong, small); + const value = document.createElement("span"); + value.className = "value"; + value.textContent = String(count); + item.append(text, value); + return item; +} + +function renderOperations() { + const container = $("#operationIssues"); + container.replaceChildren(); + const issues = state.items.filter(attentionNeeded); + if (issues.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty-compact"; + empty.textContent = state.token ? "当前页没有已知对账差异;这不代表未加载设备或其他基础设施正常。" : "建立会话后显示。"; + container.appendChild(empty); + return; + } + for (const device of issues) { + const message = device.last_error_code ? `错误 ${device.last_error_code}` : `generation ${device.observed_generation || 0} / ${device.generation || 0}`; + container.appendChild(createHealthItem(device.name, device.failure_count || 0, device.next_attempt_at ? `${message} · 下次 ${formatTime(device.next_attempt_at)}` : message)); + } +} + +function switchView(name) { + $$(".view").forEach((view) => { + const active = view.dataset.page === name; + view.hidden = !active; + view.classList.toggle("active", active); + }); + $$('[data-view]').forEach((button) => { + const active = button.dataset.view === name; + button.toggleAttribute("aria-current", active); + }); + $("#main-content").focus({ preventScroll: true }); + window.scrollTo({ top: 0, behavior: "auto" }); +} + +function resetPaging() { + state.cursors = [null]; + state.pageIndex = 0; + state.nextCursor = null; +} + +async function connect(event) { + event.preventDefault(); + const siteInput = $("#siteInput"); + const tokenInput = $("#tokenInput"); + const site = siteInput.value.trim(); + const token = tokenInput.value; + const errorBox = $("#contextError"); + errorBox.hidden = true; + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(site)) { + errorBox.textContent = "Site ID 格式无效:只能使用 1~64 位字母、数字、点、下划线、冒号或连字符。"; + errorBox.hidden = false; + siteInput.focus(); + return; + } + if (!token || token.length > 4096) { + errorBox.textContent = "请输入有效的 Bearer token。"; + errorBox.hidden = false; + tokenInput.focus(); + return; + } + state.siteId = site; + state.token = token; + tokenInput.value = ""; + setText("#siteContext", site); + resetPaging(); + try { + await loadDevices({ announce: false }); + $("#contextDialog").close(); + showToast("会话已建立;token 仅驻留当前页面内存"); + } catch (error) { + errorBox.textContent = error.status === 401 ? "认证失败,请检查 token 后重试。" : "无法读取该站点,请按页面提示检查权限或服务状态。"; + errorBox.hidden = false; + $("#contextDialog").showModal(); + } +} + +async function initialize() { + try { + const response = await fetch("/sense-console/config", { cache: "no-store" }); + if (!response.ok) throw new Error("config unavailable"); + state.config = await response.json(); + if (state.config.page_size !== 16 || state.config.max_active_previews !== 4 || state.config.recording_and_playback !== false) { + throw new Error("unsafe console bounds"); + } + } catch (_) { + showDegraded("控制台配置不可用", "页面保持只读且不会尝试连接视频,请检查 Sense 启动配置。"); + $$("button, input, select").forEach((control) => { control.disabled = true; }); + return; + } + setConnected(false); + renderAll(); + $("#contextDialog").showModal(); +} + +$$('[data-view]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view))); +$$('[data-view-target]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.viewTarget))); +$("#changeContext").addEventListener("click", () => $("#contextDialog").showModal()); +$("#closeContext").addEventListener("click", () => $("#contextDialog").close()); +$("#cancelContext").addEventListener("click", () => $("#contextDialog").close()); +$("#contextForm").addEventListener("submit", connect); +$("#refreshDevices").addEventListener("click", () => loadDevices()); +$("#refreshOperations").addEventListener("click", () => loadDevices()); +$("#retryLoad").addEventListener("click", () => state.token ? loadDevices() : $("#contextDialog").showModal()); +$("#startPreview").addEventListener("click", startSelectedPreviews); +$("#stopAll").addEventListener("click", stopAllPreviews); +$("#deviceSearch").addEventListener("input", renderDevices); + +for (const selector of ["#modalityFilter", "#desiredFilter", "#actualFilter"]) { + $(selector).addEventListener("change", () => { + resetPaging(); + if (state.token) loadDevices(); + }); +} + +$("#clearFilters").addEventListener("click", () => { + $("#deviceSearch").value = ""; + $("#modalityFilter").value = ""; + $("#desiredFilter").value = ""; + $("#actualFilter").value = ""; + resetPaging(); + if (state.token) loadDevices(); else renderDevices(); +}); + +$("#nextPage").addEventListener("click", async () => { + if (!state.nextCursor) return; + state.cursors[state.pageIndex + 1] = state.nextCursor; + state.pageIndex += 1; + try { await loadDevices(); } catch (_) { state.pageIndex -= 1; } +}); + +$("#prevPage").addEventListener("click", async () => { + if (state.pageIndex === 0) return; + state.pageIndex -= 1; + try { await loadDevices(); } catch (_) { state.pageIndex += 1; } +}); + +window.addEventListener("beforeunload", () => { + state.token = ""; + for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src"); +}); + +initialize(); diff --git a/Sense/internal/console/assets/index.html b/Sense/internal/console/assets/index.html new file mode 100644 index 0000000..656cb4c --- /dev/null +++ b/Sense/internal/console/assets/index.html @@ -0,0 +1,158 @@ + + + + + + + YoVision Sense · 接入运维工作台 + + + + + +
+ 回环工程控制台 · 非录像/回放 · 不代表生产公网入口 +
+
+ + +
+
+
+ 当前站点 + 未选择 +
+
+ 未连接 + +
+
+ + + +
+
+
+
SENSE / OVERVIEW

运行总览

先看已加载事实,再进入设备或运维中心处理。

+ +
+
+
视频配额—连接后读取 Bell 投影
+
当前页设备0默认每页 16,不代表站点总数
+
当前页已收敛0期望态与实际态分开计算
+
当前页需关注0失败、离线或尚未收敛
+
+
+
+

接入健康

只汇总当前设备页,不推断未加载设备。

+
建立会话后显示真实状态。
+
+
+ 本纵切边界 +

实时监看,不替代客户 NVR

+

常态录像仍留在客户现有 NVR;当前仅验证 Sense 设备管理面和 MediaMTX 按需播放。录像计划、录像索引与回放未实现。

+
  • 同时最多 4 路预览
  • 刷新后 Bearer token 自动丢失
  • Sense 与播放端均限回环地址
+
+
+
+ + + + + + + + +
+
+
+ + + + +
+
LOOPBACK SESSION

连接 Sense Control API

+

Site ID 只用于当前页面上下文;Bearer token 仅保存在内存,刷新页面后自动丢失。

+ + + +
+
+
+ + + + diff --git a/Sense/internal/console/handler.go b/Sense/internal/console/handler.go new file mode 100644 index 0000000..3957afb --- /dev/null +++ b/Sense/internal/console/handler.go @@ -0,0 +1,100 @@ +// Package console serves the loopback-only Sense engineering console. +package console + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +//go:embed assets/index.html assets/app.css assets/app.js +var assets embed.FS + +type handler struct { + previewBaseURL string + previewOrigin string +} + +// NewHandler builds the self-contained console handler. Config validation also +// enforces this boundary; validating here keeps the package safe in isolation. +func NewHandler(previewBaseURL string) (http.Handler, error) { + parsed, err := url.Parse(previewBaseURL) + if err != nil || parsed.Host == "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Path != "" && parsed.Path != "/") { + return nil, errors.New("invalid Sense console WebRTC base URL") + } + host := parsed.Hostname() + ip := net.ParseIP(host) + if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return nil, errors.New("Sense console WebRTC base URL is not loopback") + } + base := strings.TrimRight(parsed.String(), "/") + return &handler{ + previewBaseURL: base, + previewOrigin: parsed.Scheme + "://" + parsed.Host, + }, nil +} + +func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.securityHeaders(writer) + if request.Method != http.MethodGet && request.Method != http.MethodHead { + writer.Header().Set("Allow", "GET, HEAD") + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + switch request.URL.Path { + case "/sense-console/": + h.serveAsset(writer, request, "assets/index.html", "text/html; charset=utf-8") + case "/sense-console/app.css": + h.serveAsset(writer, request, "assets/app.css", "text/css; charset=utf-8") + case "/sense-console/app.js": + h.serveAsset(writer, request, "assets/app.js", "text/javascript; charset=utf-8") + case "/sense-console/config": + writer.Header().Set("Content-Type", "application/json") + if request.Method == http.MethodHead { + writer.WriteHeader(http.StatusOK) + return + } + _ = json.NewEncoder(writer).Encode(map[string]any{ + "webrtc_base_url": h.previewBaseURL, + "page_size": 16, + "max_active_previews": 4, + "recording_and_playback": false, + }) + default: + http.NotFound(writer, request) + } +} + +func (h *handler) securityHeaders(writer http.ResponseWriter) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Security-Policy", fmt.Sprintf( + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src %s; img-src 'self' data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + h.previewOrigin, + )) + writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Frame-Options", "DENY") +} + +func (h *handler) serveAsset(writer http.ResponseWriter, request *http.Request, path, contentType string) { + contents, err := assets.ReadFile(path) + if err != nil { + http.Error(writer, "asset unavailable", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", contentType) + writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(contents))) + writer.WriteHeader(http.StatusOK) + if request.Method == http.MethodGet { + _, _ = writer.Write(contents) + } +} diff --git a/Sense/internal/console/handler_test.go b/Sense/internal/console/handler_test.go new file mode 100644 index 0000000..cf771ba --- /dev/null +++ b/Sense/internal/console/handler_test.go @@ -0,0 +1,95 @@ +package console + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandlerServesSelfContainedConsoleWithSecurityHeaders(t *testing.T) { + handler, err := NewHandler("http://127.0.0.1:8889/") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/sense-console/", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", response.Code) + } + body := response.Body.String() + for _, required := range []string{"YoVision Sense", "/sense-console/app.css", "/sense-console/app.js"} { + if !strings.Contains(body, required) { + t.Fatalf("console HTML lacks %q", required) + } + } + for _, forbidden := range []string{"http://", "https://", "