2026-05-09 23:09:13 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Admin page templates: overview, cameras, kiosks, account, etc.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { js } from "jsx-htmx";
|
|
|
|
|
|
import { Layout } from "./layout.js";
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
import type {
|
2026-05-14 05:38:18 +00:00
|
|
|
|
AuditEntry,
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
Camera,
|
|
|
|
|
|
Display,
|
2026-05-10 21:18:44 +00:00
|
|
|
|
Entity,
|
2026-05-13 18:56:42 +00:00
|
|
|
|
FirmwareRelease,
|
2026-05-14 05:28:20 +00:00
|
|
|
|
FirmwareRollout,
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
Kiosk,
|
2026-05-12 23:18:22 +00:00
|
|
|
|
KioskGpioBinding,
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
Label,
|
|
|
|
|
|
Layout as LayoutType,
|
|
|
|
|
|
LayoutCell,
|
|
|
|
|
|
PairingCode,
|
|
|
|
|
|
EventLog,
|
|
|
|
|
|
} from "../shared/types.js";
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Overview ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface OverviewProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
cameraCount: number;
|
|
|
|
|
|
kioskCount: number;
|
|
|
|
|
|
onlineKioskCount: number;
|
|
|
|
|
|
layoutCount: number;
|
|
|
|
|
|
events: EventLog[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function OverviewPage(props: OverviewProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Overview" user={props.user} activeNav="overview">
|
|
|
|
|
|
<div class="stats-grid">
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Cameras</div>
|
|
|
|
|
|
<div class="stat-value">{String(props.cameraCount)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Kiosks</div>
|
|
|
|
|
|
<div class="stat-value">{String(props.kioskCount)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Kiosks Online</div>
|
|
|
|
|
|
<div class="stat-value">{String(props.onlineKioskCount)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Displays</div>
|
|
|
|
|
|
<div class="stat-value">{String(props.layoutCount)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">Quick Links</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stats-grid" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<a href="/admin/cameras/new" class="card" style="text-decoration:none; color:inherit">
|
|
|
|
|
|
<strong>Add Camera</strong>
|
|
|
|
|
|
<div style="color:#666; font-size:0.85rem">RTSP or ONVIF</div>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<a href="/admin/kiosks" class="card" style="text-decoration:none; color:inherit">
|
|
|
|
|
|
<strong>Pair Kiosk</strong>
|
|
|
|
|
|
<div style="color:#666; font-size:0.85rem">Enter pairing code</div>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<a href="/nrdp/" class="card" style="text-decoration:none; color:inherit">
|
|
|
|
|
|
<strong>Rule Engine</strong>
|
|
|
|
|
|
<div style="color:#666; font-size:0.85rem">Node-RED dashboard</div>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">Recent Events</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Time</th>
|
|
|
|
|
|
<th>Topic</th>
|
|
|
|
|
|
<th>Source</th>
|
|
|
|
|
|
<th>Payload</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.events.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No events yet</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.events.map((ev) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td style="white-space:nowrap; font-size:0.8rem">{formatTime(ev.received_at)}</td>
|
|
|
|
|
|
<td>{ev.topic}</td>
|
|
|
|
|
|
<td><span class="badge badge-gray">{ev.source_type}</span></td>
|
|
|
|
|
|
<td style="font-size:0.8rem; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
|
|
|
|
|
{JSON.stringify(ev.payload)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Cameras ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface CamerasProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
cameras: Camera[];
|
|
|
|
|
|
streamCounts: Map<number, number>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function CamerasPage(props: CamerasProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Cameras" user={props.user} activeNav="cameras">
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">All Cameras</h2>
|
|
|
|
|
|
<a href="/admin/cameras/new" class="btn btn-primary">Add Camera</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Type</th>
|
|
|
|
|
|
<th>Streams</th>
|
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.cameras.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No cameras configured</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.cameras.map((cam) => (
|
|
|
|
|
|
<tr>
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
<td><a href={`/admin/cameras/${cam.id}`}><strong>{cam.name}</strong></a></td>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<td><span class="badge badge-blue">{cam.type.toUpperCase()}</span></td>
|
|
|
|
|
|
<td>{String(props.streamCounts.get(cam.id) ?? 0)}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{cam.enabled
|
|
|
|
|
|
? <span class="badge badge-green">Enabled</span>
|
|
|
|
|
|
: <span class="badge badge-gray">Disabled</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Camera New -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface CameraNewProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
values?: Record<string, string>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function CameraNewPage(props: CameraNewProps) {
|
|
|
|
|
|
const v = props.values ?? {};
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title="Add Camera"
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="cameras"
|
|
|
|
|
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style="max-width:600px">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<div style="margin-bottom:1rem">
|
|
|
|
|
|
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover via ONVIF →</a>
|
|
|
|
|
|
</div>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<form method="post" action="/admin/cameras/new">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<input type="hidden" name="type" value="rtsp" />
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name">Camera Name</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="name"
|
|
|
|
|
|
name="name"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
class="form-input"
|
|
|
|
|
|
required
|
|
|
|
|
|
maxlength="128"
|
|
|
|
|
|
value={v["name"] ?? ""}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<label for="rtsp_host">Host</label>
|
|
|
|
|
|
<input id="rtsp_host" name="rtsp_host" type="text" class="form-input" placeholder="192.168.1.100" value={v["rtsp_host"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:grid; grid-template-columns:1fr 2fr; gap:0.75rem">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_port">Port</label>
|
|
|
|
|
|
<input id="rtsp_port" name="rtsp_port" type="number" class="form-input" value={v["rtsp_port"] ?? "554"} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_path">Path</label>
|
|
|
|
|
|
<input id="rtsp_path" name="rtsp_path" type="text" class="form-input" placeholder="/Streaming/Channels/101" value={v["rtsp_path"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_username">Username</label>
|
|
|
|
|
|
<input id="rtsp_username" name="rtsp_username" type="text" class="form-input" value={v["rtsp_username"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_password">Password</label>
|
|
|
|
|
|
<input id="rtsp_password" name="rtsp_password" type="password" class="form-input" value={v["rtsp_password"] ?? ""} />
|
2026-05-09 23:09:13 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<button type="submit" class="btn btn-primary">Add Camera</button>
|
|
|
|
|
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Camera ONVIF Discovery ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface CameraDiscoverProps {
|
|
|
|
|
|
user: string;
|
2026-05-20 04:16:27 +00:00
|
|
|
|
kiosks: Kiosk[];
|
2026-05-10 21:18:44 +00:00
|
|
|
|
error?: string;
|
|
|
|
|
|
values?: Record<string, string>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function CameraDiscoverPage(props: CameraDiscoverProps) {
|
|
|
|
|
|
const v = props.values ?? {};
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title="Discover ONVIF Cameras"
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="cameras"
|
|
|
|
|
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style="max-width:600px">
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
2026-05-10 22:20:48 +00:00
|
|
|
|
Connect to an ONVIF camera or NVR by host and credentials. Profiles
|
|
|
|
|
|
from the same video source are imported as streams on one camera.
|
2026-05-10 21:18:44 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
<form method="post" action="/admin/cameras/discover" class="card">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="host">Host</label>
|
|
|
|
|
|
<input id="host" name="host" type="text" class="form-input" placeholder="192.168.1.100" required value={v["host"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="port">Port</label>
|
|
|
|
|
|
<input id="port" name="port" type="number" class="form-input" value={v["port"] ?? "80"} />
|
|
|
|
|
|
</div>
|
2026-05-20 04:16:27 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="discovery_runner">Run discovery from</label>
|
|
|
|
|
|
<select id="discovery_runner" name="discovery_runner" class="form-input">
|
|
|
|
|
|
<option value="server" selected={(v["discovery_runner"] ?? "server") === "server"}>Server</option>
|
|
|
|
|
|
{props.kiosks.map((k) => (
|
|
|
|
|
|
<option
|
|
|
|
|
|
value={`kiosk:${String(k.id)}`}
|
|
|
|
|
|
selected={v["discovery_runner"] === `kiosk:${String(k.id)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{k.name}{k.local_last_ip ? ` (${k.local_last_ip})` : ""}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
)).join("")}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<div class="form-group">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<label for="username">Username</label>
|
|
|
|
|
|
<input id="username" name="username" type="text" class="form-input" value={v["username"] ?? ""} />
|
2026-05-10 13:35:47 +00:00
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="password">Password</label>
|
|
|
|
|
|
<input id="password" name="password" type="password" class="form-input" value={v["password"] ?? ""} />
|
2026-05-10 13:35:47 +00:00
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Discover</button>
|
|
|
|
|
|
<a href="/admin/cameras/new" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface DiscoveredProfileRow {
|
|
|
|
|
|
profile_name: string;
|
|
|
|
|
|
profile_token: string;
|
2026-05-10 22:20:48 +00:00
|
|
|
|
source_token: string | null;
|
2026-05-10 21:18:44 +00:00
|
|
|
|
encoding: string | null;
|
|
|
|
|
|
width: number | null;
|
|
|
|
|
|
height: number | null;
|
|
|
|
|
|
framerate: number | null;
|
|
|
|
|
|
stream_uri: string;
|
2026-05-11 06:40:25 +00:00
|
|
|
|
snapshot_uri: string | null;
|
2026-05-10 22:20:48 +00:00
|
|
|
|
role: "main" | "sub" | "other";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface DiscoveredCameraRow {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
source_token: string | null;
|
|
|
|
|
|
profiles: DiscoveredProfileRow[];
|
2026-05-10 21:18:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CameraDiscoverResultsProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
host: string;
|
2026-05-10 22:20:48 +00:00
|
|
|
|
username: string;
|
|
|
|
|
|
password: string;
|
|
|
|
|
|
cameras: DiscoveredCameraRow[];
|
2026-05-10 21:18:44 +00:00
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 06:40:25 +00:00
|
|
|
|
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; }
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
2026-05-10 22:20:48 +00:00
|
|
|
|
function CameraDiscoverResultsPageLegacy(props: {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
host: string;
|
|
|
|
|
|
profiles: DiscoveredProfileRow[];
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}) {
|
2026-05-10 21:18:44 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
2026-05-10 22:20:48 +00:00
|
|
|
|
title="ONVIF Cameras"
|
2026-05-10 21:18:44 +00:00
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="cameras"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
2026-05-10 22:20:48 +00:00
|
|
|
|
Video sources reported by <strong>{props.host}</strong>. Each source imports
|
|
|
|
|
|
as one camera with its profiles saved as streams.
|
2026-05-10 21:18:44 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Profile</th>
|
|
|
|
|
|
<th>Encoding</th>
|
|
|
|
|
|
<th>Resolution</th>
|
|
|
|
|
|
<th>FPS</th>
|
|
|
|
|
|
<th>Stream URI</th>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.profiles.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="6" style="text-align:center; color:#999; padding:2rem">No profiles returned</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.profiles.map((p) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>{p.profile_name}</strong></td>
|
|
|
|
|
|
<td>{p.encoding ? <span class="badge badge-blue">{p.encoding}</span> : "—"}</td>
|
|
|
|
|
|
<td>{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "—"}</td>
|
|
|
|
|
|
<td>{p.framerate != null ? String(p.framerate) : "—"}</td>
|
|
|
|
|
|
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<form method="post" action="/admin/cameras/discover/add" style="display:inline">
|
|
|
|
|
|
<input type="hidden" name="name" value={`${props.host}: ${p.profile_name}`} />
|
|
|
|
|
|
<input type="hidden" name="rtsp_url" value={p.stream_uri} />
|
|
|
|
|
|
<input type="hidden" name="encoding" value={p.encoding ?? ""} />
|
|
|
|
|
|
<input type="hidden" name="width" value={String(p.width ?? "")} />
|
|
|
|
|
|
<input type="hidden" name="height" value={String(p.height ?? "")} />
|
|
|
|
|
|
<input type="hidden" name="framerate" value={String(p.framerate ?? "")} />
|
|
|
|
|
|
<input type="hidden" name="profile_token" value={p.profile_token} />
|
|
|
|
|
|
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="margin-top:1rem">
|
|
|
|
|
|
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover Another</a>
|
|
|
|
|
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back to Cameras</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 22:20:48 +00:00
|
|
|
|
export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title="ONVIF Cameras"
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="cameras"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-05-11 06:40:25 +00:00
|
|
|
|
<style>{DISCOVER_RESULTS_CSS}</style>
|
|
|
|
|
|
<div id="discover-results-root" data-view="list">
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
|
|
|
|
|
Video sources reported by <strong>{props.host}</strong>. Each source imports
|
|
|
|
|
|
as one camera with its profiles saved as streams.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{props.cameras.length === 0 ? (
|
|
|
|
|
|
<div class="card" style="text-align:center; color:#999; padding:2rem">No profiles returned</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<form method="post" action="/admin/cameras/discover/add">
|
|
|
|
|
|
<input type="hidden" name="username" value={props.username} />
|
|
|
|
|
|
<input type="hidden" name="password" value={props.password} />
|
|
|
|
|
|
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:1rem; flex-wrap:wrap">
|
|
|
|
|
|
<button type="button" class="btn btn-ghost" data-action="check-all">Check all</button>
|
|
|
|
|
|
<button type="button" class="btn btn-ghost" data-action="uncheck-all">Uncheck all</button>
|
|
|
|
|
|
<button type="button" class="btn btn-ghost" data-view="list">List</button>
|
|
|
|
|
|
<button type="button" class="btn btn-ghost" data-view="cards">Cards</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Add selected</button>
|
2026-05-10 22:20:48 +00:00
|
|
|
|
</div>
|
2026-05-11 06:40:25 +00:00
|
|
|
|
<div class="discover-results">
|
|
|
|
|
|
{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 (
|
|
|
|
|
|
<div class="card discover-camera-card">
|
|
|
|
|
|
<input type="hidden" name={`camera_${String(idx)}_name`} value={cam.name} />
|
|
|
|
|
|
<input type="hidden" name={`camera_${String(idx)}_streams_json`} value={JSON.stringify(cam.profiles)} />
|
|
|
|
|
|
<div class="section-header" style="margin-bottom:0.75rem">
|
|
|
|
|
|
<label style="display:flex; gap:0.5rem; align-items:center">
|
|
|
|
|
|
<input type="checkbox" name="selected" value={String(idx)} checked />
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{cam.name}</strong>
|
|
|
|
|
|
{cam.source_token ? <span style="color:#666; font-size:0.8rem"> Source: {cam.source_token}</span> : ""}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="discover-snaps">
|
|
|
|
|
|
{[main, sub].filter(Boolean).map((p) => (
|
|
|
|
|
|
<div class="discover-snap">
|
|
|
|
|
|
<div class="discover-snap-label">{p!.role}</div>
|
|
|
|
|
|
{p!.snapshot_uri
|
|
|
|
|
|
? <img src={p!.snapshot_uri} loading="lazy" />
|
|
|
|
|
|
: <div class="discover-snap-empty">No snapshot</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Role</th>
|
|
|
|
|
|
<th>Profile</th>
|
|
|
|
|
|
<th>Encoding</th>
|
|
|
|
|
|
<th>Resolution</th>
|
|
|
|
|
|
<th>FPS</th>
|
|
|
|
|
|
<th>Stream URI</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{cam.profiles.map((p) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><span class="badge badge-gray">{p.role}</span></td>
|
|
|
|
|
|
<td><strong>{p.profile_name}</strong></td>
|
|
|
|
|
|
<td>{p.encoding ? <span class="badge badge-blue">{p.encoding}</span> : "-"}</td>
|
|
|
|
|
|
<td>{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"}</td>
|
|
|
|
|
|
<td>{p.framerate != null ? String(p.framerate) : "-"}</td>
|
|
|
|
|
|
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}).join("")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<script>{js(discoverResultsScript("discover-results-root"))}</script>
|
2026-05-10 22:20:48 +00:00
|
|
|
|
<div style="margin-top:1rem">
|
|
|
|
|
|
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover Another</a>
|
|
|
|
|
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back to Cameras</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 21:18:44 +00:00
|
|
|
|
// ---- Entities ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface EntitiesPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
entities: Entity[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function entityBadge(type: string) {
|
2026-05-12 23:47:53 +00:00
|
|
|
|
const cls =
|
|
|
|
|
|
type === "camera" ? "badge-blue" :
|
|
|
|
|
|
type === "web" ? "badge-green" :
|
|
|
|
|
|
type === "dashboard" ? "badge-blue" :
|
|
|
|
|
|
"badge-gray";
|
2026-05-10 21:18:44 +00:00
|
|
|
|
return <span class={`badge ${cls}`}>{type}</span>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)}…` : "—";
|
2026-05-12 23:47:53 +00:00
|
|
|
|
if (e.type === "dashboard") return e.dashboard_id ? `/dash/${e.dashboard_id}` : "—";
|
2026-05-10 21:18:44 +00:00
|
|
|
|
return "—";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function EntitiesPage(props: EntitiesPageProps) {
|
2026-05-12 23:47:53 +00:00
|
|
|
|
const dashboards = props.entities.filter((e) => e.type === "dashboard");
|
|
|
|
|
|
const others = props.entities.filter((e) => e.type !== "dashboard");
|
2026-05-10 21:18:44 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Entities" user={props.user} activeNav="entities">
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">All Entities</h2>
|
2026-05-12 23:47:53 +00:00
|
|
|
|
<div style="display:flex; gap:0.5rem">
|
|
|
|
|
|
<form method="post" action="/admin/entities/sync-dashboards" style="display:inline">
|
|
|
|
|
|
<button type="submit" class="btn btn-ghost">Sync Dashboards</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<a href="/admin/entities/new" class="btn btn-primary">New Entity</a>
|
|
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1.25rem">
|
|
|
|
|
|
Entities are reusable content blocks (a camera reference, an HTML
|
2026-05-12 23:47:53 +00:00
|
|
|
|
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.
|
2026-05-10 21:18:44 +00:00
|
|
|
|
</p>
|
2026-05-12 23:47:53 +00:00
|
|
|
|
<div class="table-wrap" style="margin-bottom:1.5rem">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Type</th>
|
|
|
|
|
|
<th>Detail</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-05-12 23:47:53 +00:00
|
|
|
|
{others.length === 0 ? (
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No entities yet</td></tr>
|
|
|
|
|
|
) : (
|
2026-05-12 23:47:53 +00:00
|
|
|
|
others.map((e) => (
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<tr>
|
|
|
|
|
|
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
|
|
|
|
|
|
<td>{entityBadge(e.type)}</td>
|
|
|
|
|
|
<td style="color:#666; font-size:0.85rem">{entityDetail(e)}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-05-12 23:47:53 +00:00
|
|
|
|
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">Dashboards (Node-RED)</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
|
|
|
|
|
Auto-synced from Node-RED. Press <b>Sync Dashboards</b> after adding or
|
|
|
|
|
|
renaming tabs in Node-RED. Editing a dashboard happens in the Node-RED
|
|
|
|
|
|
editor.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Tab ID</th>
|
|
|
|
|
|
<th>URL</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{dashboards.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No dashboards synced yet — press Sync.</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
dashboards.map((e) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
|
|
|
|
|
|
<td style="font-family:monospace; font-size:0.8rem; color:#666">{e.dashboard_id ?? "—"}</td>
|
|
|
|
|
|
<td style="color:#666; font-size:0.85rem">{e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface EntityNewPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
cameras: Camera[];
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
values?: Record<string, string>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title="New Entity"
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="entities"
|
|
|
|
|
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div id="entity-new-root" style="max-width:600px">
|
|
|
|
|
|
<form method="post" action="/admin/entities/new" class="card">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name">Name</label>
|
|
|
|
|
|
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="type">Type</label>
|
|
|
|
|
|
<select id="type" name="type" class="form-input">
|
|
|
|
|
|
<option value="html" selected={selType === "html"}>HTML snippet</option>
|
|
|
|
|
|
<option value="web" selected={selType === "web"}>Web URL</option>
|
|
|
|
|
|
<option value="camera" selected={selType === "camera"}>Camera reference</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="description">Description (optional)</label>
|
|
|
|
|
|
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ent-fields-camera" style={selType === "camera" ? "" : "display:none"}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="camera_id">Camera</label>
|
|
|
|
|
|
<select id="camera_id" name="camera_id" class="form-input">
|
|
|
|
|
|
<option value="">-- Select --</option>
|
|
|
|
|
|
{props.cameras.map((cam) => (
|
|
|
|
|
|
<option value={String(cam.id)} selected={v["camera_id"] === String(cam.id)}>{cam.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ent-fields-web" style={selType === "web" ? "" : "display:none"}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="web_url">URL</label>
|
|
|
|
|
|
<input id="web_url" name="web_url" type="url" class="form-input" placeholder="https://example.com" value={v["web_url"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ent-fields-html" style={selType === "html" ? "" : "display:none"}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="html_content">HTML</label>
|
|
|
|
|
|
<textarea id="html_content" name="html_content" class="form-input" rows="6">{v["html_content"] ?? ""}</textarea>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<button type="submit" class="btn btn-primary">Create Entity</button>
|
|
|
|
|
|
<a href="/admin/entities" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<script>{js(entityFormScript("entity-new-root"))}</script>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface EntityEditPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
entity: Entity;
|
|
|
|
|
|
cameras: Camera[];
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function EntityEditPage(props: EntityEditPageProps) {
|
|
|
|
|
|
const e = props.entity;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title={`Entity: ${e.name}`}
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="entities"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div id="entity-edit-root" style="max-width:600px">
|
|
|
|
|
|
<div class="card" style="margin-bottom:1rem">
|
|
|
|
|
|
<div style="margin-bottom:0.75rem">Type: {entityBadge(e.type)}</div>
|
|
|
|
|
|
<form method="post" action={`/admin/entities/${e.id}`}>
|
|
|
|
|
|
{/* Type is fixed after creation — switching type would break attached cells. */}
|
|
|
|
|
|
<input type="hidden" name="type" value={e.type} />
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<div class="form-group">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<label for="name">Name</label>
|
|
|
|
|
|
<input id="name" name="name" type="text" class="form-input" required value={e.name} maxlength="128" />
|
2026-05-09 23:09:13 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<label for="description">Description</label>
|
|
|
|
|
|
<input id="description" name="description" type="text" class="form-input" value={e.description ?? ""} />
|
2026-05-09 23:09:13 +00:00
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
|
|
|
|
|
|
{e.type === "camera" && (
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="camera_id">Camera</label>
|
|
|
|
|
|
<select id="camera_id" name="camera_id" class="form-input">
|
|
|
|
|
|
<option value="">-- Select --</option>
|
|
|
|
|
|
{props.cameras.map((cam) => (
|
|
|
|
|
|
<option value={String(cam.id)} selected={e.camera_id === cam.id}>{cam.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-12 23:18:22 +00:00
|
|
|
|
{e.type === "camera" && e.camera_id != null && (
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>Live Preview</label>
|
|
|
|
|
|
<div style="background:#111827; border-radius:4px; overflow:hidden; aspect-ratio:16/9; display:flex; align-items:center; justify-content:center">
|
|
|
|
|
|
<img
|
|
|
|
|
|
id={`snap-${String(e.id)}`}
|
|
|
|
|
|
src={`/admin/entities/${e.id}/snapshot?t=${String(Date.now())}`}
|
|
|
|
|
|
alt="Camera snapshot"
|
|
|
|
|
|
style="width:100%; height:100%; object-fit:contain; display:block"
|
|
|
|
|
|
{...{ "onerror": "this.style.display='none'; var s=this.nextElementSibling; if(s) s.style.display='block';" }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span style="display:none; color:#fca5a5; font-size:0.85rem">Snapshot failed — camera unreachable or RTSP not configured</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="margin-top:0.5rem">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-ghost"
|
|
|
|
|
|
{...{ "onclick": `(function(){var img=document.getElementById('snap-${String(e.id)}'); if(img){img.style.display='block'; img.src='/admin/entities/${String(e.id)}/snapshot?t='+Date.now();}})()` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Refresh
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span style="margin-left:0.5rem; color:#666; font-size:0.8rem">Pulls one frame via ffmpeg/gst (up to ~8s).</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-10 21:18:44 +00:00
|
|
|
|
{e.type === "web" && (
|
2026-05-10 13:35:47 +00:00
|
|
|
|
<div class="form-group">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<label for="web_url">URL</label>
|
|
|
|
|
|
<input id="web_url" name="web_url" type="url" class="form-input" value={e.web_url ?? ""} />
|
2026-05-10 13:35:47 +00:00
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{e.type === "html" && (
|
2026-05-10 13:35:47 +00:00
|
|
|
|
<div class="form-group">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<label for="html_content">HTML</label>
|
|
|
|
|
|
<textarea id="html_content" name="html_content" class="form-input" rows="8">{e.html_content ?? ""}</textarea>
|
2026-05-10 13:35:47 +00:00
|
|
|
|
</div>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
)}
|
2026-05-12 23:47:53 +00:00
|
|
|
|
{e.type === "dashboard" && (
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>Node-RED Tab ID</label>
|
|
|
|
|
|
<code style="display:block; padding:0.5rem; background:#f9fafb; border-radius:4px; font-size:0.85rem">{e.dashboard_id ?? "—"}</code>
|
|
|
|
|
|
<div class="form-hint">
|
|
|
|
|
|
Synced from Node-RED. Resolved as <code>/dash/{e.dashboard_id ?? "?"}</code> in
|
|
|
|
|
|
kiosk bundles. Edit the dashboard contents in the Node-RED editor.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
|
|
|
|
<a href="/admin/entities" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
2026-05-12 23:47:53 +00:00
|
|
|
|
{e.type === "dashboard" && (
|
|
|
|
|
|
<a href="/admin/nodered" class="btn btn-ghost" style="margin-left:0.5rem" target="_blank" rel="noopener">Open in Node-RED</a>
|
|
|
|
|
|
)}
|
2026-05-10 21:18:44 +00:00
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form method="post" action={`/admin/entities/${e.id}/delete`}>
|
|
|
|
|
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this entity? Cells using it will be left empty.')"}}>Delete Entity</button>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Kiosks -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface KiosksProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
kiosks: Kiosk[];
|
|
|
|
|
|
pendingCodes: PairingCode[];
|
2026-05-10 01:12:07 +00:00
|
|
|
|
error?: string;
|
2026-05-09 23:09:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function KiosksPage(props: KiosksProps) {
|
|
|
|
|
|
return (
|
2026-05-10 01:12:07 +00:00
|
|
|
|
<Layout title="Kiosks" user={props.user} activeNav="kiosks" flash={props.error ? { type: "error", message: props.error } : undefined}>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<div class="two-col">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">Paired Kiosks</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Hardware</th>
|
|
|
|
|
|
<th>Last Seen</th>
|
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.kiosks.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.kiosks.map((k) => (
|
|
|
|
|
|
<tr>
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
<td><a href={`/admin/kiosks/${k.id}`}><strong>{k.name}</strong></a></td>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<td style="font-size:0.85rem">{k.hardware_model ?? "—"}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{k.enabled
|
|
|
|
|
|
? <span class="badge badge-green">Active</span>
|
|
|
|
|
|
: <span class="badge badge-gray">Disabled</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">Pair New Kiosk</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<form method="post" action="/admin/kiosks/pair">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="code">Pairing Code</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="code"
|
|
|
|
|
|
name="code"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
class="form-input"
|
|
|
|
|
|
required
|
|
|
|
|
|
maxlength="8"
|
|
|
|
|
|
pattern="[A-Z2-9]{8}"
|
|
|
|
|
|
style="text-transform:uppercase; text-align:center; font-size:1.25rem; letter-spacing:0.2rem"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="form-hint">8-character code shown on kiosk screen.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
2026-05-13 18:56:42 +00:00
|
|
|
|
<label for="replace_kiosk_id">Replacing existing kiosk?</label>
|
|
|
|
|
|
<select id="replace_kiosk_id" name="replace_kiosk_id" class="form-input">
|
|
|
|
|
|
<option value="">-- No, this is a new kiosk --</option>
|
|
|
|
|
|
{props.kiosks.map((k) => (
|
|
|
|
|
|
<option value={String(k.id)}>{k.name}{k.last_seen_at ? ` (last seen ${formatTime(k.last_seen_at)})` : " (never seen)"}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<div class="form-hint">
|
|
|
|
|
|
Pick the kiosk this device replaces. Display, layouts, labels, and GPIO
|
|
|
|
|
|
bindings stay; only the device credentials roll. Old kiosk's key is revoked.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name_override">Name Override (new kiosks only)</label>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<input id="name_override" name="name_override" type="text" class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
2026-05-13 18:56:42 +00:00
|
|
|
|
<label for="initial_labels">Initial Labels (new kiosks only)</label>
|
2026-05-09 23:09:13 +00:00
|
|
|
|
<input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" />
|
|
|
|
|
|
<div class="form-hint">Comma-separated label names.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary btn-block">Pair Kiosk</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
{props.pendingCodes.length > 0 && (
|
|
|
|
|
|
<div style="margin-top:1.25rem; border-top:1px solid #eee; padding-top:1rem">
|
|
|
|
|
|
<div style="font-weight:600; font-size:0.85rem; margin-bottom:0.5rem">Pending Codes</div>
|
|
|
|
|
|
{props.pendingCodes.map((pc) => (
|
|
|
|
|
|
<div style="display:flex; justify-content:space-between; font-size:0.85rem; padding:0.25rem 0">
|
|
|
|
|
|
<code>{pc.code}</code>
|
|
|
|
|
|
<span style="color:#666">{formatTime(pc.expires_at)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Account ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface AccountProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
totpEnabled: boolean;
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function AccountPage(props: AccountProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title="Account"
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="account"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error
|
|
|
|
|
|
? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success
|
|
|
|
|
|
? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style="max-width:600px">
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Change Password</h2>
|
|
|
|
|
|
<form method="post" action="/admin/account/password">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="current_password">Current Password</label>
|
|
|
|
|
|
<input id="current_password" name="current_password" type="password" class="form-input" required autocomplete="current-password" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="new_password">New Password</label>
|
|
|
|
|
|
<input id="new_password" name="new_password" type="password" class="form-input" required minlength="12" autocomplete="new-password" />
|
|
|
|
|
|
<div class="form-hint">At least 12 characters.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Change Password</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Two-Factor Authentication</h2>
|
|
|
|
|
|
{props.totpEnabled ? (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p style="color:#065f46; margin-bottom:1rem">
|
|
|
|
|
|
<span class="badge badge-green">Enabled</span>
|
|
|
|
|
|
{" "}TOTP is active on this account.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<form method="post" action="/admin/account/totp/disable">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="disable_password">Enter password to disable</label>
|
|
|
|
|
|
<input id="disable_password" name="password" type="password" class="form-input" required />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-danger">Disable 2FA</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
|
|
|
|
|
Protect your account with a TOTP authenticator app.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<form method="post" action="/admin/account/totp/begin">
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Enable 2FA</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- TOTP Enrollment --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface TotpEnrollProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
secret: string;
|
|
|
|
|
|
provisioningUri: string;
|
|
|
|
|
|
recoveryCodes: string[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function TotpEnrollPage(props: TotpEnrollProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Enable Two-Factor Auth" user={props.user} activeNav="account">
|
|
|
|
|
|
<div style="max-width:600px">
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 1: Scan QR Code</h2>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
|
|
|
|
|
Scan this with your authenticator app (Google Authenticator, Authy, etc.).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div style="text-align:center; padding:1rem; background:#f9fafb; border-radius:4px; margin-bottom:1rem">
|
|
|
|
|
|
<div id="qr-code" style="display:inline-block"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<details>
|
|
|
|
|
|
<summary style="cursor:pointer; color:#666; font-size:0.85rem">Can't scan? Enter manually</summary>
|
|
|
|
|
|
<code style="display:block; padding:0.75rem; background:#f9fafb; border-radius:4px; margin-top:0.5rem; word-break:break-all; font-size:0.9rem">{props.secret}</code>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 2: Save Recovery Codes</h2>
|
|
|
|
|
|
<p style="color:#dc2626; font-weight:500; margin-bottom:1rem">
|
|
|
|
|
|
Save these codes somewhere safe. They will not be shown again.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="code-grid">
|
|
|
|
|
|
{props.recoveryCodes.map((code) => (
|
|
|
|
|
|
<div class="code-item">{code}</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 3: Verify</h2>
|
|
|
|
|
|
<form method="post" action="/admin/account/totp/confirm">
|
|
|
|
|
|
<input type="hidden" name="recovery_codes" value={JSON.stringify(props.recoveryCodes)} />
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="code">Enter code from your authenticator</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="code"
|
|
|
|
|
|
name="code"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
class="form-input"
|
|
|
|
|
|
required
|
|
|
|
|
|
maxlength="6"
|
|
|
|
|
|
pattern="[0-9]{6}"
|
|
|
|
|
|
autocomplete="one-time-code"
|
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
|
style="text-align:center; font-size:1.5rem; letter-spacing:0.3rem; max-width:250px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Confirm & Enable</button>
|
|
|
|
|
|
<a href="/admin/account" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 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 (
|
|
|
|
|
|
<Layout title={props.pageTitle} user={props.user} activeNav={props.activeNav}>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1.25rem">{props.description}</p>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Details</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.items.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.items.map((item) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<strong>{item.name}</strong>
|
|
|
|
|
|
{item.badge && (
|
|
|
|
|
|
<span class="badge" style={`margin-left:0.5rem; background-color:${item.badge}`}>{item.badge}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style="color:#666">{item.detail ?? ""}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
// ---- Camera Edit ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface CameraEditProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
camera: Camera;
|
|
|
|
|
|
labels: Array<{ label_id: number; name: string }>;
|
|
|
|
|
|
allLabels: Label[];
|
|
|
|
|
|
streams: Array<{ id: number; role: string; name: string; rtsp_uri: string }>;
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 23:37:15 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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-<id>" hx-swap="innerHTML".
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function renderCameraLabels(
|
|
|
|
|
|
cameraId: number,
|
|
|
|
|
|
labels: Array<{ label_id: number; name: string }>,
|
|
|
|
|
|
allLabels: Label[],
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const labelsTargetSelector = `#camera-labels-${String(cameraId)}`;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{labels.length > 0 ? (
|
|
|
|
|
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
|
|
|
|
|
{labels.map((l) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="badge badge-blue"
|
|
|
|
|
|
style="cursor:pointer; border:none"
|
|
|
|
|
|
title="Click to remove"
|
|
|
|
|
|
hx-post={`/admin/cameras/${String(cameraId)}/labels/remove`}
|
|
|
|
|
|
hx-vals={JSON.stringify({ label_id: l.label_id })}
|
|
|
|
|
|
hx-target={labelsTargetSelector}
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
>
|
|
|
|
|
|
{l.name} ×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<form
|
|
|
|
|
|
hx-post={`/admin/cameras/${String(cameraId)}/labels`}
|
|
|
|
|
|
hx-target={labelsTargetSelector}
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
style="display:flex; gap:0.5rem"
|
|
|
|
|
|
>
|
|
|
|
|
|
<select name="label_id" class="form-input" style="flex:1">
|
|
|
|
|
|
{allLabels
|
|
|
|
|
|
.filter((al) => !labels.some((l) => l.label_id === al.id))
|
|
|
|
|
|
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Add</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<form
|
|
|
|
|
|
hx-post={`/admin/cameras/${String(cameraId)}/labels`}
|
|
|
|
|
|
hx-target={labelsTargetSelector}
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
style="display:flex; gap:0.5rem; margin-top:0.5rem"
|
|
|
|
|
|
>
|
|
|
|
|
|
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
|
|
|
|
|
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
export function CameraEditPage(props: CameraEditProps) {
|
|
|
|
|
|
const cam = props.camera;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title={`Camera: ${cam.name}`}
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="cameras"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style="max-width:700px">
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Camera</h2>
|
|
|
|
|
|
<form method="post" action={`/admin/cameras/${cam.id}`}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name">Name</label>
|
|
|
|
|
|
<input id="name" name="name" type="text" class="form-input" value={cam.name} required maxlength="128" />
|
|
|
|
|
|
</div>
|
2026-05-10 13:35:47 +00:00
|
|
|
|
{cam.type === "rtsp" && (() => {
|
|
|
|
|
|
const parts = parseRtspUrl(cam.rtsp_url ?? "");
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_host">Host</label>
|
|
|
|
|
|
<input id="rtsp_host" name="rtsp_host" type="text" class="form-input" value={parts.host} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:grid; grid-template-columns:1fr 2fr; gap:0.75rem">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_port">Port</label>
|
|
|
|
|
|
<input id="rtsp_port" name="rtsp_port" type="number" class="form-input" value={parts.port} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_path">Path</label>
|
|
|
|
|
|
<input id="rtsp_path" name="rtsp_path" type="text" class="form-input" value={parts.path} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_username">Username</label>
|
|
|
|
|
|
<input id="rtsp_username" name="rtsp_username" type="text" class="form-input" value={parts.username} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rtsp_password">Password (leave blank to keep)</label>
|
|
|
|
|
|
<input id="rtsp_password" name="rtsp_password" type="password" class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
{cam.type === "onvif" && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="onvif_host">ONVIF Host</label>
|
|
|
|
|
|
<input id="onvif_host" name="onvif_host" type="text" class="form-input" value={cam.onvif_host ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="onvif_port">Port</label>
|
|
|
|
|
|
<input id="onvif_port" name="onvif_port" type="number" class="form-input" value={String(cam.onvif_port ?? 80)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="onvif_username">Username</label>
|
|
|
|
|
|
<input id="onvif_username" name="onvif_username" type="text" class="form-input" value={cam.onvif_username ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="onvif_password">Password (leave blank to keep)</label>
|
|
|
|
|
|
<input id="onvif_password" name="onvif_password" type="password" class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<input type="checkbox" name="enabled" value="1" checked={cam.enabled} />
|
|
|
|
|
|
{" "}Enabled
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
|
|
|
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<div id={`camera-labels-${String(cam.id)}`}>
|
|
|
|
|
|
{renderCameraLabels(cam.id, props.labels, props.allLabels)}
|
|
|
|
|
|
</div>
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Streams</h2>
|
|
|
|
|
|
{props.streams.length > 0 ? (
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead><tr><th>Role</th><th>Name</th><th>URI</th></tr></thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.streams.map((s) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><span class="badge badge-gray">{s.role}</span></td>
|
|
|
|
|
|
<td>{s.name}</td>
|
|
|
|
|
|
<td style="font-size:0.8rem; word-break:break-all">{s.rtsp_uri}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style="color:#999">No streams configured</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form method="post" action={`/admin/cameras/${cam.id}/delete`} style="margin-top:1rem">
|
|
|
|
|
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Kiosk Edit -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface KioskEditProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
kiosk: Kiosk;
|
|
|
|
|
|
labels: Array<{ label_id: number; name: string; role: string }>;
|
|
|
|
|
|
allLabels: Label[];
|
2026-05-10 19:39:09 +00:00
|
|
|
|
displays?: Display[];
|
2026-05-21 00:03:05 +00:00
|
|
|
|
displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
|
2026-05-12 23:18:22 +00:00
|
|
|
|
gpioBindings?: KioskGpioBinding[];
|
2026-05-13 18:56:42 +00:00
|
|
|
|
firmwareReleases?: FirmwareRelease[];
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 23:37:15 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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-<id>" hx-swap="innerHTML".
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function renderKioskLabels(
|
|
|
|
|
|
kioskId: number,
|
|
|
|
|
|
labels: Array<{ label_id: number; name: string; role: string }>,
|
|
|
|
|
|
allLabels: Label[],
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{labels.length > 0 ? (
|
|
|
|
|
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
|
|
|
|
|
{labels.map((l) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="badge badge-blue"
|
|
|
|
|
|
style="cursor:pointer; border:none"
|
|
|
|
|
|
title="Click to remove"
|
|
|
|
|
|
hx-post={`/admin/kiosks/${String(kioskId)}/labels/remove`}
|
|
|
|
|
|
hx-vals={JSON.stringify({ label_id: l.label_id })}
|
|
|
|
|
|
hx-target={labelsTargetSelector}
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
>
|
|
|
|
|
|
{l.name} ({l.role}) ×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<form
|
|
|
|
|
|
hx-post={`/admin/kiosks/${String(kioskId)}/labels`}
|
|
|
|
|
|
hx-target={labelsTargetSelector}
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
style="display:flex; gap:0.5rem"
|
|
|
|
|
|
>
|
|
|
|
|
|
<select name="label_id" class="form-input" style="flex:1">
|
|
|
|
|
|
{allLabels
|
|
|
|
|
|
.filter((al) => !labels.some((l) => l.label_id === al.id))
|
|
|
|
|
|
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<select name="role" class="form-input" style="width:120px">
|
|
|
|
|
|
<option value="consume">consume</option>
|
|
|
|
|
|
<option value="operate">operate</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Add</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<form
|
|
|
|
|
|
hx-post={`/admin/kiosks/${String(kioskId)}/labels`}
|
|
|
|
|
|
hx-target={labelsTargetSelector}
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
style="display:flex; gap:0.5rem; margin-top:0.5rem"
|
|
|
|
|
|
>
|
|
|
|
|
|
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
|
|
|
|
|
<select name="role" class="form-input" style="width:120px">
|
|
|
|
|
|
<option value="consume">consume</option>
|
|
|
|
|
|
<option value="operate">operate</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 01:18:11 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 (
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Managed Config (Pi image)</h2>
|
|
|
|
|
|
<div style="font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
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 ? <span style="color:#b06; margin-left:0.5rem">pending push…</span> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{k.managed_config_error
|
|
|
|
|
|
? <div style="color:#b00; margin-top:0.25rem">Last error: {k.managed_config_error}</div>
|
|
|
|
|
|
: null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<form method="post" action={`/admin/kiosks/${k.id}/managed-config`}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_hostname">Hostname</label>
|
|
|
|
|
|
<input id="mc_hostname" name="hostname" type="text" class="form-input"
|
|
|
|
|
|
value={cfg.hostname ?? ""} placeholder="betterframe-kiosk" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_timezone">Timezone</label>
|
|
|
|
|
|
<input id="mc_timezone" name="timezone" type="text" class="form-input"
|
|
|
|
|
|
value={cfg.timezone ?? ""} placeholder="Etc/UTC" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<h3 style="margin:1rem 0 0.5rem; font-size:0.95rem">Network</h3>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_net_mode">Mode</label>
|
|
|
|
|
|
<select id="mc_net_mode" name="network_mode" class="form-input">
|
|
|
|
|
|
<option value="" selected={!net.mode}>—</option>
|
|
|
|
|
|
<option value="dhcp" selected={net.mode === "dhcp"}>DHCP</option>
|
|
|
|
|
|
<option value="static" selected={net.mode === "static"}>Static</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_net_iface">Interface</label>
|
|
|
|
|
|
<input id="mc_net_iface" name="network_interface" type="text" class="form-input"
|
|
|
|
|
|
value={net.interface ?? ""} placeholder="eth0" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_net_ip">Static IP (CIDR)</label>
|
|
|
|
|
|
<input id="mc_net_ip" name="network_ip_cidr" type="text" class="form-input"
|
|
|
|
|
|
value={net.ip_cidr ?? ""} placeholder="192.168.1.50/24" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_net_gw">Gateway</label>
|
|
|
|
|
|
<input id="mc_net_gw" name="network_gateway" type="text" class="form-input"
|
|
|
|
|
|
value={net.gateway ?? ""} placeholder="192.168.1.1" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_net_dns">DNS (comma-separated)</label>
|
|
|
|
|
|
<input id="mc_net_dns" name="network_dns" type="text" class="form-input"
|
|
|
|
|
|
value={(net.dns ?? []).join(", ")} placeholder="1.1.1.1, 8.8.8.8" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_net_vlan">VLAN ID</label>
|
|
|
|
|
|
<input id="mc_net_vlan" name="network_vlan_id" type="number" min="1" max="4094"
|
|
|
|
|
|
class="form-input" value={net.vlan_id != null ? String(net.vlan_id) : ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<h3 style="margin:1rem 0 0.5rem; font-size:0.95rem">Wi-Fi (optional)</h3>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_wifi_ssid">SSID</label>
|
|
|
|
|
|
<input id="mc_wifi_ssid" name="wifi_ssid" type="text" class="form-input"
|
|
|
|
|
|
value={wifi.ssid ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="mc_wifi_psk">PSK</label>
|
|
|
|
|
|
<input id="mc_wifi_psk" name="wifi_psk" type="password" class="form-input"
|
|
|
|
|
|
placeholder={wifi.ssid ? "(unchanged — leave blank to keep)" : ""} />
|
|
|
|
|
|
<div style="font-size:0.75rem; color:#999; margin-top:0.2rem">
|
|
|
|
|
|
Encrypted with cluster key before storage. Leave blank to keep existing PSK.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Save & Push</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
export function KioskEditPage(props: KioskEditProps) {
|
|
|
|
|
|
const k = props.kiosk;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title={`Kiosk: ${k.name}`}
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="kiosks"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style="max-width:700px">
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Kiosk</h2>
|
|
|
|
|
|
<form method="post" action={`/admin/kiosks/${k.id}`}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name">Name</label>
|
|
|
|
|
|
<input id="name" name="name" type="text" class="form-input" value={k.name} required />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<input type="checkbox" name="enabled" value="1" checked={k.enabled} />
|
|
|
|
|
|
{" "}Enabled
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
|
|
|
|
<a href="/admin/kiosks" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<div style="margin-top:1rem; color:#666; font-size:0.85rem">
|
|
|
|
|
|
<div>Hardware: {k.hardware_model ?? "—"}</div>
|
|
|
|
|
|
<div>Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}</div>
|
|
|
|
|
|
<div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div>
|
|
|
|
|
|
</div>
|
2026-05-10 20:45:56 +00:00
|
|
|
|
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
2026-05-11 06:55:42 +00:00
|
|
|
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Display Power</div>
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/power/wake`,
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Wake</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-ghost"
|
|
|
|
|
|
style="margin-left:0.5rem"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/power/standby`,
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Standby</button>
|
2026-05-10 20:45:56 +00:00
|
|
|
|
</div>
|
2026-05-11 09:47:07 +00:00
|
|
|
|
|
2026-05-21 00:03:05 +00:00
|
|
|
|
{props.displayLayouts && props.displayLayouts.length > 0 ? (
|
2026-05-12 23:00:11 +00:00
|
|
|
|
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
2026-05-21 00:03:05 +00:00
|
|
|
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout By Display</div>
|
|
|
|
|
|
<div style="display:grid; gap:0.75rem">
|
|
|
|
|
|
{props.displayLayouts.map(({ display, layouts }) => (
|
|
|
|
|
|
<div style="display:grid; grid-template-columns:minmax(130px, 0.8fr) minmax(180px, 1fr) auto; gap:0.5rem; align-items:center">
|
|
|
|
|
|
<div style="font-size:0.85rem">
|
|
|
|
|
|
<a href={`/admin/displays/${String(display.id)}`}><strong>{display.name}</strong></a>
|
|
|
|
|
|
<div style="color:#666; font-size:0.75rem">{String(display.width_px)}x{String(display.height_px)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{layouts.length > 0 ? (
|
|
|
|
|
|
<select id={`display-layout-pick-${String(display.id)}`} class="form-input">
|
|
|
|
|
|
{layouts.map((l) => (
|
|
|
|
|
|
<option value={String(l.id)}>{l.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span style="color:#999; font-size:0.85rem">No attached layouts</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm"
|
|
|
|
|
|
disabled={layouts.length === 0}
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/displays/${String(display.id)}/layout/0`,
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
"hx-on::config-request": `event.detail.path = event.detail.path.replace(/\\/layout\\/.*/, '/layout/' + document.getElementById('display-layout-pick-${String(display.id)}').value);`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Switch</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)).join("")}
|
2026-05-12 23:37:15 +00:00
|
|
|
|
</div>
|
2026-05-12 23:00:11 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-05-11 09:47:07 +00:00
|
|
|
|
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
|
|
|
|
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Hardware</div>
|
|
|
|
|
|
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
|
|
|
|
|
<div>CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}</div>
|
|
|
|
|
|
<div>Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}</div>
|
2026-05-21 00:03:05 +00:00
|
|
|
|
<div>CPU Load: {percentText(k.cpu_load_percent)}</div>
|
|
|
|
|
|
<div>RAM: {mbPair(k.memory_used_mb, k.memory_total_mb)}</div>
|
|
|
|
|
|
<div>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)}%)` : ""}</div>
|
2026-05-11 09:47:07 +00:00
|
|
|
|
<div>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-ghost"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
|
|
|
|
|
"hx-vals": JSON.stringify({ mode: "auto" }),
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Auto</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-ghost"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
|
|
|
|
|
"hx-vals": JSON.stringify({ pwm: "0" }),
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Off</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-ghost"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
|
|
|
|
|
"hx-vals": JSON.stringify({ pwm: "128" }),
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>50%</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-ghost"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
|
|
|
|
|
"hx-vals": JSON.stringify({ pwm: "255" }),
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Full</button>
|
2026-05-11 09:47:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 19:39:09 +00:00
|
|
|
|
{/* Associated displays */}
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Displays</h2>
|
|
|
|
|
|
{props.displays && props.displays.length > 0 ? (
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead><tr><th>Name</th><th>Resolution</th><th>Index</th></tr></thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.displays.map((d) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td>
|
|
|
|
|
|
<td>{String(d.width_px)}x{String(d.height_px)}</td>
|
|
|
|
|
|
<td>{String(d.index)}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style="color:#999">No displays associated with this kiosk</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-13 18:56:42 +00:00
|
|
|
|
{props.firmwareReleases && (
|
|
|
|
|
|
KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases })
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-14 05:24:21 +00:00
|
|
|
|
{(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })}
|
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
|
{/* GPIO bindings */}
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
|
|
|
|
|
|
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
|
|
|
|
|
Each input binding fires an event with the configured topic when the
|
|
|
|
|
|
pin's edge triggers. Pi 5's main GPIO chip is <code>gpiochip4</code>;
|
|
|
|
|
|
older Pis use <code>gpiochip0</code>.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{props.gpioBindings && props.gpioBindings.length > 0 ? (
|
|
|
|
|
|
<div class="table-wrap" style="margin-bottom:1rem">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Chip</th>
|
|
|
|
|
|
<th>Pin</th>
|
|
|
|
|
|
<th>Dir</th>
|
|
|
|
|
|
<th>Pull</th>
|
|
|
|
|
|
<th>Edge</th>
|
|
|
|
|
|
<th>Topic</th>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.gpioBindings.map((g) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td style="font-family:monospace; font-size:0.85rem">{g.chip}</td>
|
|
|
|
|
|
<td style="font-family:monospace">{String(g.pin)}</td>
|
|
|
|
|
|
<td><span class="badge badge-gray">{g.direction}</span></td>
|
|
|
|
|
|
<td style="font-size:0.85rem">{g.pull ?? "—"}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">{g.edge ?? "—"}</td>
|
|
|
|
|
|
<td style="font-family:monospace; font-size:0.85rem">{g.topic}</td>
|
|
|
|
|
|
<td>
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-danger"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/gpio/${String(g.id)}/delete`,
|
|
|
|
|
|
"hx-target": "closest tr",
|
|
|
|
|
|
"hx-swap": "outerHTML",
|
|
|
|
|
|
"hx-confirm": "Remove GPIO binding?",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>×</button>
|
2026-05-12 23:18:22 +00:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style="color:#999; margin-bottom:1rem">No GPIO bindings configured</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<form method="post" action={`/admin/kiosks/${k.id}/gpio`} style="display:grid; grid-template-columns:repeat(6, 1fr) auto; gap:0.5rem; align-items:end">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="font-size:0.75rem; color:#666">Chip</label>
|
|
|
|
|
|
<input name="chip" class="form-input" value="gpiochip0" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="font-size:0.75rem; color:#666">Pin</label>
|
|
|
|
|
|
<input name="pin" type="number" class="form-input" required min="0" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="font-size:0.75rem; color:#666">Dir</label>
|
|
|
|
|
|
<select name="direction" class="form-input">
|
|
|
|
|
|
<option value="in">in</option>
|
|
|
|
|
|
<option value="out">out</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="font-size:0.75rem; color:#666">Pull</label>
|
|
|
|
|
|
<select name="pull" class="form-input">
|
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
|
<option value="up">up</option>
|
|
|
|
|
|
<option value="down">down</option>
|
|
|
|
|
|
<option value="none">none</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="font-size:0.75rem; color:#666">Edge</label>
|
|
|
|
|
|
<select name="edge" class="form-input">
|
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
|
<option value="rising">rising</option>
|
|
|
|
|
|
<option value="falling">falling</option>
|
|
|
|
|
|
<option value="both">both</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="font-size:0.75rem; color:#666">Topic</label>
|
|
|
|
|
|
<input name="topic" class="form-input" required placeholder="gpio/button-1" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<div id={`kiosk-labels-${String(k.id)}`}>
|
|
|
|
|
|
{renderKioskLabels(k.id, props.labels, props.allLabels)}
|
|
|
|
|
|
</div>
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-20 01:18:11 +00:00
|
|
|
|
{k.managed_image ? <ManagedConfigCard kiosk={k} /> : null}
|
|
|
|
|
|
|
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable,
attach/detach labels, view streams, delete
- Kiosk edit page: rename, enable/disable, attach/detach labels
with role (consume/operate), delete
- Labels page: create with color picker, delete
- Camera/kiosk names now link to edit pages
- Repository: added updateCamera, deleteCamera, updateKiosk,
deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel,
updateLabel, cameraLabelIds
2026-05-10 01:24:04 +00:00
|
|
|
|
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
|
|
|
|
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Labels Management ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface LabelsPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
labels: Label[];
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function LabelsPage(props: LabelsPageProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title="Labels"
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="labels"
|
|
|
|
|
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">All Labels</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="max-width:500px; margin-bottom:1.5rem">
|
|
|
|
|
|
<form method="post" action="/admin/labels/new" style="display:flex; gap:0.5rem">
|
|
|
|
|
|
<input name="name" type="text" class="form-input" placeholder="New label name" required pattern="[a-z0-9][a-z0-9_-]*" style="flex:1" />
|
|
|
|
|
|
<input name="color" type="color" value="#2563eb" style="width:40px; height:38px; border:1px solid #d0d0d0; border-radius:4px" />
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Create</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead><tr><th>Name</th><th>Color</th><th>Actions</th></tr></thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.labels.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No labels</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.labels.map((l) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>{l.name}</strong></td>
|
|
|
|
|
|
<td>{l.color ? <span class="badge" style={`background-color:${l.color}; color:#fff`}>{l.color}</span> : "—"}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<form method="post" action={`/admin/labels/${l.id}/delete`} style="display:inline">
|
|
|
|
|
|
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Delete label?')"}}>Delete</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
// ---- Layouts ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface LayoutsPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
layouts: LayoutType[];
|
2026-05-10 19:55:19 +00:00
|
|
|
|
/** layout_id → number of displays the layout is attached to */
|
|
|
|
|
|
displayCounts: Map<number, number>;
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function LayoutsPage(props: LayoutsPageProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Layouts" user={props.user} activeNav="layouts">
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h2 class="section-title">All Layouts</h2>
|
|
|
|
|
|
<a href="/admin/layouts/new" class="btn btn-primary">New Layout</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1.25rem">
|
2026-05-10 19:55:19 +00:00
|
|
|
|
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.
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<th>Displays</th>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
<th>Priority</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.layouts.length === 0 ? (
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No layouts created yet</td></tr>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
props.layouts.map((l) => {
|
2026-05-10 19:55:19 +00:00
|
|
|
|
const count = props.displayCounts.get(l.id) ?? 0;
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<td>
|
|
|
|
|
|
{count === 0
|
|
|
|
|
|
? <span style="color:#999">unattached</span>
|
|
|
|
|
|
: <span>{String(count)} display{count !== 1 ? "s" : ""}</span>}
|
|
|
|
|
|
</td>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
<td>
|
|
|
|
|
|
{l.priority === "hot"
|
|
|
|
|
|
? <span class="badge badge-red">hot</span>
|
|
|
|
|
|
: l.priority === "cold"
|
|
|
|
|
|
? <span class="badge badge-blue">cold</span>
|
|
|
|
|
|
: <span class="badge badge-gray">normal</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Layout New -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface LayoutNewPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
values?: Record<string, string>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function LayoutNewPage(props: LayoutNewPageProps) {
|
|
|
|
|
|
const v = props.values ?? {};
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title="New Layout"
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="layouts"
|
|
|
|
|
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
|
|
|
|
|
>
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<div style="max-width:600px">
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1.25rem">
|
|
|
|
|
|
Create an empty layout. You'll add cells visually on the next page,
|
|
|
|
|
|
then attach the layout to one or more displays.
|
|
|
|
|
|
</p>
|
2026-05-10 19:39:09 +00:00
|
|
|
|
<div class="card">
|
|
|
|
|
|
<form method="post" action="/admin/layouts/new">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name">Layout Name</label>
|
|
|
|
|
|
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<label for="description">Description (optional)</label>
|
|
|
|
|
|
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
|
2026-05-10 19:39:09 +00:00
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
|
2026-05-10 19:39:09 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="priority">Priority</label>
|
|
|
|
|
|
<select id="priority" name="priority" class="form-input">
|
|
|
|
|
|
<option value="normal" selected={v["priority"] !== "hot" && v["priority"] !== "cold"}>Normal</option>
|
|
|
|
|
|
<option value="hot" selected={v["priority"] === "hot"}>Hot (always warm)</option>
|
|
|
|
|
|
<option value="cold" selected={v["priority"] === "cold"}>Cold</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
|
2026-05-10 19:39:09 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<input type="checkbox" name="resets_idle_timer" value="1" checked={v["resets_idle_timer"] !== "0"} />
|
|
|
|
|
|
{" "}Resets idle timer when activated
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
|
2026-05-10 19:39:09 +00:00
|
|
|
|
<button type="submit" class="btn btn-primary">Create Layout</button>
|
|
|
|
|
|
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Layout Edit ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface LayoutEditPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
layout: LayoutType;
|
2026-05-10 19:55:19 +00:00
|
|
|
|
/** Displays this layout is attached to (informational, read-only). */
|
|
|
|
|
|
displays: Display[];
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
cells: LayoutCell[];
|
|
|
|
|
|
cameras: Camera[];
|
2026-05-10 21:18:44 +00:00
|
|
|
|
entities: Entity[];
|
2026-05-10 19:55:19 +00:00
|
|
|
|
/** If set, render the content-assignment form for this cell beneath the grid. */
|
|
|
|
|
|
selectedCellId?: number | null;
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 20:31:37 +00:00
|
|
|
|
export const LAYOUT_BUILDER_CSS = `
|
2026-05-10 19:55:19 +00:00
|
|
|
|
.layout-builder { display: grid; gap: 4px; aspect-ratio: 16/9; max-width: 100%; background: #ddd; padding: 4px; border-radius: 4px; }
|
2026-05-10 22:29:33 +00:00
|
|
|
|
.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; }
|
2026-05-10 19:55:19 +00:00
|
|
|
|
.layout-cell:hover { background: #f0f7ff; }
|
2026-05-10 20:31:37 +00:00
|
|
|
|
.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; }
|
2026-05-10 19:55:19 +00:00
|
|
|
|
.layout-cell-empty-text { color: #999; font-size: 0.75rem; font-weight: 400; }
|
2026-05-10 22:29:33 +00:00
|
|
|
|
.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; }
|
2026-05-10 22:31:44 +00:00
|
|
|
|
.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%); }
|
2026-05-10 22:29:33 +00:00
|
|
|
|
.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; }
|
2026-05-10 19:55:19 +00:00
|
|
|
|
.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; }
|
2026-05-10 20:31:37 +00:00
|
|
|
|
.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; }
|
2026-05-10 19:55:19 +00:00
|
|
|
|
.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; }
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
2026-05-10 21:18:44 +00:00
|
|
|
|
function cellLabel(
|
|
|
|
|
|
c: LayoutCell,
|
|
|
|
|
|
entityById: Map<number, Entity>,
|
|
|
|
|
|
cameraById: Map<number, Camera>,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
if (c.entity_id != null) {
|
|
|
|
|
|
const ent = entityById.get(c.entity_id);
|
|
|
|
|
|
if (ent) return ent.name;
|
|
|
|
|
|
}
|
2026-05-10 20:31:37 +00:00
|
|
|
|
if (c.content_type === "camera" && c.camera_id) {
|
|
|
|
|
|
return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`;
|
|
|
|
|
|
}
|
2026-05-11 07:38:50 +00:00
|
|
|
|
if (c.content_type === "none") return "None";
|
2026-05-10 20:31:37 +00:00
|
|
|
|
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 `<div class="layout-cell" ...>` element
|
|
|
|
|
|
* suitable for hx-swap="outerHTML" against itself.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function renderCell(
|
|
|
|
|
|
layoutId: number,
|
|
|
|
|
|
c: LayoutCell,
|
2026-05-10 21:18:44 +00:00
|
|
|
|
entities: Entity[],
|
2026-05-10 20:31:37 +00:00
|
|
|
|
cameras: Camera[],
|
|
|
|
|
|
mode: "read" | "edit",
|
|
|
|
|
|
): string {
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
const cameraById = new Map<number, Camera>();
|
2026-05-10 20:31:37 +00:00
|
|
|
|
for (const cam of cameras) cameraById.set(cam.id, cam);
|
2026-05-10 21:18:44 +00:00
|
|
|
|
const entityById = new Map<number, Entity>();
|
|
|
|
|
|
for (const e of entities) entityById.set(e.id, e);
|
2026-05-10 20:31:37 +00:00
|
|
|
|
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 (
|
|
|
|
|
|
<div class="layout-cell editing" style={style} id={`cell-${String(c.id)}`}>
|
|
|
|
|
|
<form
|
|
|
|
|
|
class="layout-cell-edit-form"
|
|
|
|
|
|
hx-post={cellGetUrl}
|
|
|
|
|
|
hx-target={`#cell-${String(c.id)}`}
|
|
|
|
|
|
hx-swap="outerHTML"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="form-group">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<label>Entity</label>
|
|
|
|
|
|
<select name="entity_id" class="form-input">
|
|
|
|
|
|
<option value="">-- Empty --</option>
|
|
|
|
|
|
{entities.map((e) => (
|
|
|
|
|
|
<option value={String(e.id)} selected={c.entity_id === e.id}>
|
|
|
|
|
|
[{e.type}] {e.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<div class="form-hint" style="font-size:0.7rem">
|
|
|
|
|
|
<a href="/admin/entities/new" target="_blank">+ New entity</a>
|
2026-05-10 20:31:37 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 21:18:44 +00:00
|
|
|
|
<div class="form-group" style={(c.entity_id != null && entityById.get(c.entity_id)?.type === "camera") ? "" : "display:none"}>
|
|
|
|
|
|
<label>Stream</label>
|
|
|
|
|
|
<select name="stream_selector" class="form-input">
|
|
|
|
|
|
<option value="auto" selected={c.stream_selector === "auto"}>Auto</option>
|
|
|
|
|
|
<option value="main" selected={c.stream_selector === "main"}>Main</option>
|
|
|
|
|
|
<option value="sub" selected={c.stream_selector === "sub"}>Sub</option>
|
|
|
|
|
|
</select>
|
2026-05-10 20:31:37 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-11 11:52:22 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>Fit</label>
|
|
|
|
|
|
<select name="fit" class="form-input">
|
|
|
|
|
|
<option value="cover" selected={c.fit === "cover"}>Cover (fill, crop overflow)</option>
|
|
|
|
|
|
<option value="contain" selected={c.fit === "contain"}>Contain (letterbox)</option>
|
|
|
|
|
|
<option value="fill" selected={c.fit === "fill"}>Fill (stretch)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 20:31:37 +00:00
|
|
|
|
<div class="form-group span-grid">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label>Width</label>
|
|
|
|
|
|
<input name="col_span" type="number" class="form-input" min="1" value={String(c.col_span)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label>Height</label>
|
|
|
|
|
|
<input name="row_span" type="number" class="form-input" min="1" value={String(c.row_span)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="layout-cell-edit-form-actions">
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-ghost"
|
|
|
|
|
|
hx-get={cellGetUrl}
|
|
|
|
|
|
hx-target={`#cell-${String(c.id)}`}
|
|
|
|
|
|
hx-swap="outerHTML"
|
|
|
|
|
|
>Cancel</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-danger"
|
|
|
|
|
|
hx-post={deleteUrl}
|
|
|
|
|
|
hx-target="#layout-grid"
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
hx-confirm="Delete this cell?"
|
|
|
|
|
|
>Delete</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 21:18:44 +00:00
|
|
|
|
// Read mode. Empty when no entity is bound.
|
|
|
|
|
|
const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null;
|
|
|
|
|
|
const isEmpty = !ent && (
|
2026-05-11 07:38:50 +00:00
|
|
|
|
c.content_type === "none"
|
|
|
|
|
|
|| (c.content_type === "html" && !c.html_content)
|
2026-05-10 20:31:37 +00:00
|
|
|
|
|| (c.content_type === "camera" && !c.camera_id)
|
2026-05-10 21:18:44 +00:00
|
|
|
|
|| (c.content_type === "web" && !c.web_url)
|
|
|
|
|
|
);
|
|
|
|
|
|
const label = cellLabel(c, entityById, cameraById);
|
2026-05-10 20:31:37 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="layout-cell"
|
|
|
|
|
|
id={`cell-${String(c.id)}`}
|
|
|
|
|
|
style={style}
|
|
|
|
|
|
hx-get={cellEditUrl}
|
|
|
|
|
|
hx-target="this"
|
|
|
|
|
|
hx-swap="outerHTML"
|
|
|
|
|
|
hx-trigger="click"
|
|
|
|
|
|
>
|
|
|
|
|
|
{isEmpty
|
|
|
|
|
|
? <span class="layout-cell-empty-text">{label}</span>
|
|
|
|
|
|
: <span>{label}</span>}
|
|
|
|
|
|
|
|
|
|
|
|
{/* + 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 (
|
2026-05-10 22:29:33 +00:00
|
|
|
|
<div class={`layout-cell-side layout-cell-side-${dir}`} {...{ "onclick": "event.stopPropagation()" }}>
|
|
|
|
|
|
<button type="button" class="layout-cell-side-trigger" title={`Actions ${dir}`}>+</button>
|
|
|
|
|
|
<div class="layout-cell-side-menu">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={`Expand ${dir}`}
|
|
|
|
|
|
hx-post={resizeUrl}
|
|
|
|
|
|
hx-vals={JSON.stringify({ direction: directionParam })}
|
|
|
|
|
|
hx-target="#layout-grid"
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
>Expand</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={`Add cell ${dir}`}
|
|
|
|
|
|
hx-post={addUrl}
|
|
|
|
|
|
hx-vals={JSON.stringify({ after_cell_id: c.id, direction: directionParam })}
|
|
|
|
|
|
hx-target="#layout-grid"
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
>Add</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-10 20:31:37 +00:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* delete button */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="layout-cell-delete"
|
|
|
|
|
|
title="Delete cell"
|
|
|
|
|
|
hx-post={deleteUrl}
|
|
|
|
|
|
hx-target="#layout-grid"
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
hx-confirm="Delete this cell?"
|
|
|
|
|
|
{...{ "onclick": "event.stopPropagation()" }}
|
|
|
|
|
|
>×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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: number,
|
|
|
|
|
|
cells: LayoutCell[],
|
2026-05-10 21:18:44 +00:00
|
|
|
|
entities: Entity[],
|
2026-05-10 20:31:37 +00:00
|
|
|
|
cameras: Camera[],
|
|
|
|
|
|
): string {
|
|
|
|
|
|
if (cells.length === 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div class="layout-empty">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="layout-empty-add"
|
|
|
|
|
|
title="Add first cell"
|
|
|
|
|
|
hx-post={`/admin/layouts/${String(layoutId)}/cells`}
|
|
|
|
|
|
hx-vals={JSON.stringify({ row: 0, col: 0 })}
|
|
|
|
|
|
hx-target="#layout-grid"
|
|
|
|
|
|
hx-swap="innerHTML"
|
|
|
|
|
|
>+</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Compute grid dimensions.
|
2026-05-10 19:55:19 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 20:31:37 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="layout-builder"
|
|
|
|
|
|
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
|
|
|
|
|
|
>
|
2026-05-10 21:18:44 +00:00
|
|
|
|
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
|
2026-05-10 20:31:37 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function LayoutEditPage(props: LayoutEditPageProps) {
|
|
|
|
|
|
const l = props.layout;
|
|
|
|
|
|
const cells = props.cells;
|
|
|
|
|
|
|
|
|
|
|
|
// Compute grid dimensions from cells (for summary text).
|
|
|
|
|
|
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;
|
2026-05-10 19:55:19 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title={`Layout: ${l.name}`}
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="layouts"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<style>{LAYOUT_BUILDER_CSS}</style>
|
|
|
|
|
|
<div style="max-width:900px">
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
{/* Settings */}
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Settings</h2>
|
|
|
|
|
|
<form method="post" action={`/admin/layouts/${l.id}`}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name">Name</label>
|
|
|
|
|
|
<input id="name" name="name" type="text" class="form-input" value={l.name} required maxlength="128" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="description">Description</label>
|
|
|
|
|
|
<input id="description" name="description" type="text" class="form-input" value={l.description ?? ""} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="priority">Priority</label>
|
|
|
|
|
|
<select id="priority" name="priority" class="form-input">
|
|
|
|
|
|
<option value="normal" selected={l.priority === "normal"}>Normal</option>
|
|
|
|
|
|
<option value="hot" selected={l.priority === "hot"}>Hot</option>
|
|
|
|
|
|
<option value="cold" selected={l.priority === "cold"}>Cold</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="cooling_timeout_seconds">Cooling Timeout (seconds)</label>
|
|
|
|
|
|
<input id="cooling_timeout_seconds" name="cooling_timeout_seconds" type="number" class="form-input" value={l.cooling_timeout_seconds != null ? String(l.cooling_timeout_seconds) : ""} min="0" placeholder="None" />
|
|
|
|
|
|
<div class="form-hint">How long streams stay warm after leaving this layout. Leave blank for no timeout.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<input type="checkbox" name="resets_idle_timer" value="1" checked={l.resets_idle_timer} />
|
|
|
|
|
|
{" "}Resets idle timer
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
|
|
|
|
<a href="/admin/layouts" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<div style="margin-top:1rem; color:#666; font-size:0.85rem">
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<div>Grid: {String(gridCols)}x{String(gridRows)}, {String(cells.length)} cell{cells.length !== 1 ? "s" : ""}</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{props.displays.length === 0
|
|
|
|
|
|
? <span>Attached to no displays — attach from a display's edit page.</span>
|
|
|
|
|
|
: (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
Attached to:{" "}
|
|
|
|
|
|
{props.displays.map((d, i) => (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{i > 0 ? ", " : ""}
|
|
|
|
|
|
<a href={`/admin/displays/${d.id}`}>{d.name}</a>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 19:55:19 +00:00
|
|
|
|
{/* Visual builder */}
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Layout Builder</h2>
|
|
|
|
|
|
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
2026-05-10 22:29:33 +00:00
|
|
|
|
Hover a side <strong>+</strong> to add a neighbour or expand the cell.
|
|
|
|
|
|
Expanding pushes cells in that direction out of the way. Click a cell
|
|
|
|
|
|
to edit content in-place.
|
2026-05-10 19:55:19 +00:00
|
|
|
|
</p>
|
2026-05-10 20:31:37 +00:00
|
|
|
|
<div id="layout-grid">
|
2026-05-10 21:18:44 +00:00
|
|
|
|
{renderGrid(l.id, cells, props.entities, props.cameras)}
|
2026-05-10 19:55:19 +00:00
|
|
|
|
</div>
|
2026-05-10 20:31:37 +00:00
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
|
|
|
|
|
|
<form method="post" action={`/admin/layouts/${l.id}/delete`} style="margin-top:1rem">
|
|
|
|
|
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this layout and all its cells?')"}}>Delete Layout</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Display Edit -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface DisplayEditPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
display: Display;
|
2026-05-10 19:55:19 +00:00
|
|
|
|
/** Layouts currently attached to this display. */
|
|
|
|
|
|
attachedLayouts: LayoutType[];
|
|
|
|
|
|
/** All other layouts that could be attached. */
|
|
|
|
|
|
availableLayouts: LayoutType[];
|
2026-05-10 19:39:09 +00:00
|
|
|
|
kioskName?: string | null;
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 23:37:15 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Render the attached + available layouts region for a display. Returned
|
|
|
|
|
|
* standalone so htmx attach/detach can swap just this fragment via
|
|
|
|
|
|
* hx-target="#display-layouts-<id>" hx-swap="innerHTML".
|
2026-05-13 01:46:58 +00:00
|
|
|
|
*
|
|
|
|
|
|
* Both lists render as flat tables with the same columns. Available rows
|
|
|
|
|
|
* leave Priority + Default blank and show an Attach button. The
|
|
|
|
|
|
* default-layout `<select>` outside this fragment is kept in sync via an
|
|
|
|
|
|
* htmx out-of-band swap appended by the route handler — see
|
|
|
|
|
|
* `renderDefaultLayoutSelect`.
|
2026-05-12 23:37:15 +00:00
|
|
|
|
*/
|
|
|
|
|
|
export function renderDisplayLayouts(
|
|
|
|
|
|
displayId: number,
|
|
|
|
|
|
defaultLayoutId: number | null,
|
|
|
|
|
|
attached: LayoutType[],
|
|
|
|
|
|
available: LayoutType[],
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const target = `#display-layouts-${String(displayId)}`;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
2026-05-13 01:46:58 +00:00
|
|
|
|
<h3 style="margin:0 0 0.5rem; font-size:0.95rem">Attached</h3>
|
2026-05-12 23:37:15 +00:00
|
|
|
|
{attached.length === 0 ? (
|
2026-05-13 01:46:58 +00:00
|
|
|
|
<p style="color:#999; margin-bottom:1rem; font-size:0.85rem">No layouts attached yet.</p>
|
2026-05-12 23:37:15 +00:00
|
|
|
|
) : (
|
2026-05-13 01:46:58 +00:00
|
|
|
|
<div class="table-wrap" style="margin-bottom:1.5rem">
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Priority</th>
|
|
|
|
|
|
<th>Default</th>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{attached.map((l) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><a href={`/admin/layouts/${String(l.id)}`}><strong>{l.name}</strong></a></td>
|
|
|
|
|
|
<td><span class={`badge ${l.priority === "hot" ? "badge-red" : l.priority === "cold" ? "badge-blue" : "badge-gray"}`}>{l.priority}</span></td>
|
|
|
|
|
|
<td>{defaultLayoutId === l.id ? <span class="badge badge-green">Yes</span> : ""}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm"
|
|
|
|
|
|
style="margin-right:0.25rem"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/displays/${String(displayId)}/layout/${String(l.id)}`,
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Show</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-danger"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/displays/${String(displayId)}/layouts/${String(l.id)}/remove`,
|
|
|
|
|
|
"hx-target": target,
|
|
|
|
|
|
"hx-swap": "innerHTML",
|
|
|
|
|
|
"hx-confirm": "Detach this layout from the display?",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Detach</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-13 01:46:58 +00:00
|
|
|
|
<h3 style="margin:0 0 0.5rem; font-size:0.95rem">Available</h3>
|
|
|
|
|
|
{available.length === 0 ? (
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<p style="color:#999; font-size:0.85rem; margin:0">
|
|
|
|
|
|
{attached.length === 0
|
|
|
|
|
|
? <span>No layouts exist yet. <a href="/admin/layouts/new">Create one</a>.</span>
|
|
|
|
|
|
: "All existing layouts are already attached."}
|
|
|
|
|
|
</p>
|
2026-05-13 01:46:58 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Priority</th>
|
|
|
|
|
|
<th>Default</th>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{available.map((l) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><a href={`/admin/layouts/${String(l.id)}`}><strong>{l.name}</strong></a></td>
|
|
|
|
|
|
<td></td>
|
|
|
|
|
|
<td></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-success"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/displays/${String(displayId)}/layouts`,
|
|
|
|
|
|
"hx-vals": `{"layout_id": "${String(l.id)}"}`,
|
|
|
|
|
|
"hx-target": target,
|
|
|
|
|
|
"hx-swap": "innerHTML",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Attach</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-05-12 23:37:15 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 01:46:58 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Render the "Default Layout" select for a display. Wrapped in an out-of-band
|
|
|
|
|
|
* htmx swap so attach/detach responses can refresh it without the rest of the
|
|
|
|
|
|
* page. The id matches the in-page select so swap-by-id works.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function renderDefaultLayoutSelect(
|
|
|
|
|
|
defaultLayoutId: number | null,
|
|
|
|
|
|
attached: LayoutType[],
|
|
|
|
|
|
oob: boolean = false,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const oobAttr = oob ? { "hx-swap-oob": "outerHTML" } : {};
|
|
|
|
|
|
return (
|
|
|
|
|
|
<select id="default_layout_id" name="default_layout_id" class="form-input" {...oobAttr}>
|
|
|
|
|
|
<option value="">-- None --</option>
|
|
|
|
|
|
{attached.map((l) => (
|
|
|
|
|
|
<option value={String(l.id)} selected={defaultLayoutId === l.id}>
|
|
|
|
|
|
{l.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
export function DisplayEditPage(props: DisplayEditPageProps) {
|
|
|
|
|
|
const d = props.display;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout
|
|
|
|
|
|
title={`Display: ${d.name}`}
|
|
|
|
|
|
user={props.user}
|
|
|
|
|
|
activeNav="displays"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style="max-width:600px">
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Display Info</h2>
|
|
|
|
|
|
<div style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
|
|
|
|
|
<div>Index: {String(d.index)}</div>
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<div>Resolution: {String(d.width_px)}x{String(d.height_px)} <span style="color:#999">(reported by kiosk)</span></div>
|
2026-05-10 19:39:09 +00:00
|
|
|
|
{d.kiosk_id && (
|
|
|
|
|
|
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
|
|
|
|
|
|
)}
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
</div>
|
2026-05-12 23:18:22 +00:00
|
|
|
|
{props.attachedLayouts.length > 0 && d.kiosk_id ? (
|
|
|
|
|
|
<div style="margin-bottom:1rem; padding:0.75rem; background:#f9fafb; border-radius:4px">
|
|
|
|
|
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout Now</div>
|
2026-05-12 23:32:25 +00:00
|
|
|
|
<div style="display:flex; gap:0.5rem; align-items:center">
|
|
|
|
|
|
<select id={`layout-pick-${d.id}`} class="form-input" style="flex:1">
|
2026-05-12 23:18:22 +00:00
|
|
|
|
{props.attachedLayouts.map((l) => (
|
|
|
|
|
|
<option value={String(l.id)}>{l.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
2026-05-12 23:32:25 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/displays/${d.id}/layout/0`,
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
"hx-vals": "js:{}",
|
|
|
|
|
|
"hx-on::config-request": `event.detail.path = event.detail.path.replace(/\\/layout\\/.*/, '/layout/' + document.getElementById('layout-pick-${d.id}').value);`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Switch</button>
|
|
|
|
|
|
</div>
|
2026-05-12 23:18:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
<form method="post" action={`/admin/displays/${d.id}`}>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="name">Name</label>
|
|
|
|
|
|
<input id="name" name="name" type="text" class="form-input" value={d.name} required />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-13 00:59:28 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
name="is_enabled"
|
|
|
|
|
|
value="on"
|
|
|
|
|
|
checked={d.is_enabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>Enabled</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="form-hint">
|
|
|
|
|
|
When disabled, the kiosk will not open a window on this display. Display stays in the list so you can re-enable it later.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="default_layout_id">Default Layout</label>
|
2026-05-13 01:46:58 +00:00
|
|
|
|
{renderDefaultLayoutSelect(d.default_layout_id, props.attachedLayouts)}
|
2026-05-10 19:55:19 +00:00
|
|
|
|
<div class="form-hint">
|
|
|
|
|
|
Layout shown on idle revert. Only layouts attached below are eligible.
|
|
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="idle_timeout_seconds">Idle Timeout (seconds)</label>
|
|
|
|
|
|
<input id="idle_timeout_seconds" name="idle_timeout_seconds" type="number" class="form-input" value={String(d.idle_timeout_seconds)} min="0" />
|
|
|
|
|
|
<div class="form-hint">Revert to default layout after this many seconds of inactivity. 0 to disable.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="sleep_timeout_seconds">Sleep Timeout (seconds)</label>
|
|
|
|
|
|
<input id="sleep_timeout_seconds" name="sleep_timeout_seconds" type="number" class="form-input" value={String(d.sleep_timeout_seconds)} min="0" />
|
2026-05-11 06:55:42 +00:00
|
|
|
|
<div class="form-hint">Send display standby after this many seconds of inactivity. 0 to disable.</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
|
|
|
|
<a href="/admin/displays" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 19:55:19 +00:00
|
|
|
|
{/* Layout attachments */}
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Available Layouts</h2>
|
|
|
|
|
|
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
|
|
|
|
|
Pick which layouts this display can show. The kiosk receives only
|
|
|
|
|
|
attached layouts in its bundle.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
2026-05-12 23:37:15 +00:00
|
|
|
|
<div id={`display-layouts-${String(d.id)}`}>
|
|
|
|
|
|
{renderDisplayLayouts(
|
|
|
|
|
|
d.id,
|
|
|
|
|
|
d.default_layout_id ?? null,
|
|
|
|
|
|
props.attachedLayouts,
|
|
|
|
|
|
props.availableLayouts,
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-10 19:55:19 +00:00
|
|
|
|
</div>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Displays List (with clickable links) -----------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface DisplaysPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
displays: Display[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function DisplaysPage(props: DisplaysPageProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Displays" user={props.user} activeNav="displays">
|
2026-05-10 19:39:09 +00:00
|
|
|
|
<p style="color:#666; margin-bottom:1.25rem">Physical HDMI displays. Created automatically when kiosks are paired.</p>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
|
<th>Details</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.displays.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.displays.map((d) => (
|
|
|
|
|
|
<tr>
|
2026-05-13 00:59:28 +00:00
|
|
|
|
<td>
|
|
|
|
|
|
<a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a>
|
|
|
|
|
|
{!d.is_enabled && (
|
|
|
|
|
|
<span style="margin-left:0.5rem; padding:0.1rem 0.4rem; font-size:0.7rem; background:#fee; color:#a00; border-radius:3px">disabled</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
feat: layout/template/display CRUD + display-chain bundle routing
Major changes:
- Bundle now follows kiosk → display → layouts → cells → cameras
(no label filtering for v0.1)
- Setup creates default Fullscreen template + Default layout with
BetterFrame logo on the primary display
- Pairing auto-assigns kiosk to primary display
- Admin UI: full template CRUD with presets (fullscreen, 2x2, 1+3, 3x3)
- Admin UI: layout CRUD with cell management (assign cameras/web/html
to template regions)
- Admin UI: display editing (default layout, idle/sleep timeouts)
- Repository: added createLayoutTemplate, createLayout, createLayoutCell,
updateLayout, deleteLayout, layoutsForDisplayId, camerasForLayoutIds,
updateDisplay, and more
2026-05-10 01:45:53 +00:00
|
|
|
|
<td style="color:#666">{String(d.width_px)}x{String(d.height_px)} — index {String(d.index)}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 23:09:13 +00:00
|
|
|
|
// ---- Helpers ----------------------------------------------------------------
|
|
|
|
|
|
|
2026-05-10 13:35:47 +00:00
|
|
|
|
function parseRtspUrl(url: string): { host: string; port: string; path: string; username: string; password: string } {
|
|
|
|
|
|
const m = url.match(/^rtsp:\/\/(?:([^:@]+)(?::([^@]*))?@)?([^:/]+)(?::(\d+))?(\/.*)?$/);
|
|
|
|
|
|
if (!m) return { host: "", port: "554", path: "", username: "", password: "" };
|
|
|
|
|
|
return {
|
|
|
|
|
|
username: decodeURIComponent(m[1] ?? ""),
|
|
|
|
|
|
password: decodeURIComponent(m[2] ?? ""),
|
|
|
|
|
|
host: m[3] ?? "",
|
|
|
|
|
|
port: m[4] ?? "554",
|
|
|
|
|
|
path: m[5] ?? "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 23:09:13 +00:00
|
|
|
|
function formatTime(iso: string): string {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
|
return d.toLocaleString("en-US", {
|
|
|
|
|
|
month: "short",
|
|
|
|
|
|
day: "numeric",
|
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return iso;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-12 23:18:22 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- System Health ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface SystemHealthRow {
|
|
|
|
|
|
kiosk: Kiosk;
|
|
|
|
|
|
online: boolean;
|
|
|
|
|
|
bundleMismatch: boolean;
|
|
|
|
|
|
expectedBundleVersion: string | null;
|
|
|
|
|
|
displays: Display[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SystemHealthPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
rows: SystemHealthRow[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tempBadge(temp: number | null) {
|
|
|
|
|
|
if (temp == null) return <span class="badge badge-gray">—</span>;
|
|
|
|
|
|
const txt = `${temp.toFixed(1)}°C`;
|
|
|
|
|
|
if (temp >= 80) return <span class="badge badge-red">{txt}</span>;
|
|
|
|
|
|
if (temp >= 70) return <span class="badge" style="background-color:#fef3c7; color:#92400e">{txt}</span>;
|
|
|
|
|
|
return <span class="badge badge-green">{txt}</span>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 00:03:05 +00:00
|
|
|
|
function percentText(value: number | null): string {
|
|
|
|
|
|
return value == null ? "—" : `${value.toFixed(1)}%`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mbPair(used: number | null, total: number | null): string {
|
|
|
|
|
|
if (used == null || total == null) return "—";
|
|
|
|
|
|
return `${String(used)} / ${String(total)} MB`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
|
// ---- Node-RED Embed ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export function NoderedEmbedPage(props: { user: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Node-RED" user={props.user} activeNav="nodered">
|
|
|
|
|
|
<div style="position:fixed; top:48px; left:220px; right:0; bottom:0; background:#fff">
|
|
|
|
|
|
<iframe
|
|
|
|
|
|
src="/nrdp/"
|
|
|
|
|
|
style="width:100%; height:100%; border:none; display:block"
|
|
|
|
|
|
{...{ "sandbox": "allow-same-origin allow-scripts allow-forms allow-popups allow-downloads" }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function SystemHealthPage(props: SystemHealthPageProps) {
|
|
|
|
|
|
const total = props.rows.length;
|
|
|
|
|
|
const online = props.rows.filter((r) => r.online).length;
|
|
|
|
|
|
const hot = props.rows.filter((r) => r.kiosk.cpu_temp_c != null && r.kiosk.cpu_temp_c >= 70).length;
|
|
|
|
|
|
const mismatched = props.rows.filter((r) => r.bundleMismatch).length;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="System Health" user={props.user} activeNav="health">
|
|
|
|
|
|
<meta http-equiv="refresh" content="30" />
|
|
|
|
|
|
<div class="stats-grid">
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Kiosks</div>
|
|
|
|
|
|
<div class="stat-value">{String(total)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Online</div>
|
|
|
|
|
|
<div class="stat-value" style={online === total ? "color:#065f46" : "color:#92400e"}>{String(online)}/{String(total)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Hot (≥70°C)</div>
|
|
|
|
|
|
<div class="stat-value" style={hot > 0 ? "color:#b91c1c" : "color:#065f46"}>{String(hot)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">Bundle Mismatch</div>
|
|
|
|
|
|
<div class="stat-value" style={mismatched > 0 ? "color:#b91c1c" : "color:#065f46"}>{String(mismatched)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
|
|
|
|
|
Auto-refresh every 30 seconds. Online = last seen within 5 minutes.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Kiosk</th>
|
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
|
<th>Last Seen</th>
|
|
|
|
|
|
<th>CPU Temp</th>
|
2026-05-21 00:03:05 +00:00
|
|
|
|
<th>CPU Load</th>
|
|
|
|
|
|
<th>RAM</th>
|
|
|
|
|
|
<th>Disk</th>
|
2026-05-12 23:18:22 +00:00
|
|
|
|
<th>Fan</th>
|
|
|
|
|
|
<th>Bundle</th>
|
|
|
|
|
|
<th>Displays</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.rows.length === 0 ? (
|
2026-05-21 00:03:05 +00:00
|
|
|
|
<tr><td colspan="10" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr>
|
2026-05-12 23:18:22 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
props.rows.map((row) => {
|
|
|
|
|
|
const k = row.kiosk;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><a href={`/admin/kiosks/${k.id}`}><strong>{k.name}</strong></a></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{row.online
|
|
|
|
|
|
? <span class="badge badge-green">Online</span>
|
|
|
|
|
|
: <span class="badge badge-red">Offline</span>}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
|
|
|
|
|
|
<td>{tempBadge(k.cpu_temp_c)}</td>
|
2026-05-21 00:03:05 +00:00
|
|
|
|
<td style="font-size:0.85rem">{percentText(k.cpu_load_percent)}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">{mbPair(k.memory_used_mb, k.memory_total_mb)}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">
|
|
|
|
|
|
{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 ? <span style="color:#999"> ({k.disk_used_percent.toFixed(1)}%)</span> : ""}
|
|
|
|
|
|
</td>
|
2026-05-12 23:18:22 +00:00
|
|
|
|
<td style="font-size:0.85rem">
|
|
|
|
|
|
{k.fan_rpm != null ? `${String(k.fan_rpm)} RPM` : "—"}
|
|
|
|
|
|
{k.fan_pwm != null && (
|
|
|
|
|
|
<span style="color:#999"> ({String(k.fan_pwm)}/255)</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">
|
|
|
|
|
|
{row.bundleMismatch ? (
|
|
|
|
|
|
<span class="badge badge-red" title={`expected ${row.expectedBundleVersion ?? "?"}, have ${k.last_bundle_version ?? "none"}`}>mismatch</span>
|
|
|
|
|
|
) : k.last_bundle_version ? (
|
|
|
|
|
|
<span class="badge badge-green">{k.last_bundle_version.slice(0, 8)}</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span class="badge badge-gray">—</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">
|
|
|
|
|
|
{row.displays.length === 0 ? (
|
|
|
|
|
|
<span style="color:#999">none</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
row.displays.map((d) => (
|
|
|
|
|
|
<div>{d.name}: {String(d.width_px)}×{String(d.height_px)}</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-13 18:56:42 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Firmware ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface FirmwarePageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
releases: FirmwareRelease[];
|
|
|
|
|
|
publicKeyPem: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function FirmwarePage(props: FirmwarePageProps) {
|
|
|
|
|
|
return (
|
2026-05-14 05:28:20 +00:00
|
|
|
|
<Layout title="Firmware" user={props.user} activeNav="firmware">
|
2026-05-13 18:56:42 +00:00
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
|
|
|
|
|
Signed kiosk firmware artifacts. Uploaded binaries are hashed +
|
|
|
|
|
|
Ed25519-signed by the server before kiosks can install them.
|
2026-05-14 05:28:20 +00:00
|
|
|
|
<a href="/admin/firmware/rollouts" style="margin-left:0.5rem">Rollouts →</a>
|
2026-05-13 18:56:42 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Upload release</h2>
|
|
|
|
|
|
<form
|
|
|
|
|
|
method="post"
|
|
|
|
|
|
action="/admin/firmware/upload"
|
|
|
|
|
|
enctype="multipart/form-data"
|
|
|
|
|
|
style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="form-group" style="grid-column:1/-1">
|
|
|
|
|
|
<label for="artifact">Binary</label>
|
|
|
|
|
|
<input id="artifact" name="artifact" type="file" required class="form-input" />
|
|
|
|
|
|
<div class="form-hint">Stripped release binary, no archive wrapper.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="version">Version</label>
|
|
|
|
|
|
<input id="version" name="version" type="text" required class="form-input" placeholder="0.4.2" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="channel">Channel</label>
|
|
|
|
|
|
<select id="channel" name="channel" class="form-input">
|
|
|
|
|
|
<option value="stable">stable</option>
|
|
|
|
|
|
<option value="beta">beta</option>
|
|
|
|
|
|
<option value="dev">dev</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group" style="grid-column:1/-1">
|
|
|
|
|
|
<label for="arch">Arch</label>
|
|
|
|
|
|
<select id="arch" name="arch" class="form-input">
|
|
|
|
|
|
<option value="aarch64-unknown-linux-gnu">aarch64 (Pi5)</option>
|
|
|
|
|
|
<option value="x86_64-unknown-linux-gnu">x86_64</option>
|
|
|
|
|
|
<option value="armv7-unknown-linux-gnueabihf">armv7</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group" style="grid-column:1/-1">
|
|
|
|
|
|
<label for="release_notes">Release notes</label>
|
|
|
|
|
|
<textarea id="release_notes" name="release_notes" class="form-input" rows="3" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary" style="grid-column:1/-1">Upload + sign</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="table-wrap" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Version</th>
|
|
|
|
|
|
<th>Channel</th>
|
|
|
|
|
|
<th>Arch</th>
|
|
|
|
|
|
<th>Size</th>
|
|
|
|
|
|
<th>SHA256</th>
|
|
|
|
|
|
<th>Uploaded</th>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.releases.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No firmware releases yet.</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.releases.map((r) => (
|
|
|
|
|
|
<tr style={r.yanked_at ? "opacity:0.4" : ""}>
|
|
|
|
|
|
<td><strong>{r.version}</strong></td>
|
|
|
|
|
|
<td><span class={`badge ${r.channel === "stable" ? "badge-green" : r.channel === "beta" ? "badge-yellow" : "badge-gray"}`}>{r.channel}</span></td>
|
|
|
|
|
|
<td style="font-family:monospace; font-size:0.8rem">{r.arch}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">{Math.round(r.size_bytes / 1024)} KiB</td>
|
|
|
|
|
|
<td style="font-family:monospace; font-size:0.75rem">{r.sha256.slice(0, 12)}…</td>
|
|
|
|
|
|
<td style="font-size:0.85rem; white-space:nowrap">{formatTime(r.uploaded_at)}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{r.yanked_at ? (
|
|
|
|
|
|
<span style="color:#999; font-size:0.8rem">yanked</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-danger"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/firmware/${r.id}/yank`,
|
|
|
|
|
|
"hx-confirm": "Yank this release? Devices already running it stay, but no new devices will pick it up.",
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
"hx-on::after-request": "location.reload()",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Yank</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<details class="card" style="font-size:0.85rem">
|
|
|
|
|
|
<summary style="cursor:pointer; font-weight:600">Signing public key</summary>
|
|
|
|
|
|
<p style="color:#666; margin:0.5rem 0">
|
|
|
|
|
|
Ed25519 public key kiosks pin during pairing. Safe to share. Kept here for backup.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<pre style="background:#fafafa; padding:0.75rem; overflow:auto; font-size:0.75rem">{props.publicKeyPem}</pre>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface KioskFirmwarePanelProps {
|
|
|
|
|
|
kiosk: Kiosk;
|
|
|
|
|
|
releases: FirmwareRelease[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function KioskFirmwarePanel(props: KioskFirmwarePanelProps) {
|
|
|
|
|
|
const k = props.kiosk;
|
|
|
|
|
|
const current = k.kiosk_app_version ?? "unknown";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div id={`kiosk-firmware-${String(k.id)}`} class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h3 style="margin:0 0 0.75rem; font-size:1rem">Firmware</h3>
|
|
|
|
|
|
<div style="font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
|
|
|
|
|
<div>Running: <code>{current}</code></div>
|
|
|
|
|
|
{k.firmware_last_attempt_version && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
Last attempt: <code>{k.firmware_last_attempt_version}</code>
|
|
|
|
|
|
{k.firmware_last_attempt_at && <span> at {formatTime(k.firmware_last_attempt_at)}</span>}
|
|
|
|
|
|
{k.firmware_last_error && <span style="color:#a00"> — {k.firmware_last_error}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<form
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/firmware`,
|
|
|
|
|
|
"hx-target": `#kiosk-firmware-${String(k.id)}`,
|
|
|
|
|
|
"hx-swap": "outerHTML",
|
|
|
|
|
|
}}
|
|
|
|
|
|
style="display:grid; grid-template-columns:1fr 1fr; gap:0.5rem; align-items:end"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for={`channel-${String(k.id)}`}>Channel</label>
|
|
|
|
|
|
<select id={`channel-${String(k.id)}`} name="channel" class="form-input">
|
|
|
|
|
|
{(["stable", "beta", "dev"] as const).map((c) => (
|
|
|
|
|
|
<option value={c} selected={k.firmware_channel === c}>{c}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for={`target-${String(k.id)}`}>Pin to version</label>
|
|
|
|
|
|
<select id={`target-${String(k.id)}`} name="target_version" class="form-input">
|
|
|
|
|
|
<option value="">-- follow channel --</option>
|
|
|
|
|
|
{props.releases.filter((r) => !r.yanked_at).map((r) => (
|
|
|
|
|
|
<option value={r.version} selected={k.firmware_target_version === r.version}>
|
|
|
|
|
|
{r.version} ({r.channel}, {r.arch})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="grid-column:1/-1; display:flex; gap:0.5rem">
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn"
|
|
|
|
|
|
{...{
|
|
|
|
|
|
"hx-post": `/admin/kiosks/${String(k.id)}/firmware/push`,
|
|
|
|
|
|
"hx-swap": "none",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Push update now</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-14 05:24:21 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Kiosk local-server panel (LAN GET API + admin proxy) ------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface KioskLocalPanelProps { kiosk: Kiosk }
|
|
|
|
|
|
|
|
|
|
|
|
export function KioskLocalPanel(props: KioskLocalPanelProps) {
|
|
|
|
|
|
const k = props.kiosk;
|
|
|
|
|
|
if (!k.local_key || !k.local_port) return "";
|
|
|
|
|
|
const ip = k.local_last_ip || "<kiosk-ip>";
|
|
|
|
|
|
const base = `http://${ip}:${String(k.local_port)}`;
|
|
|
|
|
|
const sample = `${base}/local/layout/<layout_id>?key=${k.local_key}`;
|
|
|
|
|
|
const proxy = `${base}/proxy/admin/...`;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h3 style="margin:0 0 0.5rem; font-size:1rem">Local LAN endpoints</h3>
|
|
|
|
|
|
<p style="font-size:0.8rem; color:#666; margin:0 0 0.75rem">
|
|
|
|
|
|
Kiosk runs an HTTP listener on its own LAN address. Bookmark-friendly
|
|
|
|
|
|
GET URLs trigger layout switches without needing an admin session.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div style="font-size:0.8rem; margin-bottom:0.5rem">
|
|
|
|
|
|
<strong>Layout switch (GET):</strong>
|
|
|
|
|
|
<pre style="background:#fafafa; padding:0.5rem; margin:0.25rem 0; font-size:0.75rem; white-space:pre-wrap; word-break:break-all">{sample}</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="font-size:0.8rem; margin-bottom:0.5rem">
|
|
|
|
|
|
<strong>Admin proxy (forwards your Bearer to server):</strong>
|
|
|
|
|
|
<pre style="background:#fafafa; padding:0.5rem; margin:0.25rem 0; font-size:0.75rem; white-space:pre-wrap">{proxy}</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="font-size:0.75rem; color:#999">
|
|
|
|
|
|
Last seen from IP: <code>{k.local_last_ip ?? "—"}</code>. Local key:
|
|
|
|
|
|
<code style="margin-left:0.25rem">{k.local_key.slice(0, 8)}…{k.local_key.slice(-4)}</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-14 05:28:20 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Firmware rollouts -----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface FirmwareRolloutsPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
rollouts: FirmwareRollout[];
|
|
|
|
|
|
releases: FirmwareRelease[];
|
|
|
|
|
|
kiosks: Kiosk[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function FirmwareRolloutsPage(props: FirmwareRolloutsPageProps) {
|
|
|
|
|
|
const releaseById = new Map(props.releases.map((r) => [r.id, r]));
|
|
|
|
|
|
const kioskById = new Map(props.kiosks.map((k) => [k.id, k]));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Firmware rollouts" user={props.user} activeNav="kiosks">
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
|
|
|
|
|
Push a specific release to a slice of the fleet. <code>percentage</code>
|
|
|
|
|
|
buckets kiosks deterministically by id, so re-running a 50% rollout
|
|
|
|
|
|
with the same targets touches the same half.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">New rollout</h2>
|
|
|
|
|
|
<form method="post" action="/admin/firmware/rollouts/new"
|
|
|
|
|
|
style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="release_id">Release</label>
|
|
|
|
|
|
<select id="release_id" name="release_id" class="form-input" required>
|
|
|
|
|
|
<option value="">--</option>
|
|
|
|
|
|
{props.releases.filter((r) => !r.yanked_at).map((r) => (
|
|
|
|
|
|
<option value={r.id}>{r.version} · {r.channel} · {r.arch}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="percentage">Percentage</label>
|
|
|
|
|
|
<input id="percentage" name="percentage" type="number" min="1" max="100" value="100" class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group" style="grid-column:1/-1">
|
|
|
|
|
|
<label for="target_kiosk_ids">Targets (leave empty = all kiosks on release channel)</label>
|
|
|
|
|
|
<select id="target_kiosk_ids" name="target_kiosk_ids" class="form-input" multiple size="6">
|
|
|
|
|
|
{props.kiosks.map((k) => (
|
|
|
|
|
|
<option value={String(k.id)}>{k.name} (#{String(k.id)})</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<div class="form-hint">Cmd/Ctrl-click to multi-select. Or post a comma-separated id list via API.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary" style="grid-column:1/-1">Create + activate</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Release</th>
|
|
|
|
|
|
<th>State</th>
|
|
|
|
|
|
<th>%</th>
|
|
|
|
|
|
<th>Targets</th>
|
|
|
|
|
|
<th>Created</th>
|
|
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.rollouts.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="6" style="text-align:center; color:#999; padding:2rem">No rollouts yet.</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.rollouts.map((r) => {
|
|
|
|
|
|
const rel = releaseById.get(r.release_id);
|
|
|
|
|
|
const targetCount = r.target_kiosk_ids.length;
|
|
|
|
|
|
const targetSummary = targetCount === 0
|
|
|
|
|
|
? "(all on channel)"
|
|
|
|
|
|
: r.target_kiosk_ids.slice(0, 3).map((id) => kioskById.get(id)?.name ?? `#${String(id)}`).join(", ")
|
|
|
|
|
|
+ (targetCount > 3 ? ` +${String(targetCount - 3)} more` : "");
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>{rel?.version ?? r.release_id}</strong>{rel && <span style="color:#999"> ({rel.channel}/{rel.arch})</span>}</td>
|
|
|
|
|
|
<td><span class={`badge ${r.state === "active" ? "badge-green" : r.state === "paused" ? "badge-yellow" : r.state === "complete" ? "badge-gray" : "badge-blue"}`}>{r.state}</span></td>
|
|
|
|
|
|
<td>{String(r.percentage)}%</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">{targetSummary}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem; white-space:nowrap">{formatTime(r.created_at)}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<form method="post" action={`/admin/firmware/rollouts/${r.id}/state`} style="display:inline">
|
|
|
|
|
|
<input type="hidden" name="state" value={r.state === "paused" ? "active" : "paused"} />
|
|
|
|
|
|
<button type="submit" class="btn btn-sm" style="margin-right:0.25rem">
|
|
|
|
|
|
{r.state === "paused" ? "Resume" : "Pause"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<form method="post" action={`/admin/firmware/rollouts/${r.id}/state`} style="display:inline">
|
|
|
|
|
|
<input type="hidden" name="state" value="complete" />
|
|
|
|
|
|
<button type="submit" class="btn btn-sm btn-danger">Complete</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-14 05:38:18 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Audit log -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface AuditLogPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
entries: AuditEntry[];
|
|
|
|
|
|
filterAction?: string;
|
|
|
|
|
|
filterActorType?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function AuditLogPage(props: AuditLogPageProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Audit log" user={props.user} activeNav="audit">
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
|
|
|
|
|
Append-only record of admin + kiosk + system actions. Most recent first.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<form method="get" action="/admin/audit" style="display:flex; gap:0.5rem; margin-bottom:1rem">
|
|
|
|
|
|
<input type="text" name="action" placeholder="action prefix (e.g. firmware.)"
|
|
|
|
|
|
value={props.filterAction ?? ""} class="form-input" />
|
|
|
|
|
|
<select name="actor_type" class="form-input" style="max-width:200px">
|
|
|
|
|
|
<option value="" selected={!props.filterActorType}>any actor</option>
|
|
|
|
|
|
{(["user", "api_key", "kiosk", "system"] as const).map((t) => (
|
|
|
|
|
|
<option value={t} selected={props.filterActorType === t}>{t}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button type="submit" class="btn">Filter</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>IP</th><th>Result</th><th>Metadata</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{props.entries.length === 0 ? (
|
|
|
|
|
|
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No entries.</td></tr>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
props.entries.map((e) => (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td style="font-size:0.8rem; white-space:nowrap">{formatTime(e.ts)}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">
|
|
|
|
|
|
<span class="badge badge-gray">{e.actor_type}</span>
|
|
|
|
|
|
{e.actor_label && <span style="margin-left:0.25rem">{e.actor_label}</span>}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style="font-family:monospace; font-size:0.8rem">{e.action}</td>
|
|
|
|
|
|
<td style="font-size:0.85rem">{e.resource_type ? `${e.resource_type}#${e.resource_id ?? ""}` : ""}</td>
|
|
|
|
|
|
<td style="font-size:0.8rem">{e.ip ?? ""}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<span class={`badge ${e.result === "ok" ? "badge-green" : "badge-red"}`}>{e.result}</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style="font-size:0.75rem; font-family:monospace; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap" title={JSON.stringify(e.metadata)}>
|
|
|
|
|
|
{Object.keys(e.metadata).length === 0 ? "" : JSON.stringify(e.metadata)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-14 05:44:01 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Backup / restore -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
interface BackupPageProps {
|
|
|
|
|
|
user: string;
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
success?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function BackupPage(props: BackupPageProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout title="Backup & restore" user={props.user} activeNav="backup"
|
|
|
|
|
|
flash={
|
|
|
|
|
|
props.error ? { type: "error", message: props.error }
|
|
|
|
|
|
: props.success ? { type: "success", message: props.success }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p style="color:#666; margin-bottom:1rem">
|
|
|
|
|
|
Encrypted snapshot of the SQLite DB + master secret + firmware signing
|
|
|
|
|
|
key. Passphrase protects the file (AES-256-GCM, PBKDF2 200k). Lose
|
|
|
|
|
|
the passphrase = lose the backup. Firmware binaries are excluded.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="two-col">
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Download backup</h2>
|
|
|
|
|
|
<form method="post" action="/admin/backup/download">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="dl_pass">Passphrase</label>
|
|
|
|
|
|
<input id="dl_pass" name="passphrase" type="password" minlength="8" required class="form-input" />
|
|
|
|
|
|
<div class="form-hint">Min 8 chars. Store somewhere safe.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">Download .bfbak</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Restore from backup</h2>
|
|
|
|
|
|
<form method="post" action="/admin/backup/restore" enctype="multipart/form-data">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="blob">Backup file (.bfbak)</label>
|
|
|
|
|
|
<input id="blob" name="blob" type="file" required class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label for="rs_pass">Passphrase</label>
|
|
|
|
|
|
<input id="rs_pass" name="passphrase" type="password" required class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="background:#fee; border:1px solid #fcc; padding:0.5rem; font-size:0.85rem; margin-bottom:0.75rem">
|
|
|
|
|
|
<strong>Warning:</strong> overwrites DB and master keys.
|
|
|
|
|
|
Restart the server immediately after restore.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" class="btn btn-danger">Restore</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|