diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index ccf8dbe..1a5e89f 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -713,10 +713,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } const cameraId = ent.camera_id; - // 1. Try kiosks currently rendering this camera. listKiosksRenderingCamera - // returns kiosks whose active_layout_id has at least one layout_cell - // pointing at cameraId. Filter to ones we can actually reach. - const candidates = deps.repo.listKiosksRenderingCamera(cameraId); + // 1. Try kiosks that have this camera in ANY layout (bundle-level). + // Even if the camera isn't on screen right now, the kiosk is on the + // same LAN and can open a one-shot RTSP connection for the snapshot. + // 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 now = Date.now(); for (const k of candidates) { @@ -1225,12 +1226,37 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const id = Number(getRouterParam(event, "id")); const camera = deps.repo.getCameraById(id); 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({ user: user.username, camera, labels: deps.repo.cameraLabelIds(id), allLabels: deps.repo.listLabels(), streams: deps.repo.listCameraStreams(id), + subscriptions, })); }); diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 8c22bae..d59cb6b 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -419,13 +419,8 @@ export class Repository { } /** - * Kiosks currently rendering this camera. Join chain: - * displays.active_layout_id == layouts.id - * → 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. + * Kiosks currently rendering this camera (active layout has a cell + * pointing at it). Subset of listKiosksWithCameraInBundle. */ listKiosksRenderingCamera(cameraId: number): Kiosk[] { const rs = this.prep( @@ -440,6 +435,27 @@ export class Repository { return rs.map((r) => rowToKiosk(r as Record)); } + /** + * 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)); + } + 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; return (r?.m ?? -1) + 1; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 1eac00a..6cbca76 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1141,12 +1141,19 @@ export function SimpleListPage(props: SimpleListProps) { // ---- 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 { user: string; camera: Camera; labels: Array<{ label_id: number; name: string }>; allLabels: Label[]; streams: Array<{ id: number; role: string; name: string; rtsp_uri: string }>; + subscriptions: CameraSubscription[]; error?: string; success?: string; } @@ -1322,6 +1329,38 @@ export function CameraEditPage(props: CameraEditProps) { )} +
+

Kiosk Subscriptions

+

+ Kiosks whose layouts reference this camera. Snapshots are pulled + from a subscribed kiosk (same LAN as camera) when available. +

+ {props.subscriptions.length > 0 ? ( +
+ + + + {props.subscriptions.map((sub) => ( + + + + + + ))} + +
KioskLayoutsStatus
+ {sub.kiosk.name} + {sub.layouts.join(", ") || "—"} + {sub.active + ? active + : bundled} +
+
+ ) : ( +

No kiosk has this camera in any layout.

+ )} +
+