Skip to content

feat: web-server mode#59

Merged
keyldev merged 33 commits into
mainfrom
feat/phase-21-web-react-spa
Jul 21, 2026
Merged

feat: web-server mode#59
keyldev merged 33 commits into
mainfrom
feat/phase-21-web-react-spa

Conversation

@keyldev

@keyldev keyldev commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

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
<video> elements live in a pool above the router and are borrowed by tiles,
with a 30s retention window before the stream is closed.

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.

Related

Type

  • Bug fix
  • Feature
  • Refactor / cleanup
  • Docs / CI
  • Other:

Checklist

  • Builds with 0 warnings (TreatWarningsAsErrors=true).
  • Tests pass (dotnet test); new Core logic has unit tests.
  • No layering violation — App references Core only (Infrastructure / Video / Devices wired via DI in a head).
  • Scope stays within one phase (didn't pull work from a later phase's "Не входит").
  • README / docs updated if public commands, options, or setup changed.

Platforms tested

  • Windows
  • Linux
  • macOS
  • Android
  • iOS
  • CI build only

Screenshots / notes

keyldev added 30 commits July 20, 2026 21:58
- New OpenIPC.Viewer.Web project: Kestrel host via ASP.NET shared framework
- WebServer + WebServerOptions: localhost-only bind by default, opt-in --lan
- --server-only headless entry in Desktop (Program.cs early-return, no GUI)
- /healthz, /api/v1/version endpoints + minimal security-header floor
- IWebAuthProvider seam; public build ships single-admin PasswordAuthProvider (PBKDF2)
- Admin password from OPENIPC_WEB_ADMIN_PASSWORD env, else generated + logged once
- SessionStore: 256-bit opaque bearer tokens, stores only SHA-256 digest, sliding TTL
- login (rate-limited 5/min/IP) / logout / me endpoints + bearer guard over /api/v1
- Origin check on mutations; private .ovac RBAC can plug in as another provider later
- WebBackend.AddWebBackend(dbPath): SQLite persistence only, no Avalonia/App
- WebServer runs migrations on startup when a backend is composed
- GET /api/v1/cameras behind auth; CameraDto strips RTSP creds, exposes hasCredentials bool
- Desktop server-only points the web host at the same on-disk database
- New OpenIPC.Viewer.Web.Tests: golden no-secret DTO test + auth/session tests
- POST/PUT/DELETE /api/v1/cameras via CameraDirectoryService (creds to secret store)
- CameraWriteRequest.TryValidate: required fields, port ranges, RTSP URI, creds all-or-nothing
- Audit line per mutation (action/camera/user/ip); 400/404/503 handled
- WebBackend registers CameraDirectoryService; server-only reuses Desktop platform services
- GET/POST/PUT/DELETE /api/v1/groups via IGroupRepository; delete-in-use -> 409
- Shared ApiHelpers (backend/validation/not-found/audit); CameraApi refactored onto them
- Pivot UI to server-rendered Razor (static SSR): no wasm-tools/node, same Kestrel host
- Web project -> Microsoft.NET.Sdk.Razor; AddRazorComponents + MapRazorComponents<App>
- Cookie-carried session (HttpOnly openipc_session); AuthApi.TokenFrom = bearer or cookie
- Login/logout form-post endpoints under /app/auth/*; cameras list page renders sanitized DTOs
- UseForwardedHeaders (X-Forwarded-For/Proto/Host) first in the pipeline
- Trusts loopback by default + WebServerOptions.TrustedProxies for off-host proxies
- Fixes Origin/CSRF check rejecting proxied mutations; direct exposure ignores spoofed headers
- WS GET /api/v1/cameras/{id}/live: ffmpeg remuxes RTSP H.264 -> fMP4 (copy) to the socket
- Credentials injected into the RTSP URL for ffmpeg only, never surfaced to any client
- Live.razor plays via MSE; JS reads the exact codec from the avcC init box (any H.264 profile)
- UseWebSockets; auth-guarded (cookie/bearer); ffmpeg killed on client disconnect
- Verified live: valid fMP4 (ftyp/moov/moof/avcC) over WS on a real camera; no token -> 401
- Grid.razor at /app/grid[/{size}] with 1/4/9 layouts, auth-guarded
- Auto-fills cells from grid-included cameras; each tile plays via shared MSE starter
- Extract window.openipcLive.start into App head; Live.razor reuses it
- Cameras header links to Grid
- localhost / 127.0.0.1 / ::1 mix no longer false-rejects with bad_origin
- Compare hostnames port-agnostically (matches SameSite scope); cross-site still blocked
- Referrer-Policy no-referrer made Chrome send "Origin: null" on same-site form POST,
  tripping the CSRF check; switch to strict-origin-when-cross-origin
- Treat null/opaque Origin as allowed (SameSite=Strict is the real CSRF defense)
- Log Origin/Host on bad_origin rejections for diagnosis
- CameraEditor page (/app/cameras/new + /{id}/edit) with a full server-rendered form
- Form-post create/update/delete under /app/cameras/*, reusing validation + directory service
- Cameras list gains Add / Edit / Delete (confirm) actions; edit keeps creds when blank
…ce 20.4)

- Live WS honours ?transcode=1: re-encodes to H.264 with libopenh264 (LGPL build has no libx264)
- Client detects hvcC / unsupported codec and reconnects asking for transcode
- H.264 stays on cheap copy; transcode CPU only for codecs the browser can't play
- Verified: transcode WS delivers valid H.264 fMP4 (avc1.42C01F, constrained baseline)
…20 slice 20.4)

- LiveStreamHub: shared per-(camera,mode) ffmpeg; parses MP4 boxes, caches the
  init segment, broadcasts whole fragments so late joiners never get a partial
- Per-subscriber bounded channel (drop-oldest) so a slow viewer can't stall others
- Refcounted: ffmpeg starts on first viewer, stops when the last leaves
- LiveApi slimmed to subscribe + pump; LiveFfmpeg holds the process helpers
- Verified: 2 viewers of one camera share a single ffmpeg; cleaned up on leave
- WebStrings (EN/RU) + scoped WebLocalizer (lang cookie, else Accept-Language)
- /app/lang/{code} switcher (cookie) + EN·RU links in headers
- All pages (login, cameras, grid, editor, live) use @l["key"]
- Desktop Localizer stays in the App layer; web keeps its own small dictionary
…lish)

- Port desktop design tokens (Colors/Theme.axaml) to CSS variables: indigo accent,
  dark surfaces, text scale, radii, 8px grid, Inter stack
- Replace top headers with a left NavSidebar (icon nav, user + lang + logout)
- All pages use the .shell layout; login is a centered card
… 20 tail)

- SystemPage (/app/system): version, camera/group counts, active sessions & streams
- GET /app/config/export downloads browser-safe JSON (no camera passwords)
- POST /app/config/import (file upload) applies via IConfigBackupService
- POST /app/sessions/revoke-all clears every session; NavSidebar gains a System item
- WebBackend registers ILayoutRepository + IConfigBackupService
- New src/OpenIPC.Viewer.Web.Client: Vite+React+TS SPA over the frozen
  Phase 20 /api/v1 — Login, Cameras+CRUD, Grid, Groups, System, RU/EN.
- Grid tiles keyed by cameraId survive 1/4/9 layout switches without
  restarting the <video>/WS/ffmpeg session (verified: SAME DOM nodes).
- useLiveTile hook ports the proven MSE+WS client (avcC/hvcC, HEVC transcode).
- Cookie auth: JSON /api/v1/auth/login now also sets the HttpOnly cookie so
  REST + same-origin live-WS authenticate off it; logout clears it.
- SPA embedded into Web.dll (ManifestEmbeddedFileProvider) + MapFallbackToFile;
  MSBuild target runs npm build on dotnet build/publish; CI gets Node+npm cache.
- Razor kept at /app as transitional fallback; SPA served at /.
- Live-status pill with colored dot (live/connecting/error/offline) +
  centered spinner until the first frame arrives.
- Tile hover highlight + fullscreen (hover button or double-click).
- Pad short layouts to a regular NxN with dashed empty slots.
- Grid uses full width (camera wall) instead of the 1100px content cap.
- Quick start (LAN), flags/env reference, adding cameras, ffmpeg.
- Reverse-proxy (Caddy/nginx) HTTPS + systemd service examples.
…EADME

- web-server.md: "How it's built" — React/Vite compiled + embedded into the
  server DLL, no Node/dist at runtime.
- README: web-console feature bullet + self-host section linking the guide.
- Add docs/web-server.ru.md (full RU), cross-link EN <-> RU at top.
- README self-host section links both languages.
- Api/LayoutApi.cs: GET/POST/PUT/DELETE /api/v1/layouts + PUT tiles over the
  Phase 19 ILayoutRepository (name, NxN grid size, ordered camera tiles).
- Grid page: layout tabs, switch layouts, create/rename/delete, edit mode to
  assign a camera per cell and change grid size (1..25 cells).
- Switching layouts keeps cameras present in both playing — no video restart
  (verified: shared tile survives X->Y->X as the same DOM node).
- Editor preserves tiles beyond the visible page (no truncation of paginated
  desktop layouts). RU/EN strings, n16/n25 grid CSS.
- components/Modals.tsx: TextPromptModal + ConfirmModal on the shared skin.
- Grid layout create/rename/delete now use them (rename pre-filled, delete
  gets a red confirm) instead of browser dialogs.
- Cameras delete, Groups rename/delete and System revoke-all now use the
  in-app TextPromptModal / ConfirmModal.
- Confirm dialogs name the affected item; destructive ones get a red button.
- No confirm()/prompt()/alert() left in the client.
- New /camera/:id page: one large live tile, details (host, group, RTSP,
  capabilities, chip/firmware), edit + delete. Replaces /grid?only=<id>.
- Extract the live tile into components/LiveTile.tsx, shared by grid and page.
- Responsive at the app-wide 700px breakpoint: sidebar becomes a bottom tab
  strip (safe-area aware), grids clamp to 2 columns, key/value tables stack,
  wide list tables scroll inside their container, modals go full width.
- Account controls (user, EN/RU, sign out) added to System, since the mobile
  tab strip has no room for the sidebar footer.
The two are one commit because they meet in WebServer/csproj: the PTZ
endpoints are mapped in the same file the Razor mapping leaves, and both
edit the project's SDK/references.

- Web backend references Devices and registers SoapOnvifClient; new
  PtzApi with move/stop and preset list/save/goto/delete. Movement is
  stateless: each move carries an ONVIF self-stop timeout that the client
  refreshes while held, so a closed tab cannot leave the camera panning.
- CameraDto gains PtzReady (HasPtz + a probed profile token); the SPA
  shows the pad only when the calls can actually work.
- delete Components/**, WebLocalizer/WebStrings, UiApi, CameraFormApi;
  the project goes back to the plain SDK (no .razor left to compile).
- /app/config/export|import and /app/sessions/revoke-all become /api/v1
  endpoints; import answers JSON counts instead of a redirect.
- SPA fallback no longer swallows unmatched /api paths: they 404 with
  JSON instead of returning index.html as 200 text/html.
- self-host Inter from the inter-ui package (rsms' own); the full
  variable file, not the latin subset, which drops Cyrillic
- grid pages a layout the way the desktop does: page = gridSize^2 cells,
  only the current page gets live tiles
- the editor now edits the current page and splices it back between the
  pages before and after, instead of always editing the first one
- live sessions move into a pool above the router: a tile borrows a
  <video> and hands it back on unmount, so grid -> camera -> grid
  re-attaches a stream that never stopped (30s retention, then closed)
- parked videos stay in the document; the spec pauses media elements
  removed from one, so they park in a 1px off-screen container
- fix a pre-existing player bug: fMP4 carries the ffmpeg session's
  timestamps, so a viewer joining a running stream got a buffer starting
  minutes ahead of currentTime and never played at all. A watchdog now
  seeks to the live edge and resumes.
- Web backend composes the same discovery stack as the desktop (ONVIF
  WS-Discovery, mDNS, opt-in subnet sweep) behind DiscoveryApi
- a scan is a background job polled by id, so it survives navigation and
  a second viewer joins the running one instead of starting a rival
- add 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. The probe result is persisted as ONVIF metadata,
  the way the desktop add flow does, or PTZ would be lost.
- ServerSettings supplies the IUserSettingsAccessor defaults the sources
  need; the only implementation lives in the App layer, which the web
  backend must not reference.
keyldev added 3 commits July 21, 2026 18:00
Replaces the single admin account with a roster, using the same model as
the desktop access config so the two stay one feature: permission flags
(ViewLive/ViewArchive/Ptz/Export/Manage) plus a camera subset per user.

- WebUserStore: JSON roster next to the database, PBKDF2 hashes only,
  written atomically so a crash cannot lock everyone out
- the bootstrap admin keeps working after a roster exists — it is the way
  back in when the last Manage account is lost
- PermissionGuard: one line per endpoint. A camera outside the caller's
  subset answers 404, not 403, so ids can't be probed for existence
- live video and PTZ check the subset before touching the backend
- /api/v1/users for management, /me carries permissions so the SPA can
  hide what the caller may not do (the server still enforces all of it)
- restricted users get layout tiles filtered to what they can see, and no
  layout editing at all: saving a partly visible layout would splice the
  hidden cameras out of it
- self-hosting guide: discovery as a way to add cameras, a users and
  permissions section, refreshed feature list and security notes
- spell out that the web server is a separate door to the same cameras
  with its own accounts
- README: same in one line, both languages of the guide unchanged in shape
@keyldev
keyldev merged commit b7bfb36 into main Jul 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant