feat(cameras): show kiosk subscriptions on camera detail page

Camera edit page now shows a "Kiosk Subscriptions" table: every kiosk
whose layouts reference this camera, which specific layout names, and
whether the camera is in the kiosk's active layout (green "active"
badge) or just bundled (gray "bundled" badge).

Snapshot route switched from listKiosksRenderingCamera (active-only)
to listKiosksWithCameraInBundle (any layout). The kiosk's LAN endpoint
opens a one-shot RTSP connection from its own network position even when
the camera isn't on screen — no warm pipeline needed. Server falls back
to direct pull only when NO kiosk has the camera in any layout at all.
This commit is contained in:
Mitchell R 2026-05-21 11:54:25 +02:00
parent 4c1edbd3b2
commit 5edf9d4b0b
No known key found for this signature in database
3 changed files with 92 additions and 11 deletions

View file

@ -713,10 +713,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
} }
const cameraId = ent.camera_id; const cameraId = ent.camera_id;
// 1. Try kiosks currently rendering this camera. listKiosksRenderingCamera // 1. Try kiosks that have this camera in ANY layout (bundle-level).
// returns kiosks whose active_layout_id has at least one layout_cell // Even if the camera isn't on screen right now, the kiosk is on the
// pointing at cameraId. Filter to ones we can actually reach. // same LAN and can open a one-shot RTSP connection for the snapshot.
const candidates = deps.repo.listKiosksRenderingCamera(cameraId); // Only fall through to server-direct when NO kiosk has it at all.
const candidates = deps.repo.listKiosksWithCameraInBundle(cameraId);
const STALE_MS = 2 * 60 * 1000; // kiosk silent > 2 min → don't bother const STALE_MS = 2 * 60 * 1000; // kiosk silent > 2 min → don't bother
const now = Date.now(); const now = Date.now();
for (const k of candidates) { for (const k of candidates) {
@ -1225,12 +1226,37 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
const camera = deps.repo.getCameraById(id); const camera = deps.repo.getCameraById(id);
if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
// Build subscription list: which kiosks have this camera in any layout?
const bundleKiosks = deps.repo.listKiosksWithCameraInBundle(id);
const activeKiosks = new Set(deps.repo.listKiosksRenderingCamera(id).map((k) => k.id));
const subscriptions = bundleKiosks.map((k) => {
// Find layout names that reference this camera on this kiosk's displays
const displays = deps.repo.listDisplaysForKiosk(k.id);
const layoutNames: string[] = [];
for (const d of displays) {
const layouts = deps.repo.listLayoutsForDisplay(d.id);
for (const l of layouts) {
const cells = deps.repo.listLayoutCells(l.id);
if (cells.some((c) => c.camera_id === id)) {
layoutNames.push(l.name);
}
}
}
return {
kiosk: k,
layouts: layoutNames,
active: activeKiosks.has(k.id),
};
});
return htmlPage(CameraEditPage({ return htmlPage(CameraEditPage({
user: user.username, user: user.username,
camera, camera,
labels: deps.repo.cameraLabelIds(id), labels: deps.repo.cameraLabelIds(id),
allLabels: deps.repo.listLabels(), allLabels: deps.repo.listLabels(),
streams: deps.repo.listCameraStreams(id), streams: deps.repo.listCameraStreams(id),
subscriptions,
})); }));
}); });

View file

@ -419,13 +419,8 @@ export class Repository {
} }
/** /**
* Kiosks currently rendering this camera. Join chain: * Kiosks currently rendering this camera (active layout has a cell
* displays.active_layout_id == layouts.id * pointing at it). Subset of listKiosksWithCameraInBundle.
* layout_cells.layout_id == layouts.id
* layout_cells.camera_id == ?
* kiosks via displays.kiosk_id
* Active layout is set by kiosk's layout.changed event. Stale ones may
* still appear here caller filters by last_seen_at + local_port.
*/ */
listKiosksRenderingCamera(cameraId: number): Kiosk[] { listKiosksRenderingCamera(cameraId: number): Kiosk[] {
const rs = this.prep( const rs = this.prep(
@ -440,6 +435,27 @@ export class Repository {
return rs.map((r) => rowToKiosk(r as Record<string, unknown>)); return rs.map((r) => rowToKiosk(r as Record<string, unknown>));
} }
/**
* Kiosks that have this camera in ANY of their layouts (bundle-level).
* The kiosk's cached bundle includes the camera even when it's not the
* active layout, so snapshot requests via the kiosk LAN endpoint still
* resolve the kiosk opens a short-lived RTSP connection from its own
* LAN position. Only when NO kiosk has the camera should the server
* fall back to pulling the stream itself.
*/
listKiosksWithCameraInBundle(cameraId: number): Kiosk[] {
const rs = this.prep(
`SELECT DISTINCT k.*
FROM kiosks k
JOIN displays d ON d.kiosk_id = k.id
JOIN layouts l ON l.display_id = d.id
JOIN layout_cells lc ON lc.layout_id = l.id
WHERE lc.camera_id = ?
AND k.enabled = 1`,
).all(cameraId);
return rs.map((r) => rowToKiosk(r as Record<string, unknown>));
}
private nextDisplayIndexForKiosk(kioskId: number): number { private nextDisplayIndexForKiosk(kioskId: number): number {
const r = this.prep('SELECT MAX("index") AS m FROM displays WHERE kiosk_id = ?').get(kioskId) as { m: number | null } | undefined; const r = this.prep('SELECT MAX("index") AS m FROM displays WHERE kiosk_id = ?').get(kioskId) as { m: number | null } | undefined;
return (r?.m ?? -1) + 1; return (r?.m ?? -1) + 1;

View file

@ -1141,12 +1141,19 @@ export function SimpleListPage(props: SimpleListProps) {
// ---- Camera Edit ------------------------------------------------------------ // ---- Camera Edit ------------------------------------------------------------
interface CameraSubscription {
kiosk: Kiosk;
layouts: string[]; // layout names that reference this camera
active: boolean; // true if camera is in the kiosk's active layout right now
}
interface CameraEditProps { interface CameraEditProps {
user: string; user: string;
camera: Camera; camera: Camera;
labels: Array<{ label_id: number; name: string }>; labels: Array<{ label_id: number; name: string }>;
allLabels: Label[]; allLabels: Label[];
streams: Array<{ id: number; role: string; name: string; rtsp_uri: string }>; streams: Array<{ id: number; role: string; name: string; rtsp_uri: string }>;
subscriptions: CameraSubscription[];
error?: string; error?: string;
success?: string; success?: string;
} }
@ -1322,6 +1329,38 @@ export function CameraEditPage(props: CameraEditProps) {
)} )}
</div> </div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Kiosk Subscriptions</h2>
<p style="color:#666; font-size:0.85rem; margin-bottom:0.75rem">
Kiosks whose layouts reference this camera. Snapshots are pulled
from a subscribed kiosk (same LAN as camera) when available.
</p>
{props.subscriptions.length > 0 ? (
<div class="table-wrap">
<table>
<thead><tr><th>Kiosk</th><th>Layouts</th><th>Status</th></tr></thead>
<tbody>
{props.subscriptions.map((sub) => (
<tr>
<td>
<a href={`/admin/kiosks/${sub.kiosk.id}`}><strong>{sub.kiosk.name}</strong></a>
</td>
<td style="font-size:0.85rem">{sub.layouts.join(", ") || "—"}</td>
<td>
{sub.active
? <span class="badge badge-green">active</span>
: <span class="badge badge-gray">bundled</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p style="color:#999">No kiosk has this camera in any layout.</p>
)}
</div>
<form method="post" action={`/admin/cameras/${cam.id}/delete`} style="margin-top:1rem"> <form method="post" action={`/admin/cameras/${cam.id}/delete`} style="margin-top:1rem">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button> <button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button>
</form> </form>