mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
4c1edbd3b2
commit
5edf9d4b0b
3 changed files with 92 additions and 11 deletions
|
|
@ -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,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
)}
|
||||
</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">
|
||||
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button>
|
||||
</form>
|
||||
|
|
|
|||
Loading…
Reference in a new issue