mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
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:
parent
d149ed68e5
commit
90346f4efd
7 changed files with 400 additions and 13 deletions
|
|
@ -1354,6 +1354,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
}));
|
}));
|
||||||
const gpioBindings = deps.repo.listGpioBindings(id);
|
const gpioBindings = deps.repo.listGpioBindings(id);
|
||||||
const firmwareReleases = deps.repo.listFirmwareReleases();
|
const firmwareReleases = deps.repo.listFirmwareReleases();
|
||||||
|
const osReleases = deps.repo.listOsUpdateReleases();
|
||||||
return htmlPage(KioskEditPage({
|
return htmlPage(KioskEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
kiosk,
|
kiosk,
|
||||||
|
|
@ -1363,6 +1364,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
displayLayouts,
|
displayLayouts,
|
||||||
gpioBindings,
|
gpioBindings,
|
||||||
firmwareReleases,
|
firmwareReleases,
|
||||||
|
osReleases,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,124 @@
|
||||||
/**
|
/**
|
||||||
* Admin OS-update routes.
|
* Admin OS-update routes — release upload (via CI URL pull), list page,
|
||||||
*
|
* yank, per-kiosk channel/pin, and rollouts. Mirrors routes-firmware.ts
|
||||||
* Full OS OTA artifacts are RAUC `.raucb` bundles. CI imports by URL so large
|
* structure for RAUC bundles.
|
||||||
* bundles are streamed server-side instead of base64 encoded into JSON.
|
|
||||||
*/
|
*/
|
||||||
import { type H3, readBody, createError } from "h3";
|
import { type H3, getRouterParam, readBody, createError } from "h3";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { htmlPage, htmlFragment } from "./html-response.js";
|
||||||
import type { AdminDeps } from "./index.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 type { FirmwareChannel } from "../../shared/types.js";
|
||||||
import { audit } from "../../shared/audit.js";
|
import { audit } from "../../shared/audit.js";
|
||||||
|
|
||||||
const ALLOWED_CHANNELS: ReadonlySet<FirmwareChannel> = new Set(["stable", "beta", "dev"]);
|
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 {
|
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) => {
|
app.post("/api/admin/os/import", async (event) => {
|
||||||
const body = await readBody<{
|
const body = await readBody<{
|
||||||
version: string;
|
version: string;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import type {
|
||||||
Kiosk,
|
Kiosk,
|
||||||
KioskGpioBinding,
|
KioskGpioBinding,
|
||||||
KioskLabel,
|
KioskLabel,
|
||||||
|
KioskLog,
|
||||||
|
KioskLogLevel,
|
||||||
Label,
|
Label,
|
||||||
LabelRole,
|
LabelRole,
|
||||||
Layout,
|
Layout,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export interface NoderedDashboard {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoderedBridge {
|
export interface NoderedBridge {
|
||||||
forward(topic: string, payload: Record<string, unknown>): void;
|
forward(topic: string, payload: Record<string, unknown>, onSuccess?: () => void): void;
|
||||||
listDashboards(): Promise<NoderedDashboard[]>;
|
listDashboards(): Promise<NoderedDashboard[]>;
|
||||||
/**
|
/**
|
||||||
* Idempotently provision a `bf-server-config` node in Node-RED's flow graph
|
* 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;
|
const timeoutMs = config.timeoutMs ?? 3000;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
forward(topic: string, payload: Record<string, unknown>): void {
|
forward(topic: string, payload: Record<string, unknown>, onSuccess?: () => void): void {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
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)}`;
|
const url = `${base}/api/internal/${encodeURIComponent(topic)}`;
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -104,7 +99,11 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
})
|
})
|
||||||
.then((r) => {
|
.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}`))
|
.catch((err) => log.warn(`nodered ${topic} failed: ${(err as Error).message}`))
|
||||||
.finally(() => clearTimeout(t));
|
.finally(() => clearTimeout(t));
|
||||||
|
|
|
||||||
|
|
@ -375,3 +375,33 @@ export interface EventLog {
|
||||||
received_at: string;
|
received_at: string;
|
||||||
forwarded_to_nodered: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import type {
|
||||||
Label,
|
Label,
|
||||||
Layout as LayoutType,
|
Layout as LayoutType,
|
||||||
LayoutCell,
|
LayoutCell,
|
||||||
|
OsUpdateRelease,
|
||||||
|
OsUpdateRollout,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
EventLog,
|
EventLog,
|
||||||
} from "../shared/types.js";
|
} from "../shared/types.js";
|
||||||
|
|
@ -1338,6 +1340,7 @@ interface KioskEditProps {
|
||||||
displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
|
displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
|
||||||
gpioBindings?: KioskGpioBinding[];
|
gpioBindings?: KioskGpioBinding[];
|
||||||
firmwareReleases?: FirmwareRelease[];
|
firmwareReleases?: FirmwareRelease[];
|
||||||
|
osReleases?: OsUpdateRelease[];
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: string;
|
success?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1695,6 +1698,10 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases })
|
KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases })
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{props.osReleases && (
|
||||||
|
KioskOsUpdatePanel({ kiosk: props.kiosk, releases: props.osReleases })
|
||||||
|
)}
|
||||||
|
|
||||||
{(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })}
|
{(props.kiosk.local_key && props.kiosk.local_port) && KioskLocalPanel({ kiosk: props.kiosk })}
|
||||||
|
|
||||||
{/* GPIO bindings */}
|
{/* GPIO bindings */}
|
||||||
|
|
@ -3398,3 +3405,244 @@ export function BackupPage(props: BackupPageProps) {
|
||||||
</Layout>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||||
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||||
|
<NavItem href="/admin/os-updates" label="OS Updates" icon="●" active={a === "os-updates"} />
|
||||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue