diff --git a/.gitignore b/.gitignore index 268d7bff..f08dbed2 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ package-lock.json /task_plan.md /findings.md /progress.md + +# Local worktrees +/.worktrees/ diff --git a/README.md b/README.md index 49c60dde..5167c63b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ LiveForge is a modular live streaming media server that ingests, transmuxes, and | 📡 | **GB28181 video surveillance** | Full SIP signaling stack, device registration, live invite, playback, PTZ control, alarm handling — plus a built-in device simulator for testing | | 🌐 | **Multi-protocol cluster** | Origin-edge cascading via RTMP / SRT / RTSP / RTP / GB28181 with HTTP scheduler callback for dynamic topology | | ⚡ | **LL-HLS** | Low-Latency HLS with fMP4 partial segments, blocking playlist reload (`_HLS_msn`/`_HLS_part`), and delta playlist updates | -| 🖥️ | **Web console** | Permission-aware tabs for Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security; Recent Audit is inside Security; grouped as Workspace, Operations, and System so Config/Security are not peer stream pages; includes browser preview/publish | +| 🖥️ | **Web console** | Permission-aware tabs for Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security; Recent Audit is inside Security; grouped as Workspace, Operations, and System; Streams has stable rows with inline four-metric trends; `/console/publish` is a WHIP/SIP/GB28181 broadcaster workspace | | 🛡️ | **Production-ready** | Slow consumer protection (EWMA frame dropping), GCC congestion control, per-IP rate limiting, Prometheus metrics | ## Features @@ -130,7 +130,7 @@ Apple LL-HLS implementation for sub-second latency HLS delivery: ### Management & Operations -- **Web console** — Seven permission-aware tabs with multi-protocol preview and WHIP publish: Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security. Recent Audit is a surface inside Security, not a separate tab. +- **Web console** — Seven permission-aware tabs with multi-protocol preview and a dedicated `/console/publish` broadcaster workspace. All management views share the dark teal, mint/cyan/amber/coral visual system used by the console direction demo. Streams keeps fixed column tracks, restores a Preview action, and expands the selected row with four compact 60-second trends (bitrate, video FPS, audio FPS, and GOP duration) sampled once per second. The publish workspace selects WHIP/WebRTC, SIP, or GB28181 while preserving the existing protocol-lab lifecycle. Recent Audit is a surface inside Security, not a separate tab. - **REST API** — Stream lifecycle, config refresh/status, cluster status, SIP call control, recording/DVR management, security/audit, GB28181, and public health probes - **Auth and RBAC** — Named viewer/operator/admin API tokens, console sessions, JWT/callback publish/subscribe auth, bounded redacted audit trail - **Recording and DVR** — FLV, fragmented MP4, MP4, MPEG-TS, and HLS recording; new recordings default to fMP4/`.mp4`; every rotated recording file retains the declared tracks and latest codec initialization, starts each track on a zero-based file-local timeline, and remains independently parseable; TS emits PAT/PMT before its first media PES, while classic MP4 calculates audio/video durations on their independent clocks, saturates version-0 timing fields instead of wrapping, uses signed `ctts` version 1 for negative B-frame composition offsets, and writes expandable AAC ESDS lengths; fMP4 declares AAC directly, converts non-AAC source audio such as G.711, Opus, and MP3 to AAC through the optional `audiocodec`/FFmpeg build, and filters audio to keep playable video-only output when that path is unavailable; a stopped transformed recording drains source frames already committed before finalizing, while publisher-generation completion flushes retained resampler samples, pads the final partial PCM frame, and emits delayed encoder packets exactly once before Record/DVR output closes; DVR TS similarly normalizes audio unsupported by its target; segmentation, storage health, download/range/inline-play/delete management, exact full-ID action routing, retryable cleanup-before-primary deletion, zero-byte session protection, and time-shift status. Recording play/download acquire the global connection budget before opening media and apply a 10-second write deadline. diff --git a/README.zh-CN.md b/README.zh-CN.md index aad9f43a..38a62d7e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,7 +33,7 @@ LiveForge 是一个模块化的直播流媒体服务器,支持实时音视频 | 📡 | **GB28181 视频监控** | 完整 SIP 信令栈,设备注册、实时拉流、录像回放、云台控制、报警处理 —— 附带内置设备模拟器 | | 🌐 | **多协议集群** | 支持 RTMP / SRT / RTSP / RTP / GB28181 的 Origin-Edge 级联,支持 HTTP 调度回调动态拓扑 | | ⚡ | **LL-HLS 低延迟** | fMP4 部分分片、阻塞式播放列表刷新(`_HLS_msn`/`_HLS_part`)、增量播放列表 | -| 🖥️ | **Web 控制台** | 权限感知标签页依次为 Streams、GB28181、Config、Cluster、SIP Calls、Storage、Security;Recent Audit 位于 Security 内部;按 Workspace、Operations、System 分组,Config/Security 不再与视频流页面平级;支持浏览器预览/推流 | +| 🖥️ | **Web 控制台** | 权限感知标签页依次为 Streams、GB28181、Config、Cluster、SIP Calls、Storage、Security;Recent Audit 位于 Security 内部;按 Workspace、Operations、System 分组;Streams 提供稳定行和行内四指标趋势;`/console/publish` 是支持 WHIP/SIP/GB28181 的主播工作台 | | 🛡️ | **生产级可靠性** | 慢消费者保护(EWMA 丢帧)、GCC 拥塞控制、IP 级限流、Prometheus 监控 | ## 特性 @@ -132,7 +132,7 @@ Apple LL-HLS 标准实现,亚秒级延迟 HLS 分发: ### 管理与运维 -- **Web 控制台** — 七个权限感知标签页及多协议预览和 WHIP 推流:Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security。Recent Audit 是 Security 内部的界面,不是单独的第八个标签页。 +- **Web 控制台** — 七个权限感知标签页及多协议预览,并提供独立的 `/console/publish` 主播工作台。所有管理页统一使用 console demo 的深青黑底色与 mint/cyan/amber/coral 色彩系统。Streams 使用固定列宽并恢复 Preview 操作,点击流行会在该行下方展开四张同时显示的紧凑 60 秒趋势图(码率、视频 FPS、音频 FPS、GOP 时长),每秒采样一次。主播工作台可在 WHIP/WebRTC、SIP、GB28181 之间选择协议,并复用现有协议实验室生命周期。Recent Audit 是 Security 内部的界面,不是单独的第八个标签页。 - **REST API** — 流生命周期、配置刷新/状态、集群状态、SIP 呼叫、录制/DVR、安全/审计、GB28181 和公开健康探针 - **鉴权与 RBAC** — viewer/operator/admin 命名令牌、控制台会话、推拉流 JWT/回调鉴权,以及有界脱敏审计记录 - **录制与 DVR** — FLV、FMP4、MP4、MPEG-TS、HLS 录制;新录像默认使用 fMP4/`.mp4`;每个轮转录像文件都会保留已声明轨道和最新 codec 初始化,并让每条轨道从文件内零时间轴开始,确保文件可独立解析;TS 会在首个媒体 PES 前写入 PAT/PMT,经典 MP4 按音视频各自时钟计算 duration、对超出 version-0 表示范围的时间字段做饱和而不回绕、对负 B 帧合成偏移使用有符号 `ctts` version 1,并使用可扩展 AAC ESDS 长度编码;fMP4 仅直接写入 AAC,启用可选 `audiocodec`/FFmpeg 构建时会将 G.711、Opus、MP3 等非 AAC 音频转为 AAC,未启用时过滤音频并保留可播放的纯视频输出;转码录制停止时会先排空停止边界前已经提交的源帧再完成文件,publisher generation 结束时还会先排空重采样滤波器保留的样本,再用静音补齐最后一个不完整 PCM 帧,并在 Record/DVR 输出关闭前仅一次排空编码器延迟包;DVR TS 同样会将目标不支持的音频统一转换;支持分段、存储健康、下载/Range/在线预览/删除管理、精确完整 ID 操作路由、清理失败后可重试且最后删除主文件、零字节会话保护和时移状态。录制在线预览/下载会在打开媒体前占用全局连接配额,并设置 10 秒写期限。 diff --git a/agent-manifest.json b/agent-manifest.json index 3e2f189b..c704bd66 100644 --- a/agent-manifest.json +++ b/agent-manifest.json @@ -160,6 +160,12 @@ "audit": "bounded in-memory entries plus structured logs with secret metadata redaction", "production_requirements": ["Change console credentials", "Enable TLS", "Enable API and stream authentication", "Restrict exposed ports", "Configure rate limits"] }, + "console_observability": { + "publish_route": "/console/publish", + "publish_protocols": ["whip", "sip", "gb28181"], + "stream_trends": "The Streams Console uses keyed fixed-layout rows, keeps Preview actions, inserts detail directly after the selected row, and samples bitrate, video FPS, audio FPS, and GOP duration once per second with at most 60 samples rendered as simultaneous inline SVG charts.", + "visual_system": "All management views share the console direction demo's dark teal surfaces with mint, cyan, amber, and coral accents; /console/publish uses the same two-column broadcaster workspace layout." + }, "limitations": [ "There is no current public release until a v* tag completes the release workflow.", "The Docker image is only available after the release workflow publishes it.", diff --git a/docs/superpowers/demos/console-observability-demo.html b/docs/superpowers/demos/console-observability-demo.html new file mode 100644 index 00000000..4c08f457 --- /dev/null +++ b/docs/superpowers/demos/console-observability-demo.html @@ -0,0 +1,354 @@ + + + + + +LiveForge Console Direction Demo + + + +
+
+
◈
LiveForge Console direction demo
+
API connected
+
+ + + +
+
+
Workspace / live inventory

Streams

Stable rows, live signal at a glance.

+
last update --:--:--
+
+
+
Active streams
3
+
Publishing
3
+
Subscribers
21
+
Ingress
8.4 Mbps
+
GOP average
1.0 s
+
+
Select a row to inspect the last 60 seconds
+
+ + + + +
Stream keyStatePublisherCodecsBitrateFPSUptimeGOP cacheActions
+
+
+ +
+
+
◈
Workspace / broadcast

Publish studio

← Back to Console
+
+
+
READY TO PUBLISHLOCAL PREVIEW
◉
Camera previewChoose a camera to begin
+
Video out--
Audio out--
Round trip--
Packet loss--
+
+ +
+
+
+
+
+ + + diff --git a/docs/superpowers/plans/2026-08-31-console-observability-and-whip-workspace.md b/docs/superpowers/plans/2026-08-31-console-observability-and-whip-workspace.md new file mode 100644 index 00000000..b73eae32 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-console-observability-and-whip-workspace.md @@ -0,0 +1,324 @@ +# Console Observability and WHIP Workspace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every LiveForge console view one stable visual system, make Streams layout-stable with four simultaneous 60-second trend charts, and replace the WHIP publish modal with a dedicated `/console/publish` workspace. + +**Architecture:** Keep one embedded `module/api/console.html` served by both console routes. Add a shared token/component layer in that document, keyed stream-row reconciliation for stable list rendering, and a selected-stream-only one-second detail poller that feeds four low-height inline SVG charts. Keep WHIP signaling and WebRTC stats logic shared with the current implementation. + +**Tech Stack:** Embedded HTML/CSS/vanilla JavaScript, Go `net/http` routing, Chromedp browser tests, inline SVG. + +**Spec:** `docs/superpowers/specs/2026-08-31-console-observability-and-whip-workspace-design.md` + +## Global Constraints + +- The module requires Go 1.26 or newer. +- The quick package check is `go test ./...`; tagged FFmpeg verification remains optional for this frontend-only change. +- Run `tools/check-agent-docs_test.sh` after every change. +- Keep `/console` and `/console/publish` behind the existing console session authentication. +- Do not change the REST or WebRTC signaling contracts. +- Keep the demo artifact in `docs/superpowers/demos/` as a visual reference; it is not runtime code. +- Apply the shared visual system to `Streams`, `GB28181`, `Config`, `Cluster`, `SIP Calls`, `Storage`, `Security`, and Publish. + +## File Map + +- Modify `module/api/routes.go`: register the `/console/publish` GET route. +- Modify `module/api/console.html`: shared Console visual system, stable Streams table, four-chart detail trend panel, route-aware Publish workspace, and shared WHIP lifecycle wiring. +- Modify `module/api/console_management_test.go`: DOM/script and browser probes for stable rows, detail controls, and publish route markup. +- Modify `module/api/console_publish_test.go`: navigate to `/console/publish` and exercise the full-page publish flow. +- Modify `module/api/module_test.go`: verify both console paths return the embedded document. +- Modify `README.md`, `README.zh-CN.md`, `llms.txt`, `llms-full.txt`: document the new console workflow and selected-stream trends. + +### Task 1: Register the publish route and route coverage + +**Files:** +- Modify: `module/api/routes.go` +- Test: `module/api/module_test.go` + +**Interfaces:** +- Produces `GET /console/publish` serving the same embedded console document as `GET /console`. + +- [ ] **Step 1: Write the failing route test** + +Add a table-driven assertion beside the existing `/console` test: + +```go +for _, path := range []string{"/console", "/console/publish"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "LiveForge Console") { + t.Fatalf("GET %s = %d, want embedded console", path, rec.Code) + } +} +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `go test ./module/api -run '^Test.*Console.*Route|^Test.*Console$' -count=1` + +Expected: `/console/publish` is not matched by the mux. + +- [ ] **Step 3: Register the route** + +Add `mux.HandleFunc("GET /console/publish", h.handleConsole)` next to the existing `/console` registration. + +- [ ] **Step 4: Run the focused test to verify it passes** + +Run: `go test ./module/api -run '^Test.*Console.*Route|^Test.*Console$' -count=1` + +Expected: PASS for both paths. + +- [ ] **Step 5: Commit** + +```bash +git add module/api/routes.go module/api/module_test.go +git commit -m "feat: add console publish route" +``` + +### Task 2: Make the Streams list stable and selectable + +**Files:** +- Modify: `module/api/console.html` (Streams markup/CSS and `renderStreams`) +- Test: `module/api/console_management_test.go` + +**Interfaces:** +- `renderStreams(streams)` keeps rows keyed by `data-stream-key` and updates fixed value nodes in place. +- A selected row is represented by `selectedStreamKey` and calls `selectStream(key)`. + +- [ ] **Step 1: Add failing DOM/script assertions** + +Extend `TestConsoleProtocolLabMediaAndCacheRendering` to assert a ``/fixed layout, `data-stream-key`, and stable node identity: + +```js +renderStreams(firstPayload); +var firstRow = document.querySelector('#tbody tr[data-stream-key="sip/lab"]'); +renderStreams(secondPayload); +return { + sameRow: firstRow === document.querySelector('#tbody tr[data-stream-key="sip/lab"]'), + selectedKey: document.querySelector('#tbody tr[data-stream-key="sip/lab"]').dataset.streamKey, + cacheText: document.querySelector('#tbody tr[data-stream-key="sip/lab"] .gop-value-duration').textContent +}; +``` + +Assert `sameRow` is true and the duration slot changes without replacing the row. + +- [ ] **Step 2: Run the focused browser test to verify it fails** + +Run: `go test ./module/api -run '^TestConsoleProtocolLabMediaAndCacheRendering$' -count=1` + +Expected: current renderer replaces `tbody` children and has no keyed row or fixed GOP slots. + +- [ ] **Step 3: Add fixed table tracks and stable GOP markup** + +Add a `` with explicit widths, set `table { table-layout: fixed; }`, and render GOP values as stable elements with classes such as `.gop-value-frames`, `.gop-value-duration`, `.gop-value-generation`, and `.gop-value-media`. Keep values tabular and truncate stream/publisher text with ellipsis. + +- [ ] **Step 4: Implement keyed row reconciliation and row selection** + +Maintain a `streamRows` map keyed by stream key. Create a row only when absent, update its existing value nodes on refresh, remove keys no longer returned, and reorder rows with a document fragment in sorted order. Add `tabindex="0"`, `role="button"`, Enter/Space handlers, and a selected class. Keep current action-button behavior unchanged. + +- [ ] **Step 5: Run the focused test to verify it passes** + +Run: `go test ./module/api -run '^TestConsoleProtocolLabMediaAndCacheRendering$' -count=1` + +Expected: PASS with stable row identity and fixed GOP slot text. + +- [ ] **Step 6: Commit** + +```bash +git add module/api/console.html module/api/console_management_test.go +git commit -m "feat: stabilize streams table rendering" +``` + +### Task 3: Add selected-stream trend sampling and four simultaneous charts + +**Files:** +- Modify: `module/api/console.html` (detail markup/CSS and stream trend JavaScript) +- Test: `module/api/console_management_test.go` + +**Interfaces:** +- `selectStream(streamKey)` opens the detail panel and starts a selected-stream poll. +- `stopStreamTrendPolling()` cancels the one-second timer and aborts its request. +- `renderStreamTrend()` draws four metrics from at most 60 samples. + +- [ ] **Step 1: Add failing trend-panel assertions** + +Add a browser probe that calls `selectStream("sip/lab")`, stubs `apiFetch` for the detail endpoint, invokes the sample callback twice, and asserts the panel is open, the sample count is `2 / 60`, and the SVG path is non-empty. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `go test ./module/api -run '^TestConsole.*Trend|^TestConsoleProtocolLabMediaAndCacheRendering$' -count=1` + +Expected: the detail elements and trend functions do not exist. + +- [ ] **Step 3: Add detail panel markup and responsive styles** + +Place the panel directly after the selected stream row with a selected key header, close button, four compact metric summaries, and four inline SVG charts containing grid lines, `chart-area-*`, and `chart-path-*`. Keep each chart at a fixed low height and add desktop/mobile CSS matching the approved demo direction. + +- [ ] **Step 4: Implement one-second selected-stream polling** + +Add state for the selected key, generation, previous cumulative frame counts, per-metric arrays, an `AbortController`, and a timer. Poll `/api/v1/streams/{encoded key}` every second only while the Streams view and document are visible. Derive video/audio FPS from cumulative frame deltas; use `stats.bitrate_kbps` and `gop_duration_ms`. Reset arrays when generation changes, selected key disappears, the panel closes, or the view is hidden. + +- [ ] **Step 5: Implement four-chart SVG scaling** + +Render all four series into stable low-height viewBoxes such as `0 0 720 82`, scale each series to its min/max with a one-unit floor, update each current/min/max label, and show an explicit no-data state. Use the existing `newActiveViewSignal` lifecycle so hidden tabs abort trend requests. + +- [ ] **Step 6: Run focused tests and the documentation check** + +Run: `go test ./module/api -run '^TestConsole.*Trend|^TestConsoleProtocolLabMediaAndCacheRendering$' -count=1` + +Run: `tools/check-agent-docs_test.sh` + +Expected: PASS and the agent documentation check reports success. + +- [ ] **Step 7: Commit** + +```bash +git add module/api/console.html module/api/console_management_test.go +git commit -m "feat: add selected stream trend panel" +``` + +### Task 4: Normalize the visual system across all Console views + +**Files:** +- Modify: `module/api/console.html` (shared CSS tokens and page markup classes) +- Test: `module/api/console_management_test.go` + +**Interfaces:** +- Existing view IDs, permission attributes, action identifiers, and form field IDs remain stable. +- Shared classes cover page headings, toolbars, cards, tables, forms, status badges, empty/error states, and buttons. + +- [ ] **Step 1: Add failing shared-style assertions** + +Extend the console DOM test to verify every management view contains the shared page shell classes and that each operational table uses the stable table wrapper. + +- [ ] **Step 2: Run the focused DOM test to verify it fails** + +Run: `go test ./module/api -run '^TestConsoleManagementViewsExposeSupportedControlPlanes$' -count=1` + +Expected: the current views rely on mixed inline styles and do not expose the shared class contract. + +- [ ] **Step 3: Add the token and component CSS layer** + +Define shared variables and classes for surfaces, text roles, focus, buttons, stat cards, toolbars, section headers, tables, forms, badges, error/empty states, and responsive behavior. Keep the approved mint/cyan/amber/coral palette and preserve reduced-motion rules. + +- [ ] **Step 4: Apply shared classes without changing view behavior** + +Update GB28181, Config, Cluster, SIP Calls, Storage, Security, and Streams markup to use the shared classes. Remove only conflicting inline presentation styles; do not alter API calls, permission checks, action identifiers, or form submission handlers. + +- [ ] **Step 5: Run focused DOM and browser tests** + +Run: `go test ./module/api -run '^TestConsoleManagementViewsExposeSupportedControlPlanes$|^TestConsole.*Polling|^TestConsole.*Rendering' -count=1` + +Expected: PASS with all existing view behavior and IDs intact. + +- [ ] **Step 6: Commit** + +```bash +git add module/api/console.html module/api/console_management_test.go +git commit -m "feat: unify console visual system" +``` + +### Task 5: Turn WHIP publish into the dedicated workspace + +**Files:** +- Modify: `module/api/console.html` (publish markup, CSS, and initialization) +- Test: `module/api/console_publish_test.go` +- Test: `module/api/console_management_test.go` + +**Interfaces:** +- `/console/publish` sets a route marker, hides the management shell, and shows `publish-workspace`. +- `openPublishModal()` becomes a route navigation helper for backwards-compatible callers. +- Existing `startPublish`, `stopPublish`, and stats functions target the workspace elements. + +- [ ] **Step 1: Update the browser test to the publish route** + +Change `TestConsolePublishFlow` to navigate to `serverURL + "/console/publish"`, wait for `#publish-workspace`, and keep the existing device enumeration, start, connected, and cleanup assertions. + +- [ ] **Step 2: Run the browser test to verify it fails** + +Run: `go test ./module/api -run '^TestConsolePublishFlow$' -count=1` + +Expected: the route is present but the current page has no full-page workspace and the test's workspace selector is missing. + +- [ ] **Step 3: Replace the modal shell with approved workspace markup** + +Add a route marker before first paint, wrap the management shell in `#console-app`, and add `#publish-workspace` with the two-column preview/control layout, back link, stream key, camera, microphone, codec, connection state, start/stop buttons, and stats strip. Remove the publish overlay from the active flow and preserve stable IDs used by WHIP logic. + +- [ ] **Step 4: Make the page route-aware and lifecycle-safe** + +Initialize device preview only on the publish route. Make the Console header Publish button navigate to `/console/publish`. Ensure stop/failure returns to the ready state, stops local tracks, clears stats, and never starts management polling on the publish route. Keep the existing secure-context, WHIP offer/answer, ICE, and DELETE behavior. + +- [ ] **Step 5: Run the browser test to verify it passes** + +Run: `go test ./module/api -run '^TestConsolePublishFlow$' -count=1` + +Expected: PASS when Chromium and fake media devices are available; otherwise the test reports its existing environment skip. + +- [ ] **Step 6: Run console DOM tests** + +Run: `go test ./module/api -run '^TestConsoleManagementViewsExposeSupportedControlPlanes$|^TestConsole.*Polling|^TestConsole.*Publish' -count=1` + +Expected: PASS with the new workspace markup and existing tab behavior intact. + +- [ ] **Step 7: Commit** + +```bash +git add module/api/console.html module/api/console_publish_test.go module/api/console_management_test.go +git commit -m "feat: add dedicated whip publish workspace" +``` + +### Task 6: Synchronize user and AI-facing documentation + +**Files:** +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `llms.txt` +- Modify: `llms-full.txt` + +**Interfaces:** +- Documentation states that `/console/publish` is the supported browser WHIP workflow and that Streams trends are selected on demand with a 60-second/1-second client window. + +- [ ] **Step 1: Update the quick-start documentation** + +Add the new route and describe the stable list/detail workflow in the existing console sections in English and Chinese. Keep the existing security warning about development credentials and secure contexts. + +- [ ] **Step 2: Update AI-facing navigation and full facts** + +Add the route and client-side trend behavior to the console capability bullets in `llms.txt` and the corresponding console/WebRTC sections in `llms-full.txt`. Do not claim server-side historical storage. + +- [ ] **Step 3: Run documentation validation** + +Run: `tools/check-agent-docs_test.sh` + +Expected: `agent-docs: all checks passed` and `agent documentation check passed`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md README.zh-CN.md llms.txt llms-full.txt +git commit -m "docs: describe console publish workspace and trends" +``` + +### Task 7: Full verification + +**Files:** +- Test: `module/api` and repository test suites + +- [ ] **Step 1: Run focused API tests** + +Run: `go test ./module/api -count=1` + +- [ ] **Step 2: Run the quick package suite** + +Run: `go test ./...` + +- [ ] **Step 3: Run the documentation contract test** + +Run: `tools/check-agent-docs_test.sh` + +- [ ] **Step 4: Inspect the final diff** + +Run: `git diff --check` and `git status --short`. + +Expected: no whitespace errors, only scoped source/tests/docs changes, and the demo artifact remains clearly under `docs/superpowers/demos/`. diff --git a/docs/superpowers/specs/2026-08-31-console-observability-and-whip-workspace-design.md b/docs/superpowers/specs/2026-08-31-console-observability-and-whip-workspace-design.md new file mode 100644 index 00000000..2b5f986c --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-console-observability-and-whip-workspace-design.md @@ -0,0 +1,141 @@ +# Console Observability and WHIP Workspace Design + +## Scope + +Improve the embedded web console across the complete management surface: + +1. Keep the Streams list visually stable while live values change, especially + GOP cache values. +2. Add an on-demand stream detail panel with a 60-second, one-sample-per-second + trend view for bitrate, video/audio frame rate, and GOP duration. +3. Replace the WHIP publish modal flow with a dedicated `/console/publish` + workspace that feels like a broadcaster or video-conference control surface. +4. Apply one visual system to Streams, GB28181, Config, Cluster, SIP Calls, + Storage, Security, and the Publish workspace without changing their business + operations. + +The existing REST API remains the source of truth. No new media or signaling +protocol is introduced. + +## Recommended Approach + +Keep one embedded `module/api/console.html` and serve it for both `/console` and +`/console/publish`. A small route-aware bootstrap hides the management shell and +shows the publish shell when the latter path is loaded. This keeps WHIP device, +ICE, lifecycle, and stats code in one place while preserving the existing +console session authentication and static asset paths. + +The header Publish action navigates to `/console/publish`; the publish shell +provides a clear return link to `/console`. The old publish overlay is removed +from the user flow. + +## Streams Presentation + +### Stable list + +- Render the table with a fixed layout and explicit column tracks so changing + values cannot resize neighboring columns. +- Reconcile rows by stream key. Existing `tr` nodes and their child value nodes + are updated in place; rows for removed keys are deleted and new keys inserted + in sorted order. +- Keep GOP values in fixed-width numeric slots with tabular numerals. Use + separate labels for generation, frame count, duration, and V/A frame counts + so text changes do not cause reflow. +- Make each stream row selectable with keyboard support. The selected row has a + stable visual state and does not depend on a modal. + +### Detail trend panel + +- Place a detail panel directly below the Streams table. It is hidden until a + row is selected and closes when the selected stream disappears. +- Poll `GET /api/v1/streams/{stream_key}` once per second while the Streams view + is visible and a stream is selected. Keep the latest 60 samples in memory. +- Derive video FPS and audio FPS from cumulative `video_frames` and + `audio_frames` deltas. Use the server-provided instantaneous bitrate and GOP + duration for the other series. +- Show four compact metric charts at the same time: bitrate, video FPS, audio + FPS, and GOP duration. Each chart uses a fixed low-height inline SVG and the + same 60-second x-axis; the title and latest value sit above its path. +- Show min/max range and a no-data state for each chart without changing the + row or panel height as values update. +- Reset the sample window when the stream generation changes or the selected + stream is replaced. Stop the detail poller when the view is hidden, the row is + deselected, or the page is not visible. + +The existing three-second management refresh remains the list refresh cadence; +the one-second detail request is scoped to the selected stream only. + +## WHIP Publish Workspace + +### Layout + +- Use the existing dark console palette with a dedicated page header, product + identity, live connection badge, and a return-to-console link. +- Desktop: two-column grid with a large mirrored local preview on the left and + a control rail on the right. +- Control rail: stream key, camera, microphone, codec selection, start/stop + action, and an explicit status/error region. +- Show a compact stats strip below the preview for outbound video/audio bitrate, + encoded FPS, keyframes, RTT, jitter, and packet loss. Reuse the current + `RTCPeerConnection#getStats` sampling and delta calculations. +- Mobile: collapse to one column with the controls below the preview; all + controls remain reachable without horizontal scrolling. + +### Lifecycle and failure behavior + +- Entering `/console/publish` only prepares the page. Camera/microphone access + is requested in the same user-visible flow as today, with clear secure-context + and permission errors. +- Starting publish keeps the existing WHIP offer/answer and ICE behavior. +- Stopping publish closes the peer connection, deletes the WHIP session, stops + local tracks, clears stats, and leaves the user on the publish page so they can + start another stream or return to Console. +- A failed or disconnected connection restores the start state and leaves the + error visible until the next attempt. + +## Cross-Console Visual System + +- Define one token layer for background surfaces, borders, text roles, accent + colors, spacing, radius, focus rings, and motion. Keep the palette balanced + across mint, cyan, amber, and coral accents rather than tinting every + component with one hue. +- Normalize page headings and toolbar rows, stat cards, section headers, + tables, forms, status badges, empty/error states, and action buttons across + `Streams`, `GB28181`, `Config`, `Cluster`, `SIP Calls`, `Storage`, and + `Security`. +- Replace page-specific inline presentation rules where they conflict with the + shared system, while preserving existing element IDs, permission attributes, + keyboard behavior, and API-driven content. +- Keep table layouts fixed where values are live or variable width, ensure + dense operational views remain scannable, and retain mobile stacking and + horizontal-scroll fallbacks for wide data tables. +- The Publish workspace consumes the same tokens and primitives but may use its + own two-column layout and media preview treatment. + +## Server and Routing + +- Add `GET /console/publish` to the API route registration and serve the same + embedded console document. +- Keep `/console` and `/console/publish` behind the existing console session + authentication middleware. +- No API schema or WebRTC signaling contract changes are required. + +## Testing and Documentation + +- Update console DOM/script tests for keyed stream reconciliation, fixed GOP + rendering, selection/detail sampling, and route-aware publish initialization. +- Update the browser publish test to navigate to `/console/publish` and verify + the full-page flow and existing WHIP session behavior. +- Add a route test for `/console/publish` and retain `/console` compatibility. +- Run focused `module/api` tests, `go test ./...`, and + `tools/check-agent-docs_test.sh`. +- Update `README.md`, `README.zh-CN.md`, `llms.txt`, and `llms-full.txt` to + describe the stable Streams detail view and `/console/publish` workflow. + `agent-manifest.json` does not need capability changes because the underlying + WHIP capability and API contracts are unchanged. + +## Non-goals + +- No persistent time-series storage or server-side metrics history. +- No new charting dependency; the chart is rendered with browser primitives. +- No changes to stream statistics calculation or WHIP signaling semantics. diff --git a/llms-full.txt b/llms-full.txt index 96eb1284..4bc9f34a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -160,7 +160,7 @@ The local Docker Compose workflow builds the image from source by default. A rel - Persistent GB28181 lab sessions: `GET|POST /api/v1/gb28181/lab/sessions`, `DELETE /api/v1/gb28181/lab/sessions/{id}` (`gb28181:read` for list, `gb28181:control` for mutations) The complete HTTP contract is [docs/api/openapi.yaml](docs/api/openapi.yaml). Management responses, including GB28181 Lab responses and their 400/404 errors, use a JSON envelope with `code`, `message`, and optional `data`. Protocol-specific GB28181 device/session/control endpoints can return direct GB JSON errors, while WebRTC uses SDP/plain text. -Management authentication accepts `api.auth.bearer_token`, named viewer/operator/admin tokens, or an authenticated console session. `GET /api/v1/server/health` remains public. TLS API listeners set `Secure` on the HttpOnly, SameSite=Strict `lf_session` cookie; plain HTTP listeners leave it unset for local development. The permission-aware console tabs, in order, are Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security. Recent Audit is a surface inside Security, not a separate tab. Visual groups are Workspace (Streams, GB28181, SIP Calls, Storage), Operations (Cluster), and System (Config, Security). Viewer reads config document/schema and validates without writing; operator controls apply/refresh/calls/kick/live-playback; admin owns deletions/debug/internal mutation. +Management authentication accepts `api.auth.bearer_token`, named viewer/operator/admin tokens, or an authenticated console session. `GET /api/v1/server/health` remains public. TLS API listeners set `Secure` on the HttpOnly, SameSite=Strict `lf_session` cookie; plain HTTP listeners leave it unset for local development. The permission-aware console tabs, in order, are Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security. Recent Audit is a surface inside Security, not a separate tab. Visual groups are Workspace (Streams, GB28181, SIP Calls, Storage), Operations (Cluster), and System (Config, Security). All management views use the same dark teal surface tokens and mint/cyan/amber/coral accents as the console direction demo. Viewer reads config document/schema and validates without writing; operator controls apply/refresh/calls/kick/live-playback; admin owns deletions/debug/internal mutation. `GET /console/publish` serves the same authenticated embedded document in a dedicated broadcaster workspace with a Back to Console link and a selector for WHIP/WebRTC, SIP, or GB28181. WHIP keeps browser camera/microphone capture and the existing `/webrtc/whip/{stream_key}` lifecycle; SIP and GB28181 use existing persistent lab APIs. Streams uses keyed fixed-width rows, keeps Preview actions, and inserts a selected stream's inline detail row immediately after it with four low-height inline SVG charts for bitrate, video FPS, audio FPS, and GOP duration. The selected endpoint is sampled once per second, reset on publisher/GOP generation changes, capped at 60 samples, and stopped when the view is hidden or detail is closed. Recording supports FLV, FMP4, MP4, TS, and HLS plus authenticated listing, status, metadata, range download, inline range playback, and deletion. New recordings default to fMP4 and `.mp4`, with delayed media-track initialization so audio is retained when it arrives after video. Every rotated recording file restores the publisher's declared tracks and deep-copied latest audio/video sequence headers into its new container writer, then rebases each track to a zero-based file-local decode timeline, so FLV, fMP4, MP4, and TS files are independently initialized. TS emits PAT/PMT before the first media PES even when audio arrives first; classic MP4 owns independent previous-DTS state and timescales for audio and video sample durations, normalizes `mvhd`/`tkhd` durations to the movie timescale while retaining each `mdhd` media timescale, saturates out-of-range sample/version-0 timing fields instead of wrapping, emits `ctts` version 1 for any negative PTS-DTS offset and version 0 otherwise, and uses expandable AAC ESDS descriptor lengths. Deployments must retain rotation for files that could approach the version-0 duration limit. fMP4 Record declares only AAC directly; G.711, Opus, MP3, and other non-AAC source audio use the generation-bound AAC transform through the shared `audiocodec`/FFmpeg path when available. Without that optional dependency, unsupported audio is filtered and the result remains playable video-only. DVR TS applies the corresponding target-codec normalization. Sessions that end before a media frame arrives are preserved as failed and are never exposed as completed playable files. Plain GET and DELETE use the complete recording ID, including IDs ending in `/play` or `/download`; explicit `?action=play` and `?action=download` select inline range playback or range download for that full ID. Legacy suffix actions remain available only when no exact ID exists. Recording play/download acquires one global connection slot before opening media, releases it exactly once on every return path, and sets a 10-second write deadline immediately before `ServeContent`. Local deletion recognizes only exact TS segment/playlist names and their defined recovery variants as sidecars, removes cleanup artifacts before the primary, and leaves the primary retriable after any cleanup failure. When the record module is absent, `GET /api/v1/recordings/status` returns 200 with `state=disabled`; item media routes still return 503. DVR exposes session and storage status and serves time-shift playlists/segments on its configured listener. DVR `Close` captures one absolute drain deadline before waiting for admission/setup ownership; callers receive a timeout at that bound while already-started cleanup continues in the background. Finite DVR playlist and segment responses have a 10-second server write bound; admitted success, error, cancellation, and timeout paths each release exactly one global connection slot. Range handling and `ServeContent` metadata remain unchanged. The Storage Console previews completed recordings (native MP4/fMP4 or mpegts.js FLV/TS) and opens HLS playback for DVR sessions with segments. The DVR listener permits non-credentialed cross-origin HLS fetches for this split-port Console, while playlist and segment GETs still run synchronous subscribe authorization hooks only and do not emit asynchronous subscribe lifecycle work. Recording preview reuses the authenticated management session; the Console never persists or appends bearer tokens, so configure DVR subscribe authorization for the independent media listener. Use [docs/recipes/recording-dvr-management.md](docs/recipes/recording-dvr-management.md). diff --git a/llms.txt b/llms.txt index 985d1949..cf39ee36 100644 --- a/llms.txt +++ b/llms.txt @@ -49,7 +49,7 @@ This is the short Agent entrypoint. Use `agent-manifest.json` for structured fac - Console Apply captures a monotonic editor revision and never lets a stale desired snapshot overwrite newer local editor text. - HTTP source scheme must match `runtime.source`, redirects are disabled, and conditional validators come only from the last accepted snapshot. - Source failures keep the last valid snapshot. `SIGHUP` schedules an asynchronous refresh; listener/module/TLS/port changes remain restart-required. -- The console tabs, in order, are Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security. Recent Audit is a surface inside Security, not a separate tab. Visual groups are Workspace (Streams, GB28181, SIP Calls, Storage), Operations (Cluster), and System (Config, Security); Config/Security are not peer video-stream tabs. +- The console tabs, in order, are Streams, GB28181, Config, Cluster, SIP Calls, Storage, and Security. Recent Audit is a surface inside Security, not a separate tab. Visual groups are Workspace (Streams, GB28181, SIP Calls, Storage), Operations (Cluster), and System (Config, Security); Config/Security are not peer video-stream tabs. All management views share the dark teal console demo visual system with mint/cyan/amber/coral accents. `GET /console/publish` serves the dedicated broadcaster workspace: choose WHIP/WebRTC, SIP, or GB28181 and publish through the corresponding existing signaling/lab lifecycle. Streams uses keyed fixed-layout rows; selecting a row inserts inline detail directly after it and samples bitrate, video FPS, audio FPS, and GOP duration once per second, retaining at most 60 samples and rendering all four inline SVG trends together. Preview remains available on publishing stream rows. - Console preview URLs use the active HTTP/WebRTC listener reported by `/api/v1/server/info`; if another process owns `127.0.0.1:8080`, browser HTTP-FLV/HLS/DASH/FMP4 requests can receive that process's response while RTMP and WHEP remain healthy. Check the response `Server` header and move the HTTP listener or release the conflicting port. - Recording supports FLV, FMP4, MP4, TS, and HLS with authenticated metadata/download/inline-range-play/delete APIs and DVR status. New recordings default to fMP4 with a `.mp4` extension; `hls` is a TS storage alias; `record.segment.max_size` accepts empty/zero or decimal B/KB/MB/GB values only; absent recording modules report `state=disabled` instead of making the Storage page fail; active or failed recordings return JSON 409 from media actions without serving bytes. - fMP4 recording declares AAC directly, derives omitted AAC timing metadata from ASC, converts non-AAC source audio through the optional `audiocodec`/FFmpeg path, and otherwise filters audio for playable video-only output; at publisher-generation end, fixed-size transforms flush retained resampler samples, silence-pad partial PCM, and emit delayed encoder packets exactly once before Record/DVR output closes; explicit AAC timing arguments remain authoritative. diff --git a/module/api/console.html b/module/api/console.html index 2b87ea8d..ce380da8 100644 --- a/module/api/console.html +++ b/module/api/console.html @@ -87,6 +87,8 @@ overflow-x: auto; } table { width: 100%; border-collapse: collapse; } +table { table-layout: fixed; } +table col { width: auto; } th { text-align: left; padding: 12px 16px; @@ -126,6 +128,39 @@ /* GOP cache */ .gop-cell { min-width: 170px; } +.gop-cell { line-height: 1.45; min-height: 42px; } +.gop-value-frames, .gop-value-duration, .gop-value-generation, .gop-value-media { font-variant-numeric: tabular-nums; } +.stream-row { cursor: pointer; } +.streams-table tbody .stream-row { height: 68px; } +.streams-table tbody .stream-row > td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.streams-table tbody .stream-row > td:nth-child(8), .streams-table tbody .stream-row > td:nth-child(9), .streams-table tbody .stream-row > td:nth-child(10) { white-space: normal; } +.stream-row.selected td { background: rgba(108,158,255,0.08); border-bottom-color: rgba(108,158,255,0.3); } +.stream-row:focus-visible td { outline: 2px solid var(--accent); outline-offset: -2px; } +.stream-detail-row td { padding: 0; background: var(--bg); border-bottom: 1px solid var(--border); } +.stream-detail-panel { padding: 16px; border-left: 3px solid var(--accent); } +.stream-detail-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; } +.stream-detail-title { font-size:13px; font-weight:600; color:var(--text); } +.stream-detail-meta { color:var(--text2); font-size:12px; font-family:"SF Mono", "Fira Code", monospace; } +.stream-detail-close { min-width:32px; padding:4px 8px; } +.trend-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; } +.trend-card { min-width:0; padding:9px 10px 7px; background:var(--surface); border:1px solid var(--border); border-radius:6px; } +.trend-card-header { display:flex; align-items:baseline; justify-content:space-between; gap:8px; margin-bottom:4px; } +.trend-label { color:var(--text2); font-size:10px; text-transform:uppercase; letter-spacing:.05em; white-space:nowrap; } +.trend-current { color:var(--text); font:600 12px "SF Mono", "Fira Code", monospace; white-space:nowrap; } +.trend-chart { display:block; width:100%; height:68px; overflow:visible; } +.trend-gridline { stroke:var(--border); stroke-width:1; } +.trend-area { opacity:.16; } +.trend-path { fill:none; stroke-width:2; vector-effect:non-scaling-stroke; } +.trend-card[data-metric="bitrate"] .trend-area { fill:var(--accent); } +.trend-card[data-metric="bitrate"] .trend-path { stroke:var(--accent); } +.trend-card[data-metric="video-fps"] .trend-area { fill:var(--green); } +.trend-card[data-metric="video-fps"] .trend-path { stroke:var(--green); } +.trend-card[data-metric="audio-fps"] .trend-area { fill:var(--blue); } +.trend-card[data-metric="audio-fps"] .trend-path { stroke:var(--blue); } +.trend-card[data-metric="gop-duration"] .trend-area { fill:var(--yellow); } +.trend-card[data-metric="gop-duration"] .trend-path { stroke:var(--yellow); } +.trend-range { display:flex; justify-content:space-between; gap:8px; color:var(--text2); font:10px "SF Mono", "Fira Code", monospace; } +.trend-empty { color:var(--text2); font-size:12px; padding:9px 0 3px; } .gop-label { font-size: 11px; font-family: "SF Mono", "Fira Code", monospace; @@ -447,6 +482,24 @@ border-bottom: 1px solid var(--border); } .publish-header h3 { font-size: 15px; font-weight: 600; } +.publish-header .publish-back { color:var(--text2); font-size:12px; text-decoration:none; margin-left:auto; margin-right:10px; } +.publish-header .publish-back:hover { color:var(--text); } +.publish-protocol-row { display:flex; align-items:center; gap:10px; margin-bottom:14px; } +.publish-protocol-row label { color:var(--text2); font-size:12px; } +.publish-protocol-row select { flex:1; } +.publish-protocol-panel[hidden] { display:none; } +.publish-workspace-note { color:var(--text2); font-size:12px; line-height:1.5; margin:0 0 14px; } +.publish-route .container { max-width:1180px; } +.publish-route .container > header, .publish-route #cert-banner, .publish-route #nav-tabs, .publish-route .container > [id^="view-"] { display:none !important; } +.publish-route .publish-overlay { position:static; display:flex; min-height:calc(100vh - 64px); background:transparent; z-index:auto; align-items:flex-start; justify-content:stretch; } +.publish-route .publish-modal { width:100%; max-width:none; min-width:0; border-radius:10px; padding:0; box-shadow:0 12px 36px rgba(0,0,0,.25); } +.publish-route .publish-header { padding:18px 22px; } +.publish-route .publish-header h3 { font-size:18px; } +.publish-route .publish-body { padding:22px; } +.publish-route .publish-preview-wrap { max-width:860px; margin:0 auto 16px; } +.publish-route .publish-actions { max-width:860px; margin:0 auto; } +.publish-route .publish-form-row { max-width:860px; margin-left:auto; margin-right:auto; } +.publish-route .publish-protocol-row { max-width:860px; margin-left:auto; margin-right:auto; } .publish-body { padding: 20px; flex: 1; overflow: auto; } .publish-form-row { margin-bottom: 14px; @@ -804,24 +857,269 @@ .ops-toolbar { align-items: flex-start; flex-wrap: wrap; } .ops-toolbar-actions { width: 100%; } .ops-toolbar-actions .btn { width: 100%; } + .trend-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } + .publish-route .publish-body { padding:16px; } +} +@media (max-width: 460px) { + .trend-grid { grid-template-columns:1fr; } +} + +/* Demo direction: shared console visual system. */ +:root { + --ink: #081116; + --ink-2: #0d1a20; + --ink-3: #13242b; + --line: #223a42; + --line-soft: rgba(159, 195, 190, .14); + --text: #e9f0ed; + --muted: #91a8a6; + --dim: #637c7a; + --mint: #61e0c1; + --cyan: #65c7e8; + --amber: #ffbc5b; + --coral: #ff8277; + --lime: #b7e66d; + --bg: var(--ink); + --surface: var(--ink-2); + --surface2: var(--ink-3); + --border: var(--line); + --accent: var(--mint); + --green: var(--mint); + --yellow: var(--amber); + --red: var(--coral); + --blue: var(--cyan); + color-scheme: dark; +} +html, body { min-height: 100%; } +body { + background: var(--ink); + color: var(--text); + font: 14px/1.45 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} +button, input, select, textarea { font: inherit; } +.container.shell { width: min(1420px, calc(100% - 48px)); max-width: none; margin: 0 auto; padding: 26px 0 48px; } +.topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 30px; } +.brand { display: flex; align-items: center; gap: 12px; } +.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid rgba(97,224,193,.42); color: var(--mint); background: rgba(97,224,193,.08); border-radius: 9px; font-size: 18px; } +.logo h1 { font-size: 15px; font-weight: 720; letter-spacing: .02em; } +.brand-sub { color: var(--muted); font-size: 12px; margin-left: 7px; font-weight: 450; } +.top-actions { display: flex; align-items: center; gap: 12px; } +.live-indicator { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; } +.live-dot { width: 7px; height: 7px; background: var(--mint); border-radius: 99px; box-shadow: 0 0 0 4px rgba(97,224,193,.11); } +.dot { animation: none; } +.server-version, .clock { color: var(--dim); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; } +.btn { border: 1px solid var(--line); background: var(--ink-2); color: var(--text); border-radius: 6px; padding: 8px 12px; transition: border-color .16s, background .16s, color .16s; } +.btn:hover { border-color: var(--mint); background: var(--ink-3); color: var(--text); } +.btn-primary { background: var(--mint); border-color: var(--mint); color: #07201d; font-weight: 700; } +.btn-primary:hover { background: #8aead3; border-color: #8aead3; color: #07201d; } +.btn-quiet { color: var(--muted); } +.btn-publish { display: none; padding: 8px 12px; } +.view-switch { display: flex; gap: 4px; padding: 4px; margin-bottom: 22px; background: var(--ink-2); border: 1px solid var(--line); border-radius: 7px; width: fit-content; } +.view-switch .nav-group { display: contents; } +.view-switch .nav-group-label { display: none; } +.view-switch .nav-group-tabs { display: contents; } +.view-switch .nav-tab { border: 0; color: var(--muted); background: transparent; padding: 8px 16px; border-radius: 4px; font-size: 12px; font-weight: 500; } +.view-switch .nav-tab:hover { color: var(--text); border-color: transparent; } +.view-switch .nav-tab.active { background: var(--ink-3); color: var(--text); box-shadow: inset 0 -2px 0 var(--mint); border-color: transparent; } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 16px; } +.eyebrow { color: var(--mint); font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; } +.section-heading h1 { margin: 4px 0 0; font-size: 28px; line-height: 1.1; letter-spacing: -.02em; } +.heading-note { color: var(--muted); margin-top: 7px; } +.stats, .stats-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin-bottom: 18px; } +.stats .stat-card, .metric { border: 1px solid var(--line); background: var(--ink-2); border-radius: var(--radius); padding: 14px 15px; min-height: 86px; } +.stat-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 0; } +.stat-value { margin-top: 7px; font-size: 24px; line-height: 1.1; font-weight: 680; font-variant-numeric: tabular-nums; } +.stat-value.accent { color: var(--mint); } +.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 12px; } +.toolbar .search, .search { width: 270px; max-width: 100%; border: 1px solid var(--line) !important; background: var(--ink-2) !important; color: var(--text); padding: 9px 11px !important; border-radius: 6px !important; outline: none; } +.toolbar .search:focus, .search:focus, select:focus, input:focus, textarea:focus { border-color: var(--mint) !important; box-shadow: 0 0 0 2px rgba(97,224,193,.12); } +.toolbar-meta { color: var(--dim); font-size: 12px; } +.table-wrap, .table-frame { overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius); background: var(--ink-2); } +.table-wrap table, .table-frame table { width: 100%; table-layout: fixed; border-collapse: collapse; } +.table-wrap th, .table-frame th { padding: 11px 13px; background: var(--ink-3); color: var(--dim); border-bottom: 1px solid var(--line); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-align: left; text-transform: uppercase; } +.table-wrap td, .table-frame td { height: 76px; padding: 12px 13px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; overflow: hidden; } +.table-wrap tr:last-child td, .table-frame tr:last-child td { border-bottom: 0; } +.streams-table tbody .stream-row { height: 76px; } +.streams-table tbody .stream-row > td { white-space: nowrap; } +.streams-table tbody .stream-row > td:nth-child(8), .streams-table tbody .stream-row > td:nth-child(9), .streams-table tbody .stream-row > td:nth-child(10) { white-space: normal; } +.table-wrap tbody tr:hover, .table-frame tbody tr:hover, .streams-table tbody tr.selected { background: rgba(97,224,193,.055); } +.stream-key { color: var(--text); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; } +.stream-cell { display: flex; align-items: center; gap: 10px; min-width: 0; } +.stream-signal { width: 27px; height: 27px; flex: 0 0 27px; display: grid; place-items: center; color: var(--mint); border: 1px solid rgba(97,224,193,.25); border-radius: 5px; font-size: 12px; } +.stream-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; } +.stream-owner { color: var(--dim); font-size: 11px; margin-top: 2px; } +.publisher-id { color: var(--dim); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.state { display: inline-flex; align-items: center; gap: 7px; color: var(--mint); background: transparent; padding: 0; font-size: 12px; } +.state::before { content: ""; width: 6px; height: 6px; border-radius: 99px; background: currentColor; } +.state-idle, .state-no_publisher { color: var(--dim); } +.state-publishing { color: var(--mint); background: transparent; } +.state-publishing::before { background: currentColor; } +.codec-tag { display: inline-block; color: var(--cyan); border: 1px solid rgba(101,199,232,.24); background: rgba(101,199,232,.08); border-radius: 4px; padding: 3px 6px; margin: 2px 4px 2px 0; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.codec-tag.audio { color: var(--amber); border-color: rgba(255,188,91,.24); background: rgba(255,188,91,.08); } +.mono { color: var(--text); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; } +.gop-cell { min-width: 0; line-height: 1.45; } +.gop-cell .gop { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; min-width: 0; } +.gop-cell .gop-key, .gop-cell .gop-value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.gop-cell .gop-label { display: grid; grid-template-columns: auto 1fr; gap: 3px 7px; color: var(--dim); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.gop-cell .gop-label b, .gop-cell .gop-value-frames, .gop-cell .gop-value-duration, .gop-cell .gop-value-media, .gop-cell .gop-value-generation { color: var(--text); font-weight: 500; } +.gop-cell .gop-value-duration { color: var(--amber); } +.subs { display: flex; gap: 4px; flex-wrap: wrap; } +.sub-pill { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); border: 1px solid var(--line); background: var(--ink-3); border-radius: 4px; padding: 3px 6px; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.sub-pill.active { color: var(--cyan); border-color: rgba(101,199,232,.24); background: rgba(101,199,232,.08); } +.actions { display: flex; gap: 6px; flex-wrap: wrap; } +.actions .btn { padding: 6px 9px; font-size: 11px; } +.empty-state { padding: 80px 20px; text-align: center; } +.empty-title { font-size: 15px; color: var(--muted); margin-bottom: 6px; } +.empty-sub { font-size: 13px; color: var(--dim); } +.ops-section, .ops-card, .table-wrap, .modal, .player-modal { border-color: var(--line); background: var(--ink-2); } +.ops-section { border-radius: var(--radius); } +.ops-input, .ops-form input, .ops-form select, .ops-form textarea, .publish-form-row input, .publish-form-row select { border-color: var(--line) !important; background: var(--ink) !important; color: var(--text); border-radius: 5px; } +.ops-input:focus, .ops-form input:focus, .ops-form select:focus, .ops-form textarea:focus { border-color: var(--mint) !important; } +.badge-online, .badge-on { background: rgba(97,224,193,.10); color: var(--mint); } +.badge-offline { background: rgba(255,130,119,.10); color: var(--coral); } +.badge-off { background: rgba(99,124,122,.12); color: var(--dim); } +.stream-detail-row > td { height: auto; padding: 0; background: var(--ink); border-bottom: 1px solid var(--line); } +.stream-detail-panel { border: 1px solid var(--line); border-left: 2px solid var(--mint); border-radius: var(--radius); background: var(--ink-2); overflow: hidden; margin: 0 8px 8px; padding: 0; } +.stream-detail-header, .detail-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 17px; border-bottom: 1px solid var(--line); } +.stream-detail-title, .detail-title strong { color: var(--text); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; } +.stream-detail-meta { color: var(--muted); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.detail-body { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 18px; padding: 17px; } +.detail-metrics { display: grid; align-content: start; gap: 8px; } +.trend-button { display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--line); color: var(--muted); background: transparent; border-radius: 5px; padding: 9px 10px; text-align: left; } +.trend-button.active { color: var(--text); border-color: var(--mint); background: rgba(97,224,193,.07); } +.trend-button .swatch { width: 7px; height: 7px; border-radius: 99px; background: var(--mint); margin-right: 8px; display: inline-block; } +.trend-button:nth-of-type(3) .swatch { background: var(--cyan); } +.trend-button:nth-of-type(4) .swatch { background: var(--amber); } +.trend-button:nth-of-type(5) .swatch { background: var(--coral); } +.stream-detail-close, .detail-close { color: var(--muted); background: transparent; border: 0; font-size: 17px; padding: 4px 8px; } +.stream-detail-close:hover, .detail-close:hover { color: var(--text); background: var(--ink-3); } +.stream-detail-panel .trend-empty { color: var(--dim); padding: 10px 17px 0; font-size: 11px; } +.stream-detail-panel .trend-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; padding: 16px 17px 17px; } +.trend-card { min-width: 0; padding: 10px 11px 8px; background: var(--ink); border: 1px solid var(--line); border-radius: 6px; } +.trend-card-header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 5px; } +.trend-label { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; white-space: nowrap; } +.trend-current { color: var(--text); font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; } +.trend-chart { display: block; width: 100%; height: 58px; overflow: visible; } +.trend-gridline { stroke: rgba(159,195,190,.12); stroke-width: 1; } +.trend-area { opacity: .16; } +.trend-path { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linecap: round; stroke-linejoin: round; } +.trend-card[data-metric="bitrate"] .trend-area { fill: var(--mint); } +.trend-card[data-metric="bitrate"] .trend-path { stroke: var(--mint); } +.trend-card[data-metric="video-fps"] .trend-area { fill: var(--cyan); } +.trend-card[data-metric="video-fps"] .trend-path { stroke: var(--cyan); } +.trend-card[data-metric="audio-fps"] .trend-area { fill: var(--amber); } +.trend-card[data-metric="audio-fps"] .trend-path { stroke: var(--amber); } +.trend-card[data-metric="gop-duration"] .trend-area { fill: var(--coral); } +.trend-card[data-metric="gop-duration"] .trend-path { stroke: var(--coral); } +.trend-range { display: flex; justify-content: space-between; gap: 8px; color: var(--dim); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.trend-summary { display: flex; gap: 16px; padding: 0 17px 15px; color: var(--dim); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.trend-summary strong { color: var(--text); font-weight: 500; } +.publish-route .container > .topbar, .publish-route .container > #cert-banner, .publish-route .container > #nav-tabs, .publish-route .container > [id^="view-"] { display: none !important; } +.publish-overlay { background: rgba(0,0,0,.7); } +.publish-route .publish-overlay { position: static; display: block; min-height: auto; background: transparent; z-index: auto; } +.publish-route .publish-modal { width: 100%; max-width: none; max-height: none; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; box-shadow: none; overflow: visible; } +.publish-route .publish-modal.publish-shell { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } +.publish-shell { max-width: 1180px; margin: 0 auto; } +.publish-top { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 24px; } +.publish-title { display: flex; align-items: center; gap: 12px; } +.publish-title .eyebrow { margin-bottom: 6px; } +.publish-title h1 { font-size: 28px; line-height: 1.1; letter-spacing: -.02em; } +.publish-top-actions { display: flex; align-items: center; gap: 10px; } +.back-link { color: var(--muted); text-decoration: none; font-size: 12px; } +.back-link:hover { color: var(--text); } +.publish-close { display: none; } +.workspace { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); gap: 16px; align-items: start; } +.preview-column, .control-rail { min-width: 0; } +.preview-frame { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border: 1px solid var(--line); border-radius: var(--radius); background: #071014; } +.preview-frame::before { content: ""; position: absolute; inset: 0; opacity: .55; background-image: linear-gradient(rgba(97,224,193,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(97,224,193,.06) 1px, transparent 1px); background-size: 38px 38px; pointer-events: none; } +.camera-ghost { position: absolute; left: 50%; top: 45%; z-index: 1; transform: translate(-50%, -50%); color: rgba(233,240,237,.72); text-align: center; pointer-events: none; } +.camera-ghost .camera-icon { display: grid; place-items: center; width: 62px; height: 62px; margin: 0 auto 10px; border: 1px solid rgba(97,224,193,.38); border-radius: 50%; color: var(--mint); font-size: 25px; } +.camera-ghost strong { display: block; font-size: 14px; font-weight: 580; } +.camera-ghost span { display: block; color: var(--muted); margin-top: 4px; font-size: 12px; } +.preview-frame video { position: relative; z-index: 1; width: 100%; height: 100%; object-fit: contain; transform: scaleX(-1); } +.preview-topline { position: absolute; inset: 12px 13px auto; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.preview-tag { padding: 5px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 4px; background: rgba(7,16,20,.64); color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .1em; } +.preview-tag.live { color: var(--mint); border-color: rgba(97,224,193,.3); } +.preview-footer { position: absolute; inset: auto 13px 12px; z-index: 2; display: flex; align-items: center; justify-content: space-between; color: var(--muted); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.preview-wave { display: flex; align-items: end; gap: 2px; height: 16px; } +.preview-wave i { width: 2px; background: var(--mint); opacity: .7; animation: pulse-wave 1.1s ease-in-out infinite alternate; } +.preview-wave i:nth-child(2n) { animation-delay: -.4s; } .preview-wave i:nth-child(3n) { animation-delay: -.8s; } +@keyframes pulse-wave { from { height: 3px; } to { height: 15px; } } +.stat-strip { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 9px; margin-top: 12px; } +.stat-tile { border: 1px solid var(--line); background: var(--ink-2); border-radius: 6px; padding: 11px 12px; } +.stat-tile span { display: block; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } +.stat-tile strong { display: block; margin-top: 5px; color: var(--text); font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; } +.rail-card { border: 1px solid var(--line); background: var(--ink-2); border-radius: var(--radius); padding: 17px; } +.rail-card + .rail-card { margin-top: 12px; } +.rail-card h2 { margin-bottom: 15px; font-size: 18px; } +.rail-card h3 { color: var(--text); font-size: 14px; } +.protocol-switch { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; margin: 0 0 16px; border: 1px solid var(--line); border-radius: 6px; background: var(--ink); } +.protocol-switch button { border: 0; border-radius: 4px; padding: 8px 5px; color: var(--muted); background: transparent; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.protocol-switch button:hover { color: var(--text); background: var(--ink-3); } +.protocol-switch button.active { color: var(--ink); background: var(--mint); font-weight: 700; } +.protocol-native-select { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } +.protocol-fields { display: none; } +.protocol-fields.active { display: block; } +.publish-protocol-panel[hidden] { display: none !important; } +.field { margin-top: 14px; } +.field:first-of-type { margin-top: 0; } +.field label { display: block; color: var(--muted); font-size: 11px; margin-bottom: 6px; } +.field input, .field select { width: 100%; border: 1px solid var(--line); background: var(--ink); color: var(--text); padding: 10px 11px; border-radius: 5px; outline: none; } +.field input { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; } +.publish-actions { display: flex; align-items: center; gap: 8px; margin-top: 17px; } +.publish-actions .btn-start-publish { flex: 1; } +.btn-start-publish, .btn-stop-publish { padding: 9px 14px; } +.btn-stop-publish { display: none; color: var(--coral); border-color: rgba(255,130,119,.3); } +.btn-stop-publish:hover { color: var(--coral); background: rgba(255,130,119,.08); border-color: var(--coral); } +.publish-status { color: var(--dim); font-size: 11px; margin-top: 13px; line-height: 1.5; } +.publish-status.ok { color: var(--mint); } +.publish-status.error { color: var(--coral); } +.connection { display: flex; align-items: center; gap: 9px; padding: 10px 0 13px; border-bottom: 1px solid var(--line-soft); color: var(--muted); font-size: 12px; } +.connection-dot { width: 8px; height: 8px; border-radius: 99px; background: var(--amber); box-shadow: 0 0 0 4px rgba(255,188,91,.11); } +.connection-dot.ready { background: var(--mint); box-shadow: 0 0 0 4px rgba(97,224,193,.11); } +.connection strong { color: var(--text); font-weight: 600; } +.check-list { display: grid; gap: 12px; margin-top: 13px; } +.check { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 12px; } +.check b { color: var(--mint); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 500; } +.route-hint { margin-top: 18px; color: var(--dim); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-word; } +.publish-stats-overlay { z-index: 3; } +@media (max-width: 1000px) { .stats, .stats-grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 760px) { + .container.shell { width: min(100% - 24px, 620px); padding-top: 18px; } + .topbar { align-items: flex-start; margin-bottom: 22px; } + .top-actions .live-indicator, .top-actions .server-version { display: none; } + .top-actions { gap: 0; } + .view-switch { max-width: 100%; overflow-x: auto; } + .section-heading { align-items: flex-start; flex-direction: column; gap: 8px; } + .section-heading h1, .publish-title h1 { font-size: 24px; } + .stats, .stats-grid { grid-template-columns: repeat(2, 1fr); } + .toolbar { align-items: stretch; flex-direction: column; } + .toolbar .search, .search { width: 100%; } + .table-wrap, .table-frame { overflow-x: auto; } + .table-wrap table, .table-frame table { min-width: 1080px; } + .detail-body { grid-template-columns: 1fr; } + .detail-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stream-detail-panel .trend-grid { grid-template-columns: 1fr; } + .workspace { grid-template-columns: 1fr; } + .publish-top { align-items: flex-start; flex-direction: column; gap: 12px; } + .stat-strip { grid-template-columns: repeat(2, 1fr); } + .publish-route .publish-modal.publish-shell { width: calc(100% - 24px); } } -
-
-