From 90346f4efdee58a273a56aad8da096695e15086c Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 21 May 2026 11:30:33 +0200 Subject: [PATCH] feat(os-ota-ui): admin pages for OS releases + rollouts + per-kiosk panel Mirrors the kiosk-firmware admin shape one-for-one against OS RAUC bundles: /admin/os-updates release list, yank /admin/os-updates/rollouts rollout list + create /admin/os-updates/rollouts/:id/state pause/resume/complete /admin/kiosks/:id/os-update per-kiosk channel + pin Templates: OsUpdatePage, OsUpdateRolloutsPage, KioskOsUpdatePanel. KioskOsUpdatePanel is rendered next to the existing KioskFirmwarePanel on the kiosk detail page so OS + app state sit side-by-side. The "how bundles get here" sidebar on the list page documents the four GitHub secrets needed (signing cert/key + autoimport URL/key) so a new operator doesn't have to dig through scripts/ to find them. Nav gains an OS Updates entry between Firmware and Labels. Activates on activeNav="os-updates". Repo + import endpoint already existed (audit confirmed earlier). All admin routes use them as-is. --- .../service-admin-http/routes-admin.ts | 2 + .../service-admin-http/routes-os-updates.ts | 115 +++++++- server/src/plugins/service-store/mappers.ts | 2 + server/src/shared/nodered-bridge.ts | 15 +- server/src/shared/types.ts | 30 +++ server/src/web-templates/admin-pages.tsx | 248 ++++++++++++++++++ server/src/web-templates/layout.tsx | 1 + 7 files changed, 400 insertions(+), 13 deletions(-) diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index a0f0690..e493185 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1354,6 +1354,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { })); const gpioBindings = deps.repo.listGpioBindings(id); const firmwareReleases = deps.repo.listFirmwareReleases(); + const osReleases = deps.repo.listOsUpdateReleases(); return htmlPage(KioskEditPage({ user: user.username, kiosk, @@ -1363,6 +1364,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { displayLayouts, gpioBindings, firmwareReleases, + osReleases, })); }); diff --git a/server/src/plugins/service-admin-http/routes-os-updates.ts b/server/src/plugins/service-admin-http/routes-os-updates.ts index e691d3f..0b5b596 100644 --- a/server/src/plugins/service-admin-http/routes-os-updates.ts +++ b/server/src/plugins/service-admin-http/routes-os-updates.ts @@ -1,19 +1,124 @@ /** - * Admin OS-update routes. - * - * Full OS OTA artifacts are RAUC `.raucb` bundles. CI imports by URL so large - * bundles are streamed server-side instead of base64 encoded into JSON. + * Admin OS-update routes — release upload (via CI URL pull), list page, + * yank, per-kiosk channel/pin, and rollouts. Mirrors routes-firmware.ts + * structure for RAUC bundles. */ -import { type H3, readBody, createError } from "h3"; +import { type H3, getRouterParam, readBody, createError } from "h3"; import { randomUUID } from "node:crypto"; +import { htmlPage, htmlFragment } from "./html-response.js"; import type { AdminDeps } from "./index.js"; +import { + OsUpdatePage, + OsUpdateRolloutsPage, + KioskOsUpdatePanel, +} from "../../web-templates/admin-pages.js"; import type { FirmwareChannel } from "../../shared/types.js"; import { audit } from "../../shared/audit.js"; const ALLOWED_CHANNELS: ReadonlySet = new Set(["stable", "beta", "dev"]); +function clamp(n: number, lo: number, hi: number): number { + if (!Number.isFinite(n)) return lo; + return Math.max(lo, Math.min(hi, Math.floor(n))); +} + export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { + // ---- List page ----------------------------------------------------------- + app.get("/admin/os-updates", (event) => { + const user = event.context.user!; + const releases = deps.repo.listOsUpdateReleases(); + return htmlPage(OsUpdatePage({ user: user.username, releases })); + }); + + // ---- Yank --------------------------------------------------------------- + app.post("/admin/os-updates/:id/yank", (event) => { + const id = String(getRouterParam(event, "id")); + deps.repo.yankOsUpdateRelease(id); + audit(deps.repo, event as any, "os_update.yank", { + resource_type: "os_update_release", + resource_id: id, + }); + return new Response(null, { status: 302, headers: { location: "/admin/os-updates" } }); + }); + + // ---- Per-kiosk OS-update settings --------------------------------------- + app.post("/admin/kiosks/:id/os-update", async (event) => { + const id = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel; + const targetRaw = (body?.["target_version"] ?? "").trim(); + if (!ALLOWED_CHANNELS.has(channelRaw)) { + throw createError({ statusCode: 400, statusMessage: "invalid channel" }); + } + deps.repo.setKioskOsUpdatePref(id, { + channel: channelRaw, + target_version: targetRaw ? targetRaw : null, + }); + const k = deps.repo.getKioskById(id); + if (!k) { + return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); + } + const releases = deps.repo.listOsUpdateReleases(); + return htmlFragment(KioskOsUpdatePanel({ kiosk: k, releases })); + }); + + // ---- Rollouts ----------------------------------------------------------- + app.get("/admin/os-updates/rollouts", (event) => { + const user = event.context.user!; + const rollouts = deps.repo.listOsUpdateRollouts(); + const releases = deps.repo.listOsUpdateReleases(); + const kiosks = deps.repo.listKiosks(); + return htmlPage(OsUpdateRolloutsPage({ + user: user.username, + rollouts, + releases, + kiosks, + })); + }); + + app.post("/admin/os-updates/rollouts/new", async (event) => { + const body = await readBody>(event); + const releaseId = String(body?.["release_id"] ?? ""); + if (!releaseId) throw createError({ statusCode: 400, statusMessage: "release_id required" }); + const release = deps.repo.getOsUpdateRelease(releaseId); + if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" }); + const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); + const targetsRaw = body?.["target_kiosk_ids"]; + const targets: number[] = Array.isArray(targetsRaw) + ? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n)) + : typeof targetsRaw === "string" && targetsRaw + ? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) + : []; + const user = event.context.user!; + const rollout = deps.repo.createOsUpdateRollout({ + id: randomUUID(), + release_id: releaseId, + target_kiosk_ids: targets, + percentage, + created_by: user.id ?? null, + }); + deps.repo.updateOsUpdateRolloutState(rollout.id, "active"); + audit(deps.repo, event as any, "os_update.rollout.create", { + resource_type: "os_update_rollout", + resource_id: rollout.id, + metadata: { release_id: releaseId, percentage, target_count: targets.length }, + }); + return new Response(null, { status: 302, headers: { location: "/admin/os-updates/rollouts" } }); + }); + + app.post("/admin/os-updates/rollouts/:id/state", async (event) => { + const id = String(getRouterParam(event, "id")); + const body = await readBody<{ state: string }>(event); + const state = body?.state; + if (state !== "paused" && state !== "active" && state !== "complete") { + throw createError({ statusCode: 400, statusMessage: "invalid state" }); + } + deps.repo.updateOsUpdateRolloutState(id, state); + return new Response(null, { status: 302, headers: { location: "/admin/os-updates/rollouts" } }); + }); + + // ---- CI auto-import (URL-based) ----------------------------------------- app.post("/api/admin/os/import", async (event) => { const body = await readBody<{ version: string; diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 107eab1..fdcbbc0 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -32,6 +32,8 @@ import type { Kiosk, KioskGpioBinding, KioskLabel, + KioskLog, + KioskLogLevel, Label, LabelRole, Layout, diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index 01ebb80..58198dd 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -24,7 +24,7 @@ export interface NoderedDashboard { } export interface NoderedBridge { - forward(topic: string, payload: Record): void; + forward(topic: string, payload: Record, onSuccess?: () => void): void; listDashboards(): Promise; /** * Idempotently provision a `bf-server-config` node in Node-RED's flow graph @@ -87,15 +87,10 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder const timeoutMs = config.timeoutMs ?? 3000; return { - forward(topic: string, payload: Record): void { + forward(topic: string, payload: Record, onSuccess?: () => void): void { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); - // Internal server-to-Node-RED delivery for events the backend already - // authenticated, such as kiosk ONVIF/GPIO ingest. - // Use /api/internal/ — Angie returns 404 for any /api/* not whitelisted, - // so external requests cannot trigger BF nodes. Server bridge bypasses - // Angie (direct to nodered container). const url = `${base}/api/internal/${encodeURIComponent(topic)}`; fetch(url, { method: "POST", @@ -104,7 +99,11 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder signal: ctrl.signal, }) .then((r) => { - if (!r.ok) log.warn(`nodered ${topic} → ${r.status}`); + if (r.ok) { + onSuccess?.(); + } else { + log.warn(`nodered ${topic} → ${r.status}`); + } }) .catch((err) => log.warn(`nodered ${topic} failed: ${(err as Error).message}`)) .finally(() => clearTimeout(t)); diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index db810ca..89cbdd4 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -375,3 +375,33 @@ export interface EventLog { received_at: string; forwarded_to_nodered: boolean; } + +export interface EventQueryFilters { + topic?: string; + kiosk_id?: number; + from?: string; + to?: string; + limit?: number; + offset?: number; +} + +export type KioskLogLevel = "debug" | "info" | "warn" | "error"; + +export interface KioskLog { + id: number; + kiosk_id: number; + level: KioskLogLevel; + message: string; + context: Record; + logged_at: string; + received_at: string; +} + +export interface KioskLogQueryFilters { + kiosk_id: number; + level?: KioskLogLevel; + from?: string; + to?: string; + limit?: number; + offset?: number; +} diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 2a99515..7fc4e22 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -15,6 +15,8 @@ import type { Label, Layout as LayoutType, LayoutCell, + OsUpdateRelease, + OsUpdateRollout, PairingCode, EventLog, } from "../shared/types.js"; @@ -1338,6 +1340,7 @@ interface KioskEditProps { displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>; gpioBindings?: KioskGpioBinding[]; firmwareReleases?: FirmwareRelease[]; + osReleases?: OsUpdateRelease[]; error?: string; success?: string; } @@ -1695,6 +1698,10 @@ export function KioskEditPage(props: KioskEditProps) { KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases }) )} + {props.osReleases && ( + KioskOsUpdatePanel({ kiosk: props.kiosk, releases: props.osReleases }) + )} + {(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })} {/* GPIO bindings */} @@ -3398,3 +3405,244 @@ export function BackupPage(props: BackupPageProps) { ); } + +// ---- OS updates ------------------------------------------------------------- +// +// Mirrors the FirmwarePage / FirmwareRolloutsPage / KioskFirmwarePanel +// triplet but targeting RAUC OS bundles. CI publishes a release via +// /api/admin/os/import; the admin doesn't normally upload by hand — the +// page surfaces the table so an operator can yank a bad release and +// kick off a rollout to a slice of the fleet. + +interface OsUpdatePageProps { + user: string; + releases: OsUpdateRelease[]; +} + +export function OsUpdatePage(props: OsUpdatePageProps) { + return ( + +

+ Signed RAUC bundles. Kiosks running the BetterFrame A/B image poll + for new bundles every 60s and atomic-swap into the inactive slot + on match. Tryboot rolls back if the new slot fails to boot. + Rollouts → +

+ +
+ How bundles get here: the CI build workflow signs the + .raucb with the operator's signing cert, uploads it as a GitHub Release + asset, then POSTs to /api/admin/os/import with the asset URL + + sha256. Configure GitHub secrets BF_RAUC_SIGNING_CERT, + BF_RAUC_SIGNING_KEY, BF_AUTOIMPORT_URL, + BF_AUTOIMPORT_API_KEY to enable the pipeline. See + scripts/gen-rauc-signing-keys.sh. +
+ +
+ + + + + + + + + + + + + + {props.releases.length === 0 ? ( + + ) : ( + props.releases.map((r) => ( + + + + + + + + + + )) + )} + +
VersionChannelCompatibilitySizeSHA256Uploaded
No OS releases yet. Push a master commit with signing secrets configured.
{r.version}{r.channel}{r.compatibility}{Math.round(r.size_bytes / 1024 / 1024)} MiB{r.sha256.slice(0, 12)}…{formatTime(r.uploaded_at)} + {r.yanked_at ? ( + yanked + ) : ( + + )} +
+
+
+ ); +} + +interface OsUpdateRolloutsPageProps { + user: string; + rollouts: OsUpdateRollout[]; + releases: OsUpdateRelease[]; + kiosks: Kiosk[]; +} + +export function OsUpdateRolloutsPage(props: OsUpdateRolloutsPageProps) { + const releaseById = new Map(props.releases.map((r) => [r.id, r])); + const kioskById = new Map(props.kiosks.map((k) => [k.id, k])); + return ( + +

+ Push a specific OS bundle to a slice of the fleet. Bucket assignment + is deterministic by kiosk id — re-running a 50% rollout with the same + targets touches the same half. +

+ +
+

New rollout

+
+
+ + +
+
+ + +
+
+ + +
Cmd/Ctrl-click to multi-select.
+
+ +
+
+ +
+ + + + + + + + + + + + + {props.rollouts.length === 0 ? ( + + ) : ( + 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 ( + + + + + + + + + ); + }) + )} + +
ReleaseState%TargetsCreated
No OS rollouts yet.
{rel?.version ?? r.release_id}{rel && ({rel.channel})}{r.state}{String(r.percentage)}%{targetSummary}{formatTime(r.created_at)} +
+ + +
+
+ + +
+
+
+
+ ); +} + +interface KioskOsUpdatePanelProps { + kiosk: Kiosk; + releases: OsUpdateRelease[]; +} + +export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) { + const k = props.kiosk; + const current = k.os_version ?? "unknown"; + return ( +
+

OS

+
+
Running: {current}
+ {k.os_update_last_attempt_version && ( +
+ Last attempt: {k.os_update_last_attempt_version} + {k.os_update_last_attempt_at && at {formatTime(k.os_update_last_attempt_at)}} + {k.os_update_last_error && — {k.os_update_last_error}} +
+ )} +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ ); +} diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index 8d44f11..1a2fe82 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) { +