Web console: React SPA, PTZ, discovery, and user permissions
Grows the --server-only web console from the Phase 20 foundation into a
self-contained browser client for OpenIPC cameras. The Razor UI it shipped
with is removed — the SPA reached parity and then passed it.
What's new
React SPA replaces the server-rendered UI. Client-routed, so navigating
never reloads the page — and, crucially, never restarts video. Live tiles are
keyed by camera id and survive a grid resize (1 → 9 → 1 keeps the shared
cameras playing), a layout switch, a page flip, and now a route change: the
PTZ from the browser. Pan/tilt/zoom with presets, for cameras that report
PTZ and have a probed ONVIF profile. Movement is stateless on the server: each
move carries an ONVIF self-stop timeout that the client refreshes while a
button is held, so a closed tab cannot leave a camera panning.
Network discovery. ONVIF WS-Discovery, mDNS and an opt-in subnet sweep,
using the same aggregator as the desktop app. A scan is a background job polled
by id, so it survives navigation and a second viewer joins the running scan
instead of starting a rival. Adding is probe → review → add: ONVIF is the only
source of the real RTSP URI and it can be wrong behind NAT, so nothing is
written until the user has seen it.
Users and permissions. Accounts with permission flags
(ViewLive / ViewArchive / Ptz / Export / Manage) and an optional
per-user camera subset. The roster is a JSON file next to the database holding
PBKDF2 hashes only; the built-in admin keeps working as the way back in if the
last Manage account is lost. Authorization is enforced per endpoint — the UI
hides what you may not do, but hiding is not the boundary. A camera outside
your subset answers 404 rather than 403, so ids can't be probed for existence.
Also: saved grid layouts with paging (page = gridSize², like the desktop
head), a single-camera page, mobile layout at the 700px breakpoint, in-app
modals instead of prompt/confirm, and Inter bundled as a self-hosted woff2
(full variable file, not the latin subset, which drops Cyrillic).
Fixes found along the way
Live video never actually played past the first seconds. fMP4 carries the
ffmpeg session's timestamps, so a viewer joining a running stream got a buffer
starting minutes ahead of currentTime, outside every buffered range —
autoplay never fired and the tile sat frozen. A watchdog now seeks to the live
edge and resumes. Pre-existing, not caused by the pooling above (verified
against an element that is never reparented).
Unmatched /api paths returned index.html with 200 text/html via the
SPA fallback; they now 404 with JSON.
Adding a non-ONVIF camera sat through the full ONVIF timeout for nothing.
Breaking
The Razor UI under /app is gone; / serves the SPA. Its form-post endpoints
(/app/config/export|import, /app/sessions/revoke-all) moved to /api/v1
and answer JSON instead of redirects.
OpenIPC.Viewer.Web is a plain SDK project again (no .razor left to
compile); the SPA is embedded in the assembly, so dotnet publish still
produces one self-contained binary. Node is a build-time-only dependency and
CI installs it.
Verification
Exercised live against a real 82-camera database via --server-only:
grid pagination (10 pages, correct windowing, ends disabled), cross-route
persistence (same DOM nodes, currentTime advanced through the absence,
server stream count unchanged), retention expiry (streams 8 → 0, no leak);
layout editing on a middle page preserves the pages before and after
byte-for-byte (checked on a throwaway layout, then deleted);
permissions: a view-only user with two cameras sees exactly those two and gets
403 on camera CRUD, users, discovery, export, PTZ, layouts and revoke-all; an
operator gets 404 (not 403) for PTZ and live on a camera outside their subset;
discovery scan/cancel/409-while-running, probe fallback for a non-ONVIF host,
and add creating a camera with ONVIF metadata persisted;
config export/import and session revoke through the UI.
Builds with 0 warnings (TreatWarningsAsErrors is on repo-wide); eslint and
tsc are clean.
Not verified: PTZ against a physical PTZ camera, and discovery against
cameras on a real LAN — the dev machine has neither. Both paths are exercised
only through their error/fallback branches and a stubbed scan payload.
What's Changed
Full Changelog: v0.3.7...v0.3.8-rc1