/** * Admin page templates: overview, cameras, kiosks, account, etc. */ import { js } from "jsx-htmx"; import { Layout } from "./layout.js"; import type { AuditEntry, Camera, CameraEventSubscription, Display, Entity, FirmwareRelease, FirmwareRollout, Kiosk, KioskGpioBinding, KioskLog, Label, Layout as LayoutType, LayoutCell, OsUpdateRelease, OsUpdateRollout, PairingCode, EventLog, Tenant, } from "../shared/types.js"; // ---- Overview --------------------------------------------------------------- interface OverviewProps { user: string; cameraCount: number; kioskCount: number; onlineKioskCount: number; layoutCount: number; events: EventLog[]; } export function OverviewPage(props: OverviewProps) { return (
Cameras
{String(props.cameraCount)}
Kiosks
{String(props.kioskCount)}
Kiosks Online
{String(props.onlineKioskCount)}
Displays
{String(props.layoutCount)}

Quick Links

Add Camera
RTSP or ONVIF
Pair Kiosk
Enter pairing code
Rule Engine
Node-RED dashboard

Recent Events

{props.events.length === 0 ? ( ) : ( props.events.map((ev) => ( )) )}
Time Topic Source Payload
No events yet
{formatTime(ev.received_at)} {ev.topic} {ev.source_type} {JSON.stringify(ev.payload)}
); } // ---- Cameras ---------------------------------------------------------------- interface CamerasProps { user: string; cameras: Camera[]; streamCounts: Map; activeKiosks: Map; } export function CamerasPage(props: CamerasProps) { return (

All Cameras

Add Camera
{props.cameras.length === 0 ? ( ) : ( props.cameras.map((cam) => { const streams = props.streamCounts.get(cam.id) ?? 0; const active = props.activeKiosks.get(cam.id) ?? 0; const health = !cam.enabled ? "gray" : streams === 0 ? "red" : active > 0 ? "green" : "yellow"; const healthLabel = !cam.enabled ? "Disabled" : streams === 0 ? "No streams" : active > 0 ? `Live (${active} kiosk${active > 1 ? "s" : ""})` : "Idle"; return ( ); }) )}
Name Type Streams Status
No cameras configured
{cam.name} {cam.type.toUpperCase()} {String(streams)} {healthLabel}
); } // ---- Camera New ------------------------------------------------------------- interface CameraNewProps { user: string; error?: string; values?: Record; } export function CameraNewPage(props: CameraNewProps) { const v = props.values ?? {}; return (
Cancel
); } // ---- Camera ONVIF Discovery ------------------------------------------------ interface CameraDiscoverProps { user: string; kiosks: Kiosk[]; error?: string; values?: Record; } export function CameraDiscoverPage(props: CameraDiscoverProps) { const v = props.values ?? {}; return (

Connect to an ONVIF camera or NVR by host and credentials. Profiles from the same video source are imported as streams on one camera.

Cancel
); } interface DiscoveredProfileRow { profile_name: string; profile_token: string; source_token: string | null; encoding: string | null; width: number | null; height: number | null; framerate: number | null; stream_uri: string; snapshot_uri: string | null; role: "main" | "sub" | "other"; } interface DiscoveredCameraRow { name: string; source_token: string | null; profiles: DiscoveredProfileRow[]; } interface CameraDiscoverResultsProps { user: string; host: string; port?: number; username: string; password: string; cameras: DiscoveredCameraRow[]; error?: string; success?: string; } function discoverResultsScript(rootId: string): string { return ( `(function(){` + `var root=document.getElementById('${rootId}');if(!root)return;` + `var checks=function(){return Array.prototype.slice.call(root.querySelectorAll('input[name="selected"]'));};` + `root.querySelector('[data-action="check-all"]')?.addEventListener('click',function(){checks().forEach(function(c){c.checked=true;});});` + `root.querySelector('[data-action="uncheck-all"]')?.addEventListener('click',function(){checks().forEach(function(c){c.checked=false;});});` + `root.querySelector('[data-view="list"]')?.addEventListener('click',function(){root.dataset.view='list';});` + `root.querySelector('[data-view="cards"]')?.addEventListener('click',function(){root.dataset.view='cards';});` + `})()` ); } const DISCOVER_RESULTS_CSS = ` #discover-results-root[data-view="cards"] .discover-results { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; } #discover-results-root[data-view="list"] .discover-camera-card { margin-bottom: 1rem; } #discover-results-root[data-view="list"] .discover-snaps { display: none; } .discover-camera-card { margin-bottom: 1rem; } .discover-snaps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; } .discover-snap { position: relative; min-height: 150px; background: #111827; border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; } .discover-snap img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; } .discover-snap-label { position: absolute; left: 6px; top: 6px; background: rgba(17, 24, 39, 0.8); color: #fff; border-radius: 3px; padding: 2px 5px; font-size: 0.7rem; text-transform: uppercase; z-index: 1; } .discover-snap-empty { color: #d1d5db; font-size: 0.8rem; } `; function CameraDiscoverResultsPageLegacy(props: { user: string; host: string; profiles: DiscoveredProfileRow[]; error?: string; success?: string; }) { return (

Video sources reported by {props.host}. Each source imports as one camera with its profiles saved as streams.

{props.profiles.length === 0 ? ( ) : ( props.profiles.map((p) => ( )) )}
Profile Encoding Resolution FPS Stream URI
No profiles returned
{p.profile_name} {p.encoding ? {p.encoding} : "—"} {p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "—"} {p.framerate != null ? String(p.framerate) : "—"} {p.stream_uri}
); } export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) { return (

Video sources reported by {props.host}. Each source imports as one camera with its profiles saved as streams.

{props.cameras.length === 0 ? (
No profiles returned
) : (
{props.cameras.map((cam, idx) => { const main = cam.profiles.find((p) => p.role === "main") ?? cam.profiles[0] ?? null; const sub = cam.profiles.find((p) => p.role === "sub") ?? null; return (
{[main, sub].filter(Boolean).map((p) => (
{p!.role}
{p!.snapshot_uri ? :
No snapshot
}
))}
{cam.profiles.map((p) => ( ))}
Role Profile Encoding Resolution FPS Stream URI
{p.role} {p.profile_name} {p.encoding ? {p.encoding} : "-"} {p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"} {p.framerate != null ? String(p.framerate) : "-"} {p.stream_uri}
); }).join("")}
)}
); } // ---- Entities --------------------------------------------------------------- interface EntitiesPageProps { user: string; entities: Entity[]; } function entityBadge(type: string) { const cls = type === "camera" ? "badge-blue" : type === "web" ? "badge-green" : type === "dashboard" ? "badge-blue" : "badge-gray"; return {type}; } function entityDetail(e: Entity): string { if (e.type === "camera") return e.camera_id ? `cam #${String(e.camera_id)}` : "—"; if (e.type === "web") return e.web_url ?? "—"; if (e.type === "html") return e.html_content ? `${e.html_content.slice(0, 80)}…` : "—"; if (e.type === "dashboard") return e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"; return "—"; } export function EntitiesPage(props: EntitiesPageProps) { const dashboards = props.entities.filter((e) => e.type === "dashboard"); const others = props.entities.filter((e) => e.type !== "dashboard"); return (

All Entities

New Entity

Entities are reusable content blocks (a camera reference, an HTML snippet, a web page, or a Node-RED dashboard tab). Bind one entity to any number of layout cells — edit the entity once and every cell updates.

{others.length === 0 ? ( ) : ( others.map((e) => ( )) )}
Name Type Detail
No entities yet
{e.name} {entityBadge(e.type)} {entityDetail(e)}

Dashboards (Node-RED)

Auto-synced from Node-RED. Press Sync Dashboards after adding or renaming tabs in Node-RED. Editing a dashboard happens in the Node-RED editor.

{dashboards.length === 0 ? ( ) : ( dashboards.map((e) => ( )) )}
Name Tab ID URL
No dashboards synced yet — press Sync.
{e.name} {e.dashboard_id ?? "—"} {e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"}
); } interface EntityNewPageProps { user: string; cameras: Camera[]; error?: string; values?: Record; } function entityFormScript(rootId: string): string { // Show/hide type-specific fieldsets when the type select changes. return ( `(function(){` + `var root=document.getElementById('${rootId}');` + `if(!root)return;` + `var sel=root.querySelector('select[name="type"]');` + `var cf=root.querySelector('.ent-fields-camera');` + `var wf=root.querySelector('.ent-fields-web');` + `var hf=root.querySelector('.ent-fields-html');` + `function t(){var v=sel?sel.value:"html";` + `if(cf)cf.style.display=v==='camera'?'block':'none';` + `if(wf)wf.style.display=v==='web'?'block':'none';` + `if(hf)hf.style.display=v==='html'?'block':'none';}` + `if(sel)sel.addEventListener('change',t);t();})()` ); } export function EntityNewPage(props: EntityNewPageProps) { const v = props.values ?? {}; const selType = v["type"] ?? "html"; return (
Cancel
); } interface EntityEditPageProps { user: string; entity: Entity; cameras: Camera[]; error?: string; success?: string; } export function EntityEditPage(props: EntityEditPageProps) { const e = props.entity; return (
Type: {entityBadge(e.type)}
{/* Type is fixed after creation — switching type would break attached cells. */}
{e.type === "camera" && (
)} {e.type === "camera" && e.camera_id != null && (
Camera snapshot Snapshot failed — camera unreachable or RTSP not configured
Pulls one frame via ffmpeg/gst (up to ~8s).
)} {e.type === "web" && (
)} {e.type === "html" && (
)} {e.type === "dashboard" && (
{e.dashboard_id ?? "—"}
Synced from Node-RED. Resolved as /dash/{e.dashboard_id ?? "?"} in kiosk bundles. Edit the dashboard contents in the Node-RED editor.
)} Back {e.type === "dashboard" && ( Open in Node-RED )}
); } // ---- Kiosks ----------------------------------------------------------------- interface KiosksProps { user: string; kiosks: Kiosk[]; pendingCodes: PairingCode[]; error?: string; } export function KiosksPage(props: KiosksProps) { return (

Paired Kiosks

{props.kiosks.length === 0 ? ( ) : ( props.kiosks.map((k) => ( )) )}
Name Hardware Last Seen Status
No kiosks paired
{k.name} {k.hardware_model ?? "—"} {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"} {k.enabled ? Active : Disabled }

Pair New Kiosk

8-character code shown on kiosk screen.
Pick the kiosk this device replaces. Display, layouts, labels, and GPIO bindings stay; only the device credentials roll. Old kiosk's key is revoked.
Comma-separated label names.
{props.pendingCodes.length > 0 && (
Pending Codes
{props.pendingCodes.map((pc) => { const managed = pc.extras?.["managed_image"] === true; return (
{pc.code} expires {formatTime(pc.expires_at)}
{pc.kiosk_proposed_name ? <>name: {pc.kiosk_proposed_name} : "(no name)"} {pc.kiosk_hardware_model ? <> · hw: {pc.kiosk_hardware_model} : null} {managed ? <> · managed image : null}
{pc.kiosk_capabilities?.length > 0 ? (
caps: {pc.kiosk_capabilities.join(", ")}
) : null}
); })}
)}
); } // ---- Account ---------------------------------------------------------------- interface AccountProps { user: string; totpEnabled: boolean; error?: string; success?: string; } export function AccountPage(props: AccountProps) { return (

Change Password

At least 12 characters.

Two-Factor Authentication

{props.totpEnabled ? (

Enabled {" "}TOTP is active on this account.

) : (

Protect your account with a TOTP authenticator app.

)}
); } // ---- TOTP Enrollment -------------------------------------------------------- interface TotpEnrollProps { user: string; secret: string; provisioningUri: string; recoveryCodes: string[]; } export function TotpEnrollPage(props: TotpEnrollProps) { return (

Step 1: Scan QR Code

Scan this with your authenticator app (Google Authenticator, Authy, etc.).

Can't scan? Enter manually {props.secret}

Step 2: Save Recovery Codes

Save these codes somewhere safe. They will not be shown again.

{props.recoveryCodes.map((code) => (
{code}
))}

Step 3: Verify

Cancel
); } // ---- Simple list page ------------------------------------------------------- interface SimpleListProps { user: string; pageTitle: string; description: string; activeNav: string; items: Array<{ name: string; detail?: string; badge?: string }>; } export function SimpleListPage(props: SimpleListProps) { return (

{props.description}

{props.items.length === 0 ? ( ) : ( props.items.map((item) => ( )) )}
Name Details
None configured yet
{item.name} {item.badge && ( {item.badge} )} {item.detail ?? ""}
); } // ---- Camera Edit ------------------------------------------------------------ interface CameraSubscription { kiosk: Kiosk; layouts: string[]; // layout names that reference this camera active: boolean; // true if camera is in the kiosk's active layout right now } interface CameraEditProps { user: string; camera: Camera; labels: Array<{ label_id: string; name: string }>; allLabels: Label[]; 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[]; eventSubscriptions?: CameraEventSubscription[]; error?: string; success?: string; } /** * Render the camera labels region (chips + add forms). Returned standalone so * htmx label add/remove can swap just this fragment via * hx-target="#camera-labels-" hx-swap="innerHTML". */ export function renderCameraLabels( cameraId: string, labels: Array<{ label_id: string; name: string }>, allLabels: Label[], ): string { const labelsTargetSelector = `#camera-labels-${String(cameraId)}`; return (
{labels.length > 0 ? (
{labels.map((l) => ( ))}
) : (

No labels attached

)}
); } export function CameraEditPage(props: CameraEditProps) { const cam = props.camera; return (
{cam.type === "cloud" && (

Cloud {cam.name}

This camera is managed by a cloud account sync. It cannot be edited manually. Changes are applied automatically when the cloud account is synced.

Type: Cloud ({cam.cloud_stream_type ?? "unknown stream"})
Vendor Camera ID: {cam.cloud_vendor_camera_id ?? "—"}
{cam.cloud_stream_url && (
Stream URL: {cam.cloud_stream_url}
)}
Status: {cam.enabled ? Enabled : Disabled}
Last seen: {cam.last_seen_at ? formatTime(cam.last_seen_at) : "Never"}
Back
)} {cam.type !== "cloud" && (<>

Edit Camera

{cam.type === "rtsp" && (() => { const parts = parseRtspUrl(cam.rtsp_url ?? ""); return (
); })()} {cam.type === "onvif" && (
)}
{cam.type === "onvif" && (

Event Routing

)} Back
{cam.type === "onvif" && (() => { const subs = props.eventSubscriptions ?? []; const subMap = new Map(subs.map((s) => [s.topic, s])); // Merge: show all topics from subscriptions table + any in supported_event_topics not yet in DB const allTopics = new Set([...subs.map((s) => s.topic), ...cam.supported_event_topics]); const sortedTopics = [...allTopics].sort(); const hasInactive = subs.some((s) => s.status === "inactive"); return (

Event Topics & Subscriptions

Topics this camera advertises via GetEventProperties. Click refresh to re-query the camera (via the designated event source). New topics are merged into the list and never removed.

{hasInactive && (
)}
{sortedTopics.length > 0 ? (
{[...sortedTopics].map((t) => { const sub = subMap.get(t); const status = sub?.status ?? "inactive"; const dotColor = status === "active" ? "#22c55e" : status === "pending" ? "#f59e0b" : status === "failed" ? "#ef4444" : "#9ca3af"; const dotTitle = status === "active" ? "Active" : status === "pending" ? "Pending" : status === "failed" ? "Failed" : "Inactive"; return ( ); })}
StatusTopicSourceSinkLast EventError
{t} {sub?.event_source ?? "—"} {sub?.event_sink ?? "—"} {sub?.last_event_at ? formatTime(sub.last_event_at) : "—"} {sub?.error_message ?? ""}
) : (

No topics discovered yet. Click refresh above.

)}
); })()}

Labels

{renderCameraLabels(cam.id, props.labels, props.allLabels)}

Streams

{props.streams.length > 0 ? (
{props.streams.map((s) => ( ))}
RoleNameURIHostPortPath
{s.role} {s.name} {maskRtspPassword(s.rtsp_uri)} {s.rtsp_host ?? ""} {s.rtsp_port != null ? String(s.rtsp_port) : ""} {s.rtsp_path ?? ""}
) : (

No streams configured

)}
{cam.type === "onvif" && (

Live Events

ONVIF events from kiosks subscribed to this camera. Auto-refreshes every 5s. All topics shown — motion, ANPR, line crossing, I/O, analytics, unknown.

Loading...
)}

Kiosk Subscriptions

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

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

No kiosk has this camera in any layout.

)}
)}
); } // ---- Kiosk Edit ------------------------------------------------------------- interface KioskEditProps { user: string; kiosk: Kiosk; labels: Array<{ label_id: string; name: string; role: string }>; allLabels: Label[]; displays?: Display[]; displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>; gpioBindings?: KioskGpioBinding[]; firmwareReleases?: FirmwareRelease[]; osReleases?: OsUpdateRelease[]; kioskLogs?: KioskLog[]; kioskLogTotal?: number; error?: string; success?: string; } /** * Render the kiosk labels region (chips + add forms). Returned standalone so * htmx label add/remove can swap just this fragment via * hx-target="#kiosk-labels-" hx-swap="innerHTML". */ export function renderKioskLabels( kioskId: string, labels: Array<{ label_id: string; name: string; role: string }>, allLabels: Label[], ): string { const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`; return (
{labels.length > 0 ? (
{labels.map((l) => ( ))}
) : (

No labels attached

)}
); } /** * Managed-image device config editor. Only rendered when the kiosk reported * managed_image=true at pairing. Server pushes the resulting JSON on the * next heartbeat; kiosk applies it and echoes the version back, so we show * "version N applied at …" plus the last error (if any) so the operator can * see whether their change actually landed. */ function ManagedConfigCard(props: { kiosk: Kiosk }) { const k = props.kiosk; let cfg: { hostname?: string; timezone?: string; network?: { mode?: string; interface?: string; ip_cidr?: string; gateway?: string; dns?: string[]; vlan_id?: number; }; wifi?: { ssid?: string }; } = {}; if (k.managed_config_json) { try { cfg = JSON.parse(k.managed_config_json); } catch { /* ignore */ } } const net = cfg.network ?? {}; const wifi = cfg.wifi ?? {}; const pending = k.managed_config_version > k.managed_config_applied_version; return (

Managed Config (Pi image)

Version: {String(k.managed_config_version)} {" · Applied: "}{String(k.managed_config_applied_version)} {k.managed_config_applied_at ? <> ({formatTime(k.managed_config_applied_at)}) : null} {pending ? pending push… : null}
{k.managed_config_error ?
Last error: {k.managed_config_error}
: null}

Network

Wi-Fi (optional)

Encrypted with cluster key before storage. Leave blank to keep existing PSK.
); } export function KioskEditPage(props: KioskEditProps) { const k = props.kiosk; return (

Edit Kiosk

Back
Hardware: {k.hardware_model ?? "—"}
Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}
Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}
Display Power
{props.displayLayouts && props.displayLayouts.length > 0 ? (
Switch Layout By Display
{props.displayLayouts.map(({ display, layouts }) => (
{display.name}
{String(display.width_px)}x{String(display.height_px)}
{layouts.length > 0 ? ( ) : ( No attached layouts )}
)).join("")}
) : null}
Hardware
CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}
Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}
CPU Load: {percentText(k.cpu_load_percent)}
RAM: {mbPair(k.memory_used_mb, k.memory_total_mb)}
Disk: {k.disk_free_mb != null && k.disk_total_mb != null ? `${String(k.disk_free_mb)} MB free / ${String(k.disk_total_mb)} MB` : "—"} {k.disk_used_percent != null ? `(${k.disk_used_percent.toFixed(1)}%)` : ""}
PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}
{(() => { const parts = (() => { const raw = (k as any).partitions_json; if (!raw) return []; if (Array.isArray(raw)) return raw; if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return []; } } return []; })(); if (parts.length === 0) return null; return (
Partitions
{parts.map((p: any) => ( ))}
Mount Device Total Used Free %
{p.mountpoint} {p.device} {String(p.total_mb)} MB {String(p.used_mb)} MB {String(p.free_mb)} MB {typeof p.used_percent === "number" ? `${p.used_percent.toFixed(1)}%` : "—"}
); })()}
Power
Audio
|
{/* Associated displays */}

Displays

{props.displays && props.displays.length > 0 ? (
{props.displays.map((d) => ( ))}
NameResolutionIndexPower
{d.name} {String(d.width_px)}x{String(d.height_px)} {String(d.index)} {powerBadge(d.actual_power_state)}
) : (

No displays associated with this kiosk

)}
{props.firmwareReleases && ( KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases }) )} {props.osReleases && ( KioskOsUpdatePanel({ kiosk: props.kiosk, releases: props.osReleases }) )} {(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })} {/* GPIO bindings */}

GPIO Bindings

Each input binding fires an event with the configured topic when the pin's edge triggers. Pi 5's main GPIO chip is gpiochip4; older Pis use gpiochip0.

{props.gpioBindings && props.gpioBindings.length > 0 ? (
{props.gpioBindings.map((g) => ( ))}
Chip Pin Dir Pull Edge Topic
{g.chip} {String(g.pin)} {g.direction} {g.pull ?? "—"} {g.edge ?? "—"} {g.topic}
) : (

No GPIO bindings configured

)}

Labels

{renderKioskLabels(k.id, props.labels, props.allLabels)}
{k.managed_image ? : null} {/* Kiosk application logs */}

Logs {props.kioskLogTotal ? ({String(props.kioskLogTotal)}) : null}

{props.kioskLogs && props.kioskLogs.length > 0 ? (
{props.kioskLogs.map((log) => { const levelBadge = log.level === "error" ? "badge-red" : log.level === "warn" ? "badge-yellow" : log.level === "info" ? "badge-blue" : "badge-gray"; const ctx = Object.keys(log.context).length > 0 ? JSON.stringify(log.context) : ""; return ( ); })}
Time Level Message
{log.received_at.replace("T", " ").replace(/\.\d+Z$/, "Z")} {log.level} {log.message} {ctx &&
{ctx}
}
) : (

No logs received from this kiosk

)}
); } // ---- Labels Management ------------------------------------------------------ interface LabelsPageProps { user: string; labels: Label[]; error?: string; } export function LabelsPage(props: LabelsPageProps) { return (

All Labels

{props.labels.length === 0 ? ( ) : ( props.labels.map((l) => ( )) )}
NameColorActions
No labels
{l.name} {l.color ? {l.color} : "—"}
); } // ---- Layouts ---------------------------------------------------------------- interface LayoutsPageProps { user: string; layouts: LayoutType[]; /** layout_id → number of displays the layout is attached to */ displayCounts: Map; } export function LayoutsPage(props: LayoutsPageProps) { return (

All Layouts

New Layout

Layouts are standalone — they define a grid of regions and bind cameras or other content into them. Attach a layout to one or more displays from the display's edit page.

{props.layouts.length === 0 ? ( ) : ( props.layouts.map((l) => { const count = props.displayCounts.get(l.id) ?? 0; return ( ); }) )}
Name Displays Priority
No layouts created yet
{l.name} {count === 0 ? unattached : {String(count)} display{count !== 1 ? "s" : ""}} {l.priority === "hot" ? hot : l.priority === "cold" ? cold : normal }
); } // ---- Layout New ------------------------------------------------------------- interface LayoutNewPageProps { user: string; error?: string; values?: Record; } export function LayoutNewPage(props: LayoutNewPageProps) { const v = props.values ?? {}; return (

Create an empty layout. You'll add cells visually on the next page, then attach the layout to one or more displays.

Cancel
); } // ---- Layout Edit ------------------------------------------------------------ interface LayoutEditPageProps { user: string; layout: LayoutType; /** Displays this layout is attached to (informational, read-only). */ displays: Display[]; cells: LayoutCell[]; cameras: Camera[]; entities: Entity[]; /** If set, render the content-assignment form for this cell beneath the grid. */ selectedCellId?: number | null; error?: string; success?: string; } export const LAYOUT_BUILDER_CSS = ` .layout-builder { display: grid; gap: 4px; aspect-ratio: 16/9; max-width: 100%; background: #ddd; padding: 4px; border-radius: 4px; } .layout-cell { background: #fff; border: 2px solid #2563eb; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; min-height: 60px; padding: 4px; text-align: center; font-size: 0.85rem; font-weight: 600; color: #1e40af; overflow: visible; } .layout-cell:hover { background: #f0f7ff; } .layout-cell.editing { background: #f9fafb; border-color: #1e40af; box-shadow: 0 0 0 2px #1e40af33; cursor: default; align-items: stretch; justify-content: stretch; text-align: left; font-weight: 400; color: inherit; padding: 8px; overflow: auto; } .layout-cell-empty-text { color: #aaa; font-size: 0.75rem; font-weight: 400; } .layout-cell:has(.layout-cell-empty-text) { border-style: dashed; border-color: #ccc; background: #fafafa; } .layout-cell-side { position: absolute; opacity: 0; transition: opacity 0.2s; z-index: 3; } .layout-cell:hover .layout-cell-side, .layout-cell-side:hover { opacity: 1; } .layout-cell-side-top { top: -12px; left: 50%; transform: translateX(-50%); } .layout-cell-side-right { right: -12px; top: 50%; transform: translateY(-50%); } .layout-cell-side-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); } .layout-cell-side-left { left: -12px; top: 50%; transform: translateY(-50%); } .layout-cell-side-trigger { background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; padding: 0; } .layout-cell-side-trigger:hover { background: #1e40af; } .layout-cell-side-menu { display: none; position: absolute; gap: 4px; background: #111827; border-radius: 4px; padding: 4px; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.25); left: 50%; top: 50%; transform: translate(-50%, -50%); } .layout-cell-side:hover .layout-cell-side-menu { display: flex; } .layout-cell-side-menu button { background: #fff; color: #111827; border: none; border-radius: 3px; cursor: pointer; font-size: 0.7rem; line-height: 1; padding: 5px 7px; white-space: nowrap; } .layout-cell-side-menu button:hover { background: #dbeafe; color: #1e40af; } .layout-cell-delete { position: absolute; top: 4px; right: 4px; background: rgba(220, 38, 38, 0.9); color: #fff; border: none; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; font-size: 12px; line-height: 1; padding: 0; opacity: 0; transition: opacity 0.2s; z-index: 2; } .layout-cell:hover .layout-cell-delete { opacity: 1; } .layout-cell-edit-form { width: 100%; } .layout-cell-edit-form .form-group { margin-bottom: 0.5rem; } .layout-cell-edit-form label { font-size: 0.75rem; font-weight: 600; display: block; margin-bottom: 0.15rem; } .layout-cell-edit-form .form-input, .layout-cell-edit-form input, .layout-cell-edit-form select, .layout-cell-edit-form textarea { font-size: 0.8rem; padding: 0.25rem 0.4rem; width: 100%; box-sizing: border-box; } .layout-cell-edit-form .btn { font-size: 0.75rem; padding: 0.25rem 0.6rem; } .layout-cell-edit-form-actions { display: flex; gap: 0.35rem; margin-top: 0.5rem; flex-wrap: wrap; } .layout-cell-edit-form .span-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; } .layout-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/9; background: #f3f4f6; border-radius: 4px; } .layout-empty-add { background: #2563eb; color: #fff; border: none; width: 80px; height: 80px; border-radius: 50%; cursor: pointer; font-size: 36px; line-height: 1; padding: 0; } .layout-empty-add:hover { background: #1e40af; } `; function cellLabel( c: LayoutCell, entityById: Map, cameraById: Map, ): string { if (c.entity_id != null) { const ent = entityById.get(c.entity_id); if (ent) return ent.name; } if (c.content_type === "camera" && c.camera_id) { return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`; } if (c.content_type === "none") return "None"; if (c.content_type === "web") return c.web_url ? `Web: ${c.web_url}` : "Web"; if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)"; return "Empty"; } function cellGridStyle(c: LayoutCell): string { return `grid-column:${String(c.col + 1)} / span ${String(c.col_span)}; grid-row:${String(c.row + 1)} / span ${String(c.row_span)};`; } /** * Render a single cell, either in read-only display mode or edit mode (form * inline inside the cell). Returns a `
` element * suitable for hx-swap="outerHTML" against itself. */ export function renderCell( layoutId: string, c: LayoutCell, entities: Entity[], cameras: Camera[], mode: "read" | "edit", ): string { const cameraById = new Map(); for (const cam of cameras) cameraById.set(cam.id, cam); const entityById = new Map(); for (const e of entities) entityById.set(e.id, e); const style = cellGridStyle(c); const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`; const cellEditUrl = `${cellGetUrl}/edit`; const addUrl = `/admin/layouts/${String(layoutId)}/cells`; const deleteUrl = `${cellGetUrl}/delete`; const resizeUrl = `${cellGetUrl}/resize`; if (mode === "edit") { return (
{/* Smart URL step builder — shown for web/dashboard cells */}
Auto-login / navigate sequences. Leave empty for direct URL load.
{((c.options?.["smart_url"] as any)?.steps ?? []).map((step: any, idx: number) => (
))}
';document.getElementById('steps-${String(c.id)}').appendChild(d)`} >+ Add Step
); } // Read mode. Empty when no entity is bound. const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null; const isEmpty = !ent && ( c.content_type === "none" || (c.content_type === "html" && !c.html_content) || (c.content_type === "camera" && !c.camera_id) || (c.content_type === "web" && !c.web_url) ); const label = cellLabel(c, entityById, cameraById); return (
{isEmpty ? + empty : {label} } {/* + buttons (4 sides) — htmx posts to add endpoint, swaps grid */} {(["top", "right", "bottom", "left"] as const).map((dir) => { const directionParam = dir === "top" ? "above" : dir; return (
); })} {/* delete button */}
); } /** * Render the inner content of the `#layout-grid` container — either the empty * placeholder or the full builder grid with all cells. This is what htmx swaps * in via `hx-target="#layout-grid" hx-swap="innerHTML"` after add/delete/resize. */ export function renderGrid( layoutId: string, cells: LayoutCell[], entities: Entity[], cameras: Camera[], ): string { if (cells.length === 0) { return (
); } // Compute grid dimensions. let gridCols = 1; let gridRows = 1; for (const c of cells) { const right = c.col + c.col_span; const bottom = c.row + c.row_span; if (right > gridCols) gridCols = right; if (bottom > gridRows) gridRows = bottom; } return ( <>
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}

Linked Accounts

{props.accounts.length === 0 ? (

No cloud accounts linked yet.

) : (
{props.accounts.map((a) => { const vendorLabel = props.vendors.find((v) => v.value === a.vendor)?.label ?? a.vendor; return ( ); })}
Name Vendor Cameras Last Sync
{a.name} {vendorLabel} {String(a.camera_count)} {a.last_sync_at ? formatTime(a.last_sync_at) : "—"} {a.last_sync_error && (
{a.last_sync_error}
)}
)} ); } export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) { const k = props.kiosk; const current = k.os_version ?? "unknown"; return (

OS

Running: {current}
{k.os_update_last_attempt_version && (
Last attempt: {k.os_update_last_attempt_version} {k.os_update_last_attempt_at && at {formatTime(k.os_update_last_attempt_at)}} {k.os_update_last_error && — {k.os_update_last_error}}
)}
); } // ---- Tenants ---------------------------------------------------------------- interface TenantsPageProps { user: string; tenants: Tenant[]; currentTenantSlug: string; error?: string; } export function TenantsPage(props: TenantsPageProps) { return (

All Tenants

Each tenant is an isolated data boundary with its own cameras, kiosks, layouts, and displays. The "default" tenant uses the public schema.

Create Tenant

Lowercase letters, digits, hyphens, underscores. Used in schema name.
{props.tenants.length === 0 ? ( ) : ( props.tenants.map((t) => ( )) )}
Name Slug Schema Status Limits Active Actions
No tenants
{t.name} {t.slug} {t.schema_name} {t.is_active ? active : inactive } {[ t.max_kiosks != null ? `K:${String(t.max_kiosks)}` : null, t.max_cameras != null ? `C:${String(t.max_cameras)}` : null, t.max_users != null ? `U:${String(t.max_users)}` : null, ].filter(Boolean).join(" ") || "none"} {t.slug === props.currentTenantSlug ? current : (
) }
Edit {t.slug !== "default" && (
)}
); } interface TenantEditPageProps { user: string; tenant: Tenant; error?: string; } export function TenantEditPage(props: TenantEditPageProps) { const t = props.tenant; return (
← Back to Tenants

Edit Tenant

Slug cannot be changed after creation.
Cancel
); } // ---- AbleSign Pages --------------------------------------------------------- interface AbleSignPageProps { accounts: any[]; error?: string; } export function AbleSignPage(props: AbleSignPageProps) { return (

AbleSign Accounts

{props.error ?
{props.error}
: ""}

Add Account

{props.accounts.length > 0 ? (
{props.accounts.map((a: any) => ( ))}
Name Screens Last Sync Actions
{a.name} {String(a.screen_count ?? 0)} {a.last_sync_at ? formatTime(a.last_sync_at) : "Never"} {a.last_sync_error && {" (error)"}} Screens
) : ""}
); } interface AbleSignScreensPageProps { account: any | null; screens: any[]; kiosks: any[]; accounts?: any[]; } export function AbleSignScreensPage(props: AbleSignScreensPageProps) { const a = props.account; const isGlobal = !a; const title = isGlobal ? "AbleSign — All Screens" : `AbleSign — ${String(a.name)}`; return (

{isGlobal ? "All AbleSign Screens" : `${String(a.name)} — Screens`}

{a ? (

{String(a.screen_count ?? 0)} screens {a.last_sync_at ? ` · synced ${formatTime(a.last_sync_at)}` : ""}

) : ""} {a ? (

Add Screen

) : ""}

Screens

{a ? (
) : ""}
{props.screens.length === 0 ? (

No screens yet. Add one above or sync from AbleSign.

) : (
{props.screens.map((s: any) => ( ))}
Title Orientation Status Source Assigned Kiosk Actions
{s.title} {s.orientation} {s.online ? Online : Offline} {s.has_entity ? Internal : External}
)}
); } interface AbleSignContentPageProps { content: any[]; accounts: any[]; } export function AbleSignContentPage(props: AbleSignContentPageProps) { return (

AbleSign Content

{props.content.length === 0 ?

No content found. Add media or web apps in AbleSign CMS.

:
{props.content.map((c: any) => ( ))}
TitleTypeAccount
{c.title} {c.kind === "media" ? String(c.fileType || "media") : "web app"} {c.account_name}
}
); } interface AbleSignPlaylistsPageProps { playlists: any[]; } export function AbleSignPlaylistsPage(props: AbleSignPlaylistsPageProps) { const cards = props.playlists.map((pl: any) => `

${pl.screen_title as string}

Account: ${pl.account_name as string} · ${String(pl.items?.length ?? 0)} items${pl.shufflePlay ? " · Shuffle" : ""}

${Array.isArray(pl.items) && pl.items.length > 0 ? `${ (pl.items as any[]).map((item: any, idx: number) => `` ).join("") }
#TypeDuration
${String(idx + 1)}${item.mediafileId ? "Media" : item.webAppId ? "Web App" : "Unknown"}${item.displayDuration ? `${String(item.displayDuration)}s` : "—"}
` : ""}
` ).join(""); return (

AbleSign Playlists

{props.playlists.length === 0 ?

No playlists found.

: cards}
); }