feat: Live Activity page, plus stream-teardown and event-poller fixes - #316
Merged
Conversation
A dedicated route showing only monitors currently in alarm, sourced from per-monitor alarm status rather than the notification stream, which reports alarm starts but never alarm ends. Refs #313 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects three spec assumptions found while planning: the alarmStatusInterval bandwidth key, the monitorAlarmStatus query key, and the alarm-status parse in useAlarmControl all already exist. Refs #313 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
useAlarmControl parsed the alarm-status response inline. The Live Activity page needs the same parse, so it moves to a shared pure function with a named state type rather than a loose number. Refs #313
Decides which monitors the page shows and for how long. The dwell window protects the server as much as the eye: each tile entering or leaving mints a ZMS connection key and sends CMD_QUIT. Refs #313
One alarm-status query per monitor, enabled only while the caller is on screen. The states map is total over monitorIds while enabled: a monitor whose query has not resolved yet, or has errored, still reports 'unknown' rather than being left out, so the downstream dwell reducer never reads a transient gap as the caller having stopped watching that monitor. Refs #313
resolvePollIntervalMs hardcoded the event-poller interval as its bandwidth floor. It now takes the key, so a second feature with a user-tunable interval does not need a second copy of the clamp. Refs #313
Shows only monitors currently in alarm, as montage tiles. Reusing MontageMonitor brings the ZMS connection-key and CMD_QUIT lifecycle with it, so no stream teardown code is written here. Refs #313
A push event arrives before the next poll tick would notice. Hints only promote monitors already being polled, so the ignore list still holds. Tiles now show a count badge once a monitor has alarmed more than once while resident. Refs #313
react-hooks/set-state-in-effect: 14 -> 15. The mount/poll-driven effect in LiveActivity.tsx (Refs #313, commit 313fd57) calls setActive from an effect, which the rule flags on the assumption that state can instead be derived during render. It cannot here: `active` is a stateful reduction over its own previous value plus wall-clock time (reduceActiveMonitors folds in alarmCount increments and the dwell window), driven by an external poll/notification source, not a pure function of the current render's props/state. Rewriting it as the "adjust state during render" pattern (compare hintedStates to a tracked previous value, setState inline) risks either skipping the state population on first mount or, under React's Strict Mode double-render, double-applying reduceActiveMonitors and double-counting alarmCount -- the exact failure mode this project's dwell/alarm-count semantics cannot tolerate. Confirmed pre-existing as of Task 5 (2887b5c), which predates the ratchet check being run against this file; Task 5b's changes did not add a new violation, just changed the effect's second argument from states to hintedStates. Refs #313
Poll interval, dwell window, tile cap, and a page-specific monitor ignore list. The ignore list is separate from the profile-wide monitor exclusion, which hides a monitor everywhere. Refs #313
Binding the poll/dwell/tiles inputs straight to the committed store value clamped on every keystroke, so clearing a field snapped it back to the minimum instead of letting the user retype. Each field now keeps a local draft and only commits a clamped value once it parses as a real number. Refs #313
Committing a clamped value on every keystroke resynced the draft to the clamped string mid-edit, so typing "12" one digit at a time landed on 22: "1" clamped to the minimum "2", and the next digit appended to that. Move the commit to blur (and Enter), so onChange only ever updates local state and the store cannot change mid-edit. The resync-from-store runs during render (React's "adjusting state when a prop changes" pattern) rather than in an effect, to keep the lint-ratchet baseline from growing. Refs #313
lastStoredValue advanced unconditionally even while the field was focused, so an external write mid-edit was marked seen without ever being applied to the draft. On blur, commit() then wrote the stale draft back over that external value, superseding it either way but as an accident of the guard rather than a decision. lastStoredValue now only advances together with applying it to the draft, so a value can never be marked seen without being shown, and a later external change still syncs once the field is unfocused. Policy is now explicit and documented: the in-progress edit wins on blur. Refs #313
The test added alongside the draft sync guard fix passes against the pre-fix component, so it never guarded that fix. Checked whether the old guard was reachable: it is not. `commit()` ends with `setDraft(String(clamped))` and runs on every route out of focus (blur and Enter), so the draft is re-anchored to the store whenever editing stops, and `lastStoredValue`, which is never rendered, can only suppress a resync whose value is already on screen. A differential run of the old and current components over 4000 random 7-step and 1500 random 12-step sequences of focus, typing, Enter, blur, and external writes found no sequence where the rendered value or the stored value differ; the same harness did report differences once the old guard was deliberately mutated. The test still pins behavior worth keeping, the mid-edit conflict policy, so it is renamed to say that instead of claiming a regression, and its comment now states that the guard defect was defensive rather than observable. Refs #313
useQueries without `combine` re-maps its results array on every render, so the useMemo in useAlarmStates that listed it as a dependency never hit and `states` got a new identity per render. The page derives its dwell list from that in an effect, and reduceActiveMonitors stamps Date.now() into every alarming entry, so each render produced a new list, set state, and rendered again. The wall clock advancing between iterations meant it never converged. It also starved the one-second cooling timer, whose effect lists the same value and so cleared and recreated the interval before it could ever fire. Building the states map inside `combine` fixes it at the source: TanStack runs replaceEqualDeep over the combined value, so an unchanged poll yields the very same object all the way to the consumer. Measured with one alarming monitor over a 300ms window: 77 tile renders before, 0 after. Refs #313
…failed poll A failed request mapped the monitor to `unknown`, which is not alarming, so the dwell reducer marked a resident tile as cooling. The next successful poll then counted that as a fresh alarm and incremented alarmCount, so one continuous alarm on flaky wifi displayed as "x7". React Query keeps the last successful payload in `data` across a failed refetch, so parsing `data` unconditionally holds the monitor steady. A monitor that has never succeeded still has no `data` and parses to `unknown`, so the map stays total over the requested ids. Refs #313
…ng state If the monitors query failed, monitorsLoading was false and visible was empty, so the page rendered "All quiet, watching 0 monitors" right next to the error banner. A failing alarm fanout did the same thing more slowly, by dwelling every resident tile out. For a page whose whole job is answering "is anything alarming right now", a false negative during an outage is the worst possible reading, so the quiet state is now gated on there being no error. useAlarmStates already reported isLoading and the page discarded it, so a cold open painted an empty grid before anything had been asked. It now shows the shared Skeleton while the first poll is outstanding. Refs #313
The badge counted alarms against LIVE_ACTIVITY.defaultDwellSeconds, so a user with a 300s dwell still got a 30s badge window: the badge went dark while the page was still showing the tile. It now reads the profile's own liveActivityDwellSeconds, falling back to the default only when no profile settings exist yet. The badge still only re-evaluates on a store write or a sidebar render, so it can stay lit up to one dwell window after the last alarm ages out. That ceiling and its upgrade path are recorded in a ponytail comment rather than paid for with a permanent sidebar interval. Refs #313
watching_count, overflow, and alarm_count were passed a count with no _one / _other family behind them, so English read "Watching 1 monitors". Each now has both forms in all five locales, matching the convention the existing plural families already use. Chinese has one grammatical number, so its two forms carry the same text. The page test's i18next stub now resolves plural families the way i18next does, so its assertions still read the real English copy. Refs #313
minDwellSeconds was 0, which let a user turn off the exact damping the dwell window exists for: every tile entry and exit mounts or unmounts a MontageMonitor, which mints a ZMS connection key and sends CMD_QUIT, so a zero dwell thrashes nph-zms processes on the user's own server. The floor is now 5 seconds, with the reason recorded next to the constant. Refs #313
"Live-Aktivität Einstellungen" left the compound half-open. German Durchkopplung hyphenates it through: "Live-Aktivität-Einstellungen". Same length, so it still fits the dialog title. Refs #313
The user guide now covers what the page does before its first answer and when it cannot reach the server, and names the 5 second dwell floor. The developer guide and Flow 20 explain why the state map is built in the useQueries `combine` option rather than a downstream useMemo, since that placement is what keeps the page from looping. Refs #313
The watched-count assertion matched "Watching N monitors" literally, so it would fail on a server with exactly one watched monitor now that the string has a singular form. Refs #313
MontageMonitor hardcoded '/montage' as the route it was rendered from, so
a tile opened from Live Activity offered a back link to Montage. The
timeline navigate carried no state at all, so that page had nothing to
offer either.
The route is now a `fromRoute` prop defaulting to '/montage', passed to
useOpenMonitorEvents and as `{ state: { from } }` on the timeline
navigate, matching the shape the events page already reads. Live Activity
passes '/live-activity', which viewNameForPath now names too.
Refs #313
The list ordered by first-entered and never re-sorted, so the camera that just went off could sit below one that alarmed minutes ago, and the tile cap kept whoever arrived first rather than the freshest activity. reduceActiveMonitors now sorts by lastAlarmingAt descending with the monitor id as a tiebreak, so monitors alarming in the same poll hold a fixed order instead of depending on states-map iteration. The sort runs before the identity check, so an unchanged order still returns the previous array and no tile re-renders. Refs #313
The header read "Front Door(3):Alarmed". The id is noise to anyone looking at their own cameras, and the state word ate the width the name needs at 320px. The header is now a state icon plus the monitor name. The icon is a new slot on MontageMonitor rather than a widened titleOverride, because that string is also the truncation tooltip and has to stay a string. Replacing a word with a glyph would drop the state for screen readers, so the icon carries the same live_activity.state_* string as its accessible name and its hover title. live_activity.tile_title now has no reader and is gone from all five locales. Refs #313
Tiles popped into the grid, stepped straight to opacity-60 when they stopped alarming, and jumped to new rows now that the list re-sorts by recency. Entering tiles use tailwindcss-animate's fade-in-0 zoom-in-95, and cooling is a 700ms fade with the color draining out, so no library was added. Reordering runs the state update through document.startViewTransition when the browser has it, with each tile carrying a view-transition-name; the root snapshot is pinned to no animation so live video does not cross-fade behind the tiles. Electron and Capacitor webviews without the API get the instant update, as does anyone who asked for reduced motion. The update path is now one dependency-free callback reading the previous list from a ref. Putting `active` in the cooling effect's deps, or giving that callback a per-render identity, would rearm the one-second interval before it ever fired and cooling tiles would never expire. Refs #313
Twelve was a guess. Sites large enough to want this page tend to have more cameras than that alarming at once, and the overflow row is a worse default than simply showing them. Refs #313
A cooling tile carried opacity-60 and saturate-50 on the same element that carries view-transition-name, so the browser snapshotted a dimmed box. A captured image is generated with the element's own visual effects already applied while ::view-transition-new is the live element, and the pair is composited with the UA stylesheet's mix-blend-mode: plus-lighter, which only cross-fades correctly when both halves are the same image. The two halves never matched, because the tile was partway through its own 700ms fade. That repeats: the grid reorders about once a second while ZoneMinder flaps a winding-down monitor between alert (alarming) and tape (not alarming), and every reorder runs a view transition that suppresses each tile's live rendering for its duration. Measured against the reducer, a two-monitor tail produces 11 transitions in 14 seconds, so the mis-composite covers the whole window before a tile dwells out. Move the cooling opacity and filter onto an inner element and leave the captured box holding only the enter animation. The name stays permanent: dropping it while a tile cools was tried in a Chromium harness and is worse, because a name present in the old capture and absent from the new one leaves an orphaned ::view-transition-old group painting the tile a second time at its old position. Refs #313
A cooling tile now renders identically to an alarming one. The only signal that a monitor is winding down is its state icon leaving the tile header. This supersedes a1bcccf, which moved the cooling opacity and filter onto an inner element to keep them off the element carrying view-transition-name. With the dim gone there is nothing to keep off it, so that wrapper and its test go too rather than sitting in the tree as scaffolding around a rule nothing can break any more. The rule itself still holds and is worth stating: the tile is what the browser snapshots, a captured image is generated with the element's own visual effects already applied, ::view-transition-new is the live element, and the UA stylesheet composites the pair with mix-blend-mode: plus-lighter, which only cross-fades correctly when both halves are the same image. Nothing on that element may animate opacity or filter. The replacement test asserts a cooling tile's resolved class list is identical to an alarming one's, which fails against the pre-a1bcccfb markup. Refs #313
A bell for alarming, a raised shield for the part-way states, a struck-out shield for winding down. prealarm moves in with alert: both mean ZoneMinder is part way into an alarm decision, so they read the same to someone scanning the grid. The cooling tile no longer dims, so this glyph is now the only thing saying a tile is on its way out. Refs #313
reduceActiveMonitors sorted on lastAlarmingAt, which it restamps to the current clock on every pass for whatever is alarming at that instant. ZM walks a winding-down event through alarm, alert, tape or idle and back, and only alarm and alert read as alarming, so a monitor leaves and rejoins the alarming set every second or two through an event's tail and the tiles traded places on almost every tick. Each reorder starts a view transition, and captured elements are not painted while one runs, so the grid stopped showing live video for a large share of exactly the window a user is watching. Entries now carry episodeStartedAt, which is set when a monitor joins and left alone while it keeps alarming. A monitor that stops alarming has to stay quiet for LIVE_ACTIVITY.episodeGraceSeconds before its next alarm counts as a new episode and moves it back to the top, so a blip out of the alarming set and straight back in does not move the tile. lastAlarmingAt is still restamped every pass because the dwell window runs from it; it just no longer decides position. Driving the real reducer once a second over a realistic two-monitor tail: 13 reorders in 66 seconds before, 2 after, and both survivors are real dwell expiries. The same script with the tail spelled alert/idle rather than alert/tape churns identically, so treating tape as alarming would not have fixed this, and it would strand every continuous recorder on the page permanently. Refs #313
The carousel sat above the video, so on a short viewport the player itself started below the fold on a page whose whole point is watching the event. It now sits between the player and the metadata, where it reads as a detail of what is on screen above.
The count was wrong as well as unwanted. It incremented on any re-alarm while cooling, with none of the grace window that guards the sort key, and ZoneMinder drops a winding-down monitor out of the alarming set and back every second or two. So a single event's tail read as x2 or x3 rather than one alarm. alarmCount had no other reader, so the field goes with the badge.
The poller loaded the monitor name map once, in start(), and never again. start() runs while the profile is still bootstrapping, and a profile switch deliberately suppresses re-login while it is in flight, so getMonitors can come back unauthenticated. The catch logged a warning and left the map empty, and nothing retried it, so every notification for the rest of the session was titled "Monitor 4" instead of the camera's name. An event naming a monitor the map does not know now reloads it, rate limited so a genuinely deleted monitor does not refetch the list on every poll. A failed reload leaves the existing names intact.
A wall display or a TV stick wants the tiles and nothing else, which is what the Montage page already offers. useFullscreenMode hardcoded `montageIsFullscreen`, so adopting it as-is would have made the two pages share one fullscreen flag: entering fullscreen here would silently have put Montage in fullscreen too. The key is now a parameter and the hook moves to src/hooks, since it is no longer a Montage-only concern. Montage's own fullscreen bar is deliberately not reused: it carries the kiosk lock and the tile-label toggle, so importing it would pull the kiosk store and the PIN pad onto a page that offers neither. Refs #313
A tile said that a monitor is alarming, never whether that started five seconds or four minutes ago. `episodeStartedAt` already holds the answer, so this needs no request and no new state beyond a clock. Two things the label deliberately is not. It is not a MontageMonitor prop: that component is memo'd to keep a live video tile from re-rendering, and a value that changes every second would defeat that for every tile at once, so the label is a sibling overlay and the state icon is now memoized for the same reason. And it drives off the existing one-second cooling interval rather than a timer of its own; that interval is already scoped to exactly when tiles exist. The tile moves into its own component to keep the page near its size limit, and the stopwatch formatter lands next to the other time formatting. Digits and colons only, so it needs no translation and fits a 320px tile. Refs #313
The state glyph says something happened, never what. ZoneMinder's Cause tells "Motion: All" from "Forced Web" from a detection, and the notification payload already carries it. The cause belongs to the alarm episode, so it lives on the entry the reducer already keeps per episode rather than being looked up at render time: notification events expire on their own schedule, and a tile that lost its label halfway through would be worse than one that never had it. An entry adopts a cause that lands after the poll had already found the alarm, keeps it for the rest of the episode, and takes a fresh one only when a genuinely new episode starts. The hint selector now collects the causes in the same pass and the hint set derives from its keys, so this adds no second subscription. Map values stay plain strings, which is what lets useShallow compare it entry by entry. Only the notification stream reports a cause, so most tiles show none; nothing else on the tile depends on it. Refs #313
Once a user has looked at a camera it still holds a slot for the rest of its dwell window. The cross clears it now. A dismissal has to suppress re-entry or the control is a no-op with an animation: the monitor is usually still alarming, so the reducer would readmit it on the very next poll and the tile would pop straight back. The dismissed set is a reducer input, so removal goes through the same path as any other departure and the tile really unmounts, which is what quits its stream. Dismissals live in a page-local ref, not in profile settings: they are not a preference, nothing renders them, and every read happens inside applyStates, which publishes the list a dismissal changes. A dismissal is released once its monitor has genuinely stopped alarming or the page has stopped watching it, so the camera's next separate alarm shows normally. Releasing runs after the reduce, not before, or a tile dismissed while already cooling would survive its own dismissal. Refs #313
The page only ever showed "now", so a camera that alarmed while nobody was looking vanished without trace once its dwell window closed. A thin row under the grid names what left in the last few minutes. Static by design: names and how long ago, no players. Mounting tiles there would reopen the streams the dwell window had just closed. It is bounded in both count and age, with both bounds in LIVE_ACTIVITY, so it stays a footnote rather than a second, unbounded list. Departures are recorded inside applyStates, the only place the old and new lists exist together, and queued before the list is published so the strip lands in the same commit rather than repainting partway through a view transition. It starts no transition of its own and cannot reorder the grid. The existing one-second interval now also ages it out, and keeps running while the strip has entries so it cannot freeze on screen after the last tile has gone. The page header and the fullscreen bar move into a chrome component to keep the page near its size limit. Refs #313
The fullscreen toggle is a new interactive element and a behavior change, so it needs an outcome-based scenario: chrome hidden, choice remembered across a reload, and Montage still where it was left. That last step is the regression the shared settings key would have caused. The other five additions all need a camera that is actually alarming, so they stay covered by unit tests. Refs #313
The page chapter now covers the tile component and why its overlays are siblings rather than props, where a cause is recorded and why it lives on the episode, how dismissal suppresses re-entry, the bounds on the cleared strip, and the generalized fullscreen hook. Flow 20 gains the dismissal step. Refs #313
The glyphs changed to a bell, a raised shield and a struck-out shield when the cooling dim was removed, but the user guide still described the siren, warning triangle and hourglass it replaced. Refs #313
Its chip read "FrontDoor 0:45", using the same mm:ss format as the per-tile elapsed label but meaning the opposite: the tile counts how long an alarm has been running, the chip counted how long since one ended. Same shape, inverted meaning, no tooltip to tell them apart. Removed rather than relabelled: the heading already said the list was recent, the list was bounded to a few minutes, and the page reads fine without it. Refs #313
The unmount cleanup in useStreamLifecycle treated any cleanup of its empty-dependency effect as an unmount and removed the <img> src. React runs that cleanup against a live tree in three situations that are not unmounts (StrictMode's mount double-invoke, revealing a hidden Suspense subtree, a Fast Refresh update), and none of them is followed by a re-render: React's virtual DOM still carries the same URL, so the next diff writes nothing and the tile keeps a mounted <img> with no src, which is the browser's broken-image glyph. The same cleanup also sent CMD_QUIT, killing a stream the user was still watching. React detaches a real unmount's subtree in the mutation phase, before the passive cleanup runs, so a media element still connected to the document means the component is staying. Bail out in that case. A real unmount is unaffected and still aborts the nph-zms connection. refs #313
The activity glyph read as a heartbeat, which is what the page shows when something IS happening. A closed eye says nothing is being watched right now. Refs #313
The page laid tiles out in a plain CSS grid, which fixes their width and says nothing about their height, so every camera got the same box and its video was cropped or letterboxed to fit. MontageMonitor takes an optional mediaAspectRatio, applied to its video area with the flex-1 dropped, so the card's height is the h-8 header plus the video: the same sum useMontageGrid computes for a Montage tile. The ratio lands on the video area rather than the card because on the card the header would eat into the camera's shape. Montage passes nothing and keeps sizing through react-grid-layout. The grid is items-start, so a tile keeps the height its camera gives it rather than being stretched to the tallest tile in its row, which would add dead space under the picture with the elapsed label floating in it. Refs #313
Montage renders its grid with margin and containerPadding both zero, so the feeds meet edge to edge. Live Activity had an 8px gap on both its grids, which read as a different page rather than the same wall. Refs #313
A CSS grid row is as tall as the tallest tile in it, so a 16:9 camera beside a portrait fisheye left black space under the short tile and started the next row below the tall one. items-start kept each tile at its natural height but never shortened the row, which is the actual cause. The grid now uses a one pixel row unit and gives each tile a grid-row-end span of its own height, computed from the measured grid width, the column count and the camera's aspect ratio: the same header-plus-video sum useMontageGrid computes for a Montage tile. Tiles no longer share a row, so auto-placement fills the first free slot and the left-to-right, most-recent-first order is untouched. CSS multi-column masonry would have reversed that order and is not used. The width comes from useMeasuredWidth, wrapping montage's useContainerResize: first measurement immediate, everything after it debounced, and rounded into state so sub-pixel resizes cannot re-render every tile. The loading skeleton carries the same ref, so the width is known before the first tiles arrive. Refs #313
A bare 5 in a box does not say 5 what, and the answer was only in the description underneath. The poll interval and dwell window now carry the unit next to the field. Maximum tiles stays bare, being a count rather than a duration. Refs #313
w-24 was wider than any value these fields hold: the poll interval tops out at 60, the dwell window at 300, the tile cap at 40. w-20 matches the number fields in the live streaming settings section.
Some cameras record events in a container the app cannot play. The only recovery was reactive: mount the MP4 player, let it fail, show a red toast, then switch to ZMS. That state was per-mount, so every visit to every event on the camera paid the failed load and the toast again. The Video tab of the monitor settings dialog now carries a toggle that forces every event for that monitor through ZMS, so MP4 is never attempted and the error path is unreachable. It is an app preference keyed by monitor id, not a ZoneMinder monitor column, so it applies on toggle and stays out of the dialog's change detection and save payload. It lives beside the Go2RTC row in a new MonitorAppPreferences component, which makes that boundary structural rather than a comment, and pulls SettingsRow out into its own module so both files can use it without a cycle. EventDetail reads the setting off the resolved event rather than seeding the fallback state, because that state is created before the event query resolves and seeding it would flash one MP4 frame before the monitor id is known. When the setting decides playback, log.eventDetail records that the monitor is set to always use ZMS, so a reader can tell why MP4 was never tried. Refs #315
Every other top-level destination had a single-key shortcut; the new page was the only one without. `a` for activity, which was unclaimed. Refs #313
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
/live-activityroute showing only the monitors ZoneMinder currently reports as alarming, and fixes several defects found while building it.Refs #313, Refs #315
The page
Alarm status is polled per monitor (
getAlarmStatus), one query each, since ZoneMinder's alarm endpoint is addressed by a single monitor id. The notification stream is an accelerant, not the source: it reports alarm starts but never alarm ends, so it cannot decide when a tile leaves.A dwell window keeps a monitor resident after its alarm clears. That is not cosmetic. Each tile entering or leaving mounts or unmounts a video player, which mints a ZMS connection key and sends CMD_QUIT, so flicker thrashes
nph-zmson the server.Tiles sort most-recent-episode first, size from each camera's aspect ratio, and pack by row span so a short tile does not leave a hole. A settings gear covers poll interval, dwell, tile cap, and a page-specific ignore list. Continuous recorders are skipped by default, since a camera that always records is always inside an event.
Fixes found along the way
Stream teardown ran against live elements.
useEffect(() => cleanup, [])was treated as "unmount only". React also runs an[]cleanup against a still-committed tree on a Suspense reveal, which is a production path here. The cleanup stripped the<img>src, React's virtual DOM still held the same value, and the element stayed mounted showing a broken image forever. Guarded onisConnected.A disabled stream hook kept its connkey, orphaning
nph-zmson the server and letting a Go2RTC-to-MJPEG fallback remount on a dead key.The event poller never retried its monitor name map. A startup race with authentication left it empty, so every notification for the rest of the session read "Monitor 4" instead of the camera name.
Per-monitor "always use ZMS for events" (Refs #315), for cameras whose container the app cannot play. MP4 is never attempted for them, so the failure toast is unreachable rather than suppressed.
Verification
npm run gatesgreen: 3237 tests, lint ratchet unchanged at 213 across 12 rules.npm run test:e2e -- live-activity.featurepassing.Not device-verified: the broken-image fix and the tile layout work were reasoned and unit-tested but only observable on a real server with real cameras.
Known debt: the ratchet baseline for
react-hooks/set-state-in-effectwas raised 14 to 15 with reasoning in its commit; two reviewers independently found no safe restructure.Posted by Claude, assisting @pliablepixels.