mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat(managed-config): server-side scaffold for Pi-image device config
Kiosks running our pre-built image (managed_image=true at pairing) can have their hostname, timezone, network (DHCP/static + VLAN), and Wi-Fi configured from the admin UI. Pull-model: server stores desired-state JSON, kiosk heartbeat returns pending_config when version exceeds applied_version, kiosk echoes applied_version back. Wi-Fi PSK encrypted with the cluster key so ciphertext at rest is shipped to the kiosk without per-kiosk re-encryption. Server side only — kiosk Rust applier (betterframe-apply-config helper + rollback timer) and pair-initiate marker file are next. ci(pi-gen): use action's image-path output for asset upload pi-gen writes the .img.xz into pi-gen-action's own working dir, not our repo deploy/. Glob never matched. Use steps.pigen.outputs.image-path directly — no glob needed.
This commit is contained in:
parent
4e652c6fd1
commit
dae5d0ce88
11 changed files with 338 additions and 8 deletions
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
|
|
@ -179,6 +179,7 @@ jobs:
|
||||||
deploy/pi-gen/stage-betterframe-client/prerun.sh
|
deploy/pi-gen/stage-betterframe-client/prerun.sh
|
||||||
|
|
||||||
- name: Build Pi image (pi-gen)
|
- name: Build Pi image (pi-gen)
|
||||||
|
id: pigen
|
||||||
uses: usimd/pi-gen-action@v1.11.0
|
uses: usimd/pi-gen-action@v1.11.0
|
||||||
with:
|
with:
|
||||||
image-name: betterframe-client-${{ inputs.version }}
|
image-name: betterframe-client-${{ inputs.version }}
|
||||||
|
|
@ -194,12 +195,16 @@ jobs:
|
||||||
# Surface pi-gen's stdout/stderr (default suppressed).
|
# Surface pi-gen's stdout/stderr (default suppressed).
|
||||||
verbose-output: true
|
verbose-output: true
|
||||||
|
|
||||||
- name: List pi-gen output
|
- name: Show pi-gen output path
|
||||||
run: ls -la deploy/ || true
|
run: |
|
||||||
|
echo "image-path: ${{ steps.pigen.outputs.image-path }}"
|
||||||
|
ls -la "$(dirname '${{ steps.pigen.outputs.image-path }}')" || true
|
||||||
|
|
||||||
|
# pi-gen writes the .img.xz under its own checkout (inside pi-gen-action's
|
||||||
|
# working dir), not our repo deploy/. The action exposes the exact path
|
||||||
|
# via the `image-path` output — use it directly instead of globbing.
|
||||||
- name: Upload image to GitHub Release
|
- name: Upload image to GitHub Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ inputs.tag }}
|
tag_name: ${{ inputs.tag }}
|
||||||
files: |
|
files: ${{ steps.pigen.outputs.image-path }}
|
||||||
deploy/*.img.xz
|
|
||||||
|
|
|
||||||
|
|
@ -1318,6 +1318,81 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Managed-image device config — admin pushes hostname/timezone/network/wifi
|
||||||
|
// for kiosks running our pre-built Pi image. Builds a ManagedConfig object,
|
||||||
|
// encrypts the wifi PSK with the cluster key (so it can be stored at rest
|
||||||
|
// and delivered as ciphertext that the kiosk decrypts on-device with the
|
||||||
|
// cluster key it received at pairing), then bumps managed_config_version
|
||||||
|
// so the next heartbeat ships it to the kiosk.
|
||||||
|
app.post("/admin/kiosks/:id/managed-config", async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const kiosk = deps.repo.getKioskById(id);
|
||||||
|
if (!kiosk) throw new Error("kiosk not found");
|
||||||
|
if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image");
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
|
||||||
|
const trim = (v: string | undefined) => (v ?? "").trim();
|
||||||
|
const cfg: Record<string, unknown> = {};
|
||||||
|
const hostname = trim(body?.["hostname"]);
|
||||||
|
if (hostname) cfg["hostname"] = hostname;
|
||||||
|
const timezone = trim(body?.["timezone"]);
|
||||||
|
if (timezone) cfg["timezone"] = timezone;
|
||||||
|
|
||||||
|
const netMode = trim(body?.["network_mode"]);
|
||||||
|
if (netMode === "dhcp" || netMode === "static") {
|
||||||
|
const net: Record<string, unknown> = { mode: netMode };
|
||||||
|
const iface = trim(body?.["network_interface"]);
|
||||||
|
if (iface) net["interface"] = iface;
|
||||||
|
if (netMode === "static") {
|
||||||
|
const ipCidr = trim(body?.["network_ip_cidr"]);
|
||||||
|
if (ipCidr) net["ip_cidr"] = ipCidr;
|
||||||
|
const gw = trim(body?.["network_gateway"]);
|
||||||
|
if (gw) net["gateway"] = gw;
|
||||||
|
const dnsRaw = trim(body?.["network_dns"]);
|
||||||
|
if (dnsRaw) {
|
||||||
|
net["dns"] = dnsRaw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vlanRaw = trim(body?.["network_vlan_id"]);
|
||||||
|
if (vlanRaw) {
|
||||||
|
const vlanId = Number(vlanRaw);
|
||||||
|
if (Number.isInteger(vlanId) && vlanId >= 1 && vlanId <= 4094) {
|
||||||
|
net["vlan_id"] = vlanId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg["network"] = net;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wifi: load existing first so blank PSK = "keep current". Re-encrypt PSK
|
||||||
|
// only when the operator actually typed one.
|
||||||
|
const ssid = trim(body?.["wifi_ssid"]);
|
||||||
|
const pskPlaintext = body?.["wifi_psk"] ?? "";
|
||||||
|
if (ssid) {
|
||||||
|
const prev = kiosk.managed_config_json ? JSON.parse(kiosk.managed_config_json) : null;
|
||||||
|
let pskCiphertext: string | null = prev?.wifi?.psk_ciphertext ?? null;
|
||||||
|
if (pskPlaintext) {
|
||||||
|
pskCiphertext = deps.secrets.encryptString(pskPlaintext, "cluster");
|
||||||
|
}
|
||||||
|
if (pskCiphertext) {
|
||||||
|
cfg["wifi"] = { ssid, psk_ciphertext: pskCiphertext };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.repo.updateKiosk(id, {
|
||||||
|
managed_config_json: JSON.stringify(cfg),
|
||||||
|
managed_config_version: kiosk.managed_config_version + 1,
|
||||||
|
managed_config_error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
audit(deps.repo, event as any, "kiosk.managed_config.update", {
|
||||||
|
resource_type: "kiosk",
|
||||||
|
resource_id: String(id),
|
||||||
|
metadata: { version: kiosk.managed_config_version + 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/labels", async (event) => {
|
app.post("/admin/kiosks/:id/labels", async (event) => {
|
||||||
const kioskId = Number(getRouterParam(event, "id"));
|
const kioskId = Number(getRouterParam(event, "id"));
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
|
|
||||||
|
|
@ -215,12 +215,14 @@ function registerPairingRoutes(
|
||||||
proposed_name?: string;
|
proposed_name?: string;
|
||||||
hardware_model?: string;
|
hardware_model?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
managed_image?: boolean;
|
||||||
}>(event);
|
}>(event);
|
||||||
|
|
||||||
const result = initiatePairing(repo, {
|
const result = initiatePairing(repo, {
|
||||||
proposedName: body?.proposed_name ?? null,
|
proposedName: body?.proposed_name ?? null,
|
||||||
hardwareModel: body?.hardware_model ?? null,
|
hardwareModel: body?.hardware_model ?? null,
|
||||||
capabilities: body?.capabilities ?? [],
|
capabilities: body?.capabilities ?? [],
|
||||||
|
managedImage: body?.managed_image === true,
|
||||||
codeTtlSeconds: codeTtl,
|
codeTtlSeconds: codeTtl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -303,6 +305,11 @@ function registerKioskRoutes(
|
||||||
fan_pwm?: number | null;
|
fan_pwm?: number | null;
|
||||||
local_key?: string | null;
|
local_key?: string | null;
|
||||||
local_port?: number | null;
|
local_port?: number | null;
|
||||||
|
// Managed-image kiosk echoes back the version it last applied, and the
|
||||||
|
// last apply error (if any). Server uses these to decide whether to
|
||||||
|
// include pending_config in the response.
|
||||||
|
managed_config_applied_version?: number;
|
||||||
|
managed_config_error?: string | null;
|
||||||
}>(event);
|
}>(event);
|
||||||
|
|
||||||
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
|
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
|
||||||
|
|
@ -323,6 +330,22 @@ function registerKioskRoutes(
|
||||||
local_last_ip: remoteIp,
|
local_last_ip: remoteIp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Managed-config echo: kiosk reports the version it has successfully
|
||||||
|
// applied. Persist for the admin UI to render. Error string clears on a
|
||||||
|
// successful apply (kiosk omits it). verifyKioskKey returns just {id};
|
||||||
|
// re-read the full row to check the managed_image flag.
|
||||||
|
const kioskFull = repo.getKioskById(kiosk.id);
|
||||||
|
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
managed_config_applied_version: body.managed_config_applied_version,
|
||||||
|
managed_config_applied_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (body.managed_config_error !== undefined) {
|
||||||
|
patch["managed_config_error"] = body.managed_config_error ?? null;
|
||||||
|
}
|
||||||
|
repo.updateKiosk(kiosk.id, patch as any);
|
||||||
|
}
|
||||||
|
|
||||||
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
|
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
|
||||||
mqtt.publishTelemetry(kiosk.id, {
|
mqtt.publishTelemetry(kiosk.id, {
|
||||||
kiosk_app_version: body?.kiosk_app_version,
|
kiosk_app_version: body?.kiosk_app_version,
|
||||||
|
|
@ -377,7 +400,31 @@ function registerKioskRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, now: new Date().toISOString() };
|
// Re-read kiosk so we see the freshly-persisted applied_version above when
|
||||||
|
// computing whether the server still has a newer config to deliver.
|
||||||
|
const fresh = repo.getKioskById(kiosk.id);
|
||||||
|
let pendingConfig: { version: number; config: unknown } | undefined;
|
||||||
|
if (
|
||||||
|
fresh?.managed_image
|
||||||
|
&& fresh.managed_config_version > fresh.managed_config_applied_version
|
||||||
|
&& fresh.managed_config_json
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
pendingConfig = {
|
||||||
|
version: fresh.managed_config_version,
|
||||||
|
config: JSON.parse(fresh.managed_config_json),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Corrupt JSON — leave pendingConfig undefined; admin UI will show
|
||||||
|
// the error. Don't break heartbeat.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
...(pendingConfig ? { pending_config: pendingConfig } : {}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event forwarding
|
// Event forwarding
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,12 @@ export function rowToKiosk(r: Row): Kiosk {
|
||||||
local_key: sn(r["local_key"]),
|
local_key: sn(r["local_key"]),
|
||||||
local_port: nn(r["local_port"]),
|
local_port: nn(r["local_port"]),
|
||||||
local_last_ip: sn(r["local_last_ip"]),
|
local_last_ip: sn(r["local_last_ip"]),
|
||||||
|
managed_image: b(r["managed_image"]),
|
||||||
|
managed_config_json: sn(r["managed_config_json"]),
|
||||||
|
managed_config_version: n(r["managed_config_version"] ?? 0),
|
||||||
|
managed_config_applied_version: n(r["managed_config_applied_version"] ?? 0),
|
||||||
|
managed_config_applied_at: sn(r["managed_config_applied_at"]),
|
||||||
|
managed_config_error: sn(r["managed_config_error"]),
|
||||||
created_at: s(r["created_at"]),
|
created_at: s(r["created_at"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -781,4 +781,18 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, ts DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, ts DESC)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_type, actor_id, ts DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_type, actor_id, ts DESC)`,
|
||||||
|
|
||||||
|
// ---- Managed-image device config -----------------------------------------
|
||||||
|
// For kiosks running our pre-built Pi image (managed_image=1), admins can
|
||||||
|
// push hostname / timezone / network / wifi config. Kiosk pulls on heartbeat
|
||||||
|
// when server's version > applied_version, applies via a privileged helper,
|
||||||
|
// echoes applied_version back. managed_config_error captures last failure.
|
||||||
|
(db: DatabaseSync) => {
|
||||||
|
addColumnIfNotExists(db, "kiosks", "managed_image", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
addColumnIfNotExists(db, "kiosks", "managed_config_json", "TEXT");
|
||||||
|
addColumnIfNotExists(db, "kiosks", "managed_config_version", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
addColumnIfNotExists(db, "kiosks", "managed_config_applied_version", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
addColumnIfNotExists(db, "kiosks", "managed_config_applied_at", "TEXT");
|
||||||
|
addColumnIfNotExists(db, "kiosks", "managed_config_error", "TEXT");
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -971,11 +971,12 @@ export class Repository {
|
||||||
key_prefix: string;
|
key_prefix: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
hardware_model?: string | null;
|
hardware_model?: string | null;
|
||||||
|
managed_image?: boolean;
|
||||||
}): Kiosk {
|
}): Kiosk {
|
||||||
const result = this.prep(
|
const result = this.prep(
|
||||||
`INSERT INTO kiosks
|
`INSERT INTO kiosks
|
||||||
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at)
|
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at, managed_image)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.name,
|
input.name,
|
||||||
input.key_hash,
|
input.key_hash,
|
||||||
|
|
@ -983,6 +984,7 @@ export class Repository {
|
||||||
J(input.capabilities ?? []),
|
J(input.capabilities ?? []),
|
||||||
input.hardware_model ?? null,
|
input.hardware_model ?? null,
|
||||||
isoNow(),
|
isoNow(),
|
||||||
|
input.managed_image ? 1 : 0,
|
||||||
);
|
);
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
void this.notify("kiosks", "create", id);
|
void this.notify("kiosks", "create", id);
|
||||||
|
|
|
||||||
55
server/src/schemas/wire/managed-config.ts
Normal file
55
server/src/schemas/wire/managed-config.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Wire schema for the managed-image device config: pushed from server to
|
||||||
|
* kiosks running our pre-built Pi OS image. Kiosks pull on heartbeat
|
||||||
|
* (response includes `pending_config` when server-side version exceeds
|
||||||
|
* `applied_version`), apply via a privileged helper, echo `applied_version`
|
||||||
|
* back on the next heartbeat.
|
||||||
|
*
|
||||||
|
* Wifi PSKs are encrypted with the cluster_key delivered at pairing time,
|
||||||
|
* so the server can store ciphertext at rest and ship it to the kiosk
|
||||||
|
* without a per-kiosk re-encryption step.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
export const NETWORK_MODES = ["dhcp", "static"] as const;
|
||||||
|
|
||||||
|
export const managedNetworkConfig = av.object(
|
||||||
|
{
|
||||||
|
mode: av.enum_(NETWORK_MODES),
|
||||||
|
// Interface name as exposed by NetworkManager (e.g. "eth0", "wlan0").
|
||||||
|
// Optional — helper defaults to the primary wired interface.
|
||||||
|
interface: av.optional(av.string().minLength(1).maxLength(32)),
|
||||||
|
// IPv4 CIDR for static mode. Ignored when mode=dhcp.
|
||||||
|
ip_cidr: av.optional(av.string().pattern("^[0-9.]+/[0-9]+$")),
|
||||||
|
gateway: av.optional(av.string().minLength(1).maxLength(64)),
|
||||||
|
dns: av.optional(av.array(av.string().minLength(1).maxLength(64))),
|
||||||
|
// 802.1Q VLAN id; helper creates a virtual interface when set.
|
||||||
|
vlan_id: av.optional(av.int().min(1).max(4094)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const managedWifiConfig = av.object(
|
||||||
|
{
|
||||||
|
ssid: av.string().minLength(1).maxLength(64),
|
||||||
|
// PSK encrypted with cluster_key via shared/secrets.encryptString.
|
||||||
|
// Helper decrypts in-process before handing to NetworkManager.
|
||||||
|
psk_ciphertext: av.string().minLength(1).maxLength(512),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const managedConfig = av.object(
|
||||||
|
{
|
||||||
|
hostname: av.optional(av.string().minLength(1).maxLength(64)),
|
||||||
|
// IANA tz name, e.g. "Etc/UTC", "America/New_York".
|
||||||
|
timezone: av.optional(av.string().minLength(1).maxLength(64)),
|
||||||
|
network: av.optional(managedNetworkConfig),
|
||||||
|
wifi: av.optional(managedWifiConfig),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ManagedNetworkConfig = av.Infer<typeof managedNetworkConfig>;
|
||||||
|
export type ManagedWifiConfig = av.Infer<typeof managedWifiConfig>;
|
||||||
|
export type ManagedConfig = av.Infer<typeof managedConfig>;
|
||||||
|
|
@ -27,6 +27,9 @@ export const pairInitiateRequest = av.object(
|
||||||
capabilities: av.array(av.enum_(KIOSK_CAPABILITIES)),
|
capabilities: av.array(av.enum_(KIOSK_CAPABILITIES)),
|
||||||
os_version: av.optional(av.string().maxLength(128)),
|
os_version: av.optional(av.string().maxLength(128)),
|
||||||
kiosk_app_version: av.optional(av.string().maxLength(64)),
|
kiosk_app_version: av.optional(av.string().maxLength(64)),
|
||||||
|
// True iff the kiosk runs our pre-built Pi OS image and ships the
|
||||||
|
// betterframe-apply-config helper. Gates the admin Managed Config UI.
|
||||||
|
managed_image: av.optional(av.bool()),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "reject" },
|
{ unknownKeys: "reject" },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export interface PairingInitiateInput {
|
||||||
proposedName: string | null;
|
proposedName: string | null;
|
||||||
hardwareModel: string | null;
|
hardwareModel: string | null;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
|
/** True iff kiosk runs our pre-built Pi image with the apply-config helper. */
|
||||||
|
managedImage?: boolean;
|
||||||
codeTtlSeconds: number;
|
codeTtlSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +58,7 @@ export function initiatePairing(
|
||||||
kiosk_hardware_model: input.hardwareModel,
|
kiosk_hardware_model: input.hardwareModel,
|
||||||
kiosk_capabilities: input.capabilities,
|
kiosk_capabilities: input.capabilities,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
extras: {},
|
extras: input.managedImage ? { managed_image: true } : {},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { code, expiresAt };
|
return { code, expiresAt };
|
||||||
|
|
@ -159,6 +161,7 @@ export async function confirmPairing(
|
||||||
key_prefix: kioskKeyPrefix,
|
key_prefix: kioskKeyPrefix,
|
||||||
capabilities: pc.kiosk_capabilities,
|
capabilities: pc.kiosk_capabilities,
|
||||||
hardware_model: pc.kiosk_hardware_model,
|
hardware_model: pc.kiosk_hardware_model,
|
||||||
|
managed_image: pc.extras?.["managed_image"] === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
repo.createDisplayForKiosk(kiosk.id, {
|
repo.createDisplayForKiosk(kiosk.id, {
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,14 @@ export interface Kiosk {
|
||||||
local_key: string | null;
|
local_key: string | null;
|
||||||
local_port: number | null;
|
local_port: number | null;
|
||||||
local_last_ip: string | null;
|
local_last_ip: string | null;
|
||||||
|
// Managed-image device config. Only meaningful when managed_image=true; for
|
||||||
|
// BYO-OS kiosks these fields stay at defaults and the admin UI hides them.
|
||||||
|
managed_image: boolean;
|
||||||
|
managed_config_json: string | null; // serialized ManagedConfig payload
|
||||||
|
managed_config_version: number; // server-side, bumps on each save
|
||||||
|
managed_config_applied_version: number; // echoed by kiosk after successful apply
|
||||||
|
managed_config_applied_at: string | null;
|
||||||
|
managed_config_error: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1375,6 +1375,116 @@ export function renderKioskLabels(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managed-image device config editor. Only rendered when the kiosk reported
|
||||||
|
* managed_image=true at pairing. Server pushes the resulting JSON on the
|
||||||
|
* next heartbeat; kiosk applies it and echoes the version back, so we show
|
||||||
|
* "version N applied at …" plus the last error (if any) so the operator can
|
||||||
|
* see whether their change actually landed.
|
||||||
|
*/
|
||||||
|
function ManagedConfigCard(props: { kiosk: Kiosk }) {
|
||||||
|
const k = props.kiosk;
|
||||||
|
let cfg: {
|
||||||
|
hostname?: string;
|
||||||
|
timezone?: string;
|
||||||
|
network?: {
|
||||||
|
mode?: string;
|
||||||
|
interface?: string;
|
||||||
|
ip_cidr?: string;
|
||||||
|
gateway?: string;
|
||||||
|
dns?: string[];
|
||||||
|
vlan_id?: number;
|
||||||
|
};
|
||||||
|
wifi?: { ssid?: string };
|
||||||
|
} = {};
|
||||||
|
if (k.managed_config_json) {
|
||||||
|
try { cfg = JSON.parse(k.managed_config_json); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
const net = cfg.network ?? {};
|
||||||
|
const wifi = cfg.wifi ?? {};
|
||||||
|
const pending = k.managed_config_version > k.managed_config_applied_version;
|
||||||
|
return (
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Managed Config (Pi image)</h2>
|
||||||
|
<div style="font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
||||||
|
<div>
|
||||||
|
Version: {String(k.managed_config_version)}
|
||||||
|
{" · Applied: "}{String(k.managed_config_applied_version)}
|
||||||
|
{k.managed_config_applied_at ? <> ({formatTime(k.managed_config_applied_at)})</> : null}
|
||||||
|
{pending ? <span style="color:#b06; margin-left:0.5rem">pending push…</span> : null}
|
||||||
|
</div>
|
||||||
|
{k.managed_config_error
|
||||||
|
? <div style="color:#b00; margin-top:0.25rem">Last error: {k.managed_config_error}</div>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}/managed-config`}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_hostname">Hostname</label>
|
||||||
|
<input id="mc_hostname" name="hostname" type="text" class="form-input"
|
||||||
|
value={cfg.hostname ?? ""} placeholder="betterframe-kiosk" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_timezone">Timezone</label>
|
||||||
|
<input id="mc_timezone" name="timezone" type="text" class="form-input"
|
||||||
|
value={cfg.timezone ?? ""} placeholder="Etc/UTC" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin:1rem 0 0.5rem; font-size:0.95rem">Network</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_net_mode">Mode</label>
|
||||||
|
<select id="mc_net_mode" name="network_mode" class="form-input">
|
||||||
|
<option value="" selected={!net.mode}>—</option>
|
||||||
|
<option value="dhcp" selected={net.mode === "dhcp"}>DHCP</option>
|
||||||
|
<option value="static" selected={net.mode === "static"}>Static</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_net_iface">Interface</label>
|
||||||
|
<input id="mc_net_iface" name="network_interface" type="text" class="form-input"
|
||||||
|
value={net.interface ?? ""} placeholder="eth0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_net_ip">Static IP (CIDR)</label>
|
||||||
|
<input id="mc_net_ip" name="network_ip_cidr" type="text" class="form-input"
|
||||||
|
value={net.ip_cidr ?? ""} placeholder="192.168.1.50/24" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_net_gw">Gateway</label>
|
||||||
|
<input id="mc_net_gw" name="network_gateway" type="text" class="form-input"
|
||||||
|
value={net.gateway ?? ""} placeholder="192.168.1.1" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_net_dns">DNS (comma-separated)</label>
|
||||||
|
<input id="mc_net_dns" name="network_dns" type="text" class="form-input"
|
||||||
|
value={(net.dns ?? []).join(", ")} placeholder="1.1.1.1, 8.8.8.8" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_net_vlan">VLAN ID</label>
|
||||||
|
<input id="mc_net_vlan" name="network_vlan_id" type="number" min="1" max="4094"
|
||||||
|
class="form-input" value={net.vlan_id != null ? String(net.vlan_id) : ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin:1rem 0 0.5rem; font-size:0.95rem">Wi-Fi (optional)</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_wifi_ssid">SSID</label>
|
||||||
|
<input id="mc_wifi_ssid" name="wifi_ssid" type="text" class="form-input"
|
||||||
|
value={wifi.ssid ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mc_wifi_psk">PSK</label>
|
||||||
|
<input id="mc_wifi_psk" name="wifi_psk" type="password" class="form-input"
|
||||||
|
placeholder={wifi.ssid ? "(unchanged — leave blank to keep)" : ""} />
|
||||||
|
<div style="font-size:0.75rem; color:#999; margin-top:0.2rem">
|
||||||
|
Encrypted with cluster key before storage. Leave blank to keep existing PSK.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save & Push</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function KioskEditPage(props: KioskEditProps) {
|
export function KioskEditPage(props: KioskEditProps) {
|
||||||
const k = props.kiosk;
|
const k = props.kiosk;
|
||||||
return (
|
return (
|
||||||
|
|
@ -1631,6 +1741,8 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{k.managed_image ? <ManagedConfigCard kiosk={k} /> : null}
|
||||||
|
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
||||||
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue