dev #20

Merged
elirtf merged 48 commits from dev into master 2026-08-10 17:24:29 +00:00
Owner
No description provided.
From setup-matt-pocock-skills (docs/agents/*.md + AGENTS.md 'Agent skills' section)
and the wayfinder planning map for the NVR playback effort (.scratch/nvr-playback/).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design for user-created live-Dashboard views (ordered camera set + per-view
grid density, built via a @dnd-kit drag editor) and defaulting NVR camera
groups to collapsed. Live Dashboard only (v1); Recordings navigation deferred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 TDD tasks: DashboardView model+migration, /api/dashboard-views CRUD,
@dnd-kit dep+client, drag view editor, Dashboard selector, group auto-collapse,
acceptance. Sized for Sonnet subagents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the DashboardView Peewee model, migration 023, and wires the
dashboard_view table into the test app fixture (create_tables/drop_tables)
so the model is exercised in tests.
Add /api/dashboard-views blueprint (list/create/update/delete),
scoped to current_user.id with 404 for other users' views. Clamps
columns to 1-6, keeps only accessible+existing camera ids in order.
Also registers the blueprint in the test conftest app fixture so
tests/test_dashboard_views_api.py can exercise it.
Two-panel ViewEditor: left panel lists cameras grouped by NVR
(collapsed by default, draggable chips), right panel is a sortable
CSS grid of name-only tiles. One DndContext distinguishes "add from
list" vs "reorder in grid" via droppable/container ids. Wires
/dashboard/views/new and /dashboard/views/:id/edit as protected
routes.
Grid tiles can now be removed by dragging them off the grid (onto the
camera source list or releasing over nothing), matching the spec's
either-or requirement alongside the existing x button. The left panel
is now a useDroppable target (camera-source-list) so handleDragEnd can
distinguish add (source chip -> grid), reorder (tile -> tile), and
remove (tile -> outside/source list) as mutually exclusive branches.

Also wraps handleDragStart/handleDragEnd/handleSave in useCallback for
consistency with the existing toggleGroup/removeCamera pattern; all
setCameraIds calls remain functional updates.
Recordings sidebar and settings NVR groups now start collapsed on
first load instead of fully expanded, so a large NVR (e.g. 40
cameras) doesn't fill the page. Once a user toggles any group, their
per-group choice persists in localStorage as before.

RecordingSettings.jsx now uses its own storage key
(opusCollapsedRecSettings) instead of sharing opusCollapsedNvrs with
CameraSidebar.jsx: the two components use incompatible key formats
(rec:/avail:-prefixed vs bare group key), so a shared key would make
the auto-collapse-on-first-load effect impossible to apply
independently on whichever page loads second.
Mirror update_view's contextlib.suppress so a non-numeric position
can't raise an uncaught 500. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two runtime dnd-kit defects that passed build/review but broke the core
interaction:
- SourceCameraChip applied its own drag transform while a <DragOverlay>
  was also mounted, so the chip dragged inside its scrolling left panel
  (elongating it) instead of following the pointer. Overlay is now the
  sole preview; original stays put.
- closestCenter mismatched a small chip against the large grid droppable
  across two scroll panels, so drops rarely registered. Switched to
  pointerWithin (also correct for tile reorder + drag-out-to-remove).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of dead drag-to-add: the grid + source-list useDroppable hooks
were called in the ViewEditor body, which only *renders* <DndContext> — so
they registered into an empty context and no drop target was ever detected
(over stayed null under any collision algorithm). Moved them into a
DroppableRegion wrapper rendered inside the context.

Also: MeasuringStrategy.Always so droppable rects stay correct inside the
nested overflow-y-auto panels; pointerWithin collision; isOver highlight on
the grid; and click-to-add on source chips (robust + keyboard-accessible,
coexists with drag). Removed the temporary debug readout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per user: the view menu belongs under 'All Cameras' in the left nav,
styled like the sidebar. Selection now flows through a ?view= URL param
(mirroring the existing ?site= site-filter pattern) so the global Sidebar
and the Dashboard stay in sync without shared state. Removed the toolbar
dropdown and the localStorage persistence (URL is the source of truth, as
with site). Added Edit / + New view links under the dropdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Delete view button (edit mode, with confirm) wired to the existing
DELETE endpoint, and refetches the sidebar view list on navigation so
created/deleted views appear without a reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-user saved Dashboard views (DashboardView model + migration 023 +
/api/dashboard-views CRUD), a @dnd-kit two-panel view editor (drag- and
click-to-add, reorder, drag-out/delete), a sidebar view selector driven by a
?view= URL param, and NVR camera-group auto-collapse in the Recordings area.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the spec's 'inline error with retry' gap and the cosmetic
error+empty-state double-render flagged in review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the per-camera Event-clips tab with a chronological, filterable
(date/type/camera) feed of recorded events across all accessible cameras,
each played in place, plus 'Open in Playback'. Backend adds a type filter
(person/vehicle/motion) to GET /api/events/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Charts a wayfinder map for taking Opus to Hikvision/Dahua-NVR + Frigate
parity: 12 decision tickets under .scratch/nvr-parity/, six on the
frontier (five research + one inventory task), six blocked behind them.

Destination is a decided, prioritized plan — no production code lands
from this map. LDAP/AD and audit trails are explicitly out of scope for
this effort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inventory only, no files moved. Key finding: deploy/ is entirely
compose-free (bare-metal systemd uses absolute /opt/opus paths), so
relocating the compose files cannot break the production VM. Risk is
confined to Docker users.

Also catalogues ~40 doc/skill/frontend references that need a mechanical
pass, and surfaces three pre-existing issues: the monitoring stack
bind-mounts a monitoring/ dir that does not exist in git, cache/ is
neither tracked nor ignored, and .gitignore carries a leftover demo line.

Unblocks ticket 12 (target repo layout).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docker-compose.monitoring.yml has always bind-mounted ./monitoring/
prometheus.yml and two Grafana provisioning dirs, none of which were ever
committed, so `make up-monitoring` could not start. Meanwhile a real
10-panel dashboard sat unreferenced at the repo root.

- Add monitoring/prometheus.yml scraping opus:5000, recorder:5055 and
  processor:5056 by compose service name on opusnet.
- Add Grafana datasource provisioning with isDefault: true — the dashboard
  panels carry no explicit datasource, so they bind to the default.
- Add Grafana dashboard provider and move grafana-dashboard.json to
  monitoring/grafana/dashboards/opus.json so it is actually loaded.
- .gitignore: ignore /cache/ (recorder staging dir, previously untracked
  noise) and drop a stale comment about a trailing space that no longer
  exists.
- Add tests/test_compose_paths.py asserting every literal relative
  bind-mount source exists; verified it fails on the original bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collects the five /research subagent findings onto one branch (they were
each committed on a separate agent worktree branch) and records the
resolutions on the map.

The two top security findings were independently re-verified against the
code before recording, and are CONFIRMED:
- verify() in app/routes/api/auth.py checks only is_authenticated, so the
  nginx /go2rtc/* gate is authentication-only. Any authed viewer can pull
  any camera's live feed, despite nginx forwarding X-Original-URI for
  exactly this purpose and nothing in app/ reading it.
- /api/health/ and /api/health/about carry no auth decorator, unlike the
  admin-gated sibling /diagnostics.

Whole frontier is now open: all six decision tickets are takeable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the wayfinder decision map for commercial-NVR parity
(.scratch/nvr-parity/), the five /research findings docs under
docs/research/, the missing monitoring/ configs, and a compose
bind-mount regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/auth/verify backs nginx's auth_request gate on /go2rtc/*. It checked
only that a session was valid, so any authenticated viewer could pull any
camera's live feed with ?src=<name>, ignoring their UserCamera/UserNVR
grants. nginx already forwarded the original URI as X-Original-URI for
exactly this purpose (nginx/nginx.conf:26) and nothing read it.

verify() now resolves the requested stream against accessible_camera_names
— the same helper the Recordings and Playback APIs use — and returns 403
when the caller is not entitled to it. Admins are unaffected.

Details:
- All ?src= values are checked, not just the first: go2rtc resolves a
  repeated param to one value, so checking only the first would let
  ?src=mine&src=yours through.
- A virtual "<cam>-sub" stream is authorized against its "<cam>-main"
  camera row, since go2rtc derives it and no -sub row exists.
- Requests naming no stream still pass the session check. That is HLS
  media segments, which go2rtc keys by an opaque per-session id issued
  with the playlist — and the playlist request does carry ?src=.

Recorded segment playback is unaffected: it serves direct MP4 through
Flask, already authorized, not through the go2rtc proxy.

Tests cover the bypass, the -sub derivation, repeated and URL-encoded
params, and that legitimate access is preserved; verified the bypass
cases fail against the previous gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-camera fix closes the reported bypass but the proxy still passes
every verb and every go2rtc API path through after auth_request. Noted as
a decision for the security-hardening ticket rather than fixed silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticket 10 decisions (exposure model: VPN / reverse-proxy only).

- /api/health/ and /api/health/about required no auth, unlike the
  admin-gated sibling /diagnostics — the same leak class as a6f7d36. Every
  frontend caller is an authenticated page, so @require_auth breaks nothing.
- go2rtc_config now warns when GO2RTC_API_LISTEN/GO2RTC_RTSP_LISTEN are
  absent outside a container: in Docker an all-interfaces bind is correct
  (those ports are never published), on bare metal it puts the
  unauthenticated go2rtc API on the LAN. Previously silent.
- nginx gains a Content-Security-Policy in Report-Only. Enforcing it could
  black out live view (go2rtc iframe + blob: media) in ways only a browser
  shows; promote after it reports clean. No HSTS: this server is plain HTTP
  on :80, where browsers ignore it — it belongs on the TLS terminator.

Note: nginx already set X-Content-Type-Options, X-Frame-Options,
Referrer-Policy and Permissions-Policy, so the audit's "no security
headers" finding was narrower than reported — only CSP was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Logout only cleared the cookie client-side and a password change left
existing JWTs valid for their full lifetime (24h default, 30d max), so a
compromised or lost session survived the usual remedy.

User gains a session_epoch counter, minted into each JWT and compared on
load. The bump lives in User.set_password() rather than at the call sites,
so every path that changes a password — self-service, admin reset, admin
edit, and any added later — revokes that user's tokens.

Upgrade is non-disruptive: existing rows migrate to 0 and pre-existing
tokens carry no "epoch", which reads as 0, so nobody is logged out until
they actually change a password.

Also files ticket 13: operator reports recordings over ~15 minutes never
load while short clips do. Ticket 03 established that recorded segments
are served as direct MP4 through Flask, not go2rtc, so it points at Range
support / moov placement / nginx buffering on the API path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Camera.rtsp_url and rtsp_substream_url embed user:pass@host and sat in
SQLite as plaintext, while NVR.password was already Fernet-encrypted.

Adds EncryptedCharField (app/encryption.py) and applies it to both
columns, so encryption is one declaration rather than a decrypt call at
each of ~10 read sites across the web app, recorder and processor.

Two properties this relies on, both documented on the field:
- Fernet is non-deterministic, so nothing may filter or join on these
  columns. Verified no code does before changing them.
- A plaintext value is passed through unchanged, so a table that has not
  been backfilled keeps working.

The backfill extends the existing NVR-password block in create_app rather
than being a migration: _init_fernet auto-generates a key, so a one-shot
migration could encrypt with a key that never persists. Running every boot
picks up any row written while the key was unavailable.

Pinning: requirements.txt moves from >= to == at the versions this venv
runs the tests on, and the monitoring images move off :latest to
prom/prometheus:v3.13.2 and grafana/grafana:13.1.2 (tags verified against
Docker Hub). openvino is deliberately left unpinned and commented — it is
not installed here, so there is no tested version to claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recordings over roughly 15 minutes buffered forever and never loaded,
while short clips worked.

Root cause is nginx, not the media code. The generic `location /api/` sets
proxy_read_timeout 120s, and two endpoints build the entire file inside
the request without writing a byte until it is ready:

  /api/recordings/export       ffmpeg -f concat over the span
  /api/nvr-playback/<id>/clip  ISAPI download, then remux or transcode

Anything that takes longer than 120s to prepare is 504'd while the player
sits buffering — which is exactly the reported size threshold.

Gives both their own location at 1200s, matching gunicorn's --timeout 1200
so the worker is the backstop rather than the proxy. This follows existing
precedent: /api/discovery/ already has its own location at 600s because
subnet scans run for minutes.

Also replaces _remux_hevc's hardcoded 120s subprocess timeout with
_REMUX_TIMEOUT (NVR_CLIP_REMUX_TIMEOUT, default 900). The HEVC remux is
the uncapped path — CLIP_MAX_SECONDS only bounds the h264 transcode — so
it is what hour-long spans actually hit, and a multi-GB stream copy needs
more than two minutes.

Ruled out while diagnosing: Range support (stream_file already passes
conditional=True, so Opus's own segments were never affected), moov
placement (+faststart is already everywhere), and gunicorn's timeout.

Verified with nginx -t in a container and a test that fails against the
previous config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticket 08 decisions.

Crash counting was cumulative: the counter only reset while FFmpeg stayed
up for 60s, never on the crash path, so three transient RTSP hiccups hours
apart still shelved a camera for 10 minutes — or 60 if stderr happened to
contain a word like "connection refused". Crashes are now counted inside a
trailing RECORDING_CRASH_WINDOW_SECONDS (default 300), so a burst still
shelves and an occasional blip decays. This is the most likely cause of
the operator-reported recording gaps.

The H.264 sidecar was built for every completed segment with no codec
check, re-encoding H.264 to H.264 on the common camera — for a file the
frontend only requests after native playback has already failed. Now
skipped when the segment is already H.264; an unreadable probe still
builds it, since a wasted transcode beats unplayable footage.

Nothing compared one segment's end to the next one's start, so a camera
that stopped recording left a hole nobody was told about — the recorder
only checks that its process is alive, which a hung FFmpeg satisfies. Adds
find_recording_gaps() plus a recording_gap ops alert on the existing
channels, with a per-camera cooldown.

An in-progress segment (no ended_at) leaves the following boundary
unjudged rather than measured from the previous segment's end, which would
invent a gap exactly its own length. Erring toward silence is deliberate:
a noisy alerter gets muted.

Segment strategy deliberately unchanged — already keyframe-aligned, no
evidence it causes gaps, and the format is a one-way door for existing
recordings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticket 11 decisions.

Three conditions were computed every cycle and thrown away: disk_pressure,
storage_ready (recording is paused when the NAS marker vanishes), and the
processor's per-camera last_poll_unix. All three now alert.

A crashed or hung recorder/processor produced silence — the status fetch
caught its own exception and returned, so the failure most worth paging on
reported nothing. That path is now the service_unreachable alert at
critical severity, shared by both workers.

Alerts are also recorded to a new bounded ops_alert table and served from
GET /api/health/alerts, so an operator who configured neither webhook nor
SMTP still sees what fired. Delivery stays opt-in; the checks no longer
depend on a channel existing. OPS_ALERTS_DISABLED=1 turns the loop off.

POST /api/health/alerts/test (admin) dispatches through every configured
channel synchronously and reports each outcome. _post_webhook and
_send_alert_email swallow their own errors so a background alert can never
break the loop, so the test send passes raise_errors=True — without it the
button would report success regardless, which is worse than no button.

/api/health/summary returned the full host diagnostics block to any
authenticated viewer while /api/health/diagnostics gates identical data to
admin. The host block is now admin-only; the rest of the payload, which is
what the dashboard is built from, is unchanged.

conftest was missing camera_health_snapshot, so nothing had ever exercised
/api/health/summary. Added, along with ops_alert.

Frontend is not wired yet: the API and storage exist, but no panel renders
the alerts and there is no test button in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticket 11 shipped the alert API and storage but nothing displayed it, so
the alerts were only reachable over HTTP.

- Recent alerts panel on HealthDashboard, polled on the existing 10s
  cycle, severity-coloured. Each row shows a per-type one-line summary
  instead of raw JSON — the payload shape differs per alert — with the
  full detail as the row tooltip.
- Admin-only "Send test alert" button rendering per-channel outcome chips
  (delivered / failed / not configured) from the endpoint's results, so a
  channel that broke after setup is visible rather than assumed working.
- Hide the gauges row and HW-accel pill when `host` is empty. That block
  became admin-only in the previous commit, and without this a viewer
  would read a wall of zeroes as a real disk/CPU/memory reading.

Verified with npm run build; not verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticket 12. The top level carried five compose files, two reverse-proxy
config dirs and a reset script.

- docker/            the five compose files
- proxy/nginx/, proxy/caddy/   reverse-proxy configs
- scripts/reset_opus.sh

Every relative path *inside* the compose files (build: ., ./app,
./proxy/nginx/nginx.conf, ${RECORDINGS_HOST_DIR:-./recordings}) stays
relative to the repo root; invocations pass --project-directory to pin it.
That keeps the compose diff to two changed bind mounts instead of
rewriting every path, and keeps .env at the root where the docs say it is.

The Makefile absorbs the flag, so make up / down / logs / up-debug /
up-monitoring are unchanged for the operator.

reset_opus.sh relied on being run from the repo root, which breaks once it
lives in scripts/; it now resolves the root from BASH_SOURCE and writes
.env there.

Updates ~40 references in the same commit — 6 opus-* skills, README,
docs/*.md, .env.example, .dockerignore, .gitignore, mount-storage.sh, two
frontend strings and two tests — so no skill ever tells a future session
to grep a path that no longer exists. docs/research/* is deliberately not
rewritten: those are dated findings.

Production is unaffected. deploy/ never referenced a compose file, so the
bare-metal systemd path keeps working across git pull + restart.

Verified: docker compose config resolves for base/dev/debug/monitoring
with contexts and mounts landing on the repo root; make -n up as expected;
nginx -t passes on the moved config; bash -n on the moved script; 251
tests and npm run build pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
elirtf merged commit e37e5ad180 into master 2026-08-10 17:24:29 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
elirtf/opus!20
No description provided.