mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
refactor: migrate all auto-increment PKs to UUIDv7 text IDs
Replace SERIAL/AUTOINCREMENT integer primary keys with UUIDv7 text IDs across all 15 entity tables (users, api_keys, displays, cameras, camera_streams, layouts, layout_cells, entities, kiosks, labels, kiosk_gpio_bindings, event_log, kiosk_logs, audit_log, camera_event_subscriptions). SetupState keeps id=1 INTEGER singleton. Changes: - types.ts: all id fields number->string, all FK fields number->string - mappers.ts: n(r["id"])->s(r["id"]) for PKs, nn()->sn() for nullable FKs - repository.ts: import uuidv7, generate IDs before INSERT, remove RETURNING id, change all method signatures from number to string - migrations-pg.ts: SERIAL->TEXT NOT NULL PRIMARY KEY, INTEGER FK->TEXT FK - bundle.ts: all bundle interface IDs number->string - pairing.ts, auth.ts: kioskId/userId types number->string - coordinator-registry.ts: kioskId number->string - audit.ts: actor_id number->string - mqtt-bridge.ts: kioskId number->string in publish/subscribe - All route handlers: Number(getRouterParam)->getRouterParam ?? "" - admin-pages.tsx: template function params and Map types number->string - kiosk/src/bundle.rs: flexible serde deserializer that accepts both u32 (old) and String (new) IDs for backward compatibility Fresh PG database -- no data migration needed, just schema changes. SQLite migrations unchanged (dev-only, recreate DB for UUIDv7). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
69e51197bf
commit
64f47a9a6b
18 changed files with 506 additions and 490 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct KioskBundle {
|
pub struct KioskBundle {
|
||||||
pub kiosk_id: u32,
|
#[serde(deserialize_with = "de_flexible_id")]
|
||||||
|
pub kiosk_id: String,
|
||||||
pub kiosk_name: String,
|
pub kiosk_name: String,
|
||||||
/// Legacy single-display field (mirrors `displays[0]`). New code should
|
/// Legacy single-display field (mirrors `displays[0]`). New code should
|
||||||
/// iterate `displays` instead.
|
/// iterate `displays` instead.
|
||||||
|
|
@ -31,7 +33,7 @@ impl KioskBundle {
|
||||||
}
|
}
|
||||||
if let Some(d) = &self.display {
|
if let Some(d) = &self.display {
|
||||||
return vec![BundleDisplayWithLayouts {
|
return vec![BundleDisplayWithLayouts {
|
||||||
id: d.id,
|
id: d.id.clone(),
|
||||||
name: d.name.clone(),
|
name: d.name.clone(),
|
||||||
width_px: d.width_px,
|
width_px: d.width_px,
|
||||||
height_px: d.height_px,
|
height_px: d.height_px,
|
||||||
|
|
@ -90,7 +92,8 @@ pub struct BundleCell {
|
||||||
pub row_span: u32,
|
pub row_span: u32,
|
||||||
pub col_span: u32,
|
pub col_span: u32,
|
||||||
pub content_type: String,
|
pub content_type: String,
|
||||||
pub camera_id: Option<u32>,
|
#[serde(default, deserialize_with = "de_flexible_id_opt")]
|
||||||
|
pub camera_id: Option<String>,
|
||||||
pub stream_selector: Option<String>,
|
pub stream_selector: Option<String>,
|
||||||
pub web_url: Option<String>,
|
pub web_url: Option<String>,
|
||||||
pub html_content: Option<String>,
|
pub html_content: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ declare module "h3" {
|
||||||
|
|
||||||
function syntheticApiKeyUser(keyPrefix: string): User {
|
function syntheticApiKeyUser(keyPrefix: string): User {
|
||||||
return {
|
return {
|
||||||
id: 0,
|
id: "",
|
||||||
username: `api:${keyPrefix}`,
|
username: `api:${keyPrefix}`,
|
||||||
password_hash: "",
|
password_hash: "",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
|
|
|
||||||
|
|
@ -163,9 +163,9 @@ function formValues(v: FormValue): string[] {
|
||||||
return v ? [v] : [];
|
return v ? [v] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function kioskOnvifSoapTransport(kioskId: number) {
|
function kioskOnvifSoapTransport(kioskId: string) {
|
||||||
return async (url: string, action: string, body: string, timeoutMs: number): Promise<string> => {
|
return async (url: string, action: string, body: string, timeoutMs: number): Promise<string> => {
|
||||||
if (!Number.isInteger(kioskId) || kioskId <= 0) {
|
if (!kioskId) {
|
||||||
throw new Error("invalid kiosk selected for discovery");
|
throw new Error("invalid kiosk selected for discovery");
|
||||||
}
|
}
|
||||||
const response = await getCoordinator().requestKiosk<{
|
const response = await getCoordinator().requestKiosk<{
|
||||||
|
|
@ -209,7 +209,7 @@ async function importDiscoveredCamera(
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
streams: DiscoverAddStream[],
|
streams: DiscoverAddStream[],
|
||||||
): Promise<number | null> {
|
): Promise<string | null> {
|
||||||
if (streams.length === 0) return null;
|
if (streams.length === 0) return null;
|
||||||
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
|
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
|
||||||
// Camera row's rtsp_url: full URL with credentials for display / backward compat.
|
// Camera row's rtsp_url: full URL with credentials for display / backward compat.
|
||||||
|
|
@ -283,7 +283,7 @@ function cellsOverlap(
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CellPos {
|
interface CellPos {
|
||||||
id: number;
|
id: string;
|
||||||
row: number;
|
row: number;
|
||||||
col: number;
|
col: number;
|
||||||
row_span: number;
|
row_span: number;
|
||||||
|
|
@ -292,12 +292,12 @@ interface CellPos {
|
||||||
|
|
||||||
async function resolveOverlaps(
|
async function resolveOverlaps(
|
||||||
deps: AdminDeps,
|
deps: AdminDeps,
|
||||||
layoutId: number,
|
layoutId: string,
|
||||||
anchorId: number,
|
anchorId: string,
|
||||||
pushAxis: "row" | "col",
|
pushAxis: "row" | "col",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const all = await deps.repo.layoutCells(layoutId);
|
const all = await deps.repo.layoutCells(layoutId);
|
||||||
const positions = new Map<number, CellPos>();
|
const positions = new Map<string, CellPos>();
|
||||||
for (const c of all) {
|
for (const c of all) {
|
||||||
positions.set(c.id, { id: c.id, row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span });
|
positions.set(c.id, { id: c.id, row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span });
|
||||||
}
|
}
|
||||||
|
|
@ -353,8 +353,8 @@ async function resolveOverlaps(
|
||||||
|
|
||||||
async function shiftCellsForExpansion(
|
async function shiftCellsForExpansion(
|
||||||
deps: AdminDeps,
|
deps: AdminDeps,
|
||||||
layoutId: number,
|
layoutId: string,
|
||||||
cellId: number,
|
cellId: string,
|
||||||
direction: "left" | "right" | "above" | "bottom",
|
direction: "left" | "right" | "above" | "bottom",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cell = await deps.repo.getLayoutCellById(cellId);
|
const cell = await deps.repo.getLayoutCellById(cellId);
|
||||||
|
|
@ -379,7 +379,7 @@ async function shiftCellsForExpansion(
|
||||||
|
|
||||||
async function shiftCellsForInsertion(
|
async function shiftCellsForInsertion(
|
||||||
deps: AdminDeps,
|
deps: AdminDeps,
|
||||||
layoutId: number,
|
layoutId: string,
|
||||||
axis: "row" | "col",
|
axis: "row" | "col",
|
||||||
fromIndex: number,
|
fromIndex: number,
|
||||||
crossStart: number,
|
crossStart: number,
|
||||||
|
|
@ -541,8 +541,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
app.get("/admin/cameras", async (event) => {
|
app.get("/admin/cameras", async (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const cameras = await deps.repo.listCameras();
|
const cameras = await deps.repo.listCameras();
|
||||||
const streamCounts = new Map<number, number>();
|
const streamCounts = new Map<string, number>();
|
||||||
const activeKiosks = new Map<number, number>(); // camera_id → count of kiosks rendering
|
const activeKiosks = new Map<string, number>(); // camera_id → count of kiosks rendering
|
||||||
for (const cam of cameras) {
|
for (const cam of cameras) {
|
||||||
streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length);
|
streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length);
|
||||||
activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length);
|
activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length);
|
||||||
|
|
@ -643,7 +643,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const soapTransport = runner.startsWith("kiosk:")
|
const soapTransport = runner.startsWith("kiosk:")
|
||||||
? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length)))
|
? kioskOnvifSoapTransport(runner.slice("kiosk:".length))
|
||||||
: undefined;
|
: undefined;
|
||||||
const cameras = await onvifDiscover({ host, port, username, password, soapTransport });
|
const cameras = await onvifDiscover({ host, port, username, password, soapTransport });
|
||||||
return htmlPage(CameraDiscoverResultsPage({
|
return htmlPage(CameraDiscoverResultsPage({
|
||||||
|
|
@ -740,11 +740,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
errors.push("Select an entity type.");
|
errors.push("Select an entity type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cameraId: number | null = null;
|
let cameraId: string | null = null;
|
||||||
let htmlContent: string | null = null;
|
let htmlContent: string | null = null;
|
||||||
let webUrl: string | null = null;
|
let webUrl: string | null = null;
|
||||||
if (type === "camera") {
|
if (type === "camera") {
|
||||||
cameraId = body?.["camera_id"] ? Number(body["camera_id"]) : null;
|
cameraId = body?.["camera_id"] ? String(body["camera_id"] ?? "") : null;
|
||||||
if (!cameraId) errors.push("Pick a camera.");
|
if (!cameraId) errors.push("Pick a camera.");
|
||||||
} else if (type === "html") {
|
} else if (type === "html") {
|
||||||
htmlContent = body?.["html_content"] ?? null;
|
htmlContent = body?.["html_content"] ?? null;
|
||||||
|
|
@ -776,7 +776,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/entities/:id", async (event) => {
|
app.get("/admin/entities/:id", async (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const ent = await deps.repo.getEntityById(id);
|
const ent = await deps.repo.getEntityById(id);
|
||||||
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
||||||
return htmlPage(EntityEditPage({
|
return htmlPage(EntityEditPage({
|
||||||
|
|
@ -787,14 +787,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/entities/:id", async (event) => {
|
app.post("/admin/entities/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const ent = await deps.repo.getEntityById(id);
|
const ent = await deps.repo.getEntityById(id);
|
||||||
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const patch: {
|
const patch: {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
camera_id?: number | null;
|
camera_id?: string | null;
|
||||||
html_content?: string | null;
|
html_content?: string | null;
|
||||||
web_url?: string | null;
|
web_url?: string | null;
|
||||||
} = {
|
} = {
|
||||||
|
|
@ -802,7 +802,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
description: (body?.["description"] ?? "").trim() || null,
|
description: (body?.["description"] ?? "").trim() || null,
|
||||||
};
|
};
|
||||||
if (ent.type === "camera") {
|
if (ent.type === "camera") {
|
||||||
patch.camera_id = body?.["camera_id"] ? Number(body["camera_id"]) : null;
|
patch.camera_id = body?.["camera_id"] ? String(body["camera_id"] ?? "") : null;
|
||||||
} else if (ent.type === "html") {
|
} else if (ent.type === "html") {
|
||||||
patch.html_content = body?.["html_content"] ?? null;
|
patch.html_content = body?.["html_content"] ?? null;
|
||||||
} else if (ent.type === "web") {
|
} else if (ent.type === "web") {
|
||||||
|
|
@ -814,7 +814,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/entities/:id/delete", async (event) => {
|
app.post("/admin/entities/:id/delete", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
await deps.repo.deleteEntity(id);
|
await deps.repo.deleteEntity(id);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
|
||||||
|
|
@ -825,7 +825,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// no kiosk currently has the camera in its active layout (or every kiosk
|
// no kiosk currently has the camera in its active layout (or every kiosk
|
||||||
// attempt times out). Used by the EntityEditPage "Test" preview.
|
// attempt times out). Used by the EntityEditPage "Test" preview.
|
||||||
app.get("/admin/entities/:id/snapshot", async (event) => {
|
app.get("/admin/entities/:id/snapshot", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const ent = await deps.repo.getEntityById(id);
|
const ent = await deps.repo.getEntityById(id);
|
||||||
if (!ent || ent.type !== "camera" || ent.camera_id == null) {
|
if (!ent || ent.type !== "camera" || ent.camera_id == null) {
|
||||||
return new Response("Not a camera entity", { status: 404 });
|
return new Response("Not a camera entity", { status: 404 });
|
||||||
|
|
@ -902,7 +902,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const labelsStr = (body?.["initial_labels"] ?? "").trim();
|
const labelsStr = (body?.["initial_labels"] ?? "").trim();
|
||||||
const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||||
const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim();
|
const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim();
|
||||||
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined;
|
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? replaceIdRaw : undefined;
|
||||||
const force = body?.["force"] === "1";
|
const force = body?.["force"] === "1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -939,7 +939,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const layouts = await deps.repo.listLayouts();
|
const layouts = await deps.repo.listLayouts();
|
||||||
// For each layout, how many displays use it (for the list view).
|
// For each layout, how many displays use it (for the list view).
|
||||||
const displayCounts = new Map<number, number>();
|
const displayCounts = new Map<string, number>();
|
||||||
for (const l of layouts) {
|
for (const l of layouts) {
|
||||||
displayCounts.set(l.id, (await deps.repo.listDisplaysForLayout(l.id)).length);
|
displayCounts.set(l.id, (await deps.repo.listDisplaysForLayout(l.id)).length);
|
||||||
}
|
}
|
||||||
|
|
@ -986,7 +986,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/layouts/:id", async (event) => {
|
app.get("/admin/layouts/:id", async (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const layout = await deps.repo.getLayoutById(id);
|
const layout = await deps.repo.getLayoutById(id);
|
||||||
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
const cells = await deps.repo.layoutCells(id);
|
const cells = await deps.repo.layoutCells(id);
|
||||||
|
|
@ -1005,7 +1005,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.post("/admin/layouts/:id", async (event) => {
|
app.post("/admin/layouts/:id", async (event) => {
|
||||||
event.context.obs?.log.info("layout update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
event.context.obs?.log.info("layout update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const coolingStr = body?.["cooling_timeout_seconds"] ?? "";
|
const coolingStr = body?.["cooling_timeout_seconds"] ?? "";
|
||||||
const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10);
|
const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10);
|
||||||
|
|
@ -1026,7 +1026,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// For htmx requests (hx-request header), returns the grid fragment; otherwise
|
// For htmx requests (hx-request header), returns the grid fragment; otherwise
|
||||||
// returns a 302 to the layout edit page.
|
// returns a 302 to the layout edit page.
|
||||||
app.post("/admin/layouts/:id/cells", async (event) => {
|
app.post("/admin/layouts/:id/cells", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string | number | { row: number; col: number }>>(event);
|
const body = await readBody<Record<string, string | number | { row: number; col: number }>>(event);
|
||||||
|
|
||||||
let row = 0;
|
let row = 0;
|
||||||
|
|
@ -1036,7 +1036,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const direction = typeof body?.["direction"] === "string" ? (body["direction"] as string) : "";
|
const direction = typeof body?.["direction"] === "string" ? (body["direction"] as string) : "";
|
||||||
|
|
||||||
if (afterCellIdRaw && direction) {
|
if (afterCellIdRaw && direction) {
|
||||||
const afterId = Number(afterCellIdRaw);
|
const afterId = String(afterCellIdRaw);
|
||||||
const cells = await deps.repo.layoutCells(layoutId);
|
const cells = await deps.repo.layoutCells(layoutId);
|
||||||
const ref = cells.find((c) => c.id === afterId);
|
const ref = cells.find((c) => c.id === afterId);
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
|
|
@ -1097,8 +1097,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// GET a single cell in read mode (used by htmx Cancel button in inline edit).
|
// GET a single cell in read mode (used by htmx Cancel button in inline edit).
|
||||||
app.get("/admin/layouts/:id/cells/:cellId", async (event) => {
|
app.get("/admin/layouts/:id/cells/:cellId", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = (getRouterParam(event, "id") ?? "");
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = (getRouterParam(event, "cellId") ?? "");
|
||||||
const cell = await deps.repo.getLayoutCellById(cellId);
|
const cell = await deps.repo.getLayoutCellById(cellId);
|
||||||
if (!cell || cell.layout_id !== layoutId) {
|
if (!cell || cell.layout_id !== layoutId) {
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
|
|
@ -1110,8 +1110,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// GET a single cell in edit mode (htmx swap target for cell click).
|
// GET a single cell in edit mode (htmx swap target for cell click).
|
||||||
app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => {
|
app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = (getRouterParam(event, "id") ?? "");
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = (getRouterParam(event, "cellId") ?? "");
|
||||||
const cell = await deps.repo.getLayoutCellById(cellId);
|
const cell = await deps.repo.getLayoutCellById(cellId);
|
||||||
if (!cell || cell.layout_id !== layoutId) {
|
if (!cell || cell.layout_id !== layoutId) {
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
|
|
@ -1124,14 +1124,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// Update a cell's entity binding + dimensions. Legacy content_type/web/html
|
// Update a cell's entity binding + dimensions. Legacy content_type/web/html
|
||||||
// columns are managed by assignCellEntity for bundle compatibility.
|
// columns are managed by assignCellEntity for bundle compatibility.
|
||||||
app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
|
app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = (getRouterParam(event, "id") ?? "");
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = (getRouterParam(event, "cellId") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
|
||||||
const entityIdRaw = body?.["entity_id"];
|
const entityIdRaw = body?.["entity_id"];
|
||||||
const entityId =
|
const entityId =
|
||||||
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null;
|
entityIdRaw && String(entityIdRaw).trim() !== "" ? String(entityIdRaw) : null;
|
||||||
await deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null);
|
await deps.repo.assignCellEntity(cellId, entityId != null && entityId !== "" ? entityId : null);
|
||||||
|
|
||||||
// stream_selector + spans + fit are still settable on the cell.
|
// stream_selector + spans + fit are still settable on the cell.
|
||||||
const dimsPatch: Record<string, unknown> = {};
|
const dimsPatch: Record<string, unknown> = {};
|
||||||
|
|
@ -1221,8 +1221,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Resize a cell by ±1 on row_span or col_span. Returns the grid fragment.
|
// Resize a cell by ±1 on row_span or col_span. Returns the grid fragment.
|
||||||
app.post("/admin/layouts/:id/cells/:cellId/resize", async (event) => {
|
app.post("/admin/layouts/:id/cells/:cellId/resize", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = (getRouterParam(event, "id") ?? "");
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = (getRouterParam(event, "cellId") ?? "");
|
||||||
const body = await readBody<Record<string, string | number>>(event);
|
const body = await readBody<Record<string, string | number>>(event);
|
||||||
const dim = String(body?.["dim"] ?? "");
|
const dim = String(body?.["dim"] ?? "");
|
||||||
const delta = Number(body?.["delta"] ?? 0) || 0;
|
const delta = Number(body?.["delta"] ?? 0) || 0;
|
||||||
|
|
@ -1257,8 +1257,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Visual editor: drag-to-move a cell to a new grid position.
|
// Visual editor: drag-to-move a cell to a new grid position.
|
||||||
app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => {
|
app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = (getRouterParam(event, "id") ?? "");
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = (getRouterParam(event, "cellId") ?? "");
|
||||||
const body = await readBody<{ row: number; col: number }>(event);
|
const body = await readBody<{ row: number; col: number }>(event);
|
||||||
const row = Number(body?.row ?? 0);
|
const row = Number(body?.row ?? 0);
|
||||||
const col = Number(body?.col ?? 0);
|
const col = Number(body?.col ?? 0);
|
||||||
|
|
@ -1270,8 +1270,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => {
|
app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = (getRouterParam(event, "id") ?? "");
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = (getRouterParam(event, "cellId") ?? "");
|
||||||
await deps.repo.deleteLayoutCell(cellId);
|
await deps.repo.deleteLayoutCell(cellId);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
|
|
@ -1284,7 +1284,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/layouts/:id/clone", async (event) => {
|
app.post("/admin/layouts/:id/clone", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const clone = await deps.repo.cloneLayout(id);
|
const clone = await deps.repo.cloneLayout(id);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${clone.id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${clone.id}` } });
|
||||||
|
|
@ -1292,7 +1292,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.post("/admin/layouts/:id/delete", async (event) => {
|
app.post("/admin/layouts/:id/delete", async (event) => {
|
||||||
event.context.obs?.log.info("layout delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
event.context.obs?.log.info("layout delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
await deps.repo.deleteLayout(id);
|
await deps.repo.deleteLayout(id);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
|
|
@ -1308,7 +1308,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/displays/:id", async (event) => {
|
app.get("/admin/displays/:id", async (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const display = await deps.repo.getDisplayById(id);
|
const display = await deps.repo.getDisplayById(id);
|
||||||
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
|
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
|
||||||
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
|
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
|
||||||
|
|
@ -1325,13 +1325,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/displays/:id", async (event) => {
|
app.post("/admin/displays/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const defaultLayoutIdRaw = body?.["default_layout_id"];
|
const defaultLayoutIdRaw = body?.["default_layout_id"];
|
||||||
const defaultLayoutId = defaultLayoutIdRaw ? Number(defaultLayoutIdRaw) : null;
|
const defaultLayoutId = defaultLayoutIdRaw ? String(defaultLayoutIdRaw) : null;
|
||||||
|
|
||||||
// Validate default_layout_id is actually attached to this display.
|
// Validate default_layout_id is actually attached to this display.
|
||||||
let validatedDefault: number | null = defaultLayoutId;
|
let validatedDefault: string | null = defaultLayoutId;
|
||||||
if (defaultLayoutId != null) {
|
if (defaultLayoutId != null) {
|
||||||
const attached = await deps.repo.listLayoutsForDisplay(id);
|
const attached = await deps.repo.listLayoutsForDisplay(id);
|
||||||
if (!attached.some((l) => l.id === defaultLayoutId)) {
|
if (!attached.some((l) => l.id === defaultLayoutId)) {
|
||||||
|
|
@ -1353,7 +1353,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render the attached + available layouts region for a display.
|
// Render the attached + available layouts region for a display.
|
||||||
const renderDisplayLayoutsFragment = async (displayId: number): Promise<Response> => {
|
const renderDisplayLayoutsFragment = async (displayId: string): Promise<Response> => {
|
||||||
const display = await deps.repo.getDisplayById(displayId);
|
const display = await deps.repo.getDisplayById(displayId);
|
||||||
const attached = await deps.repo.listLayoutsForDisplay(displayId);
|
const attached = await deps.repo.listLayoutsForDisplay(displayId);
|
||||||
const attachedIds = new Set(attached.map((l) => l.id));
|
const attachedIds = new Set(attached.map((l) => l.id));
|
||||||
|
|
@ -1366,10 +1366,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Attach a layout to a display.
|
// Attach a layout to a display.
|
||||||
app.post("/admin/displays/:id/layouts", async (event) => {
|
app.post("/admin/displays/:id/layouts", async (event) => {
|
||||||
const displayId = Number(getRouterParam(event, "id"));
|
const displayId = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const layoutId = body?.["layout_id"] ? Number(body["layout_id"]) : null;
|
const layoutId = body?.["layout_id"] ? String(body["layout_id"]) : null;
|
||||||
if (layoutId && Number.isFinite(layoutId)) {
|
if (layoutId && layoutId) {
|
||||||
await deps.repo.attachLayoutToDisplay(displayId, layoutId);
|
await deps.repo.attachLayoutToDisplay(displayId, layoutId);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
}
|
}
|
||||||
|
|
@ -1381,8 +1381,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Detach a layout from a display.
|
// Detach a layout from a display.
|
||||||
app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => {
|
app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => {
|
||||||
const displayId = Number(getRouterParam(event, "id"));
|
const displayId = (getRouterParam(event, "id") ?? "");
|
||||||
const layoutId = Number(getRouterParam(event, "layoutId"));
|
const layoutId = (getRouterParam(event, "layoutId") ?? "");
|
||||||
await deps.repo.detachLayoutFromDisplay(displayId, layoutId);
|
await deps.repo.detachLayoutFromDisplay(displayId, layoutId);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
|
|
@ -1412,7 +1412,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/labels/:id/delete", async (event) => {
|
app.post("/admin/labels/:id/delete", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
await deps.repo.deleteLabel(id);
|
await deps.repo.deleteLabel(id);
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/labels" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/labels" } });
|
||||||
});
|
});
|
||||||
|
|
@ -1421,7 +1421,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/cameras/:id", async (event) => {
|
app.get("/admin/cameras/:id", async (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const camera = await deps.repo.getCameraById(id);
|
const camera = await 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" } });
|
||||||
|
|
||||||
|
|
@ -1464,7 +1464,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.post("/admin/cameras/:id", async (event) => {
|
app.post("/admin/cameras/:id", async (event) => {
|
||||||
event.context.obs?.log.info("camera update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
event.context.obs?.log.info("camera update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const cam = await deps.repo.getCameraById(id);
|
const cam = await deps.repo.getCameraById(id);
|
||||||
if (cam?.type === "cloud") {
|
if (cam?.type === "cloud") {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
||||||
|
|
@ -1541,10 +1541,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/cameras/:id/labels", async (event) => {
|
app.post("/admin/cameras/:id/labels", async (event) => {
|
||||||
const camId = Number(getRouterParam(event, "id"));
|
const camId = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
|
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
|
||||||
let labelId = body?.["label_id"] ? Number(body["label_id"]) : null;
|
let labelId = body?.["label_id"] ? String(body["label_id"] ?? "") : null;
|
||||||
|
|
||||||
if (newLabel) {
|
if (newLabel) {
|
||||||
const label = await deps.repo.ensureLabel(newLabel);
|
const label = await deps.repo.ensureLabel(newLabel);
|
||||||
|
|
@ -1560,9 +1560,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/cameras/:id/labels/remove", async (event) => {
|
app.post("/admin/cameras/:id/labels/remove", async (event) => {
|
||||||
const camId = Number(getRouterParam(event, "id"));
|
const camId = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const labelId = Number(body?.["label_id"]);
|
const labelId = String(body?.["label_id"] ?? "");
|
||||||
await deps.repo.detachCameraLabel(camId, labelId);
|
await deps.repo.detachCameraLabel(camId, labelId);
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels()));
|
return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels()));
|
||||||
|
|
@ -1573,7 +1573,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// Refresh supported ONVIF event topics from the camera.
|
// Refresh supported ONVIF event topics from the camera.
|
||||||
// MERGE: new topics are added to the existing list, never removed.
|
// MERGE: new topics are added to the existing list, never removed.
|
||||||
app.post("/admin/cameras/:id/refresh-events", async (event) => {
|
app.post("/admin/cameras/:id/refresh-events", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const cam = await deps.repo.getCameraById(id);
|
const cam = await deps.repo.getCameraById(id);
|
||||||
if (!cam || cam.type !== "onvif" || !cam.onvif_host) {
|
if (!cam || cam.type !== "onvif" || !cam.onvif_host) {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
||||||
|
|
@ -1591,7 +1591,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
runner = online ? `kiosk:${online.id}` : "server";
|
runner = online ? `kiosk:${online.id}` : "server";
|
||||||
}
|
}
|
||||||
const soapTransport = runner.startsWith("kiosk:")
|
const soapTransport = runner.startsWith("kiosk:")
|
||||||
? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length)))
|
? kioskOnvifSoapTransport(runner.slice("kiosk:".length))
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const discoveredTopics = await onvifGetEventProperties({
|
const discoveredTopics = await onvifGetEventProperties({
|
||||||
|
|
@ -1618,7 +1618,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Subscribe to all inactive event topics for this camera.
|
// Subscribe to all inactive event topics for this camera.
|
||||||
app.post("/admin/cameras/:id/subscribe-events", async (event) => {
|
app.post("/admin/cameras/:id/subscribe-events", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const cam = await deps.repo.getCameraById(id);
|
const cam = await deps.repo.getCameraById(id);
|
||||||
if (!cam) {
|
if (!cam) {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
||||||
|
|
@ -1629,7 +1629,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.post("/admin/cameras/:id/delete", async (event) => {
|
app.post("/admin/cameras/:id/delete", async (event) => {
|
||||||
event.context.obs?.log.info("camera delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
event.context.obs?.log.info("camera delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
await deps.repo.deleteCamera(id);
|
await deps.repo.deleteCamera(id);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" });
|
deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" });
|
||||||
|
|
@ -1643,7 +1643,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
};
|
};
|
||||||
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
app.get("/admin/cameras/:id/events", async (event) => {
|
app.get("/admin/cameras/:id/events", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const { events } = await deps.repo.queryEvents({
|
const { events } = await deps.repo.queryEvents({
|
||||||
camera_id: id,
|
camera_id: id,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
|
@ -1672,7 +1672,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/kiosks/:id", async (event) => {
|
app.get("/admin/kiosks/:id", async (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const kiosk = await deps.repo.getKioskById(id);
|
const kiosk = await deps.repo.getKioskById(id);
|
||||||
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({
|
const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({
|
||||||
|
|
@ -1709,7 +1709,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// ---- GPIO bindings ----------------------------------------------------
|
// ---- GPIO bindings ----------------------------------------------------
|
||||||
app.post("/admin/kiosks/:id/gpio", async (event) => {
|
app.post("/admin/kiosks/:id/gpio", async (event) => {
|
||||||
const kioskId = Number(getRouterParam(event, "id"));
|
const kioskId = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const pin = Number(body?.["pin"]);
|
const pin = Number(body?.["pin"]);
|
||||||
const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in";
|
const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in";
|
||||||
|
|
@ -1735,8 +1735,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/gpio/:bindingId/delete", async (event) => {
|
app.post("/admin/kiosks/:id/gpio/:bindingId/delete", async (event) => {
|
||||||
const kioskId = Number(getRouterParam(event, "id"));
|
const kioskId = (getRouterParam(event, "id") ?? "");
|
||||||
const bindingId = Number(getRouterParam(event, "bindingId"));
|
const bindingId = (getRouterParam(event, "bindingId") ?? "");
|
||||||
await deps.repo.deleteGpioBinding(bindingId);
|
await deps.repo.deleteGpioBinding(bindingId);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
|
|
@ -1748,7 +1748,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id", async (event) => {
|
app.post("/admin/kiosks/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const kiosk = await deps.repo.getKioskById(id);
|
const kiosk = await deps.repo.getKioskById(id);
|
||||||
await deps.repo.updateKiosk(id, {
|
await deps.repo.updateKiosk(id, {
|
||||||
|
|
@ -1776,7 +1776,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// cluster key it received at pairing), then bumps managed_config_version
|
// cluster key it received at pairing), then bumps managed_config_version
|
||||||
// so the next heartbeat ships it to the kiosk.
|
// so the next heartbeat ships it to the kiosk.
|
||||||
app.post("/admin/kiosks/:id/managed-config", async (event) => {
|
app.post("/admin/kiosks/:id/managed-config", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const kiosk = await deps.repo.getKioskById(id);
|
const kiosk = await deps.repo.getKioskById(id);
|
||||||
if (!kiosk) throw new Error("kiosk not found");
|
if (!kiosk) throw new Error("kiosk not found");
|
||||||
if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image");
|
if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image");
|
||||||
|
|
@ -1845,11 +1845,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/labels", async (event) => {
|
app.post("/admin/kiosks/:id/labels", async (event) => {
|
||||||
const kioskId = Number(getRouterParam(event, "id"));
|
const kioskId = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
|
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
|
||||||
const role = (body?.["role"] ?? "consume") as "consume" | "operate";
|
const role = (body?.["role"] ?? "consume") as "consume" | "operate";
|
||||||
let labelId = body?.["label_id"] ? Number(body["label_id"]) : null;
|
let labelId = body?.["label_id"] ? String(body["label_id"] ?? "") : null;
|
||||||
|
|
||||||
if (newLabel) {
|
if (newLabel) {
|
||||||
const label = await deps.repo.ensureLabel(newLabel);
|
const label = await deps.repo.ensureLabel(newLabel);
|
||||||
|
|
@ -1870,9 +1870,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/labels/remove", async (event) => {
|
app.post("/admin/kiosks/:id/labels/remove", async (event) => {
|
||||||
const kioskId = Number(getRouterParam(event, "id"));
|
const kioskId = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const labelId = Number(body?.["label_id"]);
|
const labelId = String(body?.["label_id"] ?? "");
|
||||||
await deps.repo.detachKioskLabel(kioskId, labelId);
|
await deps.repo.detachKioskLabel(kioskId, labelId);
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({
|
const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({
|
||||||
|
|
@ -1887,7 +1887,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/delete", async (event) => {
|
app.post("/admin/kiosks/:id/delete", async (event) => {
|
||||||
event.context.obs?.log.info("kiosk delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
event.context.obs?.log.info("kiosk delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
await deps.repo.deleteKiosk(id);
|
await deps.repo.deleteKiosk(id);
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
});
|
});
|
||||||
|
|
@ -1897,7 +1897,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// /ws/admin/debug/:kioskId and render output. The WS connection is
|
// /ws/admin/debug/:kioskId and render output. The WS connection is
|
||||||
// authenticated via the admin's API key.
|
// authenticated via the admin's API key.
|
||||||
app.get("/admin/kiosks/:id/logs", async (event) => {
|
app.get("/admin/kiosks/:id/logs", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const kiosk = await deps.repo.getKioskById(id);
|
const kiosk = await deps.repo.getKioskById(id);
|
||||||
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
|
|
@ -1945,7 +1945,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/admin/kiosks/:id/terminal", async (event) => {
|
app.get("/admin/kiosks/:id/terminal", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const kiosk = await deps.repo.getKioskById(id);
|
const kiosk = await deps.repo.getKioskById(id);
|
||||||
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
// WS auth: browser sends session cookie automatically on WS upgrade.
|
// WS auth: browser sends session cookie automatically on WS upgrade.
|
||||||
|
|
@ -2076,7 +2076,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Layout switch ----------------------------------------------------
|
// ---- Layout switch ----------------------------------------------------
|
||||||
const emitLayoutChanged = async (displayId: number | null, kioskId: number | null, layoutId: number) => {
|
const emitLayoutChanged = async (displayId: string | null, kioskId: string | null, layoutId: string) => {
|
||||||
const layout = await deps.repo.getLayoutById(layoutId);
|
const layout = await deps.repo.getLayoutById(layoutId);
|
||||||
deps.nodered.forward("layout.changed", {
|
deps.nodered.forward("layout.changed", {
|
||||||
display_id: displayId,
|
display_id: displayId,
|
||||||
|
|
@ -2088,13 +2088,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayLayoutSwitch = async (event: any) => {
|
const displayLayoutSwitch = async (event: any) => {
|
||||||
const displayId = Number(getRouterParam(event, "displayId"));
|
const displayId = (getRouterParam(event, "displayId") ?? "");
|
||||||
let layoutId = Number(getRouterParam(event, "layoutId"));
|
let layoutId = getRouterParam(event, "layoutId") ?? "";
|
||||||
if (!Number.isFinite(layoutId) || layoutId <= 0) {
|
if (!layoutId) {
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
layoutId = Number(body?.["layout_id"]);
|
layoutId = String(body?.["layout_id"] ?? "");
|
||||||
}
|
}
|
||||||
if (Number.isFinite(displayId) && Number.isFinite(layoutId)) {
|
if (displayId && layoutId) {
|
||||||
const display = await deps.repo.getDisplayById(displayId);
|
const display = await deps.repo.getDisplayById(displayId);
|
||||||
const attached = await deps.repo.listLayoutsForDisplay(displayId);
|
const attached = await deps.repo.listLayoutsForDisplay(displayId);
|
||||||
const isAttached = attached.some((l) => l.id === layoutId);
|
const isAttached = attached.some((l) => l.id === layoutId);
|
||||||
|
|
@ -2114,7 +2114,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
|
app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
|
||||||
|
|
||||||
const displayPower = async (event: any, state: "on" | "standby") => {
|
const displayPower = async (event: any, state: "on" | "standby") => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const display = await deps.repo.getDisplayById(id);
|
const display = await deps.repo.getDisplayById(id);
|
||||||
if (display?.kiosk_id) {
|
if (display?.kiosk_id) {
|
||||||
getCoordinator().sendToKiosk(display.kiosk_id, {
|
getCoordinator().sendToKiosk(display.kiosk_id, {
|
||||||
|
|
@ -2144,7 +2144,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- CEC power commands -----------------------------------------------
|
// ---- CEC power commands -----------------------------------------------
|
||||||
const emitDisplayPower = async (kioskId: number, state: "on" | "standby") => {
|
const emitDisplayPower = async (kioskId: string, state: "on" | "standby") => {
|
||||||
const displays = await deps.repo.listDisplaysForKiosk(kioskId);
|
const displays = await deps.repo.listDisplaysForKiosk(kioskId);
|
||||||
const displayId = displays[0]?.id ?? null;
|
const displayId = displays[0]?.id ?? null;
|
||||||
const actual = state === "on" ? "awake" : "standby";
|
const actual = state === "on" ? "awake" : "standby";
|
||||||
|
|
@ -2164,7 +2164,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
};
|
};
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/power/standby", async (event) => {
|
app.post("/admin/kiosks/:id/power/standby", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
getCoordinator().sendToKiosk(id, { type: "standby" });
|
getCoordinator().sendToKiosk(id, { type: "standby" });
|
||||||
await emitDisplayPower(id, "standby");
|
await emitDisplayPower(id, "standby");
|
||||||
await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id });
|
await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id });
|
||||||
|
|
@ -2172,7 +2172,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/power/wake", async (event) => {
|
app.post("/admin/kiosks/:id/power/wake", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
getCoordinator().sendToKiosk(id, { type: "wake" });
|
getCoordinator().sendToKiosk(id, { type: "wake" });
|
||||||
await emitDisplayPower(id, "on");
|
await emitDisplayPower(id, "on");
|
||||||
await audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id });
|
await audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id });
|
||||||
|
|
@ -2181,7 +2181,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// ---- Fan control ------------------------------------------------------
|
// ---- Fan control ------------------------------------------------------
|
||||||
app.post("/admin/kiosks/:id/fan", async (event) => {
|
app.post("/admin/kiosks/:id/fan", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const mode = body?.["mode"];
|
const mode = body?.["mode"];
|
||||||
if (mode === "auto") {
|
if (mode === "auto") {
|
||||||
|
|
@ -2216,7 +2216,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/admin/cameras/:id", async (event) => {
|
app.get("/api/admin/cameras/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const cam = await deps.repo.getCameraById(id);
|
const cam = await deps.repo.getCameraById(id);
|
||||||
if (!cam) return jsonResponse({ error: "not_found" }, 404);
|
if (!cam) return jsonResponse({ error: "not_found" }, 404);
|
||||||
const streams = await deps.repo.listCameraStreams(id);
|
const streams = await deps.repo.listCameraStreams(id);
|
||||||
|
|
@ -2231,7 +2231,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/admin/displays/:id", async (event) => {
|
app.get("/api/admin/displays/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const display = await deps.repo.getDisplayById(id);
|
const display = await deps.repo.getDisplayById(id);
|
||||||
if (!display) return jsonResponse({ error: "not_found" }, 404);
|
if (!display) return jsonResponse({ error: "not_found" }, 404);
|
||||||
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
|
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
|
||||||
|
|
@ -2258,7 +2258,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/admin/kiosks/:id", async (event) => {
|
app.get("/api/admin/kiosks/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const kiosk = await deps.repo.getKioskById(id);
|
const kiosk = await deps.repo.getKioskById(id);
|
||||||
if (!kiosk) return jsonResponse({ error: "not_found" }, 404);
|
if (!kiosk) return jsonResponse({ error: "not_found" }, 404);
|
||||||
const displays = await deps.repo.listDisplaysForKiosk(id);
|
const displays = await deps.repo.listDisplaysForKiosk(id);
|
||||||
|
|
@ -2276,7 +2276,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/admin/layouts/:id", async (event) => {
|
app.get("/api/admin/layouts/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const layout = await deps.repo.getLayoutById(id);
|
const layout = await deps.repo.getLayoutById(id);
|
||||||
if (!layout) return jsonResponse({ error: "not_found" }, 404);
|
if (!layout) return jsonResponse({ error: "not_found" }, 404);
|
||||||
const cells = await deps.repo.layoutCells(id);
|
const cells = await deps.repo.layoutCells(id);
|
||||||
|
|
@ -2290,7 +2290,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/admin/entities/:id", async (event) => {
|
app.get("/api/admin/entities/:id", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const entity = await deps.repo.getEntityById(id);
|
const entity = await deps.repo.getEntityById(id);
|
||||||
if (!entity) return jsonResponse({ error: "not_found" }, 404);
|
if (!entity) return jsonResponse({ error: "not_found" }, 404);
|
||||||
return jsonResponse({ entity });
|
return jsonResponse({ entity });
|
||||||
|
|
@ -2302,11 +2302,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// across all set ops. Returns the post-mutation entity.
|
// across all set ops. Returns the post-mutation entity.
|
||||||
|
|
||||||
app.post("/api/admin/displays/:id/default-layout", async (event) => {
|
app.post("/api/admin/displays/:id/default-layout", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
||||||
const raw = body["value"] ?? body["default_layout_id"];
|
const raw = body["value"] ?? body["default_layout_id"];
|
||||||
const layoutId = raw == null || raw === "" ? null : Number(raw);
|
const layoutId = raw == null || raw === "" ? null : String(raw);
|
||||||
if (raw != null && raw !== "" && !Number.isFinite(layoutId)) {
|
if (raw != null && raw !== "" && !layoutId) {
|
||||||
return jsonResponse({ error: "invalid_value" }, 400);
|
return jsonResponse({ error: "invalid_value" }, 400);
|
||||||
}
|
}
|
||||||
if (layoutId != null) {
|
if (layoutId != null) {
|
||||||
|
|
@ -2322,7 +2322,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/kiosks/:id/enabled", async (event) => {
|
app.post("/api/admin/kiosks/:id/enabled", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
||||||
const enabled = Boolean(body["value"] ?? body["enabled"]);
|
const enabled = Boolean(body["value"] ?? body["enabled"]);
|
||||||
await deps.repo.updateKiosk(id, { enabled } as any);
|
await deps.repo.updateKiosk(id, { enabled } as any);
|
||||||
|
|
@ -2332,7 +2332,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/cameras/:id/enabled", async (event) => {
|
app.post("/api/admin/cameras/:id/enabled", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
||||||
const enabled = Boolean(body["value"] ?? body["enabled"]);
|
const enabled = Boolean(body["value"] ?? body["enabled"]);
|
||||||
await deps.repo.updateCamera(id, { enabled } as any);
|
await deps.repo.updateCamera(id, { enabled } as any);
|
||||||
|
|
@ -2344,7 +2344,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/layouts/:id/priority", async (event) => {
|
app.post("/api/admin/layouts/:id/priority", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
||||||
const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase();
|
const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase();
|
||||||
if (value !== "hot" && value !== "normal" && value !== "cold") {
|
if (value !== "hot" && value !== "normal" && value !== "cold") {
|
||||||
|
|
@ -2358,7 +2358,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/entities/:id/name", async (event) => {
|
app.post("/api/admin/entities/:id/name", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
|
||||||
const name = String(body["value"] ?? body["name"] ?? "").trim();
|
const name = String(body["value"] ?? body["name"] ?? "").trim();
|
||||||
if (!name || name.length > 128) {
|
if (!name || name.length > 128) {
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// ---- Per-kiosk firmware settings ----------------------------------------
|
// ---- Per-kiosk firmware settings ----------------------------------------
|
||||||
// POST channel + target_version (used by KioskFirmwarePanel form)
|
// POST channel + target_version (used by KioskFirmwarePanel form)
|
||||||
app.post("/admin/kiosks/:id/firmware", async (event) => {
|
app.post("/admin/kiosks/:id/firmware", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
|
const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
|
||||||
const targetRaw = (body?.["target_version"] ?? "").trim();
|
const targetRaw = (body?.["target_version"] ?? "").trim();
|
||||||
|
|
@ -176,7 +176,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// and pulls /api/kiosk/firmware/check immediately. The actual download
|
// and pulls /api/kiosk/firmware/check immediately. The actual download
|
||||||
// happens kiosk-side over the existing kiosk_key channel.
|
// happens kiosk-side over the existing kiosk_key channel.
|
||||||
app.post("/admin/kiosks/:id/firmware/push", (event) => {
|
app.post("/admin/kiosks/:id/firmware/push", (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const dispatched = getCoordinator().sendToKiosk(id, { type: "firmware_check" });
|
const dispatched = getCoordinator().sendToKiosk(id, { type: "firmware_check" });
|
||||||
return { ok: true, dispatched };
|
return { ok: true, dispatched };
|
||||||
});
|
});
|
||||||
|
|
@ -204,10 +204,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||||
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
|
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
|
||||||
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
|
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
|
||||||
const targetsRaw = body?.["target_kiosk_ids"];
|
const targetsRaw = body?.["target_kiosk_ids"];
|
||||||
const targets: number[] = Array.isArray(targetsRaw)
|
const targets: string[] = Array.isArray(targetsRaw)
|
||||||
? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n))
|
? targetsRaw.map((s) => String(s)).filter((s) => s !== "")
|
||||||
: typeof targetsRaw === "string" && targetsRaw
|
: typeof targetsRaw === "string" && targetsRaw
|
||||||
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))
|
? targetsRaw.split(",").map((s) => s.trim()).filter((s) => s !== "")
|
||||||
: [];
|
: [];
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const rollout = await deps.repo.createFirmwareRollout({
|
const rollout = await deps.repo.createFirmwareRollout({
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// ---- Per-kiosk OS-update settings ---------------------------------------
|
// ---- Per-kiosk OS-update settings ---------------------------------------
|
||||||
app.post("/admin/kiosks/:id/os-update", async (event) => {
|
app.post("/admin/kiosks/:id/os-update", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
|
const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
|
||||||
const targetRaw = (body?.["target_version"] ?? "").trim();
|
const targetRaw = (body?.["target_version"] ?? "").trim();
|
||||||
|
|
@ -65,7 +65,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Push OS update now: server pings the kiosk via WS coordinator.
|
// Push OS update now: server pings the kiosk via WS coordinator.
|
||||||
app.post("/admin/kiosks/:id/os-update/push", async (event) => {
|
app.post("/admin/kiosks/:id/os-update/push", async (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
const { getCoordinator } = await import("../../shared/coordinator-registry.js");
|
const { getCoordinator } = await import("../../shared/coordinator-registry.js");
|
||||||
const dispatched = getCoordinator().sendToKiosk(id, { type: "os_check" });
|
const dispatched = getCoordinator().sendToKiosk(id, { type: "os_check" });
|
||||||
return { ok: true, dispatched };
|
return { ok: true, dispatched };
|
||||||
|
|
@ -93,10 +93,10 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
|
||||||
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
|
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
|
||||||
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
|
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
|
||||||
const targetsRaw = body?.["target_kiosk_ids"];
|
const targetsRaw = body?.["target_kiosk_ids"];
|
||||||
const targets: number[] = Array.isArray(targetsRaw)
|
const targets: string[] = Array.isArray(targetsRaw)
|
||||||
? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n))
|
? targetsRaw.map((s) => String(s)).filter((s) => s !== "")
|
||||||
: typeof targetsRaw === "string" && targetsRaw
|
: typeof targetsRaw === "string" && targetsRaw
|
||||||
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))
|
? targetsRaw.split(",").map((s) => s.trim()).filter((s) => s !== "")
|
||||||
: [];
|
: [];
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const rollout = await deps.repo.createOsUpdateRollout({
|
const rollout = await deps.repo.createOsUpdateRollout({
|
||||||
|
|
|
||||||
|
|
@ -557,7 +557,7 @@ function registerKioskRoutes(
|
||||||
// Sync displays reported by the kiosk
|
// Sync displays reported by the kiosk
|
||||||
if (Array.isArray(body?.displays)) {
|
if (Array.isArray(body?.displays)) {
|
||||||
const existing = await repo.listDisplaysForKiosk(kiosk.id);
|
const existing = await repo.listDisplaysForKiosk(kiosk.id);
|
||||||
const seenDisplayIds = new Set<number>();
|
const seenDisplayIds = new Set<string>();
|
||||||
for (const [position, reported] of body.displays.entries()) {
|
for (const [position, reported] of body.displays.entries()) {
|
||||||
const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0
|
const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0
|
||||||
? reported.index!
|
? reported.index!
|
||||||
|
|
@ -659,7 +659,7 @@ function registerKioskRoutes(
|
||||||
const body = await readBody<{
|
const body = await readBody<{
|
||||||
topic: string;
|
topic: string;
|
||||||
source_type?: string;
|
source_type?: string;
|
||||||
camera_id?: number;
|
camera_id?: string;
|
||||||
property_op?: string;
|
property_op?: string;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
}>(event);
|
}>(event);
|
||||||
|
|
@ -699,9 +699,9 @@ function registerKioskRoutes(
|
||||||
// Side-effect: persist active layout per display so the admin UI can
|
// Side-effect: persist active layout per display so the admin UI can
|
||||||
// surface "currently showing X" without having to query event_log.
|
// surface "currently showing X" without having to query event_log.
|
||||||
if (body.topic === "layout.changed") {
|
if (body.topic === "layout.changed") {
|
||||||
const displayId = Number(body.payload?.["display_id"]);
|
const displayId = String(body.payload?.["display_id"] ?? "");
|
||||||
const layoutId = Number(body.payload?.["layout_id"]);
|
const layoutId = String(body.payload?.["layout_id"] ?? "");
|
||||||
if (Number.isInteger(displayId) && Number.isInteger(layoutId)) {
|
if (displayId && layoutId) {
|
||||||
try {
|
try {
|
||||||
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -1080,7 +1080,7 @@ function registerKioskRoutes(
|
||||||
const kiosk = await auth.verifyKioskKey(token);
|
const kiosk = await auth.verifyKioskKey(token);
|
||||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||||
|
|
||||||
const cameraId = Number(getRouterParam(event, "id"));
|
const cameraId = (getRouterParam(event, "id") ?? "");
|
||||||
const camera = await repo.getCameraById(cameraId);
|
const camera = await repo.getCameraById(cameraId);
|
||||||
if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) {
|
if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) {
|
||||||
throw createError({ statusCode: 404, statusMessage: "Cloud camera not found" });
|
throw createError({ statusCode: 404, statusMessage: "Cloud camera not found" });
|
||||||
|
|
@ -1117,11 +1117,11 @@ function registerKioskRoutes(
|
||||||
* targets the same half of the fleet across re-checks. Switch from 50%→100%
|
* targets the same half of the fleet across re-checks. Switch from 50%→100%
|
||||||
* gracefully adds the previously-excluded half rather than reshuffling.
|
* gracefully adds the previously-excluded half rather than reshuffling.
|
||||||
*/
|
*/
|
||||||
function isKioskInRolloutBucket(kioskId: number, rolloutId: string, percentage: number): boolean {
|
function isKioskInRolloutBucket(kioskId: string, rolloutId: string, percentage: number): boolean {
|
||||||
if (percentage >= 100) return true;
|
if (percentage >= 100) return true;
|
||||||
if (percentage <= 0) return false;
|
if (percentage <= 0) return false;
|
||||||
const h = createHash("sha256")
|
const h = createHash("sha256")
|
||||||
.update(`${rolloutId}:${String(kioskId)}`)
|
.update(`${rolloutId}:${kioskId}`)
|
||||||
.digest();
|
.digest();
|
||||||
const bucket = h.readUInt32BE(0) % 100;
|
const bucket = h.readUInt32BE(0) % 100;
|
||||||
return bucket < percentage;
|
return bucket < percentage;
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,14 @@ export const EventSchemas = createEventSchemas({
|
||||||
// ---- Connected kiosks -------------------------------------------------------
|
// ---- Connected kiosks -------------------------------------------------------
|
||||||
|
|
||||||
interface ConnectedKiosk {
|
interface ConnectedKiosk {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectedKiosks = new Map<number, ConnectedKiosk>();
|
const connectedKiosks = new Map<string, ConnectedKiosk>();
|
||||||
const pendingRequests = new Map<string, {
|
const pendingRequests = new Map<string, {
|
||||||
kioskId: number;
|
kioskId: string;
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
reject: (err: Error) => void;
|
reject: (err: Error) => void;
|
||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
@ -101,9 +101,9 @@ const pendingRequests = new Map<string, {
|
||||||
|
|
||||||
// Admin debug subscribers: admin WS connections subscribed to a kiosk's
|
// Admin debug subscribers: admin WS connections subscribed to a kiosk's
|
||||||
// journal/terminal output. Keyed by kiosk id → set of admin WebSockets.
|
// journal/terminal output. Keyed by kiosk id → set of admin WebSockets.
|
||||||
const debugSubscribers = new Map<number, Set<WebSocket>>();
|
const debugSubscribers = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
function addDebugSubscriber(kioskId: number, adminWs: WebSocket): void {
|
function addDebugSubscriber(kioskId: string, adminWs: WebSocket): void {
|
||||||
let subs = debugSubscribers.get(kioskId);
|
let subs = debugSubscribers.get(kioskId);
|
||||||
if (!subs) { subs = new Set(); debugSubscribers.set(kioskId, subs); }
|
if (!subs) { subs = new Set(); debugSubscribers.set(kioskId, subs); }
|
||||||
subs.add(adminWs);
|
subs.add(adminWs);
|
||||||
|
|
@ -117,7 +117,7 @@ function addDebugSubscriber(kioskId: number, adminWs: WebSocket): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function relayToDebugSubscribers(kioskId: number, message: string): void {
|
function relayToDebugSubscribers(kioskId: string, message: string): void {
|
||||||
const subs = debugSubscribers.get(kioskId);
|
const subs = debugSubscribers.get(kioskId);
|
||||||
if (!subs) return;
|
if (!subs) return;
|
||||||
for (const ws of subs) {
|
for (const ws of subs) {
|
||||||
|
|
@ -136,9 +136,9 @@ function parseCookieValue(header: string, name: string): string | null {
|
||||||
// Per-kiosk message queue: if kiosk is offline, buffer messages here.
|
// Per-kiosk message queue: if kiosk is offline, buffer messages here.
|
||||||
// Drain on reconnect. FIFO, cap at 100 messages per kiosk.
|
// Drain on reconnect. FIFO, cap at 100 messages per kiosk.
|
||||||
const MESSAGE_QUEUE_CAP = 100;
|
const MESSAGE_QUEUE_CAP = 100;
|
||||||
const offlineQueues = new Map<number, string[]>();
|
const offlineQueues = new Map<string, string[]>();
|
||||||
|
|
||||||
function sendToKiosk(kioskId: number, message: object): boolean {
|
function sendToKiosk(kioskId: string, message: object): boolean {
|
||||||
const k = connectedKiosks.get(kioskId);
|
const k = connectedKiosks.get(kioskId);
|
||||||
const payload = JSON.stringify(message);
|
const payload = JSON.stringify(message);
|
||||||
if (!k || k.ws.readyState !== WebSocket.OPEN) {
|
if (!k || k.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
|
@ -153,7 +153,7 @@ function sendToKiosk(kioskId: number, message: object): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drainOfflineQueue(kioskId: number): void {
|
function drainOfflineQueue(kioskId: string): void {
|
||||||
const q = offlineQueues.get(kioskId);
|
const q = offlineQueues.get(kioskId);
|
||||||
if (!q || q.length === 0) return;
|
if (!q || q.length === 0) return;
|
||||||
const k = connectedKiosks.get(kioskId);
|
const k = connectedKiosks.get(kioskId);
|
||||||
|
|
@ -164,7 +164,7 @@ function drainOfflineQueue(kioskId: number): void {
|
||||||
offlineQueues.delete(kioskId);
|
offlineQueues.delete(kioskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestKiosk<T = unknown>(kioskId: number, message: object, timeoutMs = 10000): Promise<T> {
|
function requestKiosk<T = unknown>(kioskId: string, message: object, timeoutMs = 10000): Promise<T> {
|
||||||
const requestId = randomUUID();
|
const requestId = randomUUID();
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -271,8 +271,8 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
// Subscribes to a kiosk's journal + terminal output stream.
|
// Subscribes to a kiosk's journal + terminal output stream.
|
||||||
if (url.pathname.startsWith("/ws/admin/debug/")) {
|
if (url.pathname.startsWith("/ws/admin/debug/")) {
|
||||||
const kioskIdStr = url.pathname.split("/").pop() ?? "";
|
const kioskIdStr = url.pathname.split("/").pop() ?? "";
|
||||||
const kioskId = Number(kioskIdStr);
|
const kioskId = String(kioskIdStr);
|
||||||
if (!Number.isInteger(kioskId) || kioskId <= 0) {
|
if (!Number.isInteger(kioskId) || kioskId === "") {
|
||||||
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
|
|
@ -454,7 +454,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
requestKiosk,
|
requestKiosk,
|
||||||
broadcastAll,
|
broadcastAll,
|
||||||
notifyBundleChanged: () => broadcastAll({ type: "reload-bundle" }),
|
notifyBundleChanged: () => broadcastAll({ type: "reload-bundle" }),
|
||||||
notifyKioskBundleChanged: (kioskId: number) =>
|
notifyKioskBundleChanged: (kioskId: string) =>
|
||||||
sendToKiosk(kioskId, { type: "reload-bundle" }),
|
sendToKiosk(kioskId, { type: "reload-bundle" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import type { AuditActorType, AuditResult } from "./types.js";
|
||||||
|
|
||||||
interface AuditCtx {
|
interface AuditCtx {
|
||||||
context?: {
|
context?: {
|
||||||
user?: { id?: number; username?: string };
|
user?: { id?: string; username?: string };
|
||||||
apiKeyPrefix?: string;
|
apiKeyPrefix?: string;
|
||||||
session?: unknown;
|
session?: unknown;
|
||||||
};
|
};
|
||||||
|
|
@ -26,7 +26,7 @@ export interface AuditInput {
|
||||||
result?: AuditResult;
|
result?: AuditResult;
|
||||||
/** Override actor (e.g. when system performs action on behalf of nobody). */
|
/** Override actor (e.g. when system performs action on behalf of nobody). */
|
||||||
actor_type?: AuditActorType;
|
actor_type?: AuditActorType;
|
||||||
actor_id?: number | null;
|
actor_id?: string | null;
|
||||||
actor_label?: string | null;
|
actor_label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ export async function audit(
|
||||||
try {
|
try {
|
||||||
const ctx = event?.context;
|
const ctx = event?.context;
|
||||||
let actor_type: AuditActorType = input.actor_type ?? "system";
|
let actor_type: AuditActorType = input.actor_type ?? "system";
|
||||||
let actor_id: number | null = input.actor_id ?? null;
|
let actor_id: string | null = input.actor_id ?? null;
|
||||||
let actor_label: string | null = input.actor_label ?? null;
|
let actor_label: string | null = input.actor_label ?? null;
|
||||||
|
|
||||||
if (!input.actor_type && ctx) {
|
if (!input.actor_type && ctx) {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export interface AuthApi {
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
}): Promise<{ apiKey: ApiKey; plaintext: string }>;
|
}): Promise<{ apiKey: ApiKey; plaintext: string }>;
|
||||||
verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null>;
|
verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null>;
|
||||||
verifyKioskKey(plaintext: string): Promise<{ id: number } | null>;
|
verifyKioskKey(plaintext: string): Promise<{ id: string } | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Constants --------------------------------------------------------------
|
// ---- Constants --------------------------------------------------------------
|
||||||
|
|
@ -274,7 +274,7 @@ export function createAuth(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> {
|
async function verifyKioskKey(plaintext: string): Promise<{ id: string } | null> {
|
||||||
if (plaintext.length < 8) return null;
|
if (plaintext.length < 8) return null;
|
||||||
const prefix = plaintext.slice(0, 8);
|
const prefix = plaintext.slice(0, 8);
|
||||||
const candidates = await repo.listKiosksByKeyPrefix(prefix);
|
const candidates = await repo.listKiosksByKeyPrefix(prefix);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function buildStreamRtspUri(stream: CameraStream, cam: Camera): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleCamera {
|
export interface BundleCamera {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
rtsp_url: string | null;
|
rtsp_url: string | null;
|
||||||
|
|
@ -52,7 +52,7 @@ export interface BundleCamera {
|
||||||
event_sink: string;
|
event_sink: string;
|
||||||
stream_policy: string;
|
stream_policy: string;
|
||||||
streams: Array<{
|
streams: Array<{
|
||||||
id: number;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** Final playable RTSP URL with properly encoded credentials. */
|
/** Final playable RTSP URL with properly encoded credentials. */
|
||||||
|
|
@ -70,7 +70,7 @@ export interface BundleCell {
|
||||||
row_span: number;
|
row_span: number;
|
||||||
col_span: number;
|
col_span: number;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
camera_id: number | null;
|
camera_id: string | null;
|
||||||
stream_selector: string | null;
|
stream_selector: string | null;
|
||||||
web_url: string | null;
|
web_url: string | null;
|
||||||
html_content: string | null;
|
html_content: string | null;
|
||||||
|
|
@ -94,7 +94,7 @@ export interface BundleCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleLayout {
|
export interface BundleLayout {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** Computed from cells: max(col + col_span). 1 if no cells. */
|
/** Computed from cells: max(col + col_span). 1 if no cells. */
|
||||||
grid_cols: number;
|
grid_cols: number;
|
||||||
|
|
@ -102,7 +102,7 @@ export interface BundleLayout {
|
||||||
grid_rows: number;
|
grid_rows: number;
|
||||||
priority: string;
|
priority: string;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
preload_camera_ids: number[];
|
preload_camera_ids: string[];
|
||||||
resets_idle_timer: boolean;
|
resets_idle_timer: boolean;
|
||||||
/** True if the kiosk's display has this layout as its default_layout_id. */
|
/** True if the kiosk's display has this layout as its default_layout_id. */
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
|
|
@ -110,13 +110,13 @@ export interface BundleLayout {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleDisplay {
|
export interface BundleDisplay {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
width_px: number;
|
width_px: number;
|
||||||
height_px: number;
|
height_px: number;
|
||||||
idle_timeout_seconds: number;
|
idle_timeout_seconds: number;
|
||||||
sleep_timeout_seconds: number;
|
sleep_timeout_seconds: number;
|
||||||
default_layout_id: number | null;
|
default_layout_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleDisplayWithLayouts extends BundleDisplay {
|
export interface BundleDisplayWithLayouts extends BundleDisplay {
|
||||||
|
|
@ -124,7 +124,7 @@ export interface BundleDisplayWithLayouts extends BundleDisplay {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleGpioBinding {
|
export interface BundleGpioBinding {
|
||||||
id: number;
|
id: string;
|
||||||
chip: string;
|
chip: string;
|
||||||
pin: number;
|
pin: number;
|
||||||
direction: "in" | "out";
|
direction: "in" | "out";
|
||||||
|
|
@ -134,7 +134,7 @@ export interface BundleGpioBinding {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KioskBundle {
|
export interface KioskBundle {
|
||||||
kiosk_id: number;
|
kiosk_id: string;
|
||||||
kiosk_name: string;
|
kiosk_name: string;
|
||||||
/**
|
/**
|
||||||
* @deprecated Use `displays` (array). Kept for backward compat with older
|
* @deprecated Use `displays` (array). Kept for backward compat with older
|
||||||
|
|
@ -156,7 +156,7 @@ export interface KioskBundle {
|
||||||
export async function generateBundle(
|
export async function generateBundle(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
secrets: SecretsApi,
|
secrets: SecretsApi,
|
||||||
kioskId: number,
|
kioskId: string,
|
||||||
clusterKey: string | undefined,
|
clusterKey: string | undefined,
|
||||||
obs?: Observable,
|
obs?: Observable,
|
||||||
): Promise<KioskBundle | null> {
|
): Promise<KioskBundle | null> {
|
||||||
|
|
@ -194,13 +194,13 @@ export async function generateBundle(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect camera IDs across ALL displays' layouts (de-duped).
|
// Collect camera IDs across ALL displays' layouts (de-duped).
|
||||||
const allLayoutIds = new Set<number>();
|
const allLayoutIds = new Set<string>();
|
||||||
for (const d of displays) {
|
for (const d of displays) {
|
||||||
for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
|
for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
|
||||||
}
|
}
|
||||||
const cameras = await repo.camerasForLayoutIds([...allLayoutIds]);
|
const cameras = await repo.camerasForLayoutIds([...allLayoutIds]);
|
||||||
|
|
||||||
async function buildLayouts(displayId: number, defaultLayoutId: number | null): Promise<BundleLayout[]> {
|
async function buildLayouts(displayId: string, defaultLayoutId: string | null): Promise<BundleLayout[]> {
|
||||||
const layouts = await repo.layoutsForDisplayId(displayId);
|
const layouts = await repo.layoutsForDisplayId(displayId);
|
||||||
const result: BundleLayout[] = [];
|
const result: BundleLayout[] = [];
|
||||||
for (const l of layouts) {
|
for (const l of layouts) {
|
||||||
|
|
@ -308,7 +308,7 @@ export async function generateBundle(
|
||||||
const effectiveStreams = streams.length > 0 ? streams : (
|
const effectiveStreams = streams.length > 0 ? streams : (
|
||||||
cam.type === "rtsp" && cam.rtsp_url
|
cam.type === "rtsp" && cam.rtsp_url
|
||||||
? [{
|
? [{
|
||||||
id: 0,
|
id: "",
|
||||||
role: "main" as const,
|
role: "main" as const,
|
||||||
name: "Main",
|
name: "Main",
|
||||||
rtsp_uri: cam.rtsp_url,
|
rtsp_uri: cam.rtsp_url,
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
* service-coordinator-ws sets the implementation in its init().
|
* service-coordinator-ws sets the implementation in its init().
|
||||||
*/
|
*/
|
||||||
export interface CoordinatorApi {
|
export interface CoordinatorApi {
|
||||||
sendToKiosk(kioskId: number, message: object): boolean;
|
sendToKiosk(kioskId: string, message: object): boolean;
|
||||||
requestKiosk<T = unknown>(kioskId: number, message: object, timeoutMs?: number): Promise<T>;
|
requestKiosk<T = unknown>(kioskId: string, message: object, timeoutMs?: number): Promise<T>;
|
||||||
broadcastAll(message: object): void;
|
broadcastAll(message: object): void;
|
||||||
notifyBundleChanged(): void;
|
notifyBundleChanged(): void;
|
||||||
notifyKioskBundleChanged(kioskId: number): void;
|
notifyKioskBundleChanged(kioskId: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noop: CoordinatorApi = {
|
const noop: CoordinatorApi = {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ const nn = (v: unknown): number | null =>
|
||||||
|
|
||||||
export function rowToUser(r: Row): User {
|
export function rowToUser(r: Row): User {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
username: s(r["username"]),
|
username: s(r["username"]),
|
||||||
password_hash: s(r["password_hash"]),
|
password_hash: s(r["password_hash"]),
|
||||||
role: s(r["role"]) as UserRole,
|
role: s(r["role"]) as UserRole,
|
||||||
|
|
@ -88,7 +88,7 @@ export function rowToUser(r: Row): User {
|
||||||
export function rowToSession(r: Row): Session {
|
export function rowToSession(r: Row): Session {
|
||||||
return {
|
return {
|
||||||
id: s(r["id"]),
|
id: s(r["id"]),
|
||||||
user_id: n(r["user_id"]),
|
user_id: s(r["user_id"]),
|
||||||
csrf_token: s(r["csrf_token"]),
|
csrf_token: s(r["csrf_token"]),
|
||||||
totp_pending: b(r["totp_pending"]),
|
totp_pending: b(r["totp_pending"]),
|
||||||
user_agent: sn(r["user_agent"]),
|
user_agent: sn(r["user_agent"]),
|
||||||
|
|
@ -102,7 +102,7 @@ export function rowToSession(r: Row): Session {
|
||||||
|
|
||||||
export function rowToApiKey(r: Row): ApiKey {
|
export function rowToApiKey(r: Row): ApiKey {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
key_hash: s(r["key_hash"]),
|
key_hash: s(r["key_hash"]),
|
||||||
key_prefix: s(r["key_prefix"]),
|
key_prefix: s(r["key_prefix"]),
|
||||||
|
|
@ -128,14 +128,14 @@ export function rowToSetupState(r: Row): SetupState {
|
||||||
|
|
||||||
export function rowToDisplay(r: Row): Display {
|
export function rowToDisplay(r: Row): Display {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
index: n(r["index"]),
|
index: n(r["index"]),
|
||||||
is_primary: b(r["is_primary"]),
|
is_primary: b(r["is_primary"]),
|
||||||
kiosk_id: nn(r["kiosk_id"]),
|
kiosk_id: sn(r["kiosk_id"]),
|
||||||
width_px: n(r["width_px"]),
|
width_px: n(r["width_px"]),
|
||||||
height_px: n(r["height_px"]),
|
height_px: n(r["height_px"]),
|
||||||
default_layout_id: nn(r["default_layout_id"]),
|
default_layout_id: sn(r["default_layout_id"]),
|
||||||
idle_timeout_seconds: n(r["idle_timeout_seconds"]),
|
idle_timeout_seconds: n(r["idle_timeout_seconds"]),
|
||||||
sleep_timeout_seconds: n(r["sleep_timeout_seconds"]),
|
sleep_timeout_seconds: n(r["sleep_timeout_seconds"]),
|
||||||
cec_enabled: b(r["cec_enabled"]),
|
cec_enabled: b(r["cec_enabled"]),
|
||||||
|
|
@ -147,13 +147,13 @@ export function rowToDisplay(r: Row): Display {
|
||||||
state_check_enabled: b(r["state_check_enabled"]),
|
state_check_enabled: b(r["state_check_enabled"]),
|
||||||
state_check_interval_seconds: n(r["state_check_interval_seconds"]),
|
state_check_interval_seconds: n(r["state_check_interval_seconds"]),
|
||||||
is_enabled: b(r["is_enabled"]),
|
is_enabled: b(r["is_enabled"]),
|
||||||
active_layout_id: nn(r["active_layout_id"]),
|
active_layout_id: sn(r["active_layout_id"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToCamera(r: Row): Camera {
|
export function rowToCamera(r: Row): Camera {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
type: s(r["type"]) as CameraType,
|
type: s(r["type"]) as CameraType,
|
||||||
rtsp_url: sn(r["rtsp_url"]),
|
rtsp_url: sn(r["rtsp_url"]),
|
||||||
|
|
@ -178,8 +178,8 @@ export function rowToCamera(r: Row): Camera {
|
||||||
|
|
||||||
export function rowToCameraStream(r: Row): CameraStream {
|
export function rowToCameraStream(r: Row): CameraStream {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
camera_id: n(r["camera_id"]),
|
camera_id: s(r["camera_id"]),
|
||||||
role: s(r["role"]) as StreamRole,
|
role: s(r["role"]) as StreamRole,
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
profile_token: sn(r["profile_token"]),
|
profile_token: sn(r["profile_token"]),
|
||||||
|
|
@ -198,7 +198,7 @@ export function rowToCameraStream(r: Row): CameraStream {
|
||||||
|
|
||||||
export function rowToLayoutTemplate(r: Row): LayoutTemplate {
|
export function rowToLayoutTemplate(r: Row): LayoutTemplate {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
description: sn(r["description"]),
|
description: sn(r["description"]),
|
||||||
regions: j<LayoutRegion[]>(r["regions"], []),
|
regions: j<LayoutRegion[]>(r["regions"], []),
|
||||||
|
|
@ -210,17 +210,17 @@ export function rowToLayoutTemplate(r: Row): LayoutTemplate {
|
||||||
|
|
||||||
export function rowToLayout(r: Row): Layout {
|
export function rowToLayout(r: Row): Layout {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
description: sn(r["description"]),
|
description: sn(r["description"]),
|
||||||
template_id: nn(r["template_id"]),
|
template_id: sn(r["template_id"]),
|
||||||
regions: j<LayoutRegion[]>(r["regions"], []),
|
regions: j<LayoutRegion[]>(r["regions"], []),
|
||||||
grid_cols: n(r["grid_cols"]) || 1,
|
grid_cols: n(r["grid_cols"]) || 1,
|
||||||
grid_rows: n(r["grid_rows"]) || 1,
|
grid_rows: n(r["grid_rows"]) || 1,
|
||||||
display_id: nn(r["display_id"]),
|
display_id: sn(r["display_id"]),
|
||||||
priority: s(r["priority"]) as LayoutPriority,
|
priority: s(r["priority"]) as LayoutPriority,
|
||||||
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||||
preload_camera_ids: j<number[]>(r["preload_camera_ids"], []),
|
preload_camera_ids: j<string[]>(r["preload_camera_ids"], []),
|
||||||
is_default: b(r["is_default"]),
|
is_default: b(r["is_default"]),
|
||||||
resets_idle_timer: b(r["resets_idle_timer"]),
|
resets_idle_timer: b(r["resets_idle_timer"]),
|
||||||
};
|
};
|
||||||
|
|
@ -228,32 +228,32 @@ export function rowToLayout(r: Row): Layout {
|
||||||
|
|
||||||
export function rowToLayoutCell(r: Row): LayoutCell {
|
export function rowToLayoutCell(r: Row): LayoutCell {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
layout_id: n(r["layout_id"]),
|
layout_id: s(r["layout_id"]),
|
||||||
region_name: s(r["region_name"]),
|
region_name: s(r["region_name"]),
|
||||||
row: n(r["row"]),
|
row: n(r["row"]),
|
||||||
col: n(r["col"]),
|
col: n(r["col"]),
|
||||||
row_span: n(r["row_span"]) || 1,
|
row_span: n(r["row_span"]) || 1,
|
||||||
col_span: n(r["col_span"]) || 1,
|
col_span: n(r["col_span"]) || 1,
|
||||||
content_type: s(r["content_type"]) as CellContentType,
|
content_type: s(r["content_type"]) as CellContentType,
|
||||||
camera_id: nn(r["camera_id"]),
|
camera_id: sn(r["camera_id"]),
|
||||||
stream_selector: s(r["stream_selector"]) as StreamSelector,
|
stream_selector: s(r["stream_selector"]) as StreamSelector,
|
||||||
web_url: sn(r["web_url"]),
|
web_url: sn(r["web_url"]),
|
||||||
html_content: sn(r["html_content"]),
|
html_content: sn(r["html_content"]),
|
||||||
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||||
options: j<Record<string, unknown>>(r["options"], {}),
|
options: j<Record<string, unknown>>(r["options"], {}),
|
||||||
entity_id: nn(r["entity_id"]),
|
entity_id: sn(r["entity_id"]),
|
||||||
fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill",
|
fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToEntity(r: Row): Entity {
|
export function rowToEntity(r: Row): Entity {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
type: s(r["type"]) as EntityType,
|
type: s(r["type"]) as EntityType,
|
||||||
description: sn(r["description"]),
|
description: sn(r["description"]),
|
||||||
camera_id: nn(r["camera_id"]),
|
camera_id: sn(r["camera_id"]),
|
||||||
html_content: sn(r["html_content"]),
|
html_content: sn(r["html_content"]),
|
||||||
web_url: sn(r["web_url"]),
|
web_url: sn(r["web_url"]),
|
||||||
dashboard_id: sn(r["dashboard_id"]),
|
dashboard_id: sn(r["dashboard_id"]),
|
||||||
|
|
@ -263,7 +263,7 @@ export function rowToEntity(r: Row): Entity {
|
||||||
|
|
||||||
export function rowToKiosk(r: Row): Kiosk {
|
export function rowToKiosk(r: Row): Kiosk {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
description: sn(r["description"]),
|
description: sn(r["description"]),
|
||||||
key_hash: s(r["key_hash"]),
|
key_hash: s(r["key_hash"]),
|
||||||
|
|
@ -276,7 +276,7 @@ export function rowToKiosk(r: Row): Kiosk {
|
||||||
paired_at: sn(r["paired_at"]),
|
paired_at: sn(r["paired_at"]),
|
||||||
last_seen_at: sn(r["last_seen_at"]),
|
last_seen_at: sn(r["last_seen_at"]),
|
||||||
last_bundle_version: sn(r["last_bundle_version"]),
|
last_bundle_version: sn(r["last_bundle_version"]),
|
||||||
display_id: nn(r["display_id"]),
|
display_id: sn(r["display_id"]),
|
||||||
cpu_temp_c: nn(r["cpu_temp_c"]),
|
cpu_temp_c: nn(r["cpu_temp_c"]),
|
||||||
cpu_load_percent: nn(r["cpu_load_percent"]),
|
cpu_load_percent: nn(r["cpu_load_percent"]),
|
||||||
fan_rpm: nn(r["fan_rpm"]),
|
fan_rpm: nn(r["fan_rpm"]),
|
||||||
|
|
@ -314,10 +314,10 @@ export function rowToKiosk(r: Row): Kiosk {
|
||||||
|
|
||||||
export function rowToAuditEntry(r: Row): AuditEntry {
|
export function rowToAuditEntry(r: Row): AuditEntry {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
ts: s(r["ts"]),
|
ts: s(r["ts"]),
|
||||||
actor_type: s(r["actor_type"]) as AuditActorType,
|
actor_type: s(r["actor_type"]) as AuditActorType,
|
||||||
actor_id: nn(r["actor_id"]),
|
actor_id: sn(r["actor_id"]),
|
||||||
actor_label: sn(r["actor_label"]),
|
actor_label: sn(r["actor_label"]),
|
||||||
action: s(r["action"]),
|
action: s(r["action"]),
|
||||||
resource_type: sn(r["resource_type"]),
|
resource_type: sn(r["resource_type"]),
|
||||||
|
|
@ -340,7 +340,7 @@ export function rowToFirmwareRelease(r: Row): FirmwareRelease {
|
||||||
signature: s(r["signature"]),
|
signature: s(r["signature"]),
|
||||||
release_notes: sn(r["release_notes"]),
|
release_notes: sn(r["release_notes"]),
|
||||||
uploaded_at: s(r["uploaded_at"]),
|
uploaded_at: s(r["uploaded_at"]),
|
||||||
uploaded_by: nn(r["uploaded_by"]),
|
uploaded_by: sn(r["uploaded_by"]),
|
||||||
yanked_at: sn(r["yanked_at"]),
|
yanked_at: sn(r["yanked_at"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -349,13 +349,13 @@ export function rowToFirmwareRollout(r: Row): FirmwareRollout {
|
||||||
return {
|
return {
|
||||||
id: s(r["id"]),
|
id: s(r["id"]),
|
||||||
release_id: s(r["release_id"]),
|
release_id: s(r["release_id"]),
|
||||||
target_kiosk_ids: j<number[]>(r["target_kiosk_ids"], []),
|
target_kiosk_ids: j<string[]>(r["target_kiosk_ids"], []),
|
||||||
state: s(r["state"]) as FirmwareRolloutState,
|
state: s(r["state"]) as FirmwareRolloutState,
|
||||||
percentage: n(r["percentage"]),
|
percentage: n(r["percentage"]),
|
||||||
started_at: sn(r["started_at"]),
|
started_at: sn(r["started_at"]),
|
||||||
finished_at: sn(r["finished_at"]),
|
finished_at: sn(r["finished_at"]),
|
||||||
created_at: s(r["created_at"]),
|
created_at: s(r["created_at"]),
|
||||||
created_by: nn(r["created_by"]),
|
created_by: sn(r["created_by"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +371,7 @@ export function rowToOsUpdateRelease(r: Row): OsUpdateRelease {
|
||||||
bundle_format: "raucb",
|
bundle_format: "raucb",
|
||||||
release_notes: sn(r["release_notes"]),
|
release_notes: sn(r["release_notes"]),
|
||||||
uploaded_at: s(r["uploaded_at"]),
|
uploaded_at: s(r["uploaded_at"]),
|
||||||
uploaded_by: nn(r["uploaded_by"]),
|
uploaded_by: sn(r["uploaded_by"]),
|
||||||
yanked_at: sn(r["yanked_at"]),
|
yanked_at: sn(r["yanked_at"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -380,19 +380,19 @@ export function rowToOsUpdateRollout(r: Row): OsUpdateRollout {
|
||||||
return {
|
return {
|
||||||
id: s(r["id"]),
|
id: s(r["id"]),
|
||||||
release_id: s(r["release_id"]),
|
release_id: s(r["release_id"]),
|
||||||
target_kiosk_ids: j<number[]>(r["target_kiosk_ids"], []),
|
target_kiosk_ids: j<string[]>(r["target_kiosk_ids"], []),
|
||||||
state: s(r["state"]) as OsUpdateRolloutState,
|
state: s(r["state"]) as OsUpdateRolloutState,
|
||||||
percentage: n(r["percentage"]),
|
percentage: n(r["percentage"]),
|
||||||
started_at: sn(r["started_at"]),
|
started_at: sn(r["started_at"]),
|
||||||
finished_at: sn(r["finished_at"]),
|
finished_at: sn(r["finished_at"]),
|
||||||
created_at: s(r["created_at"]),
|
created_at: s(r["created_at"]),
|
||||||
created_by: nn(r["created_by"]),
|
created_by: sn(r["created_by"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToLabel(r: Row): Label {
|
export function rowToLabel(r: Row): Label {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
description: sn(r["description"]),
|
description: sn(r["description"]),
|
||||||
color: sn(r["color"]),
|
color: sn(r["color"]),
|
||||||
|
|
@ -402,8 +402,8 @@ export function rowToLabel(r: Row): Label {
|
||||||
|
|
||||||
export function rowToKioskLabel(r: Row): KioskLabel {
|
export function rowToKioskLabel(r: Row): KioskLabel {
|
||||||
return {
|
return {
|
||||||
kiosk_id: n(r["kiosk_id"]),
|
kiosk_id: s(r["kiosk_id"]),
|
||||||
label_id: n(r["label_id"]),
|
label_id: s(r["label_id"]),
|
||||||
role: s(r["role"]) as LabelRole,
|
role: s(r["role"]) as LabelRole,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -417,7 +417,7 @@ export function rowToPairingCode(r: Row): PairingCode {
|
||||||
issued_at: s(r["issued_at"]),
|
issued_at: s(r["issued_at"]),
|
||||||
expires_at: s(r["expires_at"]),
|
expires_at: s(r["expires_at"]),
|
||||||
consumed_at: sn(r["consumed_at"]),
|
consumed_at: sn(r["consumed_at"]),
|
||||||
consumed_by_kiosk_id: nn(r["consumed_by_kiosk_id"]),
|
consumed_by_kiosk_id: sn(r["consumed_by_kiosk_id"]),
|
||||||
extras: j<Record<string, unknown>>(r["extras"], {}),
|
extras: j<Record<string, unknown>>(r["extras"], {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -426,8 +426,8 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
|
||||||
const pullRaw = sn(r["pull"]);
|
const pullRaw = sn(r["pull"]);
|
||||||
const edgeRaw = sn(r["edge"]);
|
const edgeRaw = sn(r["edge"]);
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
kiosk_id: n(r["kiosk_id"]),
|
kiosk_id: s(r["kiosk_id"]),
|
||||||
chip: s(r["chip"]) || "gpiochip0",
|
chip: s(r["chip"]) || "gpiochip0",
|
||||||
pin: n(r["pin"]),
|
pin: n(r["pin"]),
|
||||||
direction: s(r["direction"]) as GpioDirection,
|
direction: s(r["direction"]) as GpioDirection,
|
||||||
|
|
@ -440,9 +440,9 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
|
||||||
|
|
||||||
export function rowToEventLog(r: Row): EventLog {
|
export function rowToEventLog(r: Row): EventLog {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
source_kiosk_id: nn(r["source_kiosk_id"]),
|
source_kiosk_id: sn(r["source_kiosk_id"]),
|
||||||
source_camera_id: nn(r["source_camera_id"]),
|
source_camera_id: sn(r["source_camera_id"]),
|
||||||
source_type: s(r["source_type"]) as EventSourceType,
|
source_type: s(r["source_type"]) as EventSourceType,
|
||||||
topic: s(r["topic"]),
|
topic: s(r["topic"]),
|
||||||
property_op: sn(r["property_op"]),
|
property_op: sn(r["property_op"]),
|
||||||
|
|
@ -454,8 +454,8 @@ export function rowToEventLog(r: Row): EventLog {
|
||||||
|
|
||||||
export function rowToKioskLog(r: Row): KioskLog {
|
export function rowToKioskLog(r: Row): KioskLog {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
kiosk_id: n(r["kiosk_id"]),
|
kiosk_id: s(r["kiosk_id"]),
|
||||||
level: s(r["level"]) as KioskLogLevel,
|
level: s(r["level"]) as KioskLogLevel,
|
||||||
message: s(r["message"]),
|
message: s(r["message"]),
|
||||||
context: j<Record<string, unknown>>(r["context"], {}),
|
context: j<Record<string, unknown>>(r["context"], {}),
|
||||||
|
|
@ -480,11 +480,11 @@ export function rowToCloudAccount(r: Row): CloudAccount {
|
||||||
|
|
||||||
export function rowToCameraEventSubscription(r: Row): CameraEventSubscription {
|
export function rowToCameraEventSubscription(r: Row): CameraEventSubscription {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: s(r["id"]),
|
||||||
camera_id: n(r["camera_id"]),
|
camera_id: s(r["camera_id"]),
|
||||||
topic: s(r["topic"]),
|
topic: s(r["topic"]),
|
||||||
status: s(r["status"]) as EventSubscriptionStatus,
|
status: s(r["status"]) as EventSubscriptionStatus,
|
||||||
subscribed_by_kiosk_id: nn(r["subscribed_by_kiosk_id"]),
|
subscribed_by_kiosk_id: sn(r["subscribed_by_kiosk_id"]),
|
||||||
event_source: sn(r["event_source"]),
|
event_source: sn(r["event_source"]),
|
||||||
event_sink: sn(r["event_sink"]),
|
event_sink: sn(r["event_sink"]),
|
||||||
last_event_at: sn(r["last_event_at"]),
|
last_event_at: sn(r["last_event_at"]),
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
`CREATE TABLE IF NOT EXISTS global_admins (
|
`CREATE TABLE IF NOT EXISTS global_admins (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
@ -60,7 +60,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [
|
||||||
export const TENANT_MIGRATIONS: readonly string[] = [
|
export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
// ---- users ---------------------------------------------------------------
|
// ---- users ---------------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')),
|
role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')),
|
||||||
|
|
@ -78,7 +78,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
// ---- sessions ------------------------------------------------------------
|
// ---- sessions ------------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS sessions (
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
csrf_token TEXT NOT NULL,
|
csrf_token TEXT NOT NULL,
|
||||||
totp_pending BOOLEAN NOT NULL DEFAULT false,
|
totp_pending BOOLEAN NOT NULL DEFAULT false,
|
||||||
user_agent TEXT,
|
user_agent TEXT,
|
||||||
|
|
@ -93,7 +93,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- api_keys ------------------------------------------------------------
|
// ---- api_keys ------------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS api_keys (
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
key_hash TEXT NOT NULL,
|
key_hash TEXT NOT NULL,
|
||||||
key_prefix TEXT NOT NULL,
|
key_prefix TEXT NOT NULL,
|
||||||
|
|
@ -119,14 +119,14 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- displays (final schema — no UNIQUE on index, has kiosk_id) ----------
|
// ---- displays (final schema — no UNIQUE on index, has kiosk_id) ----------
|
||||||
`CREATE TABLE IF NOT EXISTS displays (
|
`CREATE TABLE IF NOT EXISTS displays (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
"index" INTEGER NOT NULL,
|
"index" INTEGER NOT NULL,
|
||||||
is_primary BOOLEAN NOT NULL DEFAULT false,
|
is_primary BOOLEAN NOT NULL DEFAULT false,
|
||||||
kiosk_id INTEGER,
|
kiosk_id TEXT,
|
||||||
width_px INTEGER NOT NULL DEFAULT 1920,
|
width_px INTEGER NOT NULL DEFAULT 1920,
|
||||||
height_px INTEGER NOT NULL DEFAULT 1080,
|
height_px INTEGER NOT NULL DEFAULT 1080,
|
||||||
default_layout_id INTEGER,
|
default_layout_id TEXT,
|
||||||
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
|
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
|
||||||
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
|
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
|
||||||
cec_enabled BOOLEAN NOT NULL DEFAULT true,
|
cec_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
@ -140,14 +140,14 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
state_check_enabled BOOLEAN NOT NULL DEFAULT false,
|
state_check_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60,
|
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60,
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
active_layout_id INTEGER
|
active_layout_id TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk_index ON displays(kiosk_id, "index")`,
|
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk_index ON displays(kiosk_id, "index")`,
|
||||||
|
|
||||||
// ---- cameras -------------------------------------------------------------
|
// ---- cameras -------------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS cameras (
|
`CREATE TABLE IF NOT EXISTS cameras (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')),
|
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')),
|
||||||
rtsp_url TEXT,
|
rtsp_url TEXT,
|
||||||
|
|
@ -172,8 +172,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
`CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`,
|
||||||
|
|
||||||
`CREATE TABLE IF NOT EXISTS camera_streams (
|
`CREATE TABLE IF NOT EXISTS camera_streams (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
||||||
role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')),
|
role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
profile_token TEXT,
|
profile_token TEXT,
|
||||||
|
|
@ -192,7 +192,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- kiosks (final schema — all telemetry + update columns) --------------
|
// ---- kiosks (final schema — all telemetry + update columns) --------------
|
||||||
`CREATE TABLE IF NOT EXISTS kiosks (
|
`CREATE TABLE IF NOT EXISTS kiosks (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
key_hash TEXT NOT NULL,
|
key_hash TEXT NOT NULL,
|
||||||
|
|
@ -205,7 +205,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
paired_at TIMESTAMPTZ,
|
paired_at TIMESTAMPTZ,
|
||||||
last_seen_at TIMESTAMPTZ,
|
last_seen_at TIMESTAMPTZ,
|
||||||
last_bundle_version TEXT,
|
last_bundle_version TEXT,
|
||||||
display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL,
|
display_id TEXT REFERENCES displays(id) ON DELETE SET NULL,
|
||||||
encrypt_key_encrypted TEXT,
|
encrypt_key_encrypted TEXT,
|
||||||
cpu_temp_c REAL,
|
cpu_temp_c REAL,
|
||||||
cpu_load_percent REAL,
|
cpu_load_percent REAL,
|
||||||
|
|
@ -243,7 +243,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- layouts (final schema — no template_id, no display_id) --------------
|
// ---- layouts (final schema — no template_id, no display_id) --------------
|
||||||
`CREATE TABLE IF NOT EXISTS layouts (
|
`CREATE TABLE IF NOT EXISTS layouts (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
|
||||||
|
|
@ -254,28 +254,28 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- display_layouts (join table) ----------------------------------------
|
// ---- display_layouts (join table) ----------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS display_layouts (
|
`CREATE TABLE IF NOT EXISTS display_layouts (
|
||||||
display_id INTEGER NOT NULL REFERENCES displays(id) ON DELETE CASCADE,
|
display_id TEXT NOT NULL REFERENCES displays(id) ON DELETE CASCADE,
|
||||||
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (display_id, layout_id)
|
PRIMARY KEY (display_id, layout_id)
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`,
|
||||||
|
|
||||||
// ---- layout_cells (final schema — no region_name) ------------------------
|
// ---- layout_cells (final schema — no region_name) ------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS layout_cells (
|
`CREATE TABLE IF NOT EXISTS layout_cells (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
||||||
"row" INTEGER NOT NULL DEFAULT 0,
|
"row" INTEGER NOT NULL DEFAULT 0,
|
||||||
col INTEGER NOT NULL DEFAULT 0,
|
col INTEGER NOT NULL DEFAULT 0,
|
||||||
row_span INTEGER NOT NULL DEFAULT 1,
|
row_span INTEGER NOT NULL DEFAULT 1,
|
||||||
col_span INTEGER NOT NULL DEFAULT 1,
|
col_span INTEGER NOT NULL DEFAULT 1,
|
||||||
content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')),
|
content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')),
|
||||||
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
camera_id TEXT REFERENCES cameras(id) ON DELETE SET NULL,
|
||||||
stream_selector TEXT,
|
stream_selector TEXT,
|
||||||
web_url TEXT,
|
web_url TEXT,
|
||||||
html_content TEXT,
|
html_content TEXT,
|
||||||
cooling_timeout_seconds INTEGER,
|
cooling_timeout_seconds INTEGER,
|
||||||
options JSONB NOT NULL DEFAULT '{}',
|
options JSONB NOT NULL DEFAULT '{}',
|
||||||
entity_id INTEGER,
|
entity_id TEXT,
|
||||||
fit TEXT NOT NULL DEFAULT 'cover'
|
fit TEXT NOT NULL DEFAULT 'cover'
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`,
|
||||||
|
|
@ -283,26 +283,26 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- labels --------------------------------------------------------------
|
// ---- labels --------------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS labels (
|
`CREATE TABLE IF NOT EXISTS labels (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS kiosk_labels (
|
`CREATE TABLE IF NOT EXISTS kiosk_labels (
|
||||||
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
||||||
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||||
role TEXT NOT NULL CHECK(role IN ('consume', 'operate')),
|
role TEXT NOT NULL CHECK(role IN ('consume', 'operate')),
|
||||||
PRIMARY KEY (kiosk_id, label_id, role)
|
PRIMARY KEY (kiosk_id, label_id, role)
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS camera_labels (
|
`CREATE TABLE IF NOT EXISTS camera_labels (
|
||||||
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
||||||
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (camera_id, label_id)
|
PRIMARY KEY (camera_id, label_id)
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS layout_labels (
|
`CREATE TABLE IF NOT EXISTS layout_labels (
|
||||||
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
||||||
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (layout_id, label_id)
|
PRIMARY KEY (layout_id, label_id)
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
|
@ -315,15 +315,15 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
consumed_at TIMESTAMPTZ,
|
consumed_at TIMESTAMPTZ,
|
||||||
consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
consumed_by_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
|
||||||
extras JSONB NOT NULL DEFAULT '{}'
|
extras JSONB NOT NULL DEFAULT '{}'
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
// ---- event_log -----------------------------------------------------------
|
// ---- event_log -----------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS event_log (
|
`CREATE TABLE IF NOT EXISTS event_log (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
source_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
|
||||||
source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
source_camera_id TEXT REFERENCES cameras(id) ON DELETE SET NULL,
|
||||||
source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')),
|
source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')),
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
property_op TEXT,
|
property_op TEXT,
|
||||||
|
|
@ -336,11 +336,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- entities ------------------------------------------------------------
|
// ---- entities ------------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS entities (
|
`CREATE TABLE IF NOT EXISTS entities (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')),
|
type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')),
|
||||||
description TEXT,
|
description TEXT,
|
||||||
camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE,
|
camera_id TEXT REFERENCES cameras(id) ON DELETE CASCADE,
|
||||||
html_content TEXT,
|
html_content TEXT,
|
||||||
web_url TEXT,
|
web_url TEXT,
|
||||||
dashboard_id TEXT,
|
dashboard_id TEXT,
|
||||||
|
|
@ -360,7 +360,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
signature TEXT NOT NULL,
|
signature TEXT NOT NULL,
|
||||||
release_notes TEXT,
|
release_notes TEXT,
|
||||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
uploaded_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
yanked_at TIMESTAMPTZ,
|
yanked_at TIMESTAMPTZ,
|
||||||
UNIQUE(version, arch)
|
UNIQUE(version, arch)
|
||||||
)`,
|
)`,
|
||||||
|
|
@ -375,7 +375,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
started_at TIMESTAMPTZ,
|
started_at TIMESTAMPTZ,
|
||||||
finished_at TIMESTAMPTZ,
|
finished_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
created_by TEXT REFERENCES users(id) ON DELETE SET NULL
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`,
|
`CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`,
|
||||||
|
|
||||||
|
|
@ -391,7 +391,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'),
|
bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'),
|
||||||
release_notes TEXT,
|
release_notes TEXT,
|
||||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
uploaded_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
yanked_at TIMESTAMPTZ,
|
yanked_at TIMESTAMPTZ,
|
||||||
UNIQUE(version, compatibility)
|
UNIQUE(version, compatibility)
|
||||||
)`,
|
)`,
|
||||||
|
|
@ -406,16 +406,16 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
started_at TIMESTAMPTZ,
|
started_at TIMESTAMPTZ,
|
||||||
finished_at TIMESTAMPTZ,
|
finished_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
created_by TEXT REFERENCES users(id) ON DELETE SET NULL
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`,
|
`CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`,
|
||||||
|
|
||||||
// ---- audit_log -----------------------------------------------------------
|
// ---- audit_log -----------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')),
|
actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')),
|
||||||
actor_id INTEGER,
|
actor_id TEXT,
|
||||||
actor_label TEXT,
|
actor_label TEXT,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
resource_type TEXT,
|
resource_type TEXT,
|
||||||
|
|
@ -430,8 +430,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- kiosk GPIO bindings -------------------------------------------------
|
// ---- kiosk GPIO bindings -------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
|
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
||||||
chip TEXT NOT NULL DEFAULT 'gpiochip4',
|
chip TEXT NOT NULL DEFAULT 'gpiochip4',
|
||||||
pin INTEGER NOT NULL,
|
pin INTEGER NOT NULL,
|
||||||
direction TEXT NOT NULL DEFAULT 'in' CHECK(direction IN ('in', 'out')),
|
direction TEXT NOT NULL DEFAULT 'in' CHECK(direction IN ('in', 'out')),
|
||||||
|
|
@ -445,8 +445,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- kiosk_logs ----------------------------------------------------------
|
// ---- kiosk_logs ----------------------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS kiosk_logs (
|
`CREATE TABLE IF NOT EXISTS kiosk_logs (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
||||||
level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')),
|
level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')),
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
context JSONB NOT NULL DEFAULT '{}',
|
context JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
@ -471,11 +471,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
// ---- camera_event_subscriptions ---------------------------------------------
|
// ---- camera_event_subscriptions ---------------------------------------------
|
||||||
`CREATE TABLE IF NOT EXISTS camera_event_subscriptions (
|
`CREATE TABLE IF NOT EXISTS camera_event_subscriptions (
|
||||||
id SERIAL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('inactive', 'pending', 'active', 'failed')),
|
status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('inactive', 'pending', 'active', 'failed')),
|
||||||
subscribed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
subscribed_by_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
|
||||||
last_event_at TIMESTAMPTZ,
|
last_event_at TIMESTAMPTZ,
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -25,10 +25,10 @@ export interface MqttBridgeLog {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MqttBridge {
|
export interface MqttBridge {
|
||||||
publishEvent(kioskId: number | "server", topic: string, payload: Record<string, unknown>): void;
|
publishEvent(kioskId: string | "server", topic: string, payload: Record<string, unknown>): void;
|
||||||
publishTelemetry(kioskId: number, payload: Record<string, unknown>): void;
|
publishTelemetry(kioskId: string, payload: Record<string, unknown>): void;
|
||||||
/** Subscribe to inbound RPC. Callback gets parsed JSON or {} on parse error. */
|
/** Subscribe to inbound RPC. Callback gets parsed JSON or {} on parse error. */
|
||||||
onRpc(handler: (kioskId: number, method: string, body: Record<string, unknown>) => void): void;
|
onRpc(handler: (kioskId: string, method: string, body: Record<string, unknown>) => void): void;
|
||||||
end(): void;
|
end(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ export function initMqttBridge(config: MqttConfig, log: MqttBridgeLog): MqttBrid
|
||||||
const password = config.password;
|
const password = config.password;
|
||||||
|
|
||||||
let client: MqttClient | undefined;
|
let client: MqttClient | undefined;
|
||||||
let rpcHandlers: Array<(k: number, m: string, b: Record<string, unknown>) => void> = [];
|
let rpcHandlers: Array<(k: string, m: string, b: Record<string, unknown>) => void> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
client = mqtt.connect(url, {
|
client = mqtt.connect(url, {
|
||||||
|
|
@ -97,9 +97,9 @@ export function initMqttBridge(config: MqttConfig, log: MqttBridgeLog): MqttBrid
|
||||||
// Expected: <prefix>/<kiosk_id>/rpc/req/<method>
|
// Expected: <prefix>/<kiosk_id>/rpc/req/<method>
|
||||||
const parts = topic.split("/");
|
const parts = topic.split("/");
|
||||||
if (parts.length !== 5 || parts[0] !== prefix || parts[2] !== "rpc" || parts[3] !== "req") return;
|
if (parts.length !== 5 || parts[0] !== prefix || parts[2] !== "rpc" || parts[3] !== "req") return;
|
||||||
const kioskId = Number(parts[1]);
|
const kioskId = parts[1] ?? "";
|
||||||
const method = parts[4];
|
const method = parts[4];
|
||||||
if (!Number.isFinite(kioskId) || !method) return;
|
if (!kioskId || !method) return;
|
||||||
let body: Record<string, unknown> = {};
|
let body: Record<string, unknown> = {};
|
||||||
try { body = JSON.parse(payload.toString("utf8")) as Record<string, unknown>; }
|
try { body = JSON.parse(payload.toString("utf8")) as Record<string, unknown>; }
|
||||||
catch { /* ignore body parse errors — handler can use empty */ }
|
catch { /* ignore body parse errors — handler can use empty */ }
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export async function initiatePairing(
|
||||||
|
|
||||||
export interface PairingClaimResult {
|
export interface PairingClaimResult {
|
||||||
status: "pending" | "claimed";
|
status: "pending" | "claimed";
|
||||||
kioskId?: number;
|
kioskId?: string;
|
||||||
kioskName?: string;
|
kioskName?: string;
|
||||||
kioskKey?: string;
|
kioskKey?: string;
|
||||||
clusterKey?: string;
|
clusterKey?: string;
|
||||||
|
|
@ -123,7 +123,7 @@ export interface PairingConfirmInput {
|
||||||
* pointed at the same kiosk id — only credentials + hardware metadata roll.
|
* pointed at the same kiosk id — only credentials + hardware metadata roll.
|
||||||
* When set, nameOverride and initialLabels are ignored.
|
* When set, nameOverride and initialLabels are ignored.
|
||||||
*/
|
*/
|
||||||
replaceKioskId?: number;
|
replaceKioskId?: string;
|
||||||
/** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */
|
/** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +134,7 @@ export async function confirmPairing(
|
||||||
secrets: SecretsApi,
|
secrets: SecretsApi,
|
||||||
input: PairingConfirmInput,
|
input: PairingConfirmInput,
|
||||||
obs?: Observable,
|
obs?: Observable,
|
||||||
): Promise<{ kioskId: number; kioskName: string }> {
|
): Promise<{ kioskId: string; kioskName: string }> {
|
||||||
obs?.log.info("confirm pairing for code {code}", { code: input.code });
|
obs?.log.info("confirm pairing for code {code}", { code: input.code });
|
||||||
const pc = await repo.getPairingCode(input.code);
|
const pc = await repo.getPairingCode(input.code);
|
||||||
if (!pc) throw new Error("pairing code not found");
|
if (!pc) throw new Error("pairing code not found");
|
||||||
|
|
@ -145,7 +145,7 @@ export async function confirmPairing(
|
||||||
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
||||||
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
||||||
|
|
||||||
let kioskId: number;
|
let kioskId: string;
|
||||||
let kioskName: string;
|
let kioskName: string;
|
||||||
|
|
||||||
if (input.replaceKioskId != null) {
|
if (input.replaceKioskId != null) {
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@ export type CellContentType = "none" | "camera" | "web" | "html";
|
||||||
export type EntityType = "camera" | "html" | "web" | "dashboard";
|
export type EntityType = "camera" | "html" | "web" | "dashboard";
|
||||||
|
|
||||||
export interface Entity {
|
export interface Entity {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: EntityType;
|
type: EntityType;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
camera_id: number | null;
|
camera_id: string | null;
|
||||||
html_content: string | null;
|
html_content: string | null;
|
||||||
web_url: string | null;
|
web_url: string | null;
|
||||||
/** Node-RED dashboard tab id; populated when type === "dashboard". */
|
/** Node-RED dashboard tab id; populated when type === "dashboard". */
|
||||||
|
|
@ -33,7 +33,7 @@ export type LabelRole = "consume" | "operate";
|
||||||
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
|
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
|
@ -50,7 +50,7 @@ export interface User {
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string; // hex32
|
id: string; // hex32
|
||||||
user_id: number;
|
user_id: string;
|
||||||
csrf_token: string;
|
csrf_token: string;
|
||||||
totp_pending: boolean;
|
totp_pending: boolean;
|
||||||
user_agent: string | null;
|
user_agent: string | null;
|
||||||
|
|
@ -62,7 +62,7 @@ export interface Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
key_hash: string;
|
key_hash: string;
|
||||||
key_prefix: string; // indexed for O(1) lookup
|
key_prefix: string; // indexed for O(1) lookup
|
||||||
|
|
@ -84,14 +84,14 @@ export interface SetupState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Display {
|
export interface Display {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
index: number; // unique
|
index: number; // unique
|
||||||
is_primary: boolean; // deprecated — kept for backward compat, not used
|
is_primary: boolean; // deprecated — kept for backward compat, not used
|
||||||
kiosk_id: number | null; // FK → kiosks; displays belong to kiosks
|
kiosk_id: string | null; // FK → kiosks; displays belong to kiosks
|
||||||
width_px: number;
|
width_px: number;
|
||||||
height_px: number;
|
height_px: number;
|
||||||
default_layout_id: number | null;
|
default_layout_id: string | null;
|
||||||
idle_timeout_seconds: number;
|
idle_timeout_seconds: number;
|
||||||
sleep_timeout_seconds: number;
|
sleep_timeout_seconds: number;
|
||||||
cec_enabled: boolean;
|
cec_enabled: boolean;
|
||||||
|
|
@ -103,14 +103,14 @@ export interface Display {
|
||||||
state_check_enabled: boolean;
|
state_check_enabled: boolean;
|
||||||
state_check_interval_seconds: number;
|
state_check_interval_seconds: number;
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
active_layout_id: number | null;
|
active_layout_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventSourceMode = "auto" | "server" | string; // string = "kiosk:<id>"
|
export type EventSourceMode = "auto" | "server" | string; // string = "kiosk:<id>"
|
||||||
export type EventSinkMode = "auto" | "server" | string;
|
export type EventSinkMode = "auto" | "server" | string;
|
||||||
|
|
||||||
export interface Camera {
|
export interface Camera {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: CameraType;
|
type: CameraType;
|
||||||
rtsp_url: string | null;
|
rtsp_url: string | null;
|
||||||
|
|
@ -133,8 +133,8 @@ export interface Camera {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraStream {
|
export interface CameraStream {
|
||||||
id: number;
|
id: string;
|
||||||
camera_id: number;
|
camera_id: string;
|
||||||
role: StreamRole;
|
role: StreamRole;
|
||||||
name: string;
|
name: string;
|
||||||
profile_token: string | null;
|
profile_token: string | null;
|
||||||
|
|
@ -154,7 +154,7 @@ export interface CameraStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayoutTemplate {
|
export interface LayoutTemplate {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
regions: LayoutRegion[];
|
regions: LayoutRegion[];
|
||||||
|
|
@ -172,10 +172,10 @@ export interface LayoutRegion {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Layout {
|
export interface Layout {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
template_id: number | null; // deprecated — kept nullable for backward compat
|
template_id: string | null; // deprecated — kept nullable for backward compat
|
||||||
/** @deprecated Cells now own their own position. Computed from cells at read time. */
|
/** @deprecated Cells now own their own position. Computed from cells at read time. */
|
||||||
regions: LayoutRegion[];
|
regions: LayoutRegion[];
|
||||||
/** @deprecated Computed from cells: max(col + col_span). */
|
/** @deprecated Computed from cells: max(col + col_span). */
|
||||||
|
|
@ -184,18 +184,18 @@ export interface Layout {
|
||||||
grid_rows: number;
|
grid_rows: number;
|
||||||
/** @deprecated Layouts are now standalone; use display_layouts join table.
|
/** @deprecated Layouts are now standalone; use display_layouts join table.
|
||||||
* Column kept on the row for backward compat — will be removed in a future migration. */
|
* Column kept on the row for backward compat — will be removed in a future migration. */
|
||||||
display_id: number | null;
|
display_id: string | null;
|
||||||
priority: LayoutPriority;
|
priority: LayoutPriority;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
preload_camera_ids: number[];
|
preload_camera_ids: string[];
|
||||||
/** @deprecated Per-display defaults live on `display.default_layout_id`. */
|
/** @deprecated Per-display defaults live on `display.default_layout_id`. */
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
resets_idle_timer: boolean;
|
resets_idle_timer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayoutCell {
|
export interface LayoutCell {
|
||||||
id: number;
|
id: string;
|
||||||
layout_id: number;
|
layout_id: string;
|
||||||
/** @deprecated Cells own their position via row/col/row_span/col_span now. */
|
/** @deprecated Cells own their position via row/col/row_span/col_span now. */
|
||||||
region_name: string;
|
region_name: string;
|
||||||
row: number;
|
row: number;
|
||||||
|
|
@ -203,18 +203,18 @@ export interface LayoutCell {
|
||||||
row_span: number;
|
row_span: number;
|
||||||
col_span: number;
|
col_span: number;
|
||||||
content_type: CellContentType;
|
content_type: CellContentType;
|
||||||
camera_id: number | null;
|
camera_id: string | null;
|
||||||
stream_selector: StreamSelector;
|
stream_selector: StreamSelector;
|
||||||
web_url: string | null;
|
web_url: string | null;
|
||||||
html_content: string | null;
|
html_content: string | null;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
entity_id: number | null;
|
entity_id: string | null;
|
||||||
fit: "cover" | "contain" | "fill";
|
fit: "cover" | "contain" | "fill";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Kiosk {
|
export interface Kiosk {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
key_hash: string;
|
key_hash: string;
|
||||||
|
|
@ -227,7 +227,7 @@ export interface Kiosk {
|
||||||
paired_at: string | null;
|
paired_at: string | null;
|
||||||
last_seen_at: string | null;
|
last_seen_at: string | null;
|
||||||
last_bundle_version: string | null;
|
last_bundle_version: string | null;
|
||||||
display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id
|
display_id: string | null; // deprecated — displays now point to kiosks via kiosk_id
|
||||||
cpu_temp_c: number | null;
|
cpu_temp_c: number | null;
|
||||||
cpu_load_percent: number | null;
|
cpu_load_percent: number | null;
|
||||||
fan_rpm: number | null;
|
fan_rpm: number | null;
|
||||||
|
|
@ -272,10 +272,10 @@ export type AuditActorType = "user" | "api_key" | "system" | "kiosk";
|
||||||
export type AuditResult = "ok" | "failed";
|
export type AuditResult = "ok" | "failed";
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
id: number;
|
id: string;
|
||||||
ts: string;
|
ts: string;
|
||||||
actor_type: AuditActorType;
|
actor_type: AuditActorType;
|
||||||
actor_id: number | null;
|
actor_id: string | null;
|
||||||
actor_label: string | null;
|
actor_label: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
resource_type: string | null;
|
resource_type: string | null;
|
||||||
|
|
@ -300,20 +300,20 @@ export interface FirmwareRelease {
|
||||||
signature: string;
|
signature: string;
|
||||||
release_notes: string | null;
|
release_notes: string | null;
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
uploaded_by: number | null;
|
uploaded_by: string | null;
|
||||||
yanked_at: string | null;
|
yanked_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirmwareRollout {
|
export interface FirmwareRollout {
|
||||||
id: string;
|
id: string;
|
||||||
release_id: string;
|
release_id: string;
|
||||||
target_kiosk_ids: number[];
|
target_kiosk_ids: string[];
|
||||||
state: FirmwareRolloutState;
|
state: FirmwareRolloutState;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
created_by: number | null;
|
created_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OsUpdateRelease {
|
export interface OsUpdateRelease {
|
||||||
|
|
@ -327,20 +327,20 @@ export interface OsUpdateRelease {
|
||||||
bundle_format: "raucb";
|
bundle_format: "raucb";
|
||||||
release_notes: string | null;
|
release_notes: string | null;
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
uploaded_by: number | null;
|
uploaded_by: string | null;
|
||||||
yanked_at: string | null;
|
yanked_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OsUpdateRollout {
|
export interface OsUpdateRollout {
|
||||||
id: string;
|
id: string;
|
||||||
release_id: string;
|
release_id: string;
|
||||||
target_kiosk_ids: number[];
|
target_kiosk_ids: string[];
|
||||||
state: OsUpdateRolloutState;
|
state: OsUpdateRolloutState;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
created_by: number | null;
|
created_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink";
|
export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink";
|
||||||
|
|
@ -358,7 +358,7 @@ export interface CloudAccount {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Label {
|
export interface Label {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
|
@ -366,8 +366,8 @@ export interface Label {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KioskLabel {
|
export interface KioskLabel {
|
||||||
kiosk_id: number;
|
kiosk_id: string;
|
||||||
label_id: number;
|
label_id: string;
|
||||||
role: LabelRole;
|
role: LabelRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,7 +379,7 @@ export interface PairingCode {
|
||||||
issued_at: string;
|
issued_at: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
consumed_at: string | null;
|
consumed_at: string | null;
|
||||||
consumed_by_kiosk_id: number | null;
|
consumed_by_kiosk_id: string | null;
|
||||||
extras: Record<string, unknown>;
|
extras: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,8 +388,8 @@ export type GpioPull = "up" | "down" | "none";
|
||||||
export type GpioEdge = "rising" | "falling" | "both";
|
export type GpioEdge = "rising" | "falling" | "both";
|
||||||
|
|
||||||
export interface KioskGpioBinding {
|
export interface KioskGpioBinding {
|
||||||
id: number;
|
id: string;
|
||||||
kiosk_id: number;
|
kiosk_id: string;
|
||||||
chip: string;
|
chip: string;
|
||||||
pin: number;
|
pin: number;
|
||||||
direction: GpioDirection;
|
direction: GpioDirection;
|
||||||
|
|
@ -400,9 +400,9 @@ export interface KioskGpioBinding {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventLog {
|
export interface EventLog {
|
||||||
id: number;
|
id: string;
|
||||||
source_kiosk_id: number | null;
|
source_kiosk_id: string | null;
|
||||||
source_camera_id: number | null;
|
source_camera_id: string | null;
|
||||||
source_type: EventSourceType;
|
source_type: EventSourceType;
|
||||||
topic: string;
|
topic: string;
|
||||||
property_op: string | null;
|
property_op: string | null;
|
||||||
|
|
@ -414,11 +414,11 @@ export interface EventLog {
|
||||||
export type EventSubscriptionStatus = "inactive" | "pending" | "active" | "failed";
|
export type EventSubscriptionStatus = "inactive" | "pending" | "active" | "failed";
|
||||||
|
|
||||||
export interface CameraEventSubscription {
|
export interface CameraEventSubscription {
|
||||||
id: number;
|
id: string;
|
||||||
camera_id: number;
|
camera_id: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
status: EventSubscriptionStatus;
|
status: EventSubscriptionStatus;
|
||||||
subscribed_by_kiosk_id: number | null;
|
subscribed_by_kiosk_id: string | null;
|
||||||
event_source: string | null;
|
event_source: string | null;
|
||||||
event_sink: string | null;
|
event_sink: string | null;
|
||||||
last_event_at: string | null;
|
last_event_at: string | null;
|
||||||
|
|
@ -428,8 +428,8 @@ export interface CameraEventSubscription {
|
||||||
|
|
||||||
export interface EventQueryFilters {
|
export interface EventQueryFilters {
|
||||||
topic?: string;
|
topic?: string;
|
||||||
kiosk_id?: number;
|
kiosk_id?: string;
|
||||||
camera_id?: number;
|
camera_id?: string;
|
||||||
source_type?: string;
|
source_type?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
@ -440,8 +440,8 @@ export interface EventQueryFilters {
|
||||||
export type KioskLogLevel = "debug" | "info" | "warn" | "error";
|
export type KioskLogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
export interface KioskLog {
|
export interface KioskLog {
|
||||||
id: number;
|
id: string;
|
||||||
kiosk_id: number;
|
kiosk_id: string;
|
||||||
level: KioskLogLevel;
|
level: KioskLogLevel;
|
||||||
message: string;
|
message: string;
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
|
|
@ -450,7 +450,7 @@ export interface KioskLog {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KioskLogQueryFilters {
|
export interface KioskLogQueryFilters {
|
||||||
kiosk_id: number;
|
kiosk_id: string;
|
||||||
level?: KioskLogLevel;
|
level?: KioskLogLevel;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,8 @@ export function OverviewPage(props: OverviewProps) {
|
||||||
interface CamerasProps {
|
interface CamerasProps {
|
||||||
user: string;
|
user: string;
|
||||||
cameras: Camera[];
|
cameras: Camera[];
|
||||||
streamCounts: Map<number, number>;
|
streamCounts: Map<string, number>;
|
||||||
activeKiosks: Map<number, number>;
|
activeKiosks: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CamerasPage(props: CamerasProps) {
|
export function CamerasPage(props: CamerasProps) {
|
||||||
|
|
@ -1165,9 +1165,9 @@ interface CameraSubscription {
|
||||||
interface CameraEditProps {
|
interface CameraEditProps {
|
||||||
user: string;
|
user: string;
|
||||||
camera: Camera;
|
camera: Camera;
|
||||||
labels: Array<{ label_id: number; name: string }>;
|
labels: Array<{ label_id: string; name: string }>;
|
||||||
allLabels: Label[];
|
allLabels: Label[];
|
||||||
streams: Array<{ id: number; role: string; name: string; rtsp_uri: string; rtsp_host: string | null; rtsp_port: number | null; rtsp_path: string | null }>;
|
streams: Array<{ id: string; role: string; name: string; rtsp_uri: string; rtsp_host: string | null; rtsp_port: number | null; rtsp_path: string | null }>;
|
||||||
subscriptions: CameraSubscription[];
|
subscriptions: CameraSubscription[];
|
||||||
eventSubscriptions?: CameraEventSubscription[];
|
eventSubscriptions?: CameraEventSubscription[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -1180,8 +1180,8 @@ interface CameraEditProps {
|
||||||
* hx-target="#camera-labels-<id>" hx-swap="innerHTML".
|
* hx-target="#camera-labels-<id>" hx-swap="innerHTML".
|
||||||
*/
|
*/
|
||||||
export function renderCameraLabels(
|
export function renderCameraLabels(
|
||||||
cameraId: number,
|
cameraId: string,
|
||||||
labels: Array<{ label_id: number; name: string }>,
|
labels: Array<{ label_id: string; name: string }>,
|
||||||
allLabels: Label[],
|
allLabels: Label[],
|
||||||
): string {
|
): string {
|
||||||
const labelsTargetSelector = `#camera-labels-${String(cameraId)}`;
|
const labelsTargetSelector = `#camera-labels-${String(cameraId)}`;
|
||||||
|
|
@ -1542,7 +1542,7 @@ export function CameraEditPage(props: CameraEditProps) {
|
||||||
interface KioskEditProps {
|
interface KioskEditProps {
|
||||||
user: string;
|
user: string;
|
||||||
kiosk: Kiosk;
|
kiosk: Kiosk;
|
||||||
labels: Array<{ label_id: number; name: string; role: string }>;
|
labels: Array<{ label_id: string; name: string; role: string }>;
|
||||||
allLabels: Label[];
|
allLabels: Label[];
|
||||||
displays?: Display[];
|
displays?: Display[];
|
||||||
displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
|
displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
|
||||||
|
|
@ -1561,8 +1561,8 @@ interface KioskEditProps {
|
||||||
* hx-target="#kiosk-labels-<id>" hx-swap="innerHTML".
|
* hx-target="#kiosk-labels-<id>" hx-swap="innerHTML".
|
||||||
*/
|
*/
|
||||||
export function renderKioskLabels(
|
export function renderKioskLabels(
|
||||||
kioskId: number,
|
kioskId: string,
|
||||||
labels: Array<{ label_id: number; name: string; role: string }>,
|
labels: Array<{ label_id: string; name: string; role: string }>,
|
||||||
allLabels: Label[],
|
allLabels: Label[],
|
||||||
): string {
|
): string {
|
||||||
const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`;
|
const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`;
|
||||||
|
|
@ -2138,7 +2138,7 @@ interface LayoutsPageProps {
|
||||||
user: string;
|
user: string;
|
||||||
layouts: LayoutType[];
|
layouts: LayoutType[];
|
||||||
/** layout_id → number of displays the layout is attached to */
|
/** layout_id → number of displays the layout is attached to */
|
||||||
displayCounts: Map<number, number>;
|
displayCounts: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutsPage(props: LayoutsPageProps) {
|
export function LayoutsPage(props: LayoutsPageProps) {
|
||||||
|
|
@ -2305,8 +2305,8 @@ export const LAYOUT_BUILDER_CSS = `
|
||||||
|
|
||||||
function cellLabel(
|
function cellLabel(
|
||||||
c: LayoutCell,
|
c: LayoutCell,
|
||||||
entityById: Map<number, Entity>,
|
entityById: Map<string, Entity>,
|
||||||
cameraById: Map<number, Camera>,
|
cameraById: Map<string, Camera>,
|
||||||
): string {
|
): string {
|
||||||
if (c.entity_id != null) {
|
if (c.entity_id != null) {
|
||||||
const ent = entityById.get(c.entity_id);
|
const ent = entityById.get(c.entity_id);
|
||||||
|
|
@ -2331,15 +2331,15 @@ function cellGridStyle(c: LayoutCell): string {
|
||||||
* suitable for hx-swap="outerHTML" against itself.
|
* suitable for hx-swap="outerHTML" against itself.
|
||||||
*/
|
*/
|
||||||
export function renderCell(
|
export function renderCell(
|
||||||
layoutId: number,
|
layoutId: string,
|
||||||
c: LayoutCell,
|
c: LayoutCell,
|
||||||
entities: Entity[],
|
entities: Entity[],
|
||||||
cameras: Camera[],
|
cameras: Camera[],
|
||||||
mode: "read" | "edit",
|
mode: "read" | "edit",
|
||||||
): string {
|
): string {
|
||||||
const cameraById = new Map<number, Camera>();
|
const cameraById = new Map<string, Camera>();
|
||||||
for (const cam of cameras) cameraById.set(cam.id, cam);
|
for (const cam of cameras) cameraById.set(cam.id, cam);
|
||||||
const entityById = new Map<number, Entity>();
|
const entityById = new Map<string, Entity>();
|
||||||
for (const e of entities) entityById.set(e.id, e);
|
for (const e of entities) entityById.set(e.id, e);
|
||||||
const style = cellGridStyle(c);
|
const style = cellGridStyle(c);
|
||||||
const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`;
|
const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`;
|
||||||
|
|
@ -2535,7 +2535,7 @@ export function renderCell(
|
||||||
* in via `hx-target="#layout-grid" hx-swap="innerHTML"` after add/delete/resize.
|
* in via `hx-target="#layout-grid" hx-swap="innerHTML"` after add/delete/resize.
|
||||||
*/
|
*/
|
||||||
export function renderGrid(
|
export function renderGrid(
|
||||||
layoutId: number,
|
layoutId: string,
|
||||||
cells: LayoutCell[],
|
cells: LayoutCell[],
|
||||||
entities: Entity[],
|
entities: Entity[],
|
||||||
cameras: Camera[],
|
cameras: Camera[],
|
||||||
|
|
@ -2721,8 +2721,8 @@ interface DisplayEditPageProps {
|
||||||
* `renderDefaultLayoutSelect`.
|
* `renderDefaultLayoutSelect`.
|
||||||
*/
|
*/
|
||||||
export function renderDisplayLayouts(
|
export function renderDisplayLayouts(
|
||||||
displayId: number,
|
displayId: string,
|
||||||
defaultLayoutId: number | null,
|
defaultLayoutId: string | null,
|
||||||
attached: LayoutType[],
|
attached: LayoutType[],
|
||||||
available: LayoutType[],
|
available: LayoutType[],
|
||||||
): string {
|
): string {
|
||||||
|
|
@ -2829,7 +2829,7 @@ export function renderDisplayLayouts(
|
||||||
* page. The id matches the in-page select so swap-by-id works.
|
* page. The id matches the in-page select so swap-by-id works.
|
||||||
*/
|
*/
|
||||||
export function renderDefaultLayoutSelect(
|
export function renderDefaultLayoutSelect(
|
||||||
defaultLayoutId: number | null,
|
defaultLayoutId: string | null,
|
||||||
attached: LayoutType[],
|
attached: LayoutType[],
|
||||||
oob: boolean = false,
|
oob: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue