Skip to content

Commit 92ce139

Browse files
committed
fix(local-ui): center single-camera tile, retry HLS, show real node id
Three small UI papercuts from the v0.1.47 fresh install: * Dashboard header showed "NODE · UNKNOWN" because Dashboard::new ran before the local_node_id KV row was generated. Lift the get-or-create above Dashboard::new so the SPA receives the stable id from the first paint; reuse it in the Local-mode branch instead of regenerating. * Single-camera grid pinned the tile to the left edge — auto-fill keeps empty tracks. Switch to auto-fit + max column width + justify-content: center so one camera centers and multi-camera rows still fill. * Live <video> stayed black because the manifest 404s for ~1s after FFmpeg starts, and manifestLoadingMaxRetry: 1 gave up immediately. Bump retries to 6 with a 1s base delay, surface fatal HLS errors as a "Stream unavailable" overlay with a Retry button instead of leaving the tile silently black.
1 parent a00c1aa commit 92ce139

5 files changed

Lines changed: 113 additions & 42 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# should reflect that. Going from `opensentry-cloudnode` to
66
# `sourcebox-sentry-cloudnode` keeps the binary name explicit + branded.
77
name = "sourcebox-sentry-cloudnode"
8-
version = "0.1.47"
8+
version = "0.1.48"
99
edition = "2021"
1010
authors = ["SourceBox LLC"]
1111
description = "SourceBox Sentry CloudNode — turns a USB webcam into a cloud-connected security camera."

src/node/runner.rs

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,39 @@ impl Node {
146146
stop_flag: Arc<AtomicBool>,
147147
render_tui: bool,
148148
) -> Result<()> {
149-
// ── Create dashboard ────────────────────────────────────────────────
150-
let node_id = self.config.node.node_id
151-
.clone()
152-
.unwrap_or_else(|| "unknown".to_string());
153-
let dash = Dashboard::new(&node_id, &self.config.cloud.api_url);
149+
// ── Resolve node_id before creating the dashboard ───────────────────
150+
// The dashboard caches the id in its state and renders it in the
151+
// status bar / web UI header. We resolve it up-front so the SPA
152+
// never shows "UNKNOWN":
153+
// - Connected mode: id comes from a prior registration, persisted
154+
// in `config.node.node_id`. First boot of a Connected install
155+
// still shows "unknown" briefly (a few seconds, until
156+
// register_with_cloud returns), which is acceptable for a flow
157+
// that's already gating on a network round-trip.
158+
// - Local mode: get-or-create a stable 8-char UUID in the SQLite
159+
// `local_node_id` KV row. Generated on first Local boot and
160+
// persisted, so per-camera IDs (and the dashboard header) stay
161+
// consistent across restarts.
162+
let initial_node_id = if self.config.mode.is_local() {
163+
match self.db.get_config("local_node_id")? {
164+
Some(id) => id,
165+
None => {
166+
let id: String = uuid::Uuid::new_v4()
167+
.simple()
168+
.to_string()
169+
.chars()
170+
.take(8)
171+
.collect();
172+
self.db.set_config("local_node_id", &id)?;
173+
id
174+
}
175+
}
176+
} else {
177+
self.config.node.node_id
178+
.clone()
179+
.unwrap_or_else(|| "unknown".to_string())
180+
};
181+
let dash = Dashboard::new(&initial_node_id, &self.config.cloud.api_url);
154182
dash.set_settings(self.build_settings_info());
155183
dash.set_db(self.db.clone(), self.hls_output_dir.clone());
156184
// Pass the API client so `/wipe confirm` can ask the backend
@@ -191,33 +219,14 @@ impl Node {
191219
// the per-camera fallback below generates IDs from
192220
// `<local_node_id>_<sanitized_device_path>`.
193221
let (node_id, camera_mapping) = if self.config.mode.is_local() {
194-
// Get-or-create the persistent local node id. 8 hex chars
195-
// matches the format Command Center registration returns,
196-
// so all the downstream string formatting (status bar
197-
// truncation to 8 chars, camera-id namespacing) keeps
198-
// working without special-casing.
199-
let local_id = match self.db.get_config("local_node_id")? {
200-
Some(id) => id,
201-
None => {
202-
let id: String = uuid::Uuid::new_v4()
203-
.simple()
204-
.to_string()
205-
.chars()
206-
.take(8)
207-
.collect();
208-
self.db.set_config("local_node_id", &id)?;
209-
dash.log_info(format!(
210-
"Generated local node id: {}",
211-
id.cyan().bold()
212-
));
213-
id
214-
}
215-
};
222+
// `initial_node_id` was already resolved (and persisted) above
223+
// before Dashboard::new so the web UI header doesn't show
224+
// "UNKNOWN". Just reuse it here.
216225
dash.log_info(format!(
217226
"Local mode — skipping Command Center registration (node {})",
218-
local_id.cyan().bold()
227+
initial_node_id.cyan().bold()
219228
));
220-
(local_id, HashMap::<String, String>::new())
229+
(initial_node_id.clone(), HashMap::<String, String>::new())
221230
} else {
222231
dash.log_info("Registering with cloud…");
223232
let registration = self.register_with_cloud(&detected_cameras, &dash).await?;

web/src/components/HlsPlayer.tsx

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Used by the live camera grid (live HLS) and the recording-playback
33
// modal (VOD HLS) — same component, different src URLs.
44

5-
import { useEffect, useRef } from "react"
5+
import { useEffect, useRef, useState } from "react"
66
import Hls from "hls.js"
77

88
interface HlsPlayerProps {
@@ -21,14 +21,27 @@ export default function HlsPlayer({
2121
controls = false,
2222
}: HlsPlayerProps) {
2323
const videoRef = useRef<HTMLVideoElement>(null)
24+
// `nonce` increments on retry-click and is appended as a cache buster
25+
// so HLS.js (and any intermediate proxy) refetches the manifest from
26+
// scratch instead of replaying the failed cached response.
27+
const [nonce, setNonce] = useState(0)
28+
const [error, setError] = useState<string | null>(null)
2429

2530
useEffect(() => {
2631
const video = videoRef.current
2732
if (!video) return
2833

34+
setError(null)
35+
36+
// Append the retry nonce so a "Retry" click after a failure forces
37+
// a fresh manifest load. Order: existing query params first.
38+
const url = nonce > 0
39+
? `${src}${src.includes("?") ? "&" : "?"}_t=${nonce}`
40+
: src
41+
2942
// Native HLS (Safari + iOS) — feed the URL directly.
3043
if (video.canPlayType("application/vnd.apple.mpegurl")) {
31-
video.src = src
44+
video.src = url
3245
if (autoPlay) {
3346
void video.play().catch(() => {
3447
// Autoplay can be blocked; the controls (or user interaction)
@@ -44,33 +57,77 @@ export default function HlsPlayer({
4457
// hls.js path (Chrome / Firefox / Edge).
4558
if (Hls.isSupported()) {
4659
const hls = new Hls({
47-
// Live tuning — fail fast on stalls so the operator sees a
48-
// black tile instead of a spinning forever. For VOD playback
49-
// these knobs don't fire.
60+
// Live tuning. These defaults are tolerant of the first ~3 s
61+
// after a camera starts when FFmpeg hasn't written the first
62+
// segment yet — we want to wait it out, not surface a black
63+
// tile. `manifestLoadingMaxRetry: 6` ≈ 6 retries with hls.js's
64+
// default exponential backoff (~64 s ceiling) before giving up.
5065
liveSyncDurationCount: 3,
51-
manifestLoadingTimeOut: 8_000,
52-
manifestLoadingMaxRetry: 1,
66+
manifestLoadingTimeOut: 10_000,
67+
manifestLoadingMaxRetry: 6,
68+
manifestLoadingRetryDelay: 1_000,
69+
levelLoadingMaxRetry: 6,
70+
levelLoadingRetryDelay: 1_000,
5371
})
54-
hls.loadSource(src)
72+
hls.loadSource(url)
5573
hls.attachMedia(video)
5674
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
5775
if (autoPlay) {
5876
void video.play().catch(() => undefined)
5977
}
6078
})
79+
hls.on(Hls.Events.ERROR, (_evt, data) => {
80+
// Only surface fatal errors — non-fatal recovers automatically.
81+
if (!data.fatal) return
82+
// Map the HLS.js error type to a short human-readable string.
83+
const detail = data.details ?? data.type ?? "playback error"
84+
setError(`${detail}`)
85+
hls.destroy()
86+
})
6187
return () => {
6288
hls.destroy()
6389
}
6490
}
6591

6692
// Final fallback: just set the src and hope the browser figures
6793
// it out. Nothing else to wire up.
68-
video.src = src
94+
video.src = url
6995
return () => {
7096
video.removeAttribute("src")
7197
video.load()
7298
}
73-
}, [src, autoPlay])
99+
}, [src, autoPlay, nonce])
100+
101+
if (error) {
102+
return (
103+
<div className={className} style={{
104+
display: "flex",
105+
flexDirection: "column",
106+
alignItems: "center",
107+
justifyContent: "center",
108+
gap: "0.75rem",
109+
background: "var(--bg-secondary)",
110+
color: "var(--text-secondary)",
111+
fontSize: "0.85rem",
112+
padding: "1rem",
113+
textAlign: "center",
114+
}}>
115+
<div style={{ color: "var(--accent-red)", fontWeight: 600 }}>
116+
Stream unavailable
117+
</div>
118+
<div style={{ color: "var(--text-muted)", fontSize: "0.75rem" }}>
119+
{error}
120+
</div>
121+
<button
122+
type="button"
123+
className="btn"
124+
onClick={() => setNonce(n => n + 1)}
125+
>
126+
Retry
127+
</button>
128+
</div>
129+
)
130+
}
74131

75132
return (
76133
<video

web/src/styles.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,14 @@ button {
154154
}
155155

156156
/* ── Camera grid ─────────────────────────────────────────────── */
157+
/* `auto-fit` (not auto-fill) collapses empty tracks so a single tile
158+
doesn't pin to the left edge — combined with `justify-content: center`
159+
and a max width per column, a one-camera install centers neatly while
160+
multi-camera installs still flow naturally across the row. */
157161
.cameras-grid {
158162
display: grid;
159-
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
163+
grid-template-columns: repeat(auto-fit, minmax(360px, 480px));
164+
justify-content: center;
160165
gap: 1rem;
161166
}
162167

0 commit comments

Comments
 (0)