mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +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;
|
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,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue