Author SHA1 Message Date
QiuSW 7c202b7ac8 docs(T-018): record product acceptance 2026-08-11 14:42:25 +08:00
QiuSW 7db707e596 feat(sense): add loopback NVR management console
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:38:22 +08:00
15 changed files with 1375 additions and 13 deletions
+18 -2
View File
@@ -1,6 +1,6 @@
# Sense M1/M2 接入骨架 # 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 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_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url | | `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS | | `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_ENABLED` | `false` | 显式开启 PostgreSQL Outbox → Bell relay;SQLite 不支持 |
| `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS | | `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS |
| `SENSE_AUDIT_RELAY_KEY_FILE` | 空 | 仓库外绝对路径 version 1 JSON key 文件,secret 至少 32 字节 | | `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` 冒充完成。 业务响应使用 `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(从仓库根目录执行): Windows 本地准备 MediaMTX(从仓库根目录执行):
```powershell ```powershell
+13
View File
@@ -15,6 +15,7 @@ import (
"yovision/sense/internal/auditrelay" "yovision/sense/internal/auditrelay"
"yovision/sense/internal/auth" "yovision/sense/internal/auth"
"yovision/sense/internal/config" "yovision/sense/internal/config"
"yovision/sense/internal/console"
"yovision/sense/internal/controlapi" "yovision/sense/internal/controlapi"
"yovision/sense/internal/metrics" "yovision/sense/internal/metrics"
"yovision/sense/internal/mtx" "yovision/sense/internal/mtx"
@@ -160,6 +161,17 @@ func run(logger *slog.Logger) error {
if cfg.ControlAPIEnabled { if cfg.ControlAPIEnabled {
mux.Handle("/api/v1/", controlHandler) 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{ server := &http.Server{
Addr: cfg.HTTPAddress, Handler: mux, Addr: cfg.HTTPAddress, Handler: mux,
@@ -173,6 +185,7 @@ func run(logger *slog.Logger) error {
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version, logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
"instance_id", instanceID, "instance_id", instanceID,
"control_api_enabled", cfg.ControlAPIEnabled, "control_api_enabled", cfg.ControlAPIEnabled,
"console_enabled", cfg.ConsoleEnabled,
"audit_relay_enabled", cfg.AuditRelayEnabled) "audit_relay_enabled", cfg.AuditRelayEnabled)
serverErrors <- server.ListenAndServe() serverErrors <- server.ListenAndServe()
}() }()
+29
View File
@@ -27,6 +27,7 @@ const (
defaultONVIFMode = "disabled" defaultONVIFMode = "disabled"
defaultControlAuthMode = "static-sha256" defaultControlAuthMode = "static-sha256"
defaultAuditRelayPeriod = time.Second defaultAuditRelayPeriod = time.Second
defaultConsoleWebRTCURL = "http://127.0.0.1:8889"
) )
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
@@ -54,6 +55,8 @@ type Config struct {
ControlAuthFile string ControlAuthFile string
ControlCursorKeyFile string ControlCursorKeyFile string
ControlAllowInsecureHTTP bool ControlAllowInsecureHTTP bool
ConsoleEnabled bool
ConsoleWebRTCBaseURL string
AuditRelayEnabled bool AuditRelayEnabled bool
AuditRelayURL string AuditRelayURL string
AuditRelayKeyFile string AuditRelayKeyFile string
@@ -98,6 +101,10 @@ func Load() (Config, error) {
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
consoleEnabled, err := boolEnv("SENSE_CONSOLE_ENABLED", false)
if err != nil {
return Config{}, err
}
auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false) auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
@@ -144,6 +151,8 @@ func Load() (Config, error) {
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""), ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""), ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
ControlAllowInsecureHTTP: controlAllowInsecure, ControlAllowInsecureHTTP: controlAllowInsecure,
ConsoleEnabled: consoleEnabled,
ConsoleWebRTCBaseURL: stringEnv("SENSE_CONSOLE_WEBRTC_BASE_URL", defaultConsoleWebRTCURL),
AuditRelayEnabled: auditRelayEnabled, AuditRelayEnabled: auditRelayEnabled,
AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""), AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""),
AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""), 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") 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 c.AuditRelayEnabled {
if databaseDriver != postgresDatabaseDriver { if databaseDriver != postgresDatabaseDriver {
return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres") return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres")
+36
View File
@@ -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) { func TestValidateAuditRelaySecurityBoundary(t *testing.T) {
base := Config{ base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres", HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
+227
View File
@@ -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; }
}
+594
View File
@@ -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();
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>YoVision Sense · 接入运维工作台</title>
<link rel="stylesheet" href="/sense-console/app.css">
<script defer src="/sense-console/app.js"></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="engineering-banner" role="note">
回环工程控制台 · 非录像/回放 · 不代表生产公网入口
</div>
<div class="shell">
<aside class="sidebar" aria-label="Sense 主导航">
<div class="brand">
<span class="brand-mark" aria-hidden="true">S</span>
<span><strong>YoVision Sense</strong><small>接入运维工作台</small></span>
</div>
<nav class="primary-nav">
<button type="button" data-view="overview" aria-current="page"><span aria-hidden="true">◫</span>运行总览</button>
<button type="button" data-view="monitor"><span aria-hidden="true">▷</span>实时监控 <span class="nav-count" id="previewCount">0/4</span></button>
<button type="button" data-view="devices"><span aria-hidden="true">▣</span>设备 <span class="nav-count" id="deviceCount">0</span></button>
<button type="button" data-view="onboarding"><span aria-hidden="true">⇧</span>接入任务</button>
<button type="button" data-view="operations"><span aria-hidden="true">⌁</span>运维中心 <span class="nav-count" id="issueCount">0</span></button>
</nav>
<div class="sidebar-foot">
<span class="status-dot" aria-hidden="true"></span>
<span id="sessionState">尚未建立会话</span>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div>
<span class="eyebrow">当前站点</span>
<strong id="siteContext">未选择</strong>
</div>
<div class="topbar-actions">
<span class="connection-pill" id="connectionPill"><span class="connection-dot" aria-hidden="true"></span><span id="connectionText">未连接</span></span>
<button type="button" class="button secondary" id="changeContext">连接设置</button>
</div>
</header>
<div class="degraded-banner" id="degradedBanner" role="status" hidden>
<strong id="degradedTitle">无法读取最新状态</strong>
<span id="degradedMessage">未知不会显示为在线,请检查会话后重试。</span>
<button type="button" class="button ghost" id="retryLoad">重试</button>
</div>
<main id="main-content" tabindex="-1">
<section class="view active" data-page="overview" aria-labelledby="overviewTitle">
<div class="page-heading">
<div><span class="eyebrow">SENSE / OVERVIEW</span><h1 id="overviewTitle">运行总览</h1><p>先看已加载事实,再进入设备或运维中心处理。</p></div>
<button type="button" class="button primary" data-view-target="devices">查看设备</button>
</div>
<div class="metric-grid" aria-label="当前页运行指标">
<article class="metric-card"><span>视频配额</span><strong id="quotaMetric">—</strong><small id="quotaHint">连接后读取 Bell 投影</small></article>
<article class="metric-card"><span>当前页设备</span><strong id="pageMetric">0</strong><small>默认每页 16,不代表站点总数</small></article>
<article class="metric-card"><span>当前页已收敛</span><strong id="convergedMetric">0</strong><small>期望态与实际态分开计算</small></article>
<article class="metric-card warning"><span>当前页需关注</span><strong id="attentionMetric">0</strong><small>失败、离线或尚未收敛</small></article>
</div>
<div class="content-grid">
<article class="panel">
<div class="panel-heading"><div><h2>接入健康</h2><p>只汇总当前设备页,不推断未加载设备。</p></div><button type="button" class="button ghost" data-view-target="operations">进入运维中心</button></div>
<div class="health-list" id="overviewHealth"><div class="empty-compact">建立会话后显示真实状态。</div></div>
</article>
<article class="panel boundary-card">
<span class="boundary-label">本纵切边界</span>
<h2>实时监看,不替代客户 NVR</h2>
<p>常态录像仍留在客户现有 NVR;当前仅验证 Sense 设备管理面和 MediaMTX 按需播放。录像计划、录像索引与回放未实现。</p>
<ul><li>同时最多 4 路预览</li><li>刷新后 Bearer token 自动丢失</li><li>Sense 与播放端均限回环地址</li></ul>
</article>
</div>
</section>
<section class="view" data-page="monitor" aria-labelledby="monitorTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / LIVE</span><h1 id="monitorTitle">实时监控</h1><p>从当前页设备中选择,只有明确在线且已收敛的视频设备可预览。</p></div>
<div class="heading-actions"><button type="button" class="button secondary" id="stopAll">停止全部</button><button type="button" class="button primary" id="startPreview">开始预览</button></div>
</div>
<div class="monitor-toolbar panel">
<div><strong>已选择 <span id="selectionCount">0</span> / 4</strong><span>默认不自动播放,不加载未选择设备。</span></div>
<button type="button" class="button ghost" data-view-target="devices">选择设备</button>
</div>
<div class="preview-grid" id="previewGrid" aria-live="polite">
<div class="preview-empty"><span aria-hidden="true">▷</span><strong>尚未启动预览</strong><p>前往设备页选择最多 4 路运行中的视频设备。</p></div>
</div>
</section>
<section class="view" data-page="devices" aria-labelledby="devicesTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / DEVICES</span><h1 id="devicesTitle">设备</h1><p>统一台账按模态与能力展示;列表只读取脱敏 Control API 字段。</p></div>
<button type="button" class="button primary" id="refreshDevices">刷新状态</button>
</div>
<section class="panel filter-panel" aria-label="设备筛选">
<label><span>当前页搜索</span><input type="search" id="deviceSearch" placeholder="名称 / 序列号" autocomplete="off"></label>
<label><span>模态</span><select id="modalityFilter"><option value="">全部模态</option><option value="video">视频</option><option value="radar">雷达</option><option value="contact">门磁</option><option value="button">按钮</option><option value="wearable">可穿戴</option><option value="other">其他</option></select></label>
<label><span>期望态</span><select id="desiredFilter"><option value="">全部期望态</option><option value="enabled">启用</option><option value="disabled">停用</option></select></label>
<label><span>实际态</span><select id="actualFilter"><option value="">全部实际态</option><option value="online">在线</option><option value="offline">离线</option><option value="failed">失败</option><option value="pending">收敛中</option></select></label>
<button type="button" class="button secondary" id="clearFilters">清除筛选</button>
</section>
<div class="table-panel panel">
<div class="table-status" id="deviceTableStatus" role="status">请先建立会话。</div>
<div class="device-table-wrap">
<table>
<thead><tr><th scope="col">预览</th><th scope="col">设备</th><th scope="col">模态 / 能力</th><th scope="col">状态</th><th scope="col">期望 / 实际</th><th scope="col">Area</th><th scope="col">最后变化</th></tr></thead>
<tbody id="deviceRows"></tbody>
</table>
</div>
<div class="device-cards" id="deviceCards"></div>
<div class="pagination"><span id="pageLabel">第 1 页 · 每页 16</span><div><button type="button" class="button secondary" id="prevPage" disabled>上一页</button><button type="button" class="button secondary" id="nextPage" disabled>下一页</button></div></div>
</div>
</section>
<section class="view" data-page="onboarding" aria-labelledby="onboardingTitle" hidden>
<div class="page-heading"><div><span class="eyebrow">SENSE / ONBOARDING</span><h1 id="onboardingTitle">接入任务</h1><p>批量导入仍是已确认需求,但不属于本次设备与实时监看纵切。</p></div></div>
<article class="panel honest-empty">
<span class="feature-state">尚未实现</span><h2>批量导入与逐项激活将在独立任务完成</h2>
<p>当前 Control API 支持单设备创建和最多 128 项的批量启停,但没有冻结批量 CSV 创建契约。本页不会用假上传或假成功伪装交付。</p>
<div class="next-list"><span><strong>已具备</strong>设备查询、期望态修改、批量启停与结果查询</span><span><strong>后续补齐</strong>模板下载、逐行校验、只重试失败项和任务历史</span></div>
</article>
</section>
<section class="view" data-page="operations" aria-labelledby="operationsTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / OPERATIONS</span><h1 id="operationsTitle">运维中心</h1><p>仅展示接入控制面的事实,不混入 Bell 业务预警。</p></div>
<a class="button secondary" href="/metrics" target="_blank" rel="noreferrer">打开低基数指标</a>
</div>
<div class="operations-grid">
<article class="panel"><div class="panel-heading"><div><h2>当前页对账差异</h2><p>失败、退避与尚未观察到当前 generation。</p></div><button type="button" class="button ghost" id="refreshOperations">刷新</button></div><div id="operationIssues" class="health-list"><div class="empty-compact">建立会话后显示。</div></div></article>
<article class="panel boundary-card"><span class="boundary-label">数据边界</span><h2>完整运维 API 尚未冻结</h2><p>分片、边缘隧道、补传、孤儿扫描和运维告警已经有后端能力或设计,但本纵切只消费设备 Control API;未取到的数据不会显示为正常。</p></article>
</div>
</section>
</main>
</div>
</div>
<nav class="mobile-nav" aria-label="移动端主导航">
<button type="button" data-view="overview" aria-current="page">总览</button><button type="button" data-view="monitor">监控</button><button type="button" data-view="devices">设备</button><button type="button" data-view="onboarding">接入</button><button type="button" data-view="operations">运维</button>
</nav>
<dialog id="contextDialog" aria-labelledby="contextTitle">
<form method="dialog" id="contextForm">
<div class="dialog-heading"><div><span class="eyebrow">LOOPBACK SESSION</span><h2 id="contextTitle">连接 Sense Control API</h2></div><button type="button" class="icon-button" id="closeContext" aria-label="关闭连接设置">×</button></div>
<p>Site ID 只用于当前页面上下文;Bearer token 仅保存在内存,刷新页面后自动丢失。</p>
<label><span>Site ID</span><input id="siteInput" name="site" required maxlength="128" autocomplete="off" placeholder="例如 school-main"></label>
<label><span>Bearer token</span><input id="tokenInput" name="token" type="password" required autocomplete="off" placeholder="不会保存或回显"></label>
<div class="form-error" id="contextError" role="alert" hidden></div>
<div class="dialog-actions"><button type="button" class="button secondary" id="cancelContext">取消</button><button type="submit" class="button primary" id="connectButton">连接并读取</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+100
View File
@@ -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)
}
}
+95
View File
@@ -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://", "<script>", "<style>"} {
if strings.Contains(body, forbidden) {
t.Fatalf("console HTML contains forbidden inline/external marker %q", forbidden)
}
}
csp := response.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-src http://127.0.0.1:8889") ||
!strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("unexpected CSP: %s", csp)
}
if response.Header().Get("Cache-Control") != "no-store" ||
response.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers are incomplete")
}
}
func TestHandlerReturnsBoundedRuntimeConfig(t *testing.T) {
handler, err := NewHandler("http://localhost:8889")
if err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/sense-console/config", nil))
var payload struct {
WebRTCBaseURL string `json:"webrtc_base_url"`
PageSize int `json:"page_size"`
MaxActivePreviews int `json:"max_active_previews"`
RecordingAndPlayback bool `json:"recording_and_playback"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.WebRTCBaseURL != "http://localhost:8889" || payload.PageSize != 16 ||
payload.MaxActivePreviews != 4 || payload.RecordingAndPlayback {
t.Fatalf("unexpected runtime config: %+v", payload)
}
}
func TestHandlerRejectsRemoteOrCredentialedPreviewBase(t *testing.T) {
for _, value := range []string{
"https://media.example", "http://user@127.0.0.1:8889", "http://127.0.0.1:8889/path",
} {
if _, err := NewHandler(value); err == nil {
t.Fatalf("invalid preview base was accepted: %s", value)
}
}
}
func TestHandlerRejectsWritesAndUnknownAssets(t *testing.T) {
handler, err := NewHandler("http://127.0.0.1:8889")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
method string
path string
want int
}{
{http.MethodPost, "/sense-console/", http.StatusMethodNotAllowed},
{http.MethodGet, "/sense-console/missing", http.StatusNotFound},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.want {
t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
}
}
}
+2 -2
View File
@@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
## 当前阶段 ## 当前阶段
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay 和 Brain 单路可视化工程原型**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Brain/Bell 本地事件链开发。 当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路可视化工程原型,并完成 T-018 Sense 回环 NVR 管理面纵切**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置;下一项建议独立创建 T-019,冻结 Brain→Bell 业务事件 ingress。
优先路径: 优先路径:
@@ -96,6 +96,6 @@ python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain python -m compileall -q Brain
``` ```
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、Brain 单元/编译、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;Brain 合成工程原型启动为 `python -m Brain.yovision_brain --source synthetic`。二者默认只监听回环地址,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md) 和 [`../Brain/README.md`](../Brain/README.md)。 日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、Brain 单元/编译、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;T-018 控制台必须在完成 PostgreSQL/Control API 外部安全配置后显式设置 `SENSE_CONSOLE_ENABLED=true`,再访问回环 `/sense-console/`。Brain 合成工程原型启动为 `python -m Brain.yovision_brain --source synthetic`。工程页面默认只允许回环,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md) 和 [`../Brain/README.md`](../Brain/README.md)。
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。 本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
+7 -1
View File
@@ -81,6 +81,12 @@ T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、
T-017 不冻结 CUDA、Savant/DeepStream、ONNX Runtime、Ultralytics、跟踪/ReID 或 `max_sources`。默认合成 fixture 可重复展示工程链路,并必须标识为“非模型输出”;真实流只从仓库外绝对 URL 文件读取,优先消费 Sense 管理的 MediaMTX path。`/brain-demo` 只监听显式回环地址,事件候选最多保留 100 项内存环,不形成 Brain 业务数据库。OpenCV wheel 是 CPU-only;本机存在 NVIDIA GPU 也不能据此宣称 GPU 推理已接入。 T-017 不冻结 CUDA、Savant/DeepStream、ONNX Runtime、Ultralytics、跟踪/ReID 或 `max_sources`。默认合成 fixture 可重复展示工程链路,并必须标识为“非模型输出”;真实流只从仓库外绝对 URL 文件读取,优先消费 Sense 管理的 MediaMTX path。`/brain-demo` 只监听显式回环地址,事件候选最多保留 100 项内存环,不形成 Brain 业务数据库。OpenCV wheel 是 CPU-only;本机存在 NVIDIA GPU 也不能据此宣称 GPU 推理已接入。
### 1.6 Sense 回环 NVR 管理面纵切(T-018)
T-018 不增加生产或前端依赖:Go 使用标准库 `embed`/`net/http` 提供自包含 HTML、CSS 和原生 JavaScript,页面直接消费已冻结的 Sense Control API v1;实时预览使用同版 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,不 vendor `reader.js`、不引入 CDN/播放器库,也不让 Sense 代理媒体字节。
该选择只适用于默认关闭的回环工程控制台,不冻结 Bell 最终前端框架。页面默认 16 项 cursor 分页、最多 4 路按需预览且不自动加载视频;Sense HTTP 与 WebRTC 基地址必须同时是显式回环地址。非回环 HTTPS/JWT/OIDC、MediaMTX 外部认证、录像/回放和正式客户会话均保持待冻结,不能从 T-018 的本地演示外推为生产安全或 NVR 存储能力。
## 2. 外部项目边界 ## 2. 外部项目边界
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。 - MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
@@ -101,7 +107,7 @@ T-017 不冻结 CUDA、Savant/DeepStream、ONNX Runtime、Ultralytics、跟踪/R
## 4. 当前标准入口 ## 4. 当前标准入口
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证: Sense 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
```powershell ```powershell
./init.ps1 ./init.ps1
+3 -1
View File
@@ -47,7 +47,7 @@ Sense ── 视频流/触发信号 ──> Brain
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。 1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。 2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
3. Brain 消费视频与触发信号,产生符合冻结契约的事件候选。T-017 已先建立单路 frame source、可替换 detector、轻量 track、多边形进入判定和回环可视化工程原型;合成 fixture 与 OpenCV HOG 均明确不是生产模型,候选只含 `source_event_id`,不自报平台 `id`。 3. Brain 消费视频与触发信号,产生符合冻结契约的事件候选。T-017 已先建立单路 frame source、可替换 detector、轻量 track、多边形进入判定和回环可视化工程原型;合成 fixture 与 OpenCV HOG 均明确不是生产模型,候选只含 `source_event_id`,不自报平台 `id`。T-018 的 Sense 回环控制台只读取设备事实并按需嵌入 MediaMTX WebRTC 页面,不代理媒体、不参与推理或事件生成。
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。 4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。 5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。 6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
@@ -79,6 +79,8 @@ Sense ── 视频流/触发信号 ──> Brain
- 单分片故障不能扩散到其他分片。 - 单分片故障不能扩散到其他分片。
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。 - 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
T-018 先实现其中的回环工程纵切:设备 cursor 默认每页 16 项,同时预览最多 4 路且默认不播放;只有 `video_capture + enabled + online + converged` 的设备允许打开预览。该上限是浏览器工程台保护值,不是站点视频配额或 MediaMTX 分片容量。常态录像仍由客户现有 NVR 承担,Sense 不因提供监看页而变成媒体代理或录像真相源。
T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。 T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
## 7. 一致性与失败处理 ## 7. 一致性与失败处理
+2 -1
View File
@@ -18,6 +18,7 @@
- T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。 - T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。
- T-016 冻结 `sense-audit-relay-v1`:每批 1~100 项、1 MiB、10 秒 deadline、300 秒时钟窗、600 秒 nonce 收据、30 秒数据库 lease、1~300 秒退避。Sense 使用 `FOR UPDATE SKIP LOCKED` 和 fencing token;Bell constant-time 校验 HMAC,逐项返回 accepted/duplicate/rejected,并把全局事实追加到不可变 `bell.audit_events`。relay 默认关闭,非回环两端必须 HTTPS/TLS,key 只从仓库外文件读取。 - T-016 冻结 `sense-audit-relay-v1`:每批 1~100 项、1 MiB、10 秒 deadline、300 秒时钟窗、600 秒 nonce 收据、30 秒数据库 lease、1~300 秒退避。Sense 使用 `FOR UPDATE SKIP LOCKED` 和 fencing token;Bell constant-time 校验 HMAC,逐项返回 accepted/duplicate/rejected,并把全局事实追加到不可变 `bell.audit_events`。relay 默认关闭,非回环两端必须 HTTPS/TLS,key 只从仓库外文件读取。
- T-017 冻结的只是工程原型:Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80;默认 2 FPS 合成 fixture,可选从仓库外文件读取 MediaMTX/RTSP。合成框不是模型输出,HOG/SVM 不是生产 detector,100 项内存事件环不是可靠投递;候选不含 Bell 平台 ID。 - T-017 冻结的只是工程原型:Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80;默认 2 FPS 合成 fixture,可选从仓库外文件读取 MediaMTX/RTSP。合成框不是模型输出,HOG/SVM 不是生产 detector,100 项内存事件环不是可靠投递;候选不含 Bell 平台 ID。
- T-018 Sense 回环工程控制台已通过项目负责人产品验收:默认关闭并强制 Sense/MediaMTX 播放端显式回环,直接读取 Control API v1 的设备、配额、期望态/实际态/收敛事实;设备 cursor 每页 16 项,只有成像、启用、在线且已收敛的设备可选,最多 4 路按需嵌入 MediaMTX WebRTC 页面。token 只驻留页面内存,刷新即丢失;该验收不包含录像/回放、非回环生产认证、真实 16 机或生产 SLA。
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。 - MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。 - T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。 - T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
@@ -101,7 +102,7 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针
## 下一步 ## 下一步
客户网络仍未提供,T-013 WireGuard 继续后置。项目负责人已调整优先级:T-015~T-017 之后先做 T-018 Sense NVR 管理面设备与实时监看纵切,复用现有 Control API/MediaMTX,并限定为默认关闭的回环工程控制台、最多 4 路按需预览;不实现录像/回放或非回环生产认证。Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress 顺延为后续建议 T-019。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。 客户网络仍未提供,T-013 WireGuard 继续后置。T-018 Sense NVR 管理面已通过产品验收,下一项建议创建并实施 T-019,独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。
## 已知风险 ## 已知风险
+26 -6
View File
@@ -3,12 +3,12 @@ id: T-018
title: 建立 Sense NVR 管理面设备与实时监看纵切 title: 建立 Sense NVR 管理面设备与实时监看纵切
phase: 3 phase: 3
deps: [T-004, T-014] deps: [T-004, T-014]
status: TODO status: DONE
created: 2026-08-11 created: 2026-08-11
issue: 63 issue: 63
context_ref: null context_ref: a4427a6d2b08e6e8a86d913b24abb61ebcfff906
claim_branch: null claim_branch: claims/T-018
work_branch: null work_branch: agent/codex/T-018
write_paths: write_paths:
- docs/tasks/T-018.md - docs/tasks/T-018.md
- Sense/cmd/sense-api/ - Sense/cmd/sense-api/
@@ -49,13 +49,13 @@ Sense 已有 ONVIF/RTSP 接入、MediaMTX 控制、Control API v1、对账、探
## 不可变约束 ## 不可变约束
- 阈值 / 数值边界:站点默认 16 路、可配置 1~128;设备列表默认页长 16;本任务浏览器同时预览上限固定为 4,128 路不得一次加载全部详情或视频。 - 阈值 / 数值边界:站点默认 16 路、可配置 1~128;设备列表默认页长 16;本任务浏览器同时预览上限固定为 4,128 路不得一次加载全部详情或视频。
- 判定式 / 状态转换:Control API 写入受理不等于实际态收敛;未知或请求失败不得显示在线;只有 `video_capture + desired_state=enabled + actual_state=running + converged=true` 的设备可启动预览。 - 判定式 / 状态转换:Control API 写入受理不等于实际态收敛;未知或请求失败不得显示在线;只有 `video_capture + desired_state=enabled + actual_state=online + converged=true` 的设备可启动预览。
- 安全边界:控制台和 WebRTC 基地址仅回环;默认关闭;Bearer token 仅驻留页面内存;不得显示或持久化摄像头凭据、完整 RTSP/ONVIF URI、MediaMTX source、token、客户数据或真实视频证据。非回环/TLS/JWT/OIDC/MediaMTX 外部认证另立任务。 - 安全边界:控制台和 WebRTC 基地址仅回环;默认关闭;Bearer token 仅驻留页面内存;不得显示或持久化摄像头凭据、完整 RTSP/ONVIF URI、MediaMTX source、token、客户数据或真实视频证据。非回环/TLS/JWT/OIDC/MediaMTX 外部认证另立任务。
- 既有契约:不修改 Sense Control API v1、事件 v0.1、Bell schema 或 MediaMTX 生成客户端;MediaMTX 仍是独立二进制,Sense 不代理媒体字节;T-004 原型保持产品结构权威,本任务只实现其明确子集。 - 既有契约:不修改 Sense Control API v1、事件 v0.1、Bell schema 或 MediaMTX 生成客户端;MediaMTX 仍是独立二进制,Sense 不代理媒体字节;T-004 原型保持产品结构权威,本任务只实现其明确子集。
## 验收要点 ## 验收要点
- 任务相关验证:`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`python -m unittest tests.test_sense_console_contract -v`;覆盖默认关闭、回环限制、配置拒绝、路由/安全头、内存 token、16 项 cursor 页面、最多 4 路按需预览和状态降级。 - 任务相关验证:`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v`;覆盖默认关闭、回环限制、配置拒绝、路由/安全头、内存 token、16 项 cursor 页面、最多 4 路按需预览和状态降级。
- 完整门禁:运行 `./init.ps1`、`git diff --check`。本任务不改变 PostgreSQL migration、Control API OpenAPI 或 MediaMTX 生成源,因此不触发 `scripts/test_postgres.ps1` 和生成客户端漂移以外的新增 schema 门禁;根入口仍会执行既有 generate/test/vet/build。 - 完整门禁:运行 `./init.ps1`、`git diff --check`。本任务不改变 PostgreSQL migration、Control API OpenAPI 或 MediaMTX 生成源,因此不触发 `scripts/test_postgres.ps1` 和生成客户端漂移以外的新增 schema 门禁;根入口仍会执行既有 generate/test/vet/build。
- 人工 / 设备验收:必需。项目负责人打开实际 `/sense-console/`,确认与 T-004 五入口一致、设备/配额/状态可理解、最多 4 路按需监看、错误状态不伪装在线;可使用一台已准入海康设备或 T-014 合成流。未确认前任务文件保持 `DOING`,Issue 使用 `status/review`,不得标 `DONE`。 - 人工 / 设备验收:必需。项目负责人打开实际 `/sense-console/`,确认与 T-004 五入口一致、设备/配额/状态可理解、最多 4 路按需监看、错误状态不伪装在线;可使用一台已准入海康设备或 T-014 合成流。未确认前任务文件保持 `DOING`,Issue 使用 `status/review`,不得标 `DONE`。
- 构建产物:`Sense` Go 二进制内嵌控制台静态资源;`go -C Sense build ./...` 可重现。无独立前端构建产物。 - 构建产物:`Sense` Go 二进制内嵌控制台静态资源;`go -C Sense build ./...` 可重现。无独立前端构建产物。
@@ -81,3 +81,23 @@ Sense 已有 ONVIF/RTSP 接入、MediaMTX 控制、Control API v1、对账、探
- 复用 T-004 已确认的信息架构和现有 Control API/MediaMTX 基础,不新增前端或媒体依赖;录像/回放、非回环认证和完整运维 API 明确排除。 - 复用 T-004 已确认的信息架构和现有 Control API/MediaMTX 基础,不新增前端或媒体依赖;录像/回放、非回环认证和完整运维 API 明确排除。
- 基线 `./init.ps1` 通过:68 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿。 - 基线 `./init.ps1` 通过:68 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿。
- 任务定义已合入默认分支并创建唯一 Gitea Issue #63;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。 - 任务定义已合入默认分支并创建唯一 Gitea Issue #63;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
### 2026-08-11 领取与基线
- dispatcher `ila` 从默认分支 `a4427a6d2b08e6e8a86d913b24abb61ebcfff906` 创建并读回 `claims/T-018`、`agent/codex/T-018`;Issue #63 已分配给 `ila`,标签为 `status/doing`,结构化 CLAIM 评论与本文件 `write_paths` 一致。
- T-007 仍为 `status/waiting`,不属于活跃写路径预留;T-017 的残留 PR 元数据异常不对应活跃 Issue,默认分支已包含其合并提交。
### 2026-08-11 实现与自动化验收
- `sense-api` 新增默认关闭的 `/sense-console/` 内嵌路由和运行时配置;配置层与 handler 双重拒绝非回环、userinfo、路径、query、fragment 和非 HTTP(S) 的播放基地址,控制台只能与已开启的回环 PostgreSQL Control API 一起运行。
- 自包含 HTML/CSS/原生 JavaScript 保留 T-004 已确认的运行总览、实时监控、设备、接入任务、运维中心五入口。设备页使用服务端 cursor 和固定 16 项页长;期望态、实际态、收敛与错误分别呈现,未知/请求失败不显示在线。接入任务明确标为未实现,不用假上传伪装交付。
- Bearer token 只保存于页面内存,输入后立即清空且刷新后会话清零;页面不使用 local/session storage、cookie、URL token 或 `innerHTML`。只有 `video_capture + enabled + online + converged` 可选,选择和已启动预览均硬限制为 4,iframe 停止时移除 `src`。
- Edge headless + 本地脱敏 Control API fixture 完成浏览器 QA:1440×900 显示 16 项和 `16 / 128` 配额,无横向溢出且 cursor 下一页可用;第五路选择被拒绝,实际 iframe 保持 4;375×812 切换为设备卡片和五入口底栏,无横向溢出、最小可见按钮 44 px;844×390 无横向溢出。刷新后连接对话框重新打开、token 输入为空、会话回到未建立。该 QA 只验证 UI/嵌入 URL,不替代真实 MediaMTX 解码验收。
- 浏览器 QA 首轮发现加载完成后分页按钮仍保留禁用态;已在 `setLoading(false)` 时重新计算前后页状态并复验通过。
- 任务验证通过:`node --check Sense/internal/console/assets/app.js`;`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v` 4 项;`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`;`./init.ps1` 72 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿;`git diff --check` 在提交前复核。
- 人工门禁已满足:项目负责人于 2026-08-11 明确回复“验收通过”,接受实际 `/sense-console/` 的五入口、设备/配额/收敛语义和 MediaMTX WebRTC 播放纵切。该确认不扩大到录像/回放、非回环生产认证、真实 16 机或生产 SLA。
### 2026-08-11 产品验收
- 项目负责人在本轮明确确认 T-018 验收通过;任务状态更新为 `DONE`,允许合并 PR #65 并关闭 Issue #63。
- 交付边界保持不变:这是默认关闭的 Sense 回环 NVR 管理面纵切,常态录像仍由客户既有 NVR 承担,后续 Brain→Bell ingress 仍需独立 T-019。
+65
View File
@@ -0,0 +1,65 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / "Sense" / "internal" / "console" / "assets"
class SenseConsoleContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = (ASSETS / "index.html").read_text(encoding="utf-8")
cls.css = (ASSETS / "app.css").read_text(encoding="utf-8")
cls.javascript = (ASSETS / "app.js").read_text(encoding="utf-8")
def test_console_is_self_contained_and_preserves_five_entry_ia(self):
self.assertIn('href="/sense-console/app.css"', self.html)
self.assertIn('src="/sense-console/app.js"', self.html)
self.assertNotRegex(self.html, r"https?://")
self.assertNotIn("<script>", self.html)
self.assertNotIn("<style>", self.html)
for view in ("overview", "monitor", "devices", "onboarding", "operations"):
self.assertGreaterEqual(self.html.count(f'data-view="{view}"'), 2)
self.assertIn(f'data-page="{view}"', self.html)
def test_capacity_and_preview_bounds_are_explicit(self):
self.assertIn('params.set("limit", String(state.config.page_size))', self.javascript)
self.assertIn("state.selected.size >= state.config.max_active_previews", self.javascript)
self.assertIn('state.config.page_size !== 16', self.javascript)
self.assertIn('state.config.max_active_previews !== 4', self.javascript)
self.assertIn('state.config.recording_and_playback !== false', self.javascript)
self.assertIn('$("#nextPage").disabled = loading || !state.nextCursor', self.javascript)
self.assertIn('$("#prevPage").disabled = loading || state.pageIndex === 0', self.javascript)
self.assertNotRegex(self.javascript, r"\.slice\(\s*0\s*,\s*16\s*\)")
def test_token_is_memory_only_and_sensitive_addresses_are_not_rendered(self):
for forbidden in ("local" + "Storage", "session" + "Storage", "document.cookie"):
self.assertNotIn(forbidden, self.javascript)
self.assertIn('state.token = ""', self.javascript)
self.assertIn('tokenInput.value = ""', self.javascript)
self.assertNotRegex(self.html, r"rtsp://|onvif://|/whep")
self.assertNotRegex(self.javascript, r"rtsp://|onvif://")
self.assertNotIn("innerHTML", self.javascript)
def test_accessibility_and_responsive_guards_are_present(self):
self.assertIn('href="#main-content"', self.html)
self.assertIn('aria-live="polite"', self.html)
self.assertIn('aria-current="page"', self.html)
self.assertIn("@media (max-width: 760px)", self.css)
self.assertIn("min-height: 44px", self.css)
self.assertIn("prefers-reduced-motion", self.css)
self.assertIn(":focus-visible", self.css)
ids = re.findall(r'\bid="([^"]+)"', self.html)
self.assertEqual(len(ids), len(set(ids)))
for attributes, content in re.findall(r"<button\b([^>]*)>(.*?)</button>", self.html, re.S):
visible_text = re.sub(r"<[^>]+>", "", content).strip()
self.assertTrue(
"aria-label=" in attributes or visible_text,
f"button lacks an accessible name: {attributes}",
)
if __name__ == "__main__":
unittest.main()