mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +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 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,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import type {
|
|||
Kiosk,
|
||||
KioskGpioBinding,
|
||||
KioskLabel,
|
||||
KioskLog,
|
||||
KioskLogLevel,
|
||||
Label,
|
||||
LabelRole,
|
||||
Layout,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||
<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/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue