Skip to content

Commit cef5230

Browse files
Sbussisoclaude
andcommitted
feat(status): ingest + surface real pipeline state from CloudNode
CloudNode's FFmpeg supervisor now reports "starting" / "streaming" / "restarting" / "failed" / "error" per camera, with an optional last_error reason when things are broken. Backend ingests these in both the HTTP and WebSocket heartbeat paths, persists them on the Camera row (new last_error column, auto-migrated on startup), and exposes them through to_dict so the dashboard can tell the user *why* a camera they expect to be live isn't showing video. Frontend: CameraCard renders new badges for the supervised states, puts the supervisor's last_error inline under "Pipeline Failed", and shows a yellow "Reconnecting — …" overlay while restarting so the user doesn't wonder if the camera just hung. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6137b7 commit cef5230

6 files changed

Lines changed: 147 additions & 12 deletions

File tree

backend/app/api/nodes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,13 @@ async def node_heartbeat(
225225
if cam:
226226
cam.status = cam_status.status
227227
cam.last_seen = now
228+
# Record (or clear) the pipeline failure reason. Healthy
229+
# states wipe the field so stale errors don't linger in
230+
# the API response after the supervisor recovers.
231+
if cam_status.status in ("restarting", "failed", "error"):
232+
cam.last_error = cam_status.last_error
233+
else:
234+
cam.last_error = None
228235

229236
db.commit()
230237

backend/app/api/ws.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,13 @@ async def _handle_heartbeat(node_id: str, node_db_id: int, org_id: str, payload:
246246
new_cam_status = cam_data.get("status", "online")
247247
cam.status = new_cam_status
248248
cam.last_seen = now
249+
# Record (or clear) the pipeline failure reason.
250+
# Healthy states wipe the field so stale errors
251+
# don't linger once the supervisor recovers.
252+
if new_cam_status in ("restarting", "failed", "error"):
253+
cam.last_error = cam_data.get("last_error")
254+
else:
255+
cam.last_error = None
249256
if (
250257
prev_cam_status != new_cam_status
251258
and new_cam_status in ("online", "offline")

backend/app/models/models.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@ class Camera(Base):
2626
capabilities = Column(String(500), default="streaming")
2727
group_id = Column(Integer, ForeignKey("camera_groups.id"), nullable=True)
2828
last_seen = Column(DateTime)
29+
# Pipeline state. In addition to the legacy "online" / "offline", the
30+
# CloudNode's FFmpeg supervisor now reports "starting", "streaming",
31+
# "restarting", "failed", and "error" so the UI can tell the user
32+
# why a camera they expect to be live isn't showing video.
2933
status = Column(String(20), default="offline")
34+
# Human-readable failure reason that goes alongside `status` when the
35+
# pipeline is `restarting` / `failed` / `error`. Cleared whenever the
36+
# node reports a healthy status.
37+
last_error = Column(String(500), nullable=True)
3038
created_at = Column(DateTime, default=lambda: datetime.now(tz=timezone.utc).replace(tzinfo=None))
3139

3240
# Codec detection fields
@@ -49,13 +57,19 @@ def effective_status(self) -> str:
4957
return self.status
5058

5159
def to_dict(self):
60+
eff = self.effective_status
61+
# Only surface last_error when the camera is actually in a
62+
# broken state — once it flips back to streaming, the stale
63+
# reason would just confuse anyone reading the API response.
64+
err = self.last_error if eff in ("restarting", "failed", "error") else None
5265
return {
5366
"camera_id": self.camera_id,
5467
"name": self.name,
5568
"node_type": self.node_type,
5669
"capabilities": self.capabilities.split(",") if self.capabilities else [],
5770
"group": self.group.name if self.group else None,
58-
"status": self.effective_status,
71+
"status": eff,
72+
"last_error": err,
5973
"last_seen": self.last_seen.isoformat() if self.last_seen else None,
6074
}
6175

backend/app/schemas/schemas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class NodeRegister(BaseModel):
3939
class CameraStatus(BaseModel):
4040
camera_id: str = Field(..., max_length=150)
4141
status: str = Field(..., max_length=20)
42+
# Optional failure reason — sent by the CloudNode supervisor when
43+
# the pipeline is restarting, failed, or errored. Old nodes that
44+
# predate the supervisor simply omit it (the field is Optional).
45+
last_error: Optional[str] = Field(None, max_length=500)
4246

4347

4448
class NodeHeartbeat(BaseModel):

frontend/src/components/CameraCard.jsx

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,44 @@ function CameraCard({
4545
}
4646
}, [cameraId, getToken, recording, showToast])
4747

48-
const isOffline = camera.status === "offline"
48+
// "failed" (supervisor gave up) and "error" mean the pipeline is
49+
// producing nothing — treat them as offline for playback purposes.
50+
// "restarting" and "starting" are transient; the HLS player will just
51+
// show its buffering state until segments arrive.
52+
const status = camera.status
53+
const isDown = status === "offline" || status === "failed" || status === "error"
54+
const isTransient = status === "starting" || status === "restarting"
4955

5056
const nodeTypeLabel = camera.node_type || "Camera"
5157
const nodeTypeIcon = "📹"
5258

53-
const statusClass = camera.status === "online" ? "online" :
54-
camera.status === "streaming" ? "streaming" :
55-
camera.status === "recording" ? "recording" : "offline"
59+
const statusClass =
60+
status === "online" ? "online" :
61+
status === "streaming" ? "streaming" :
62+
status === "recording" ? "recording" :
63+
status === "starting" ? "starting" :
64+
status === "restarting" ? "restarting" :
65+
status === "failed" ? "failed" :
66+
status === "error" ? "error" : "offline"
5667

57-
const cardClasses = `camera-card ${isOffline ? "offline" : ""}`
68+
// Down-state messages used inside the feed placeholder. "failed" and
69+
// "error" render the supervisor's last_error if we have it so the user
70+
// isn't left guessing why the camera went dark.
71+
const downLabel =
72+
status === "failed" ? "Pipeline Failed" :
73+
status === "error" ? "Pipeline Error" : "Camera Offline"
74+
const downDetail =
75+
(status === "failed" || status === "error") && camera.last_error
76+
? camera.last_error
77+
: null
78+
79+
// Tooltip content for the status badge. Shows the reason inline so the
80+
// user doesn't have to hover into the feed to see what's wrong.
81+
const badgeTitle = camera.last_error
82+
? `${status}: ${camera.last_error}`
83+
: status || "unknown"
84+
85+
const cardClasses = `camera-card ${isDown ? "offline" : ""}`
5886

5987
return (
6088
<div className={cardClasses}>
@@ -67,25 +95,33 @@ function CameraCard({
6795
<span className="node-type">{nodeTypeLabel}</span>
6896
</div>
6997
</div>
70-
<div className={`status-badge ${statusClass}`}>
98+
<div className={`status-badge ${statusClass}`} title={badgeTitle}>
7199
<span className="dot"></span>
72-
<span className="status-text">{camera.status || "unknown"}</span>
100+
<span className="status-text">{status || "unknown"}</span>
73101
</div>
74102
</div>
75103

76104
<div className="camera-feed-container">
77-
{isOffline ? (
105+
{isDown ? (
78106
<div className="feed-loading error">
79107
<span className="status-icon">⚠️</span>
80-
<span>Camera Offline</span>
108+
<span>{downLabel}</span>
109+
{downDetail && <span className="feed-detail">{downDetail}</span>}
81110
</div>
82111
) : (
83112
<HlsPlayer
84113
cameraId={cameraId}
85114
cameraName={camera.name || `Camera ${cameraId.slice(-4)}`}
86-
status={camera.status}
115+
status={status}
87116
/>
88117
)}
118+
{isTransient && (
119+
<div className="camera-feed-overlay-banner">
120+
{status === "restarting"
121+
? `Reconnecting${camera.last_error ? ` — ${camera.last_error}` : "…"}`
122+
: "Starting up…"}
123+
</div>
124+
)}
89125
</div>
90126

91127
<div className="camera-controls">
@@ -120,6 +156,7 @@ export default memo(CameraCard, (prevProps, nextProps) => {
120156
return (
121157
prevProps.cameraId === nextProps.cameraId &&
122158
prevProps.camera.status === nextProps.camera.status &&
123-
prevProps.camera.name === nextProps.camera.name
159+
prevProps.camera.name === nextProps.camera.name &&
160+
prevProps.camera.last_error === nextProps.camera.last_error
124161
)
125162
})

frontend/src/index.css

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,39 @@ body {
395395
animation: pulse-fast 1s ease-in-out infinite;
396396
}
397397

398+
/* Supervisor pipeline states — added alongside the FFmpeg auto-restart
399+
work so operators can tell apart "camera unplugged" (offline) from
400+
"pipeline respawning" (restarting) from "pipeline gave up" (failed). */
401+
.status-badge.starting {
402+
background: rgba(245, 158, 11, 0.15);
403+
color: var(--accent-amber);
404+
}
405+
.status-badge.starting .dot {
406+
background: var(--accent-amber);
407+
box-shadow: 0 0 8px var(--accent-amber-glow);
408+
animation: pulse 2s ease-in-out infinite;
409+
}
410+
411+
.status-badge.restarting {
412+
background: rgba(245, 158, 11, 0.15);
413+
color: var(--accent-amber);
414+
}
415+
.status-badge.restarting .dot {
416+
background: var(--accent-amber);
417+
box-shadow: 0 0 8px var(--accent-amber-glow);
418+
animation: pulse-fast 1s ease-in-out infinite;
419+
}
420+
421+
.status-badge.failed, .status-badge.error {
422+
background: rgba(239, 68, 68, 0.15);
423+
color: var(--accent-red);
424+
}
425+
.status-badge.failed .dot, .status-badge.error .dot {
426+
background: var(--accent-red);
427+
box-shadow: 0 0 8px var(--accent-red-glow);
428+
animation: pulse-fast 1s ease-in-out infinite;
429+
}
430+
398431
/* Video Feed */
399432
.camera-feed-container {
400433
position: relative;
@@ -446,6 +479,39 @@ body {
446479
margin-top: 4px;
447480
}
448481

482+
/* Error detail shown under "Pipeline Failed" / "Pipeline Error" — gives
483+
the operator the supervisor's last_error so they don't have to go
484+
hunting through the CloudNode TUI to figure out what's wrong. */
485+
.feed-loading .feed-detail {
486+
font-size: 0.7rem;
487+
color: var(--text-muted);
488+
margin-top: 4px;
489+
max-width: 80%;
490+
text-align: center;
491+
word-break: break-word;
492+
opacity: 0.85;
493+
}
494+
495+
/* Small banner overlaid on the HLS player when the pipeline is
496+
transient (starting / restarting). Keeps video visible if segments
497+
are still trickling in, but flags that the state isn't steady. */
498+
.camera-feed-overlay-banner {
499+
position: absolute;
500+
top: 8px;
501+
left: 8px;
502+
right: 8px;
503+
padding: 6px 10px;
504+
background: rgba(245, 158, 11, 0.85);
505+
color: #000;
506+
font-size: 0.7rem;
507+
font-weight: 600;
508+
border-radius: 4px;
509+
text-align: center;
510+
pointer-events: none;
511+
z-index: 2;
512+
text-shadow: none;
513+
}
514+
449515
.loading-spinner {
450516
width: 32px;
451517
height: 32px;

0 commit comments

Comments
 (0)