mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat: deployment artifacts + CEC relay + auth-check endpoint
Deployment (deploy/): - systemd units for server (system) and kiosk (user session) - Angie/nginx proxy config — routes admin, api, ws, node-red - Dockerfile + docker-compose for containerized deployment - deploy/README.md with install instructions Auth: - /api/admin/_check endpoint for proxy auth_request subrequest - Returns 200 if admin session valid, 401/403 otherwise - Sets X-BetterFrame-User header for upstream CEC (Pi5 HDMI control): - kiosk/src/cec.rs wraps cec-ctl subprocess - Standby/wake/active-source commands - WS message types "standby" / "wake" dispatched to CEC - Admin UI: Wake/Standby buttons on kiosk edit page - Server sendToKiosk via coordinator
This commit is contained in:
parent
766bf8dee0
commit
cbb1683c5d
13 changed files with 460 additions and 1 deletions
84
deploy/README.md
Normal file
84
deploy/README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# BetterFrame deployment
|
||||
|
||||
## Native install (Raspberry Pi)
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
# Install Node.js 23
|
||||
curl -fsSL https://deb.nodesource.com/setup_23.x | sudo bash -
|
||||
sudo apt install -y nodejs build-essential
|
||||
|
||||
# Create user + dirs
|
||||
sudo useradd -r -m -d /var/lib/betterframe betterframe
|
||||
sudo mkdir -p /opt/betterframe /var/log/betterframe /etc/betterframe
|
||||
sudo chown betterframe:betterframe /var/lib/betterframe /var/log/betterframe
|
||||
|
||||
# Deploy code
|
||||
sudo git clone https://github.com/BetterCorp/BetterFrame.git /opt/betterframe
|
||||
cd /opt/betterframe
|
||||
sudo -u betterframe npm install
|
||||
sudo cp sec-config.yaml /opt/betterframe/server/sec-config.yaml
|
||||
|
||||
# Install systemd unit
|
||||
sudo cp deploy/systemd/betterframe-server.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now betterframe-server
|
||||
```
|
||||
|
||||
### Kiosk
|
||||
|
||||
```bash
|
||||
# Install GTK4 + GStreamer + WebKit
|
||||
sudo apt install -y libgtk-4-dev libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-libav \
|
||||
gstreamer1.0-gtk4 libwebkitgtk-6.0-dev libssl-dev
|
||||
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source ~/.cargo/env
|
||||
|
||||
# Build
|
||||
cd ~/betterframe/kiosk
|
||||
cargo build --release
|
||||
sudo install -Dm755 target/release/betterframe-kiosk /opt/betterframe/kiosk/betterframe-kiosk
|
||||
|
||||
# Install systemd user unit
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp deploy/systemd/betterframe-kiosk.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now betterframe-kiosk
|
||||
```
|
||||
|
||||
### Angie proxy
|
||||
|
||||
```bash
|
||||
sudo apt install -y angie # or nginx
|
||||
sudo cp deploy/angie/betterframe.conf /etc/angie/conf.d/
|
||||
sudo systemctl reload angie
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Kiosk still runs natively on the Pi (needs Wayland/HDMI), not in Docker.
|
||||
|
||||
Access: `http://<pi-ip>/setup` for first-run.
|
||||
|
||||
## Production secrets
|
||||
|
||||
For production, store the server key via `systemd-creds`:
|
||||
|
||||
```bash
|
||||
sudo systemd-creds encrypt --name=betterframe-secret \
|
||||
/etc/betterframe/secret.key.plain /etc/betterframe/secret.key
|
||||
sudo chmod 0600 /etc/betterframe/secret.key
|
||||
sudo chown root:root /etc/betterframe/secret.key
|
||||
```
|
||||
|
||||
The systemd unit's `LoadCredential=` directive injects this into the
|
||||
service's `$CREDENTIALS_DIRECTORY`.
|
||||
113
deploy/angie/betterframe.conf
Normal file
113
deploy/angie/betterframe.conf
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# BetterFrame Angie/nginx config — routes admin, kiosk-api, ws, node-red.
|
||||
#
|
||||
# Place in /etc/angie/conf.d/betterframe.conf or /etc/nginx/conf.d/betterframe.conf
|
||||
# Run on the Pi alongside the server. TLS termination here; backend services
|
||||
# bind to 0.0.0.0 but firewall should restrict to localhost in production.
|
||||
|
||||
# Upstreams (BSB services)
|
||||
upstream betterframe_admin { server 127.0.0.1:18080; keepalive 16; }
|
||||
upstream betterframe_api { server 127.0.0.1:18081; keepalive 16; }
|
||||
upstream betterframe_ws { server 127.0.0.1:18082; }
|
||||
upstream betterframe_nodered { server 127.0.0.1:1880; keepalive 8; }
|
||||
|
||||
# Rate limiting for public endpoints
|
||||
limit_req_zone $binary_remote_addr zone=bf_public:10m rate=30r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name betterframe.local _;
|
||||
|
||||
# In production: redirect to HTTPS
|
||||
# return 301 https://$host$request_uri;
|
||||
|
||||
# For now: serve plain HTTP
|
||||
client_max_body_size 16M;
|
||||
|
||||
# ---- Admin UI + admin API (session-authenticated) ----
|
||||
location /admin/ {
|
||||
proxy_pass http://betterframe_admin;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location = /admin { return 301 /admin/; }
|
||||
location /setup { proxy_pass http://betterframe_admin; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /auth/ { proxy_pass http://betterframe_admin; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /static/ { proxy_pass http://betterframe_admin; }
|
||||
|
||||
# ---- Kiosk REST API (Bearer kiosk-key) ----
|
||||
location /api/kiosk/ {
|
||||
proxy_pass http://betterframe_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location /api/pair/ {
|
||||
# Rate-limit pairing initiate to deter brute force
|
||||
limit_req zone=bf_public burst=10 nodelay;
|
||||
proxy_pass http://betterframe_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# ---- Admin API (session-authenticated) ----
|
||||
location /api/admin/ {
|
||||
proxy_pass http://betterframe_admin;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# ---- Live kiosk WebSocket channel ----
|
||||
location /ws/kiosk {
|
||||
proxy_pass http://betterframe_ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400s; # long-lived
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# ---- Node-RED dashboard (admin-only) ----
|
||||
location /nrdp/ {
|
||||
# auth_request /api/admin/_check; # enable when auth-check endpoint ready
|
||||
rewrite ^/nrdp/(.*) /$1 break;
|
||||
proxy_pass http://betterframe_nodered;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# ---- Node-RED HTTP-in (public, rate-limited) ----
|
||||
location /in/public/ {
|
||||
limit_req zone=bf_public burst=20 nodelay;
|
||||
rewrite ^/in/public/(.*) /public/$1 break;
|
||||
proxy_pass http://betterframe_nodered;
|
||||
}
|
||||
|
||||
# ---- Node-RED HTTP-in (kiosk-gated) ----
|
||||
location /in/kiosk/ {
|
||||
# Bearer kiosk-key validated by Node-RED flow
|
||||
rewrite ^/in/kiosk/(.*) /kiosk/$1 break;
|
||||
proxy_pass http://betterframe_nodered;
|
||||
}
|
||||
|
||||
# ---- Health/readiness/version (public) ----
|
||||
location ~ ^/(healthz|readyz|version)$ {
|
||||
proxy_pass http://betterframe_admin;
|
||||
}
|
||||
|
||||
# ---- Root redirect ----
|
||||
location = / {
|
||||
proxy_pass http://betterframe_admin;
|
||||
}
|
||||
}
|
||||
45
deploy/docker/Dockerfile.server
Normal file
45
deploy/docker/Dockerfile.server
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# BetterFrame server image — Node 23 + native deps for argon2/sqlite
|
||||
FROM node:23-bookworm-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build deps for argon2
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY server/package.json ./server/
|
||||
COPY tsconfig.base.json ./
|
||||
RUN npm ci --ignore-scripts && npm rebuild argon2
|
||||
|
||||
COPY server ./server
|
||||
|
||||
# ---- Runtime image ----
|
||||
FROM node:23-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -d /var/lib/betterframe -s /bin/false betterframe
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/server ./server
|
||||
COPY --from=builder /app/tsconfig.base.json ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Default data dir
|
||||
RUN mkdir -p /var/lib/betterframe && chown betterframe:betterframe /var/lib/betterframe
|
||||
VOLUME /var/lib/betterframe
|
||||
|
||||
EXPOSE 18080 18081 18082
|
||||
|
||||
USER betterframe
|
||||
WORKDIR /app/server
|
||||
|
||||
ENV NODE_OPTIONS=--import=tsx
|
||||
|
||||
CMD ["node", "--import", "tsx", "/app/node_modules/@bsb/base/lib/scripts/bsb-plugin-cli.js", "start"]
|
||||
65
deploy/docker/docker-compose.yml
Normal file
65
deploy/docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# BetterFrame stack — server + Angie proxy + Node-RED.
|
||||
# Kiosk runs on the Pi natively (not in Docker, needs Wayland/HDMI).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f deploy/docker/docker-compose.yml up -d
|
||||
#
|
||||
# Volumes:
|
||||
# betterframe-data — sqlite DB + secret.key
|
||||
# nodered-data — Node-RED flows
|
||||
#
|
||||
# Bind 0.0.0.0:80 on the host (Angie). Backend services only reachable
|
||||
# from within the Docker network.
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile.server
|
||||
container_name: betterframe-server
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- betterframe-data:/var/lib/betterframe
|
||||
- ../../sec-config.yaml:/app/sec-config.yaml:ro
|
||||
expose:
|
||||
- "18080"
|
||||
- "18081"
|
||||
- "18082"
|
||||
networks:
|
||||
- betterframe
|
||||
|
||||
angie:
|
||||
image: nginx:alpine
|
||||
container_name: betterframe-angie
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- server
|
||||
- nodered
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ../angie/betterframe.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
networks:
|
||||
- betterframe
|
||||
|
||||
nodered:
|
||||
image: nodered/node-red:latest
|
||||
container_name: betterframe-nodered
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- nodered-data:/data
|
||||
expose:
|
||||
- "1880"
|
||||
networks:
|
||||
- betterframe
|
||||
|
||||
volumes:
|
||||
betterframe-data:
|
||||
nodered-data:
|
||||
|
||||
networks:
|
||||
betterframe:
|
||||
driver: bridge
|
||||
21
deploy/systemd/betterframe-kiosk.service
Normal file
21
deploy/systemd/betterframe-kiosk.service
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[Unit]
|
||||
Description=BetterFrame Kiosk
|
||||
Documentation=https://github.com/BetterCorp/BetterFrame
|
||||
After=graphical-session.target network-online.target
|
||||
Wants=network-online.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/.betterframe-kiosk
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/%U
|
||||
Environment=WAYLAND_DISPLAY=wayland-0
|
||||
Environment=GST_DEBUG=1
|
||||
ExecStart=/opt/betterframe/kiosk/betterframe-kiosk
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
29
deploy/systemd/betterframe-server.service
Normal file
29
deploy/systemd/betterframe-server.service
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[Unit]
|
||||
Description=BetterFrame Server (BSB)
|
||||
Documentation=https://github.com/BetterCorp/BetterFrame
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=betterframe
|
||||
Group=betterframe
|
||||
WorkingDirectory=/opt/betterframe/server
|
||||
Environment=NODE_ENV=production
|
||||
Environment=NODE_OPTIONS=--import tsx
|
||||
ExecStart=/usr/bin/node --import tsx /opt/betterframe/node_modules/@bsb/base/lib/scripts/bsb-plugin-cli.js start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/betterframe /var/log/betterframe
|
||||
LoadCredential=betterframe-secret:/etc/betterframe/secret.key
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
49
kiosk/src/cec.rs
Normal file
49
kiosk/src/cec.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! CEC (HDMI Consumer Electronics Control) — manages display power state
|
||||
//! via `cec-ctl` subprocess. v4l-utils package provides cec-ctl on Pi5.
|
||||
//!
|
||||
//! Commands:
|
||||
//! - standby: tell TV to sleep
|
||||
//! - image-view-on: wake TV
|
||||
//! - is-active-source: query state
|
||||
|
||||
use std::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const CEC_DEVICE: &str = "/dev/cec0";
|
||||
|
||||
/// Send CEC standby (sleep) to all connected devices.
|
||||
pub fn standby() -> bool {
|
||||
info!("cec: standby");
|
||||
run_cec(&["--standby", "--to", "0"])
|
||||
}
|
||||
|
||||
/// Send CEC image-view-on (wake) to TV.
|
||||
pub fn wake() -> bool {
|
||||
info!("cec: wake");
|
||||
run_cec(&["--image-view-on", "--to", "0"])
|
||||
}
|
||||
|
||||
/// Switch HDMI input on TV to this device.
|
||||
pub fn become_active_source() -> bool {
|
||||
info!("cec: become active source");
|
||||
run_cec(&["--active-source", "phys-addr=0.0.0.0"])
|
||||
}
|
||||
|
||||
fn run_cec(args: &[&str]) -> bool {
|
||||
match Command::new("cec-ctl")
|
||||
.arg("-d")
|
||||
.arg(CEC_DEVICE)
|
||||
.args(args)
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => true,
|
||||
Ok(out) => {
|
||||
warn!("cec-ctl failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("cec-ctl not available: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
mod server;
|
||||
mod bundle;
|
||||
mod cec;
|
||||
mod pipeline;
|
||||
mod ui;
|
||||
mod ws_client;
|
||||
|
||||
pub enum ServerMsg {
|
||||
ReloadBundle,
|
||||
Standby,
|
||||
Wake,
|
||||
}
|
||||
|
||||
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Lab
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::bundle::KioskBundle;
|
||||
use crate::cec;
|
||||
use crate::pipeline;
|
||||
use crate::server;
|
||||
use crate::ws_client;
|
||||
|
|
@ -79,7 +80,7 @@ fn activate(app: &Application) {
|
|||
ws_client::run(&server_ws, &key_ws, ws_tx);
|
||||
});
|
||||
|
||||
// Listen for WS messages and re-fetch bundle on reload
|
||||
// Listen for WS messages and dispatch
|
||||
std::thread::spawn(move || {
|
||||
for msg in ws_rx {
|
||||
match msg {
|
||||
|
|
@ -88,6 +89,8 @@ fn activate(app: &Application) {
|
|||
let bundle = server::fetch_bundle(&server_for_reload, &key_for_reload);
|
||||
let _ = tx_for_reload.send(WorkerMsg::RenderBundle(bundle));
|
||||
}
|
||||
ServerMsg::Standby => { cec::standby(); }
|
||||
ServerMsg::Wake => { cec::wake(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
|
|||
} else if text.contains("\"type\":\"reload-bundle\"") {
|
||||
info!("ws: reload-bundle received");
|
||||
let _ = tx.send(ServerMsg::ReloadBundle);
|
||||
} else if text.contains("\"type\":\"standby\"") {
|
||||
info!("ws: standby received");
|
||||
let _ = tx.send(ServerMsg::Standby);
|
||||
} else if text.contains("\"type\":\"wake\"") {
|
||||
info!("ws: wake received");
|
||||
let _ = tx.send(ServerMsg::Wake);
|
||||
} else {
|
||||
info!("ws: msg: {text}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,25 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
|||
registerAdminRoutes(app, deps);
|
||||
registerAccountRoutes(app, deps);
|
||||
|
||||
// Auth-check endpoint for Angie auth_request subrequest.
|
||||
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
||||
app.get("/api/admin/_check", (event) => {
|
||||
const cookie = event.req.headers.get("cookie") ?? "";
|
||||
const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`));
|
||||
if (!match) return new Response(null, { status: 401 });
|
||||
const resolved = deps.auth.resolveSession(match[1]!);
|
||||
if (!resolved || resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (resolved.user.role !== "admin") {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { "x-betterframe-user": resolved.user.username },
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/healthz", () => ({ status: "ok" }));
|
||||
app.get("/readyz", () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -748,4 +748,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
deps.repo.deleteKiosk(id);
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||
});
|
||||
|
||||
// ---- CEC power commands -----------------------------------------------
|
||||
app.post("/admin/kiosks/:id/power/standby", (event) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
getCoordinator().sendToKiosk(id, { type: "standby" });
|
||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||
});
|
||||
|
||||
app.post("/admin/kiosks/:id/power/wake", (event) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
getCoordinator().sendToKiosk(id, { type: "wake" });
|
||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -750,6 +750,15 @@ export function KioskEditPage(props: KioskEditProps) {
|
|||
<div>Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}</div>
|
||||
<div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div>
|
||||
</div>
|
||||
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Display Power (CEC)</div>
|
||||
<form method="post" action={`/admin/kiosks/${k.id}/power/wake`} style="display:inline">
|
||||
<button type="submit" class="btn btn-sm">Wake</button>
|
||||
</form>
|
||||
<form method="post" action={`/admin/kiosks/${k.id}/power/standby`} style="display:inline; margin-left:0.5rem">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Standby</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Associated displays */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue