diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index a0858b2..9ee4382 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1,7 +1,7 @@ /** * Admin page routes — overview, cameras, kiosks, labels, etc. */ -import { type H3, readBody } from "h3"; +import { type H3, readBody, getRouterParam } from "h3"; import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { confirmPairing } from "../../shared/pairing.js"; @@ -9,7 +9,10 @@ import { OverviewPage, CamerasPage, CameraNewPage, + CameraEditPage, KiosksPage, + KioskEditPage, + LabelsPage, SimpleListPage, } from "../../web-templates/admin-pages.js"; @@ -205,17 +208,149 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/labels", (event) => { const user = event.context.user!; - const labels = deps.repo.listLabels(); - return htmlPage(SimpleListPage({ + return htmlPage(LabelsPage({ user: user.username, labels: deps.repo.listLabels() })); + }); + + app.post("/admin/labels/new", async (event) => { + const body = await readBody>(event); + const name = (body?.["name"] ?? "").trim().toLowerCase(); + const color = body?.["color"] ?? null; + if (!name || !/^[a-z0-9][a-z0-9_-]*$/.test(name)) { + return htmlPage(LabelsPage({ + user: event.context.user!.username, + labels: deps.repo.listLabels(), + error: "Label name must start with letter/digit and contain only lowercase, digits, hyphens, underscores.", + })); + } + deps.repo.createLabel({ name, color }); + return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); + }); + + app.post("/admin/labels/:id/delete", (event) => { + const id = Number(getRouterParam(event, "id")); + deps.repo.deleteLabel(id); + return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); + }); + + // ---- Camera edit/delete/labels -------------------------------------------- + + app.get("/admin/cameras/:id", (event) => { + const user = event.context.user!; + const id = Number(getRouterParam(event, "id")); + const camera = deps.repo.getCameraById(id); + if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); + return htmlPage(CameraEditPage({ user: user.username, - pageTitle: "Labels", - description: "Labels route cameras, layouts, and kiosks to each other across sites.", - activeNav: "labels", - items: labels.map((l) => ({ - name: l.name, - detail: l.description ?? "", - badge: l.color ?? undefined, - })), + camera, + labels: deps.repo.cameraLabelIds(id), + allLabels: deps.repo.listLabels(), + streams: deps.repo.listCameraStreams(id), })); }); + + app.post("/admin/cameras/:id", async (event) => { + const id = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + deps.repo.updateCamera(id, { + name: body?.["name"], + rtsp_url: body?.["rtsp_url"] || null, + onvif_host: body?.["onvif_host"] || null, + onvif_port: body?.["onvif_port"] ? Number(body["onvif_port"]) : null, + onvif_username: body?.["onvif_username"] || null, + onvif_password: body?.["onvif_password"] || undefined, + enabled: body?.["enabled"] === "1", + } as any); + return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); + }); + + app.post("/admin/cameras/:id/labels", async (event) => { + const camId = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); + let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; + + if (newLabel) { + const label = deps.repo.ensureLabel(newLabel); + labelId = label.id; + } + if (labelId) { + deps.repo.attachCameraLabel(camId, labelId); + } + return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); + }); + + app.post("/admin/cameras/:id/labels/remove", async (event) => { + const camId = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + const labelId = Number(body?.["label_id"]); + deps.repo.detachCameraLabel(camId, labelId); + return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); + }); + + app.post("/admin/cameras/:id/delete", (event) => { + const id = Number(getRouterParam(event, "id")); + deps.repo.deleteCamera(id); + return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); + }); + + // ---- Kiosk edit/delete/labels --------------------------------------------- + + app.get("/admin/kiosks/:id", (event) => { + const user = event.context.user!; + const id = Number(getRouterParam(event, "id")); + const kiosk = deps.repo.getKioskById(id); + if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); + const kioskLabels = deps.repo.listKioskLabels(id).map((kl) => ({ + label_id: kl.label_id, + name: kl.name, + role: kl.role, + })); + return htmlPage(KioskEditPage({ + user: user.username, + kiosk, + labels: kioskLabels, + allLabels: deps.repo.listLabels(), + })); + }); + + app.post("/admin/kiosks/:id", async (event) => { + const id = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + deps.repo.updateKiosk(id, { + name: body?.["name"], + enabled: body?.["enabled"] === "1", + } as any); + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); + }); + + app.post("/admin/kiosks/:id/labels", async (event) => { + const kioskId = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); + const role = (body?.["role"] ?? "consume") as "consume" | "operate"; + let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; + + if (newLabel) { + const label = deps.repo.ensureLabel(newLabel); + labelId = label.id; + } + if (labelId) { + deps.repo.attachKioskLabel(kioskId, labelId, role); + } + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); + }); + + app.post("/admin/kiosks/:id/labels/remove", async (event) => { + const kioskId = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + const labelId = Number(body?.["label_id"]); + deps.repo.detachKioskLabel(kioskId, labelId); + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); + }); + + app.post("/admin/kiosks/:id/delete", (event) => { + const id = Number(getRouterParam(event, "id")); + deps.repo.deleteKiosk(id); + return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); + }); } diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 961ab49..a1d2f9b 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -800,4 +800,87 @@ export class Repository { ).all(cameraId); return rs.map((r) => String((r as Record)["name"])); } + + cameraLabelIds(cameraId: number): Array<{ label_id: number; name: string }> { + const rs = this.prep( + `SELECT cl.label_id, l.name FROM camera_labels cl + JOIN labels l ON l.id = cl.label_id + WHERE cl.camera_id = ?`, + ).all(cameraId); + return rs.map((r) => { + const row = r as Record; + return { label_id: Number(row["label_id"]), name: String(row["name"]) }; + }); + } + + updateCamera(id: number, patch: Partial): void { + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(patch)) { + if (k === "id" || k === "created_at") continue; + sets.push(`${k} = ?`); + vals.push(v === undefined ? null : v); + } + if (sets.length === 0) return; + vals.push(id); + this.db.prepare(`UPDATE cameras SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]); + void this.notify("cameras", "update", id); + } + + deleteCamera(id: number): void { + this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ?`).run(id); + this.db.prepare(`DELETE FROM camera_streams WHERE camera_id = ?`).run(id); + this.db.prepare(`DELETE FROM layout_cells WHERE camera_id = ?`).run(id); + this.db.prepare(`DELETE FROM cameras WHERE id = ?`).run(id); + void this.notify("cameras", "delete", id); + } + + updateKiosk(id: number, patch: Partial): void { + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(patch)) { + if (k === "id" || k === "created_at" || k === "paired_at") continue; + sets.push(`${k} = ?`); + vals.push(v === undefined ? null : v); + } + if (sets.length === 0) return; + vals.push(id); + this.db.prepare(`UPDATE kiosks SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]); + void this.notify("kiosks", "update", id); + } + + deleteKiosk(id: number): void { + this.db.prepare(`DELETE FROM kiosk_labels WHERE kiosk_id = ?`).run(id); + this.db.prepare(`DELETE FROM kiosks WHERE id = ?`).run(id); + void this.notify("kiosks", "delete", id); + } + + detachCameraLabel(cameraId: number, labelId: number): void { + this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ? AND label_id = ?`).run(cameraId, labelId); + } + + detachKioskLabel(kioskId: number, labelId: number): void { + this.db.prepare(`DELETE FROM kiosk_labels WHERE kiosk_id = ? AND label_id = ?`).run(kioskId, labelId); + } + + deleteLabel(id: number): void { + this.db.prepare(`DELETE FROM camera_labels WHERE label_id = ?`).run(id); + this.db.prepare(`DELETE FROM kiosk_labels WHERE label_id = ?`).run(id); + this.db.prepare(`DELETE FROM layout_labels WHERE label_id = ?`).run(id); + this.db.prepare(`DELETE FROM labels WHERE id = ?`).run(id); + void this.notify("labels", "delete", id); + } + + updateLabel(id: number, patch: { name?: string; description?: string | null; color?: string | null }): void { + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(patch)) { + sets.push(`${k} = ?`); + vals.push(v === undefined ? null : v); + } + if (sets.length === 0) return; + vals.push(id); + this.db.prepare(`UPDATE labels SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]); + void this.notify("labels", "update", id); + } } diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 8024d9b..b3e4808 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -3,7 +3,7 @@ */ import { js } from "jsx-htmx"; import { Layout } from "./layout.js"; -import type { Camera, Kiosk, PairingCode, EventLog } from "../shared/types.js"; +import type { Camera, Kiosk, Label, PairingCode, EventLog } from "../shared/types.js"; // ---- Overview --------------------------------------------------------------- @@ -122,7 +122,7 @@ export function CamerasPage(props: CamerasProps) { ) : ( props.cameras.map((cam) => ( - {cam.name} + {cam.name} {cam.type.toUpperCase()} {String(props.streamCounts.get(cam.id) ?? 0)} @@ -273,7 +273,7 @@ export function KiosksPage(props: KiosksProps) { ) : ( props.kiosks.map((k) => ( - {k.name} + {k.name} {k.hardware_model ?? "—"} {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"} @@ -524,6 +524,282 @@ export function SimpleListPage(props: SimpleListProps) { ); } +// ---- 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; +} + +export function CameraEditPage(props: CameraEditProps) { + const cam = props.camera; + return ( + +
+
+

Edit Camera

+
+
+ + +
+ {cam.type === "rtsp" && ( +
+ + +
+ )} + {cam.type === "onvif" && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ +
+ + Back +
+
+ +
+

Labels

+ {props.labels.length > 0 ? ( +
+ {props.labels.map((l) => ( +
+ + +
+ ))} +
+ ) : ( +

No labels attached

+ )} +
+ + +
+
+ + +
+
+ +
+

Streams

+ {props.streams.length > 0 ? ( +
+ + + + {props.streams.map((s) => ( + + + + + + ))} + +
RoleNameURI
{s.role}{s.name}{s.rtsp_uri}
+
+ ) : ( +

No streams configured

+ )} +
+ +
+ +
+
+
+ ); +} + +// ---- Kiosk Edit ------------------------------------------------------------- + +interface KioskEditProps { + user: string; + kiosk: Kiosk; + labels: Array<{ label_id: number; name: string; role: string }>; + allLabels: Label[]; + error?: string; + success?: string; +} + +export function KioskEditPage(props: KioskEditProps) { + const k = props.kiosk; + return ( + +
+
+

Edit Kiosk

+
+
+ + +
+
+ +
+ + Back +
+
+
Hardware: {k.hardware_model ?? "—"}
+
Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}
+
Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}
+
+
+ +
+

Labels

+ {props.labels.length > 0 ? ( +
+ {props.labels.map((l) => ( +
+ + +
+ ))} +
+ ) : ( +

No labels attached

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

All Labels

+
+
+
+ + + +
+
+
+ + + + {props.labels.length === 0 ? ( + + ) : ( + props.labels.map((l) => ( + + + + + + )) + )} + +
NameColorActions
No labels
{l.name}{l.color ? {l.color} : "—"} +
+ +
+
+
+
+ ); +} + // ---- Helpers ---------------------------------------------------------------- function formatTime(iso: string): string {