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.
This commit is contained in:
Mitchell R 2026-05-21 11:30:33 +02:00
parent d149ed68e5
commit 90346f4efd
No known key found for this signature in database
7 changed files with 400 additions and 13 deletions

View file

@ -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,
}));
});

View file

@ -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<FirmwareChannel> = 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<Record<string, string>>(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<Record<string, string | string[]>>(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;

View file

@ -32,6 +32,8 @@ import type {
Kiosk,
KioskGpioBinding,
KioskLabel,
KioskLog,
KioskLogLevel,
Label,
LabelRole,
Layout,

View file

@ -24,7 +24,7 @@ export interface NoderedDashboard {
}
export interface NoderedBridge {
forward(topic: string, payload: Record<string, unknown>): void;
forward(topic: string, payload: Record<string, unknown>, onSuccess?: () => void): void;
listDashboards(): Promise<NoderedDashboard[]>;
/**
* 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<string, unknown>): void {
forward(topic: string, payload: Record<string, unknown>, 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));

View file

@ -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<string, unknown>;
logged_at: string;
received_at: string;
}
export interface KioskLogQueryFilters {
kiosk_id: number;
level?: KioskLogLevel;
from?: string;
to?: string;
limit?: number;
offset?: number;
}

View file

@ -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) {
</Layout>
);
}
// ---- 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 (
<Layout title="OS Updates" user={props.user} activeNav="os-updates">
<p style="color:#666; margin-bottom:1rem">
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.
<a href="/admin/os-updates/rollouts" style="margin-left:0.5rem">Rollouts </a>
</p>
<div class="card" style="margin-bottom:1.5rem; background:#fafafa; font-size:0.85rem">
<strong>How bundles get here:</strong> the CI build workflow signs the
.raucb with the operator's signing cert, uploads it as a GitHub Release
asset, then POSTs to <code>/api/admin/os/import</code> with the asset URL
+ sha256. Configure GitHub secrets <code>BF_RAUC_SIGNING_CERT</code>,
<code>BF_RAUC_SIGNING_KEY</code>, <code>BF_AUTOIMPORT_URL</code>,
<code>BF_AUTOIMPORT_API_KEY</code> to enable the pipeline. See
<code>scripts/gen-rauc-signing-keys.sh</code>.
</div>
<div class="table-wrap" style="margin-bottom:1.5rem">
<table>
<thead>
<tr>
<th>Version</th>
<th>Channel</th>
<th>Compatibility</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 OS releases yet. Push a master commit with signing secrets configured.</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.compatibility}</td>
<td style="font-size:0.85rem">{Math.round(r.size_bytes / 1024 / 1024)} MiB</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/os-updates/${r.id}/yank`,
"hx-confirm": "Yank this OS release? Kiosks already updated keep it; new kiosks won't pick it up.",
"hx-swap": "none",
"hx-on::after-request": "location.reload()",
}}
>Yank</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
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 (
<Layout title="OS rollouts" user={props.user} activeNav="os-updates">
<p style="color:#666; margin-bottom:1rem">
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.
</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/os-updates/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.compatibility}</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.</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 OS 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})</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/os-updates/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/os-updates/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>
);
}
interface KioskOsUpdatePanelProps {
kiosk: Kiosk;
releases: OsUpdateRelease[];
}
export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) {
const k = props.kiosk;
const current = k.os_version ?? "unknown";
return (
<div id={`kiosk-os-${String(k.id)}`} class="card" style="margin-bottom:1.5rem">
<h3 style="margin:0 0 0.75rem; font-size:1rem">OS</h3>
<div style="font-size:0.85rem; color:#666; margin-bottom:0.75rem">
<div>Running: <code>{current}</code></div>
{k.os_update_last_attempt_version && (
<div>
Last attempt: <code>{k.os_update_last_attempt_version}</code>
{k.os_update_last_attempt_at && <span> at {formatTime(k.os_update_last_attempt_at)}</span>}
{k.os_update_last_error && <span style="color:#a00"> {k.os_update_last_error}</span>}
</div>
)}
</div>
<form
{...{
"hx-post": `/admin/kiosks/${String(k.id)}/os-update`,
"hx-target": `#kiosk-os-${String(k.id)}`,
"hx-swap": "outerHTML",
}}
style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem"
>
<div class="form-group">
<label for={`os-channel-${String(k.id)}`}>Channel</label>
<select id={`os-channel-${String(k.id)}`} name="channel" class="form-input">
{(["stable", "beta", "dev"] as const).map((c) => (
<option value={c} selected={k.os_update_channel === c}>{c}</option>
))}
</select>
</div>
<div class="form-group">
<label for={`os-target-${String(k.id)}`}>Pin to version</label>
<select id={`os-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.os_update_target_version === r.version}>
{r.version} ({r.channel})
</option>
))}
</select>
</div>
<div style="grid-column:1/-1">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
);
}

View file

@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/displays" label="Displays" icon="&#9642;" active={a === "displays"} />
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} />
<NavItem href="/admin/os-updates" label="OS Updates" icon="&#9679;" active={a === "os-updates"} />
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} />