Web UI: install/reconfigure addons, default-on install, visual redesign (v2.17.0)
Web UI now installs by default during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in, and can install/ reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates - the addons and Update action asked for by name. Privilege model: the web service itself still runs as $KIOSK_USER with zero ambient sudo. A new narrow, allow-listed root helper (menus/addon_webui.sh's webui_write_helper_script) is the only way it ever gains privilege, reachable only via a single-path passwordless sudo rule generated and validated with `visudo -c -f` before being installed, and it re-checks its own fixed action allow-list before dispatching anything. Each allow-listed action is the exact same interactive action_* function the terminal menu already uses, driven by piping the right answers on stdin - the same technique this project's own bash tests already use, so no prompt/mutation refactor of any addon file was needed. webui/lib/actions.js's stdin sequences were cross-validated against the real bash functions (not just read), which caught two real bugs (Squeezelite and Asterisk Intercom both silently lost their "decline reconfigure" path). Long-running installs stream live output via Server-Sent Events (webui/lib/jobs.js), one action at a time. Full visual redesign: a sidebar shell (Sites/Display/Lockout/Addons/ Update) replacing the single scrolling page, light+dark themes via prefers-color-scheme, no external font/CDN dependency. Actually driving the redesigned UI in a headless browser (not just reading the code) caught a real bug: refreshing an addon's pill/button after a successful install used to rebuild the whole card, racing (and usually losing to) the success status/log that job had just written. Fixed to update pill/buttons in place. Uninstall-via-web is deliberately still not offered, for any addon. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
webui/test/fixtures/.fake-state/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Ubuntu Based Kiosk
|
# Ubuntu Based Kiosk
|
||||||
|
|
||||||
**Current Version:** 2.16.0 (check script header for latest version)
|
**Current Version:** 2.17.0 (check script header for latest version)
|
||||||
**Built with Claude Sonnet 4.6 AI assistance**
|
**Built with Claude Sonnet 4.6 AI assistance**
|
||||||
**License:** GPL v3 - Keep derivatives open source
|
**License:** GPL v3 - Keep derivatives open source
|
||||||
**Repository:** https://github.com/outis1one/ubuntu-based-kiosk/
|
**Repository:** https://github.com/outis1one/ubuntu-based-kiosk/
|
||||||
@@ -1291,13 +1291,17 @@ terminal menu and the web UI, so they can't drift apart).
|
|||||||
steps, so any code or hardware-config change picked up by the pull
|
steps, so any code or hardware-config change picked up by the pull
|
||||||
actually takes effect. Also offers an on-demand Electron version
|
actually takes effect. Also offers an on-demand Electron version
|
||||||
check. See "Recent Updates (v2.15.0)" below.
|
check. See "Recent Updates (v2.15.0)" below.
|
||||||
- `webui/` + `menus/addon_webui.sh` — **Web UI** (Addons): a small
|
- `webui/` + `menus/addon_webui.sh` — **Web UI** (Addons, installed by
|
||||||
Node/Express app installed as a systemd service under `$KIOSK_USER`,
|
default during provisioning): a small Node/Express app running as a
|
||||||
giving a browser-based editor for Sites & Page Timing, Display &
|
systemd service under `$KIOSK_USER` with zero ambient `sudo`, giving
|
||||||
Interaction, and Password Protection & Lockout — no login of its own,
|
a browser-based editor for Sites & Page Timing, Display & Interaction,
|
||||||
put it behind your own reverse proxy with Authelia forward-auth if
|
and Password Protection & Lockout, plus install/reconfigure for CUPS,
|
||||||
it needs to be reachable beyond a trusted LAN. See "Recent Updates
|
LMS/Squeezelite, and Asterisk Intercom, and Update — each reached
|
||||||
(v2.16.0)" below.
|
through a narrow, allow-listed root helper
|
||||||
|
(`webui_write_helper_script`) rather than any ambient privilege on
|
||||||
|
the service itself. No login of its own, put it behind your own
|
||||||
|
reverse proxy with Authelia forward-auth if it needs to be reachable
|
||||||
|
beyond a trusted LAN. See "Recent Updates (v2.17.0)" below.
|
||||||
- `install.sh` — entry point for the modular tool, now grouped **Core
|
- `install.sh` — entry point for the modular tool, now grouped **Core
|
||||||
Settings / Addons / Advanced** like the legacy menu. On a machine
|
Settings / Addons / Advanced** like the legacy menu. On a machine
|
||||||
with no kiosk installed yet, it provisions one first (see
|
with no kiosk installed yet, it provisions one first (see
|
||||||
@@ -1353,9 +1357,18 @@ full migration pass.
|
|||||||
|
|
||||||
## Project Status & Future Plans
|
## Project Status & Future Plans
|
||||||
|
|
||||||
**Current Version:** 2.16.0
|
**Current Version:** 2.17.0
|
||||||
|
|
||||||
**Recent Updates (v2.16.0):**
|
**Recent Updates (v2.17.0):**
|
||||||
|
- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
|
||||||
|
- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions.
|
||||||
|
- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper is the only way it ever gains privilege — reachable only via a single-path passwordless sudo rule (generated and validated with `visudo -c -f` before being installed), and it re-checks its own fixed action allow-list before dispatching anything. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell.
|
||||||
|
- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped.
|
||||||
|
- **Full visual redesign:** a sidebar shell (Sites/Display/Lockout/Addons/Update) replacing the single scrolling page of three cards, both light and dark themes via `prefers-color-scheme`, no external font/CDN dependency.
|
||||||
|
- A real bug was found and fixed by actually driving the redesigned UI in a headless browser, not just by reading the code: refreshing an addon's pill/button after a successful install used to rebuild the whole card, which raced (and usually lost to) the success status/log that same job had just written a moment earlier. Fixed to update the pill/buttons in place, leaving the completed job's log exactly as the user left it.
|
||||||
|
- Uninstall-via-web is deliberately still not offered, for any addon — flagged as needing its own double-confirmation design, not bundled into this pass. WiFi, Timezone, Power/Display/Quiet Hours, Diagnostics, Remote Access, Authelia, Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings, and the fleet/multi-kiosk dashboard all remain out of scope for the web UI too — each a named, sequenced follow-up, not an oversight.
|
||||||
|
|
||||||
|
**Previous (v2.16.0):**
|
||||||
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
|
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
|
||||||
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
|
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
|
||||||
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk.
|
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk.
|
||||||
|
|||||||
@@ -33,6 +33,13 @@
|
|||||||
: "${WIREGUARD_DIR:=/etc/wireguard}"
|
: "${WIREGUARD_DIR:=/etc/wireguard}"
|
||||||
: "${WEBUI_DIR:=/opt/kiosk-webui}"
|
: "${WEBUI_DIR:=/opt/kiosk-webui}"
|
||||||
: "${WEBUI_ENV_DIR:=/etc/kiosk-webui}"
|
: "${WEBUI_ENV_DIR:=/etc/kiosk-webui}"
|
||||||
|
: "${SUDOERS_D_DIR:=/etc/sudoers.d}"
|
||||||
|
# The one root-owned script the Web UI's addon-install/Update actions are
|
||||||
|
# allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it
|
||||||
|
# and the matching /etc/sudoers.d/kiosk-webui rule - see that file's
|
||||||
|
# header). Under $BIN_DIR since that's already the convention for every
|
||||||
|
# other addon's own scripts.
|
||||||
|
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
||||||
|
|
||||||
# The admin account actually running this tool (as opposed to $KIOSK_USER,
|
# The admin account actually running this tool (as opposed to $KIOSK_USER,
|
||||||
# the kiosk's own restricted account) - used where an addon needs to grant
|
# the kiosk's own restricted account) - used where an addon needs to grant
|
||||||
|
|||||||
+47
-10
@@ -38,7 +38,8 @@
|
|||||||
# which is more risk than this pass should take on.
|
# which is more risk than this pass should take on.
|
||||||
#
|
#
|
||||||
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced
|
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced
|
||||||
# first, and every menus/*.sh this calls into for configuration.
|
# first, and every menus/*.sh this calls into for configuration
|
||||||
|
# (including menus/addon_webui.sh, for provision_configure_webui below).
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
PROVISION_FILES="$SCRIPT_DIR/provision/files"
|
PROVISION_FILES="$SCRIPT_DIR/provision/files"
|
||||||
@@ -54,7 +55,7 @@ provision_install_file() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_install_packages() {
|
provision_install_packages() {
|
||||||
echo "[1/9] Installing packages..."
|
echo "[1/10] Installing packages..."
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y \
|
sudo apt install -y \
|
||||||
xorg openbox lightdm unclutter screen curl git build-essential \
|
xorg openbox lightdm unclutter screen curl git build-essential \
|
||||||
@@ -79,7 +80,7 @@ provision_install_packages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_create_kiosk_user() {
|
provision_create_kiosk_user() {
|
||||||
echo "[2/9] Creating kiosk user..."
|
echo "[2/10] Creating kiosk user..."
|
||||||
if ! id "$KIOSK_USER" &>/dev/null; then
|
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||||
sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$KIOSK_USER"
|
sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$KIOSK_USER"
|
||||||
echo "$KIOSK_USER:kiosk" | sudo chpasswd
|
echo "$KIOSK_USER:kiosk" | sudo chpasswd
|
||||||
@@ -98,7 +99,7 @@ provision_create_kiosk_user() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_install_nodejs() {
|
provision_install_nodejs() {
|
||||||
echo "[3/9] Installing Node.js..."
|
echo "[3/10] Installing Node.js..."
|
||||||
if ! command -v node &>/dev/null; then
|
if ! command -v node &>/dev/null; then
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
sudo apt install -y nodejs
|
sudo apt install -y nodejs
|
||||||
@@ -107,7 +108,7 @@ provision_install_nodejs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_install_app() {
|
provision_install_app() {
|
||||||
echo "[4/9] Installing kiosk app..."
|
echo "[4/10] Installing kiosk app..."
|
||||||
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
||||||
sudo chmod +x "$KIOSK_DIR/start.sh"
|
sudo chmod +x "$KIOSK_DIR/start.sh"
|
||||||
@@ -150,7 +151,7 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_configure_display() {
|
provision_configure_display() {
|
||||||
echo "[5/9] Configuring display (LightDM/Openbox/touch/video)..."
|
echo "[5/10] Configuring display (LightDM/Openbox/touch/video)..."
|
||||||
|
|
||||||
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse"
|
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse"
|
||||||
sudo mkdir -p /etc/X11/xorg.conf.d
|
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||||
@@ -183,7 +184,7 @@ provision_configure_display() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_configure_firewall() {
|
provision_configure_firewall() {
|
||||||
echo "[6/9] Configuring firewall..."
|
echo "[6/10] Configuring firewall..."
|
||||||
sudo ufw --force enable
|
sudo ufw --force enable
|
||||||
sudo ufw default deny incoming
|
sudo ufw default deny incoming
|
||||||
sudo ufw default allow outgoing
|
sudo ufw default allow outgoing
|
||||||
@@ -192,7 +193,7 @@ provision_configure_firewall() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_configure_power_management() {
|
provision_configure_power_management() {
|
||||||
echo "[7/9] Configuring power management..."
|
echo "[7/10] Configuring power management..."
|
||||||
sudo mkdir -p /etc/polkit-1/localauthority/50-local.d
|
sudo mkdir -p /etc/polkit-1/localauthority/50-local.d
|
||||||
provision_install_file "etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla" /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla
|
provision_install_file "etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla" /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla
|
||||||
|
|
||||||
@@ -226,11 +227,43 @@ provision_configure_power_management() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Installed by default now, not opt-in - reuses menus/addon_webui.sh's
|
||||||
|
# own install helpers directly rather than duplicating them, with a
|
||||||
|
# fixed default port and no prompt (first-time install already has
|
||||||
|
# plenty). See that file's header for the privilege model (a narrow
|
||||||
|
# allow-listed root helper, not the service itself running as root) and
|
||||||
|
# why this exists at all: browser-based config editing, and - via that
|
||||||
|
# helper - install/reconfigure for CUPS/LMS/Squeezelite/Asterisk
|
||||||
|
# Intercom and Update, none of which are reimplemented here.
|
||||||
|
provision_configure_webui() {
|
||||||
|
echo "[8/10] Installing Web UI..."
|
||||||
|
if ! webui_install_app_files; then
|
||||||
|
log_warning "Web UI install failed - configure later: Addons -> Web UI"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local port="8090"
|
||||||
|
webui_write_env_file "$port"
|
||||||
|
webui_write_unit_file
|
||||||
|
webui_write_helper_script
|
||||||
|
if ! webui_write_sudoers_file; then
|
||||||
|
log_warning "Web UI installed but its addon-install/Update helper could not be granted permission - configure later: Addons -> Web UI"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if enable_and_start_units kiosk-webui; then
|
||||||
|
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||||
|
log_success "Web UI installed: http://$(get_ip_address):${port}"
|
||||||
|
else
|
||||||
|
log_warning "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Configuration from here on is NOT reimplemented - it's the exact same
|
# Configuration from here on is NOT reimplemented - it's the exact same
|
||||||
# Core Settings / Advanced menus this tool already uses to manage a
|
# Core Settings / Advanced menus this tool already uses to manage a
|
||||||
# kiosk after install, called directly instead of duplicated.
|
# kiosk after install, called directly instead of duplicated.
|
||||||
provision_configure_kiosk_settings() {
|
provision_configure_kiosk_settings() {
|
||||||
echo "[8/9] Configuring kiosk settings..."
|
echo "[9/10] Configuring kiosk settings..."
|
||||||
echo "Core Settings is next - sites, timezone, touch/navigation,"
|
echo "Core Settings is next - sites, timezone, touch/navigation,"
|
||||||
echo "password protection, WiFi, and schedules. Skip and configure"
|
echo "password protection, WiFi, and schedules. Skip and configure"
|
||||||
echo "later via ./install.sh if you'd rather do this after reboot."
|
echo "later via ./install.sh if you'd rather do this after reboot."
|
||||||
@@ -256,7 +289,7 @@ provision_configure_kiosk_settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provision_finish() {
|
provision_finish() {
|
||||||
echo "[9/9] Done."
|
echo "[10/10] Done."
|
||||||
echo
|
echo
|
||||||
log_success "Core installation complete!"
|
log_success "Core installation complete!"
|
||||||
echo
|
echo
|
||||||
@@ -289,6 +322,9 @@ run_first_time_install() {
|
|||||||
echo " - Hardware video acceleration"
|
echo " - Hardware video acceleration"
|
||||||
echo " - Audio support (PipeWire)"
|
echo " - Audio support (PipeWire)"
|
||||||
echo " - Time synchronization (NTP)"
|
echo " - Time synchronization (NTP)"
|
||||||
|
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
|
||||||
|
echo " addon install/reconfigure and Update - no login of its own,"
|
||||||
|
echo " see Addons -> Web UI)"
|
||||||
echo
|
echo
|
||||||
echo "OPTIONAL (configure after install, via Addons):"
|
echo "OPTIONAL (configure after install, via Addons):"
|
||||||
echo " - Lyrion Music Server (LMS) / Squeezelite"
|
echo " - Lyrion Music Server (LMS) / Squeezelite"
|
||||||
@@ -317,6 +353,7 @@ run_first_time_install() {
|
|||||||
provision_configure_display
|
provision_configure_display
|
||||||
provision_configure_firewall
|
provision_configure_firewall
|
||||||
provision_configure_power_management
|
provision_configure_power_management
|
||||||
|
provision_configure_webui
|
||||||
provision_configure_kiosk_settings
|
provision_configure_kiosk_settings
|
||||||
provision_finish
|
provision_finish
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+158
-14
@@ -3,24 +3,48 @@
|
|||||||
# menus/addon_webui.sh - "Web UI" addon: installs webui/ (a small Node/
|
# menus/addon_webui.sh - "Web UI" addon: installs webui/ (a small Node/
|
||||||
# Express app) as a systemd service running as $KIOSK_USER, giving a
|
# Express app) as a systemd service running as $KIOSK_USER, giving a
|
||||||
# browser-based editor for Sites & Page Timing, Display & Interaction,
|
# browser-based editor for Sites & Page Timing, Display & Interaction,
|
||||||
# and Password Protection & Lockout - the three Core Settings menus that
|
# and Password Protection & Lockout - config.json read/write only, no
|
||||||
# are pure config.json read/write with no privileged system mutation
|
# sudo needed, since that file is already owned by $KIOSK_USER.
|
||||||
# involved. The remaining Core Settings/Addons/Advanced menus (WiFi,
|
#
|
||||||
# Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other
|
# Installed by default during first-time provisioning now
|
||||||
# addon, everything in Advanced) stay terminal-only for now - a
|
# (lib/provision.sh's provision_configure_webui, which calls the same
|
||||||
# network-facing process shouldn't be handed sudo-level system mutation
|
# install helpers this file defines) - this menu remains here for
|
||||||
# without a lot more thought than this first pass gives it.
|
# reconfiguring the port, restarting the service, or reinstalling it on
|
||||||
|
# a kiosk provisioned before this change.
|
||||||
|
#
|
||||||
|
# Also lets the web UI trigger a fixed, vetted set of privileged actions
|
||||||
|
# - install/reconfigure CUPS/LMS/Squeezelite/Asterisk Intercom, and
|
||||||
|
# Update - through a narrow, allow-listed root helper
|
||||||
|
# (webui_write_helper_script below), rather than by giving the service
|
||||||
|
# itself any elevated privilege. The helper is reachable only via a
|
||||||
|
# single-path passwordless sudo rule (webui_write_sudoers_file) and
|
||||||
|
# re-checks its own fixed action allow-list before dispatching anything,
|
||||||
|
# even though the sudoers rule alone already restricts which script can
|
||||||
|
# run - defense in depth. Each allow-listed action is the exact same
|
||||||
|
# interactive `action_*` function the terminal menu already uses,
|
||||||
|
# driven by piping the right answers on stdin, the same technique this
|
||||||
|
# project's own bash tests already use (see webui/lib/actions.js and
|
||||||
|
# webui/test/*.test.js). No prompt/mutation refactor of any addon file
|
||||||
|
# was needed for this to work. Everything else (WiFi, Timezone, Power/
|
||||||
|
# Display/Quiet Hours, Complete Uninstall, Remote Access, Authelia,
|
||||||
|
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
|
||||||
|
# stays terminal-only for now.
|
||||||
#
|
#
|
||||||
# No login of its own, by design: this addon assumes it'll be put behind
|
# No login of its own, by design: this addon assumes it'll be put behind
|
||||||
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the
|
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the
|
||||||
# same way other self-hosted apps get protected - Authelia integration
|
# same way other self-hosted apps get protected - Authelia integration
|
||||||
# is explicitly out of scope for this repo (Authelia runs elsewhere).
|
# is explicitly out of scope for this repo (Authelia runs elsewhere).
|
||||||
# Direct LAN access with no proxy in front has no authentication at all -
|
# Direct LAN access with no proxy in front has no authentication at all -
|
||||||
# treat it the same as SSH access to this kiosk.
|
# treat it the same as SSH access to this kiosk, which is also the
|
||||||
|
# access level the privileged helper effectively grants if reached
|
||||||
|
# without a proxy in front: bounded to its fixed action list, not a
|
||||||
|
# root shell, but real system mutation all the same.
|
||||||
#
|
#
|
||||||
# webui/'s own app-level logic (config.json schema/merge, API validation)
|
# webui/'s own app-level logic (config.json schema/merge, API
|
||||||
# lives and is tested entirely under webui/ - this file only wires it up
|
# validation, the action allow-list/stdin synthesis, the job/SSE
|
||||||
# as a system service and never touches config.json itself.
|
# system) lives and is tested entirely under webui/ - this file only
|
||||||
|
# wires it up as a system service plus the privileged helper, and never
|
||||||
|
# touches config.json itself.
|
||||||
#
|
#
|
||||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -102,9 +126,122 @@ webui_write_env_file() {
|
|||||||
PORT=${port}
|
PORT=${port}
|
||||||
BIND_ADDR=0.0.0.0
|
BIND_ADDR=0.0.0.0
|
||||||
CONFIG_PATH=${CONFIG_PATH}
|
CONFIG_PATH=${CONFIG_PATH}
|
||||||
|
HELPER_PATH=${WEBUI_HELPER_PATH}
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Writes the one root-owned script the Web UI is ever allowed to reach
|
||||||
|
# via sudo (see the sudoers rule webui_write_sudoers_file writes right
|
||||||
|
# after this). Sources the exact same files install.sh does - full path
|
||||||
|
# list baked in at generation time from $SCRIPT_DIR, so the generated
|
||||||
|
# script has no runtime dependency on where it happens to be invoked
|
||||||
|
# from. The fixed ALLOWED_ACTIONS allow-list inside the script itself is
|
||||||
|
# the real gate (checked in addition to, not instead of, the sudoers
|
||||||
|
# rule only permitting this one script path) - a request that reaches
|
||||||
|
# this script can only ever trigger one of these exact, already-tested
|
||||||
|
# interactive `action_*` functions, driven the same way this project's
|
||||||
|
# own bash tests already drive them: real answers piped in on stdin, in
|
||||||
|
# the exact order the function's own prompts expect them. No prompt/
|
||||||
|
# mutation refactor of any addon file needed for this to work.
|
||||||
|
webui_write_helper_script() {
|
||||||
|
sudo mkdir -p "$(dirname "$WEBUI_HELPER_PATH")"
|
||||||
|
sudo tee "$WEBUI_HELPER_PATH" > /dev/null <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# lib/provision.sh reads \$SCRIPT_DIR directly at source time (no
|
||||||
|
# fallback default, unlike the lib/config.sh path vars) - must be a real
|
||||||
|
# exported variable here, not just used to interpolate the source paths
|
||||||
|
# below, or it's unbound under set -u.
|
||||||
|
export SCRIPT_DIR="$SCRIPT_DIR"
|
||||||
|
|
||||||
|
source "$SCRIPT_DIR/lib/menu.sh"
|
||||||
|
source "$SCRIPT_DIR/lib/config.sh"
|
||||||
|
source "$SCRIPT_DIR/lib/electron.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/sites.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/display.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/timezone.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/hidden_pin.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/lockout.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/wifi.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/power_schedule.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/diagnostics.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_cups.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_authelia.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_upgrade.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_factory_reset.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
|
||||||
|
source "$SCRIPT_DIR/menus/clone_settings.sh"
|
||||||
|
source "$SCRIPT_DIR/lib/provision.sh"
|
||||||
|
|
||||||
|
# Read-only status wrappers, not addon logic of their own - just a
|
||||||
|
# single fixed-shape JSON line so the web UI can show "Install" vs.
|
||||||
|
# "Reconfigure" per addon in one round trip. None of the underlying
|
||||||
|
# checks (dpkg query, systemctl is-active/is-enabled, a file-existence
|
||||||
|
# test under \$KIOSK_HOME) need root, but this script is still only
|
||||||
|
# reachable via the same sudo-gated path as everything else here - one
|
||||||
|
# access path is simpler to reason about than two, and the extra sudo
|
||||||
|
# call for a read-only check is negligible.
|
||||||
|
status_all() {
|
||||||
|
echo "{\"cups\":\$(cups_is_installed && echo true || echo false),\"lms\":\$(lms_is_installed && echo true || echo false),\"squeezelite\":\$(squeezelite_is_installed && echo true || echo false),\"asterisk_intercom\":\$(baresip_is_installed && echo true || echo false)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_ACTIONS=(
|
||||||
|
action_install_cups
|
||||||
|
action_reconfigure_cups
|
||||||
|
action_install_lms
|
||||||
|
action_install_squeezelite
|
||||||
|
action_configure_asterisk_intercom
|
||||||
|
action_upgrade
|
||||||
|
status_all
|
||||||
|
)
|
||||||
|
|
||||||
|
action="\${1:-}"
|
||||||
|
allowed=false
|
||||||
|
for a in "\${ALLOWED_ACTIONS[@]}"; do
|
||||||
|
[[ "\$a" == "\$action" ]] && allowed=true && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! \$allowed; then
|
||||||
|
echo "kiosk-webui-helper: action not permitted: \$action" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"\$action"
|
||||||
|
EOF
|
||||||
|
sudo chown root:root "$WEBUI_HELPER_PATH"
|
||||||
|
sudo chmod 750 "$WEBUI_HELPER_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grants $KIOSK_USER passwordless sudo on exactly this one script path -
|
||||||
|
# no argument wildcarding at the sudoers level, since the script's own
|
||||||
|
# ALLOWED_ACTIONS check above is the real gate. Validated with
|
||||||
|
# `visudo -c -f` on a temp file before it's moved into place: a
|
||||||
|
# malformed sudoers snippet can break sudo system-wide, so this step is
|
||||||
|
# never skipped.
|
||||||
|
webui_write_sudoers_file() {
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${WEBUI_HELPER_PATH}" > "$tmp"
|
||||||
|
|
||||||
|
if ! sudo visudo -c -f "$tmp" &>/dev/null; then
|
||||||
|
log_error "Generated sudoers rule failed validation - not installed"
|
||||||
|
rm -f "$tmp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "$SUDOERS_D_DIR"
|
||||||
|
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-webui"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
webui_write_unit_file() {
|
webui_write_unit_file() {
|
||||||
sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null <<EOF
|
sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -133,9 +270,10 @@ action_webui_install() {
|
|||||||
echo
|
echo
|
||||||
echo "This installs a small web app for editing Sites & Page Timing,"
|
echo "This installs a small web app for editing Sites & Page Timing,"
|
||||||
echo "Display & Interaction, and Password Protection & Lockout from a"
|
echo "Display & Interaction, and Password Protection & Lockout from a"
|
||||||
echo "browser. It has no login of its own - put it behind your own"
|
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
|
||||||
echo "reverse proxy (e.g. Caddy + Authelia) if it needs to be reachable"
|
echo "and Asterisk Intercom, and checking for updates. It has no login"
|
||||||
echo "beyond a trusted LAN."
|
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +"
|
||||||
|
echo "Authelia) if it needs to be reachable beyond a trusted LAN."
|
||||||
echo
|
echo
|
||||||
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
@@ -150,6 +288,11 @@ action_webui_install() {
|
|||||||
|
|
||||||
webui_write_env_file "$port"
|
webui_write_env_file "$port"
|
||||||
webui_write_unit_file
|
webui_write_unit_file
|
||||||
|
webui_write_helper_script
|
||||||
|
if ! webui_write_sudoers_file; then
|
||||||
|
log_error "Web UI install failed - could not grant the addon-install/Update helper permission"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
if enable_and_start_units kiosk-webui; then
|
if enable_and_start_units kiosk-webui; then
|
||||||
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||||
@@ -204,6 +347,7 @@ webui_do_uninstall() {
|
|||||||
sudo systemctl disable kiosk-webui 2>/dev/null || true
|
sudo systemctl disable kiosk-webui 2>/dev/null || true
|
||||||
sudo rm -f "$SYSTEMD_DIR/kiosk-webui.service"
|
sudo rm -f "$SYSTEMD_DIR/kiosk-webui.service"
|
||||||
sudo systemctl daemon-reload 2>/dev/null || true
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
sudo rm -f "$SUDOERS_D_DIR/kiosk-webui" "$WEBUI_HELPER_PATH"
|
||||||
sudo rm -rf "$WEBUI_DIR" "$WEBUI_ENV_DIR"
|
sudo rm -rf "$WEBUI_DIR" "$WEBUI_ENV_DIR"
|
||||||
log_success "Web UI removed"
|
log_success "Web UI removed"
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-2
@@ -1,8 +1,61 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
################################################################################
|
################################################################################
|
||||||
### Ubuntu Based Kiosk v2.16.0 ###
|
### Ubuntu Based Kiosk v2.17.0 ###
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
|
# RELEASE v2.17.0 - Web UI Now Installs by Default and Can Install/
|
||||||
|
# Reconfigure Addons + Check for Updates
|
||||||
|
# - Web UI (Addons -> Web UI) now installs by default during first-time
|
||||||
|
# provisioning (lib/provision.sh's provision_configure_webui), not
|
||||||
|
# opt-in - fixed port 8090, no prompt (matches every other core step).
|
||||||
|
# The Addons menu entry still works standalone for reconfiguring the
|
||||||
|
# port or reinstalling it on a kiosk provisioned before this change.
|
||||||
|
# - The web UI can now install/reconfigure CUPS Printing, LMS Server,
|
||||||
|
# Squeezelite Player, and Asterisk Intercom, and check for updates -
|
||||||
|
# the same four addons plus Update this project's user asked for by
|
||||||
|
# name. Every one of these is the exact same interactive action_*
|
||||||
|
# function the terminal menu already uses (action_install_cups,
|
||||||
|
# action_install_lms, action_install_squeezelite,
|
||||||
|
# action_configure_asterisk_intercom, action_upgrade) - no
|
||||||
|
# prompt/mutation refactor of any addon file, driven instead by
|
||||||
|
# piping the right answers on stdin, the same technique this
|
||||||
|
# project's own bash tests already use to drive these functions.
|
||||||
|
# - Privilege model: the web service itself still runs as $KIOSK_USER
|
||||||
|
# with zero ambient sudo. A new narrow, allow-listed root helper
|
||||||
|
# (menus/addon_webui.sh's webui_write_helper_script, reachable only
|
||||||
|
# via a single-path passwordless sudo rule generated and validated
|
||||||
|
# with `visudo -c -f` before being installed) is the only way the web
|
||||||
|
# UI ever gains privilege, and it re-checks its own fixed action
|
||||||
|
# allow-list before dispatching anything - a request that reaches it
|
||||||
|
# can only ever trigger one of five vetted actions, never a root
|
||||||
|
# shell. Chosen over running the whole service as root after asking
|
||||||
|
# directly: this repo has no login of its own by design (Authelia
|
||||||
|
# runs elsewhere), so a request that reaches it with no reverse proxy
|
||||||
|
# in front is effectively unauthenticated - the allow-list bounds
|
||||||
|
# what that can actually do.
|
||||||
|
# - Long-running installs stream live output to the browser via
|
||||||
|
# Server-Sent Events (webui/lib/jobs.js), with only one action
|
||||||
|
# running at a time (a second request while one is in flight gets a
|
||||||
|
# clear 409, not silently queued or dropped).
|
||||||
|
# - Full visual redesign: a sidebar shell (Sites/Display/Lockout/
|
||||||
|
# Addons/Update) replacing the single scrolling page of three cards,
|
||||||
|
# both light and dark themes via prefers-color-scheme, no external
|
||||||
|
# font/CDN dependency.
|
||||||
|
# - A real bug was found and fixed by actually driving the redesigned
|
||||||
|
# UI in a headless browser, not just by reading the code: refreshing
|
||||||
|
# an addon's pill/button after a successful install used to rebuild
|
||||||
|
# the whole card, which raced (and usually lost to) the success
|
||||||
|
# status/log that same job had just written a moment earlier. Fixed
|
||||||
|
# to update pill/buttons in place, leaving the completed job's log
|
||||||
|
# exactly as the user left it.
|
||||||
|
# - Uninstall-via-web is deliberately still not offered, for any addon -
|
||||||
|
# flagged as needing its own double-confirmation design, not bundled
|
||||||
|
# into this pass. WiFi, Timezone, Power/Display/Quiet Hours,
|
||||||
|
# Diagnostics, Remote Access, Authelia, Factory Reset, Virtual
|
||||||
|
# Consoles, Emergency Hotspot, Clone Settings, and the fleet/
|
||||||
|
# multi-kiosk dashboard all remain out of scope for the web UI too,
|
||||||
|
# each a named, sequenced follow-up rather than an oversight.
|
||||||
|
#
|
||||||
# RELEASE v2.16.0 - Web UI: Browser-Based Config Editor (install.sh ->
|
# RELEASE v2.16.0 - Web UI: Browser-Based Config Editor (install.sh ->
|
||||||
# Addons -> Web UI)
|
# Addons -> Web UI)
|
||||||
# - New: a small Node/Express app (webui/) installable via ./install.sh's
|
# - New: a small Node/Express app (webui/) installable via ./install.sh's
|
||||||
@@ -580,7 +633,7 @@ set -euo pipefail
|
|||||||
### SECTION 1: CONSTANTS & GLOBALS
|
### SECTION 1: CONSTANTS & GLOBALS
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
SCRIPT_VERSION="2.16.0"
|
SCRIPT_VERSION="2.17.0"
|
||||||
|
|
||||||
# Resolve the real path to this script file.
|
# Resolve the real path to this script file.
|
||||||
# When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor,
|
# When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor,
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// webui/lib/actions.js - the web UI's allow-list of privileged actions,
|
||||||
|
// and how to turn a web form's fields into the exact stdin sequence the
|
||||||
|
// real bash `action_*` function expects.
|
||||||
|
//
|
||||||
|
// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the
|
||||||
|
// generated kiosk-webui-helper script) - kept in sync by hand rather
|
||||||
|
// than shared/generated, since both lists are short and deliberately
|
||||||
|
// curated. A mismatch between the two just means an action fails closed
|
||||||
|
// on whichever side is missing it, never open on both: server.js checks
|
||||||
|
// this list before spawning anything, and the helper script re-checks
|
||||||
|
// its own list before dispatching regardless of what server.js sent.
|
||||||
|
//
|
||||||
|
// Every buildStdin() below was verified against the real menus/*.sh
|
||||||
|
// source (prompt order, defaults, and which fields reject a blank
|
||||||
|
// answer and re-prompt) - see webui/test/actions.test.js, which drives
|
||||||
|
// the actual bash functions with this exact output and checks the real
|
||||||
|
// resulting state (cups_is_installed, lms_is_installed, etc), not just
|
||||||
|
// that the process exits 0.
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
install_cups: {
|
||||||
|
helperAction: 'action_install_cups',
|
||||||
|
label: 'Install CUPS Printing',
|
||||||
|
fields: [],
|
||||||
|
// action_install_cups's only prompt is "Install CUPS printing?
|
||||||
|
// (n)" - the web UI's own install button is the confirmation,
|
||||||
|
// so this always answers yes. No pause() in this function.
|
||||||
|
buildStdin() {
|
||||||
|
return 'y\n';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
reconfigure_cups: {
|
||||||
|
helperAction: 'action_reconfigure_cups',
|
||||||
|
label: 'Reconfigure CUPS for network access',
|
||||||
|
fields: [],
|
||||||
|
// No prompts at all, no pause().
|
||||||
|
buildStdin() {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
install_lms: {
|
||||||
|
helperAction: 'action_install_lms',
|
||||||
|
label: 'Install / reconfigure LMS Server',
|
||||||
|
// fields.alreadyInstalled must reflect real current state
|
||||||
|
// (server.js fills this in from lms_is_installed via the
|
||||||
|
// helper's own status check before offering the reconfigure
|
||||||
|
// fields) - action_install_lms branches on it internally and a
|
||||||
|
// wrong guess here desyncs the stdin sequence from what the
|
||||||
|
// real function actually prompts for.
|
||||||
|
fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'],
|
||||||
|
buildStdin(f) {
|
||||||
|
if (f.alreadyInstalled) {
|
||||||
|
if (!f.reconfigurePort) {
|
||||||
|
return 'n\n\n'; // decline reconfigure, then pause()
|
||||||
|
}
|
||||||
|
const port = Number(f.newPort);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error('newPort must be an integer 1-65535');
|
||||||
|
}
|
||||||
|
return `y\n${port}\n\n`; // accept, new port, pause()
|
||||||
|
}
|
||||||
|
return '\n'; // fresh install: fully automated except pause()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
install_squeezelite: {
|
||||||
|
helperAction: 'action_install_squeezelite',
|
||||||
|
label: 'Install / reconfigure Squeezelite Player',
|
||||||
|
// If already installed, the real function first asks
|
||||||
|
// "Reconfigure?" (default n) and, if declined, returns
|
||||||
|
// immediately after just the pause() - it does NOT fall through
|
||||||
|
// to the player-name/server prompts. f.reconfigure must be
|
||||||
|
// explicit (not inferred from other fields) so the web UI can
|
||||||
|
// offer "leave it as-is" without also having to resend the
|
||||||
|
// current values.
|
||||||
|
fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'],
|
||||||
|
buildStdin(f) {
|
||||||
|
if (f.alreadyInstalled && !f.reconfigure) {
|
||||||
|
return 'n\n\n'; // decline reconfigure, then pause()
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?"
|
||||||
|
lines.push(f.playerName || ''); // blank -> "Kiosk" default
|
||||||
|
lines.push(f.lmsServer || ''); // blank -> auto-discovery
|
||||||
|
// "Reboot now?" is always answered "n" here regardless of
|
||||||
|
// what the UI shows - triggering a real `sudo reboot` from
|
||||||
|
// inside a one-click addon-install action is out of scope
|
||||||
|
// for this pass (see webui phase-2 plan). The UI surfaces
|
||||||
|
// "reboot required to start Squeezelite" as an info banner
|
||||||
|
// instead of a real remote reboot trigger.
|
||||||
|
lines.push('n');
|
||||||
|
lines.push(''); // pause()
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
configure_asterisk_intercom: {
|
||||||
|
helperAction: 'action_configure_asterisk_intercom',
|
||||||
|
label: 'Configure Asterisk Intercom',
|
||||||
|
// Same shape as Squeezelite's reconfigure gate: if already
|
||||||
|
// installed, the real function asks "Reconfigure with a
|
||||||
|
// different server/extension?" (default n) and returns after
|
||||||
|
// just the pause() if declined - the rest of this sequence is
|
||||||
|
// never reached in that case.
|
||||||
|
fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'],
|
||||||
|
buildStdin(f) {
|
||||||
|
if (f.alreadyInstalled && !f.reconfigure) {
|
||||||
|
return 'n\n\n'; // decline reconfigure, then pause()
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
// Only present at all when baresip_is_installed is already
|
||||||
|
// true - a fresh install has no "Reconfigure?" prompt.
|
||||||
|
if (f.alreadyInstalled) lines.push('y');
|
||||||
|
|
||||||
|
// Server IP and extension reject a blank answer and
|
||||||
|
// re-prompt (a `while [[ -z ... ]]` loop in the real
|
||||||
|
// function) - sending an empty line here would desync the
|
||||||
|
// rest of the sequence by consuming a second prompt cycle,
|
||||||
|
// so these are validated up front instead.
|
||||||
|
if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required');
|
||||||
|
lines.push(String(f.serverIp).trim());
|
||||||
|
|
||||||
|
lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : '');
|
||||||
|
|
||||||
|
if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required');
|
||||||
|
lines.push(String(f.extension).trim());
|
||||||
|
|
||||||
|
// Password also rejects blank and re-prompts, same reason.
|
||||||
|
if (!f.password) throw new Error('password is required');
|
||||||
|
lines.push(f.password);
|
||||||
|
|
||||||
|
lines.push(f.autoAnswer ? 'y' : 'n');
|
||||||
|
lines.push(f.useTls ? 'y' : 'n');
|
||||||
|
// "Proceed with installation?" (default y) - already
|
||||||
|
// confirmed by the web click that got us here.
|
||||||
|
lines.push('y');
|
||||||
|
lines.push(''); // pause()
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
upgrade: {
|
||||||
|
helperAction: 'action_upgrade',
|
||||||
|
label: 'Check for and apply updates',
|
||||||
|
fields: [],
|
||||||
|
buildStdin() {
|
||||||
|
// action_upgrade's own flow: "Pull these changes...?" (y),
|
||||||
|
// then - only if there was anything to pull -
|
||||||
|
// "Restart kiosk display now...?" (y), then always
|
||||||
|
// "Check for and install the latest Electron...?", answered
|
||||||
|
// n here. That sub-flow's own prompts default to declining
|
||||||
|
// and aren't a good fit for one-click automation yet (see
|
||||||
|
// webui phase-2 plan, "Explicitly deferred"). Answering "n"
|
||||||
|
// to a prompt that never actually gets shown (nothing to
|
||||||
|
// pull, or the display-restart question) is harmless - a
|
||||||
|
// synthesized line bash never reads is simply left unread,
|
||||||
|
// not an error.
|
||||||
|
return 'y\ny\nn\n';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAction(name) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ACTIONS, getAction };
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// webui/lib/jobs.js - runs one privileged action at a time via the
|
||||||
|
// allow-listed root helper (menus/addon_webui.sh's kiosk-webui-helper),
|
||||||
|
// and keeps an in-memory log so /api/actions/:name/run's caller and any
|
||||||
|
// number of SSE stream reconnects all see the same output. No database -
|
||||||
|
// this tool manages one kiosk, a Map is plenty.
|
||||||
|
//
|
||||||
|
// HELPER_PATH and SUDO_CMD are both overridable via environment (see
|
||||||
|
// webui/test/jobs.test.js): tests point HELPER_PATH at a small fake
|
||||||
|
// script and clear SUDO_CMD, so the real test suite never needs actual
|
||||||
|
// root or a real addon install - the same principle as every stubbed
|
||||||
|
// bash test in this project, just on the Node side.
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const { getAction } = require('./actions');
|
||||||
|
|
||||||
|
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
||||||
|
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
||||||
|
|
||||||
|
const jobs = new Map();
|
||||||
|
let activeJobId = null;
|
||||||
|
|
||||||
|
function startJob(actionName, fields) {
|
||||||
|
const action = getAction(actionName);
|
||||||
|
if (!action) {
|
||||||
|
const err = new Error(`Unknown action: ${actionName}`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (activeJobId) {
|
||||||
|
const err = new Error('Another action is already running - wait for it to finish first');
|
||||||
|
err.status = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildStdin() validates its own required fields and throws a plain
|
||||||
|
// Error with a human-readable message on bad input - treated as a
|
||||||
|
// 400 here, before anything is spawned.
|
||||||
|
let stdin;
|
||||||
|
try {
|
||||||
|
stdin = action.buildStdin(fields || {});
|
||||||
|
} catch (e) {
|
||||||
|
e.status = 400;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = randomUUID();
|
||||||
|
const job = {
|
||||||
|
id: jobId,
|
||||||
|
name: actionName,
|
||||||
|
label: action.label,
|
||||||
|
status: 'running',
|
||||||
|
log: [],
|
||||||
|
exitCode: null,
|
||||||
|
listeners: new Set(),
|
||||||
|
};
|
||||||
|
jobs.set(jobId, job);
|
||||||
|
activeJobId = jobId;
|
||||||
|
|
||||||
|
const child = SUDO_CMD
|
||||||
|
? spawn(SUDO_CMD, [HELPER_PATH, action.helperAction])
|
||||||
|
: spawn(HELPER_PATH, [action.helperAction]);
|
||||||
|
|
||||||
|
const appendLine = (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
job.log.push(text);
|
||||||
|
for (const listener of job.listeners) listener(text);
|
||||||
|
};
|
||||||
|
child.stdout.on('data', appendLine);
|
||||||
|
child.stderr.on('data', appendLine);
|
||||||
|
|
||||||
|
const finish = (status, exitCode) => {
|
||||||
|
if (job.status !== 'running') return; // 'error' and 'close' can both fire
|
||||||
|
job.status = status;
|
||||||
|
job.exitCode = exitCode;
|
||||||
|
for (const listener of job.listeners) listener(null);
|
||||||
|
if (activeJobId === jobId) activeJobId = null;
|
||||||
|
};
|
||||||
|
child.on('close', (code) => finish(code === 0 ? 'success' : 'failed', code));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
job.log.push(`\n[error] ${err.message}\n`);
|
||||||
|
finish('failed', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.write(stdin);
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJob(jobId) {
|
||||||
|
return jobs.get(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startJob, getJob };
|
||||||
+371
-13
@@ -1,10 +1,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
||||||
// user-controlled value (site URL/name/username) is set via .value or
|
// user-controlled value (site URL/name/username, addon form fields) is
|
||||||
// .textContent, never innerHTML, so nothing typed into a site name or
|
// set via .value or .textContent, never innerHTML, so nothing typed
|
||||||
// URL can execute as markup - the one new attack surface a browser-based
|
// into a form can execute as markup - the one new attack surface a
|
||||||
// config UI has that the terminal menus never did.
|
// browser-based config UI has that the terminal menus never did.
|
||||||
|
|
||||||
const msgEl = document.getElementById('msg');
|
const msgEl = document.getElementById('msg');
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
@@ -17,6 +17,25 @@ function showMessage(text, isError) {
|
|||||||
showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000);
|
showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Sidebar navigation */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
document.querySelectorAll('.nav-item').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(`page-${btn.dataset.page}`).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Config API (Sites / Display / Lockout) */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
async function apiGet() {
|
async function apiGet() {
|
||||||
const res = await fetch('/api/config');
|
const res = await fetch('/api/config');
|
||||||
if (!res.ok) throw new Error('Failed to load configuration');
|
if (!res.ok) throw new Error('Failed to load configuration');
|
||||||
@@ -95,7 +114,6 @@ function collectTabs() {
|
|||||||
tab.username = row.querySelector('.site-auth-username').value;
|
tab.username = row.querySelector('.site-auth-username').value;
|
||||||
const newPass = row.querySelector('.site-auth-password').value;
|
const newPass = row.querySelector('.site-auth-password').value;
|
||||||
if (newPass) tab.password = newPass;
|
if (newPass) tab.password = newPass;
|
||||||
// else: omit `password` entirely - server keeps the existing one.
|
|
||||||
} else {
|
} else {
|
||||||
tab.username = '';
|
tab.username = '';
|
||||||
tab.password = '';
|
tab.password = '';
|
||||||
@@ -104,10 +122,6 @@ function collectTabs() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// A "Save" per row would be simpler individually, but sites.sh's own
|
|
||||||
// save_config always rewrites the whole tabs array too - this mirrors
|
|
||||||
// that, saving all sites (and the home-page selection they feed into)
|
|
||||||
// together whenever anything in the Sites section changes.
|
|
||||||
async function saveSites() {
|
async function saveSites() {
|
||||||
const tabs = collectTabs();
|
const tabs = collectTabs();
|
||||||
if (tabs.some((t) => !t.url)) {
|
if (tabs.some((t) => !t.url)) {
|
||||||
@@ -227,10 +241,6 @@ lockoutForm.addEventListener('submit', async (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- */
|
|
||||||
/* Populate forms from a config snapshot */
|
|
||||||
/* ---------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function populateAll(config) {
|
function populateAll(config) {
|
||||||
displayForm.elements.swipeMode.value = config.swipeMode;
|
displayForm.elements.swipeMode.value = config.swipeMode;
|
||||||
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
||||||
@@ -251,6 +261,352 @@ function populateAll(config) {
|
|||||||
refreshLockoutFieldVisibility();
|
refreshLockoutFieldVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Shared: run a privileged action and stream its log via SSE */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
// jobPanelEls = { panel, status, log }. Returns a promise resolving to
|
||||||
|
// {status, exitCode} once the job finishes (or rejects on a request-level
|
||||||
|
// error before a job even started, e.g. validation).
|
||||||
|
async function runAction(actionName, fields, jobPanelEls) {
|
||||||
|
const res = await fetch(`/api/actions/${actionName}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fields || {}),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(body.error || 'Could not start action');
|
||||||
|
|
||||||
|
jobPanelEls.panel.classList.add('open');
|
||||||
|
jobPanelEls.log.textContent = '';
|
||||||
|
setJobStatus(jobPanelEls.status, 'running');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const source = new EventSource(`/api/actions/jobs/${body.jobId}/stream`);
|
||||||
|
source.addEventListener('log', (ev) => {
|
||||||
|
jobPanelEls.log.textContent += JSON.parse(ev.data);
|
||||||
|
jobPanelEls.log.scrollTop = jobPanelEls.log.scrollHeight;
|
||||||
|
});
|
||||||
|
source.addEventListener('done', (ev) => {
|
||||||
|
const result = JSON.parse(ev.data);
|
||||||
|
setJobStatus(jobPanelEls.status, result.status);
|
||||||
|
source.close();
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
source.onerror = () => {
|
||||||
|
source.close();
|
||||||
|
reject(new Error('Lost connection to the log stream'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setJobStatus(el, status) {
|
||||||
|
el.className = `job-status ${status}`;
|
||||||
|
if (status === 'running') {
|
||||||
|
el.innerHTML = '';
|
||||||
|
const spinner = document.createElement('span');
|
||||||
|
spinner.className = 'spinner';
|
||||||
|
el.appendChild(spinner);
|
||||||
|
el.appendChild(document.createTextNode('Running'));
|
||||||
|
} else {
|
||||||
|
el.textContent = status === 'success' ? 'Success' : 'Failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Addons */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const addonsList = document.getElementById('addons-list');
|
||||||
|
const addonCardTemplate = document.getElementById('addon-card-template');
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
printer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V3h12v6M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7"/></svg>',
|
||||||
|
music: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>',
|
||||||
|
speaker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><circle cx="12" cy="14" r="4"/><circle cx="12" cy="6" r="1"/></svg>',
|
||||||
|
phone: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .3 2 .6 2.9a2 2 0 0 1-.5 2.1L8 9.9a16 16 0 0 0 6 6l1.2-1.2a2 2 0 0 1 2.1-.5c.9.3 1.9.5 2.9.6a2 2 0 0 1 1.8 2Z"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
function pillHtml(state) {
|
||||||
|
if (state === true) return '<span class="pill installed">Installed</span>';
|
||||||
|
if (state === false) return '<span class="pill not-installed">Not installed</span>';
|
||||||
|
return '<span class="pill unknown">Unknown</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let addonStatus = {};
|
||||||
|
|
||||||
|
async function loadAddonStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/addons/status');
|
||||||
|
if (res.ok) addonStatus = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
// leave addonStatus as-is (pills show "Unknown"); not fatal to the page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyed by addon, so a single card can be refreshed in place after its
|
||||||
|
// own job finishes (see refreshAddonCard) without touching the other
|
||||||
|
// three, and - critically - without recreating the job-panel/log the
|
||||||
|
// user is currently looking at. An earlier version called the full
|
||||||
|
// renderAddons() rebuild after every successful job "to update the
|
||||||
|
// pill"; that raced (and usually lost to) the same success/log state it
|
||||||
|
// had just written a moment earlier, since rebuilding the whole list
|
||||||
|
// replaces the job-panel node with a fresh empty one. Caught by an
|
||||||
|
// actual headless-browser run, not just reading the code - the log
|
||||||
|
// looked fine reading it, but watching it in Chromium showed the
|
||||||
|
// "Success" state flash and vanish.
|
||||||
|
const ADDON_RECIPES = {};
|
||||||
|
|
||||||
|
function buildAddonCard(recipe) {
|
||||||
|
ADDON_RECIPES[recipe.key] = recipe;
|
||||||
|
const node = addonCardTemplate.content.firstElementChild.cloneNode(true);
|
||||||
|
node.dataset.addon = recipe.key;
|
||||||
|
fillAddonCard(node, recipe);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Re)fills everything in a card EXCEPT the job-panel/log, which is
|
||||||
|
// left exactly as it is - so refreshing a card's install-state after a
|
||||||
|
// job finishes doesn't erase the result the user just watched stream in.
|
||||||
|
function fillAddonCard(node, { key, title, desc, icon, buildForm }) {
|
||||||
|
node.querySelector('.addon-icon').innerHTML = ICONS[icon];
|
||||||
|
node.querySelector('.addon-name').textContent = title;
|
||||||
|
node.querySelector('.addon-desc').textContent = desc;
|
||||||
|
node.querySelector('.pill').outerHTML = pillHtml(addonStatus[key]);
|
||||||
|
|
||||||
|
const actionsEl = node.querySelector('.addon-actions');
|
||||||
|
const formEl = node.querySelector('.addon-form');
|
||||||
|
actionsEl.textContent = '';
|
||||||
|
formEl.textContent = '';
|
||||||
|
formEl.className = 'addon-form';
|
||||||
|
|
||||||
|
const jobPanelEls = {
|
||||||
|
panel: node.querySelector('.job-panel'),
|
||||||
|
status: node.querySelector('.job-status'),
|
||||||
|
log: node.querySelector('.job-log'),
|
||||||
|
};
|
||||||
|
buildForm({ node, actionsEl, formEl, jobPanelEls, installed: addonStatus[key] === true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after one addon's own job finishes - refreshes just that
|
||||||
|
// card's pill/buttons/form (e.g. "Install" -> "Reconfigure") in place.
|
||||||
|
async function refreshAddonCard(key) {
|
||||||
|
await loadAddonStatus();
|
||||||
|
const node = addonsList.querySelector(`[data-addon="${key}"]`);
|
||||||
|
if (node) fillAddonCard(node, ADDON_RECIPES[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSubmitAction(formEl, jobPanelEls, actionName, collectFields, onDone) {
|
||||||
|
formEl.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = formEl.querySelector('button[type=submit]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runAction(actionName, collectFields(), jobPanelEls);
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showMessage('Done');
|
||||||
|
if (onDone) await onDone();
|
||||||
|
} else {
|
||||||
|
showMessage('Action failed - see the log below', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(err.message, true);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAddons() {
|
||||||
|
addonsList.textContent = '';
|
||||||
|
|
||||||
|
// CUPS: no fields at all - the button itself is the whole form.
|
||||||
|
addonsList.appendChild(buildAddonCard({
|
||||||
|
key: 'cups', title: 'CUPS Printing', desc: 'Network printer sharing', icon: 'printer',
|
||||||
|
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = installed ? 'Reconfigure for network access' : 'Install CUPS Printing';
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const action = installed ? 'reconfigure_cups' : 'install_cups';
|
||||||
|
const result = await runAction(action, {}, jobPanelEls);
|
||||||
|
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('cups'); }
|
||||||
|
else showMessage('Action failed - see the log below', true);
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(err.message, true);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(btn);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// LMS Server: fresh install has no fields; once installed, an
|
||||||
|
// optional port-reconfigure field.
|
||||||
|
addonsList.appendChild(buildAddonCard({
|
||||||
|
key: 'lms', title: 'LMS Server', desc: 'Lyrion / Logitech Media Server', icon: 'music',
|
||||||
|
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||||
|
if (!installed) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = 'Install LMS Server';
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runAction('install_lms', { alreadyInstalled: false }, jobPanelEls);
|
||||||
|
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('lms'); }
|
||||||
|
else showMessage('Action failed - see the log below', true);
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(err.message, true);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(btn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.type = 'button';
|
||||||
|
toggleBtn.className = 'secondary';
|
||||||
|
toggleBtn.textContent = 'Reconfigure port';
|
||||||
|
toggleBtn.addEventListener('click', () => formEl.classList.toggle('open'));
|
||||||
|
actionsEl.appendChild(toggleBtn);
|
||||||
|
|
||||||
|
const portLabel = document.createElement('label');
|
||||||
|
portLabel.innerHTML = 'New HTTP port';
|
||||||
|
const portInput = document.createElement('input');
|
||||||
|
portInput.type = 'number'; portInput.min = '1'; portInput.max = '65535'; portInput.value = '9000';
|
||||||
|
portLabel.appendChild(portInput);
|
||||||
|
formEl.appendChild(portLabel);
|
||||||
|
const submitBtn = document.createElement('button');
|
||||||
|
submitBtn.type = 'submit';
|
||||||
|
submitBtn.textContent = 'Apply new port';
|
||||||
|
formEl.appendChild(submitBtn);
|
||||||
|
|
||||||
|
addSubmitAction(formEl, jobPanelEls, 'install_lms', () => ({
|
||||||
|
alreadyInstalled: true, reconfigurePort: true, newPort: parseInt(portInput.value, 10),
|
||||||
|
}), () => refreshAddonCard('lms'));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Squeezelite: player name + LMS server, both for install and
|
||||||
|
// reconfigure - the form is the same either way.
|
||||||
|
addonsList.appendChild(buildAddonCard({
|
||||||
|
key: 'squeezelite', title: 'Squeezelite Player', desc: 'Turns this kiosk into an LMS-connected speaker', icon: 'speaker',
|
||||||
|
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||||
|
formEl.classList.add('open');
|
||||||
|
|
||||||
|
const nameLabel = document.createElement('label');
|
||||||
|
nameLabel.textContent = 'Player name';
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.type = 'text'; nameInput.value = 'Kiosk'; nameInput.placeholder = 'Kiosk';
|
||||||
|
nameLabel.appendChild(nameInput);
|
||||||
|
formEl.appendChild(nameLabel);
|
||||||
|
|
||||||
|
const serverLabel = document.createElement('label');
|
||||||
|
serverLabel.innerHTML = 'LMS server <span class="hint">(IP:PORT, blank for auto-discovery)</span>';
|
||||||
|
const serverInput = document.createElement('input');
|
||||||
|
serverInput.type = 'text'; serverInput.placeholder = '192.168.1.100:3483';
|
||||||
|
serverLabel.appendChild(serverInput);
|
||||||
|
formEl.appendChild(serverLabel);
|
||||||
|
|
||||||
|
const rebootHint = document.createElement('p');
|
||||||
|
rebootHint.className = 'hint';
|
||||||
|
rebootHint.textContent = 'A reboot is required after install/reconfigure before Squeezelite starts.';
|
||||||
|
formEl.appendChild(rebootHint);
|
||||||
|
|
||||||
|
const submitBtn = document.createElement('button');
|
||||||
|
submitBtn.type = 'submit';
|
||||||
|
submitBtn.textContent = installed ? 'Reconfigure Squeezelite' : 'Install Squeezelite';
|
||||||
|
formEl.appendChild(submitBtn);
|
||||||
|
|
||||||
|
addSubmitAction(formEl, jobPanelEls, 'install_squeezelite', () => ({
|
||||||
|
alreadyInstalled: installed, reconfigure: true,
|
||||||
|
playerName: nameInput.value.trim(), lmsServer: serverInput.value.trim(),
|
||||||
|
}), () => refreshAddonCard('squeezelite'));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Asterisk Intercom: server/extension/password/options, always shown.
|
||||||
|
addonsList.appendChild(buildAddonCard({
|
||||||
|
key: 'asterisk_intercom', title: 'Asterisk Intercom', desc: 'SIP extension client (Baresip)', icon: 'phone',
|
||||||
|
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||||
|
formEl.classList.add('open');
|
||||||
|
|
||||||
|
const mk = (label, type, opts) => {
|
||||||
|
const l = document.createElement('label');
|
||||||
|
l.textContent = label;
|
||||||
|
const i = document.createElement('input');
|
||||||
|
i.type = type;
|
||||||
|
Object.assign(i, opts || {});
|
||||||
|
l.appendChild(i);
|
||||||
|
formEl.appendChild(l);
|
||||||
|
return i;
|
||||||
|
};
|
||||||
|
const ip = mk('Server IP or hostname', 'text', { placeholder: '10.0.0.5' });
|
||||||
|
const port = mk('Server port', 'number', { placeholder: '5060', min: '1', max: '65535' });
|
||||||
|
const ext = mk('Extension number', 'text', { placeholder: '201' });
|
||||||
|
const pass = mk('SIP password', 'password', { autocomplete: 'new-password' });
|
||||||
|
|
||||||
|
const autoAnswerLabel = document.createElement('label');
|
||||||
|
autoAnswerLabel.className = 'checkbox';
|
||||||
|
const autoAnswer = document.createElement('input');
|
||||||
|
autoAnswer.type = 'checkbox';
|
||||||
|
autoAnswerLabel.appendChild(autoAnswer);
|
||||||
|
autoAnswerLabel.appendChild(document.createTextNode('Auto-answer incoming calls (intercom mode)'));
|
||||||
|
formEl.appendChild(autoAnswerLabel);
|
||||||
|
|
||||||
|
const tlsLabel = document.createElement('label');
|
||||||
|
tlsLabel.className = 'checkbox';
|
||||||
|
const useTls = document.createElement('input');
|
||||||
|
useTls.type = 'checkbox';
|
||||||
|
tlsLabel.appendChild(useTls);
|
||||||
|
tlsLabel.appendChild(document.createTextNode('Use TLS encryption'));
|
||||||
|
formEl.appendChild(tlsLabel);
|
||||||
|
|
||||||
|
const submitBtn = document.createElement('button');
|
||||||
|
submitBtn.type = 'submit';
|
||||||
|
submitBtn.textContent = installed ? 'Reconfigure Asterisk Intercom' : 'Connect to Asterisk server';
|
||||||
|
formEl.appendChild(submitBtn);
|
||||||
|
|
||||||
|
addSubmitAction(formEl, jobPanelEls, 'configure_asterisk_intercom', () => ({
|
||||||
|
alreadyInstalled: installed, reconfigure: true,
|
||||||
|
serverIp: ip.value.trim(), serverPort: port.value ? parseInt(port.value, 10) : undefined,
|
||||||
|
extension: ext.value.trim(), password: pass.value,
|
||||||
|
autoAnswer: autoAnswer.checked, useTls: useTls.checked,
|
||||||
|
}), () => refreshAddonCard('asterisk_intercom'));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Update */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
document.getElementById('run-upgrade').addEventListener('click', async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runAction('upgrade', {}, {
|
||||||
|
panel: document.getElementById('job-panel-upgrade'),
|
||||||
|
status: document.getElementById('job-status-upgrade'),
|
||||||
|
log: document.getElementById('job-log-upgrade'),
|
||||||
|
});
|
||||||
|
showMessage(result.status === 'success' ? 'Update finished' : 'Update failed - see the log below', result.status !== 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(err.message, true);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Init */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
currentConfig = await apiGet();
|
currentConfig = await apiGet();
|
||||||
@@ -259,6 +615,8 @@ async function init() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
showMessage(e.message, true);
|
showMessage(e.message, true);
|
||||||
}
|
}
|
||||||
|
await loadAddonStatus();
|
||||||
|
renderAddons();
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
+193
-92
@@ -7,100 +7,177 @@
|
|||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<div class="shell">
|
||||||
<h1>Kiosk Web UI</h1>
|
<nav class="sidebar">
|
||||||
<p class="subtitle">Sites, display behavior, and lockout - saved directly to this kiosk's config.json.</p>
|
<div class="brand">
|
||||||
</header>
|
<div class="brand-mark">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||||
<main>
|
|
||||||
<section id="msg" class="banner" hidden></section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Sites & Page Timing</h2>
|
|
||||||
<div id="sites-list"></div>
|
|
||||||
<div class="button-row">
|
|
||||||
<button type="button" id="add-site" class="secondary">Add a page</button>
|
|
||||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Display & Interaction</h2>
|
|
||||||
<form id="display-form">
|
|
||||||
<label>
|
|
||||||
Touch gesture mode
|
|
||||||
<select name="swipeMode">
|
|
||||||
<option value="dual">Dual-direction (recommended for touchscreens)</option>
|
|
||||||
<option value="standard">Standard</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Link navigation security
|
|
||||||
<select name="allowNavigation">
|
|
||||||
<option value="restricted">Restricted - only the loaded URL</option>
|
|
||||||
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
|
|
||||||
<option value="open">Open - any link</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Home page</legend>
|
|
||||||
<label>
|
|
||||||
Home page
|
|
||||||
<select name="homeTabIndex" id="home-tab-select">
|
|
||||||
<option value="-1">Disabled</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Inactivity timeout (minutes)
|
|
||||||
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<button type="submit">Save Display & Interaction</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Password Protection & Lockout</h2>
|
|
||||||
<form id="lockout-form">
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
|
|
||||||
|
|
||||||
<div id="lockout-fields">
|
|
||||||
<label>
|
|
||||||
<span id="password-label">Set lockout password</span>
|
|
||||||
<input type="password" name="newLockoutPassword" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Confirm password
|
|
||||||
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Inactivity lockout timeout (minutes, 0 = boot/wake only)
|
|
||||||
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Daily lock time
|
|
||||||
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<strong>Kiosk Web UI</strong>
|
||||||
|
<span id="brand-sub">this kiosk</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Save Password Protection & Lockout</button>
|
<div class="nav">
|
||||||
</form>
|
<button class="nav-item active" data-page="sites">
|
||||||
</section>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||||||
</main>
|
Sites & Timing
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-page="display">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></svg>
|
||||||
|
Display
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-page="lockout">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
||||||
|
Lockout
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-page="addons">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
|
||||||
|
Addons
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-page="update">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div id="msg" class="banner" hidden></div>
|
||||||
|
|
||||||
|
<!-- Sites & Page Timing -->
|
||||||
|
<section class="page active" id="page-sites">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Sites & Page Timing</h1>
|
||||||
|
<p>The pages this kiosk rotates through, and how long each stays on screen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div id="sites-list"></div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" id="add-site" class="secondary">Add a page</button>
|
||||||
|
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Display & Interaction -->
|
||||||
|
<section class="page" id="page-display">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Display & Interaction</h1>
|
||||||
|
<p>Touch gestures, link navigation, on-screen buttons, and the home page.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<form id="display-form">
|
||||||
|
<label>
|
||||||
|
Touch gesture mode
|
||||||
|
<select name="swipeMode">
|
||||||
|
<option value="dual">Dual-direction (recommended for touchscreens)</option>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Link navigation security
|
||||||
|
<select name="allowNavigation">
|
||||||
|
<option value="restricted">Restricted - only the loaded URL</option>
|
||||||
|
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
|
||||||
|
<option value="open">Open - any link</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Home page</legend>
|
||||||
|
<label>
|
||||||
|
Home page
|
||||||
|
<select name="homeTabIndex" id="home-tab-select">
|
||||||
|
<option value="-1">Disabled</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Inactivity timeout (minutes)
|
||||||
|
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="button-row"><button type="submit">Save Display & Interaction</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Password Protection & Lockout -->
|
||||||
|
<section class="page" id="page-lockout">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Password Protection & Lockout</h1>
|
||||||
|
<p>Blank the screen after inactivity and require a password to unlock.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<form id="lockout-form">
|
||||||
|
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
|
||||||
|
|
||||||
|
<div id="lockout-fields">
|
||||||
|
<label>
|
||||||
|
<span id="password-label">Set lockout password</span>
|
||||||
|
<input type="password" name="newLockoutPassword" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Confirm password
|
||||||
|
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Inactivity lockout timeout (minutes, 0 = boot/wake only)
|
||||||
|
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox"><input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily</label>
|
||||||
|
<label>
|
||||||
|
Daily lock time
|
||||||
|
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row"><button type="submit">Save Password Protection & Lockout</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Addons -->
|
||||||
|
<section class="page" id="page-addons">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Addons</h1>
|
||||||
|
<p>Install and configure the same addons the terminal menu offers. Uninstall isn't available here yet.</p>
|
||||||
|
</div>
|
||||||
|
<div id="addons-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Update -->
|
||||||
|
<section class="page" id="page-update">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Update</h1>
|
||||||
|
<p>Pull the latest code from git and re-apply setup (packages, app files, hardware config).</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="button-row"><button type="button" id="run-upgrade">Check for and apply updates</button></div>
|
||||||
|
<div class="job-panel" id="job-panel-upgrade">
|
||||||
|
<div class="job-panel-header">
|
||||||
|
<span>Update</span>
|
||||||
|
<span class="job-status" id="job-status-upgrade"></span>
|
||||||
|
</div>
|
||||||
|
<pre class="job-log" id="job-log-upgrade"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template id="site-row-template">
|
<template id="site-row-template">
|
||||||
<div class="site-row card-inset">
|
<div class="site-row card-inset">
|
||||||
@@ -134,6 +211,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="addon-card-template">
|
||||||
|
<div class="card addon-card">
|
||||||
|
<div class="addon-card-top">
|
||||||
|
<div class="addon-title">
|
||||||
|
<div class="addon-icon"></div>
|
||||||
|
<div>
|
||||||
|
<div class="addon-name"></div>
|
||||||
|
<div class="addon-desc"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="pill unknown">checking…</span>
|
||||||
|
</div>
|
||||||
|
<div class="button-row addon-actions"></div>
|
||||||
|
<form class="addon-form"></form>
|
||||||
|
<div class="job-panel">
|
||||||
|
<div class="job-panel-header">
|
||||||
|
<span>Log</span>
|
||||||
|
<span class="job-status"></span>
|
||||||
|
</div>
|
||||||
|
<pre class="job-log"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+302
-89
@@ -1,85 +1,222 @@
|
|||||||
|
/* Design tokens - light by default, dark via prefers-color-scheme.
|
||||||
|
No external font/CDN dependency (self-hosted admin tool shouldn't
|
||||||
|
phone out to Google Fonts) - a well-tuned system-ui stack plus real
|
||||||
|
spacing/depth/motion is what actually reads as "modern", not the
|
||||||
|
typeface. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1115;
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, Roboto, Helvetica, Arial, sans-serif;
|
||||||
--panel: #171a21;
|
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||||
--panel-inset: #1e222b;
|
|
||||||
--border: #2a2f3a;
|
--bg: #f5f6f8;
|
||||||
--text: #e6e9ef;
|
--bg-elevated: #ffffff;
|
||||||
--text-dim: #9aa3b2;
|
--bg-sunken: #eef0f3;
|
||||||
--accent: #4f8cff;
|
--border: #e2e5ea;
|
||||||
|
--border-strong: #cbd0d8;
|
||||||
|
--text: #14161a;
|
||||||
|
--text-dim: #5c6370;
|
||||||
|
--text-faint: #8b929e;
|
||||||
|
--accent: #3b6ff0;
|
||||||
|
--accent-hover: #2f5cd6;
|
||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
--danger: #e5566a;
|
--accent-soft: #e8effe;
|
||||||
--success: #3fb87f;
|
--success: #1c8a5b;
|
||||||
--error: #e5566a;
|
--success-soft: #e3f6ec;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
--warning: #a9660a;
|
||||||
|
--warning-soft: #fdf1de;
|
||||||
|
--danger: #d13a3a;
|
||||||
|
--danger-soft: #fbe8e8;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(20, 22, 26, 0.06);
|
||||||
|
--shadow-md: 0 4px 16px rgba(20, 22, 26, 0.08);
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--sidebar-w: 232px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #101216;
|
||||||
|
--bg-elevated: #17191f;
|
||||||
|
--bg-sunken: #0c0d10;
|
||||||
|
--border: #262a33;
|
||||||
|
--border-strong: #363c48;
|
||||||
|
--text: #eceef2;
|
||||||
|
--text-dim: #9aa1ad;
|
||||||
|
--text-faint: #6b7280;
|
||||||
|
--accent: #5b8cff;
|
||||||
|
--accent-hover: #7ba0ff;
|
||||||
|
--accent-text: #0a0e18;
|
||||||
|
--accent-soft: #17233f;
|
||||||
|
--success: #3ecf8e;
|
||||||
|
--success-soft: #103527;
|
||||||
|
--warning: #e2a53f;
|
||||||
|
--warning-soft: #3a2c11;
|
||||||
|
--danger: #f0605f;
|
||||||
|
--danger-soft: #3a1616;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
::selection { background: var(--accent-soft); }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.5;
|
font-family: var(--font);
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
a { color: var(--accent); }
|
||||||
padding: 2rem 1.5rem 1rem;
|
|
||||||
max-width: 720px;
|
/* ---------------------------------------------------------------- */
|
||||||
margin: 0 auto;
|
/* Shell: fixed sidebar + main content */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
|
.sidebar {
|
||||||
.subtitle { margin: 0; color: var(--text-dim); font-size: 0.9rem; }
|
width: var(--sidebar-w);
|
||||||
|
flex-shrink: 0;
|
||||||
main {
|
background: var(--bg-elevated);
|
||||||
max-width: 720px;
|
border-right: 1px solid var(--border);
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1.5rem 3rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
padding: 20px 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 10px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-mark svg { width: 17px; height: 17px; }
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.25;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.brand-text strong { font-size: 14px; }
|
||||||
|
.brand-text span { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; opacity: 0.85; }
|
||||||
|
.nav-item:hover { background: var(--bg-sunken); color: var(--text); }
|
||||||
|
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.nav-item.active svg { opacity: 1; }
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 32px 40px 60px;
|
||||||
|
max-width: 880px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page { display: none; }
|
||||||
|
.page.active { display: block; }
|
||||||
|
|
||||||
|
.page-header { margin-bottom: 24px; }
|
||||||
|
.page-header h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
|
||||||
|
.page-header p { margin: 0; color: var(--text-dim); font-size: 13.5px; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Cards, forms, buttons */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.25rem;
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h2 {
|
.card h2 { margin: 0 0 14px; font-size: 14.5px; font-weight: 600; }
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-inset {
|
.card-inset {
|
||||||
background: var(--panel-inset);
|
background: var(--bg-sunken);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 1rem;
|
padding: 14px 16px;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
form { display: flex; flex-direction: column; gap: 1rem; }
|
form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 5px;
|
||||||
font-size: 0.9rem;
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
label.checkbox {
|
label.checkbox {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
label.checkbox input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||||
|
|
||||||
label.checkbox input { width: 1.05rem; height: 1.05rem; }
|
.hint { color: var(--text-faint); font-size: 11.5px; font-weight: normal; }
|
||||||
|
|
||||||
.hint { color: var(--text-dim); font-size: 0.8rem; font-weight: normal; }
|
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
@@ -87,96 +224,172 @@ input[type="number"],
|
|||||||
input[type="time"],
|
input[type="time"],
|
||||||
select {
|
select {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 8px 10px;
|
||||||
font-size: 0.95rem;
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus, select:focus {
|
input:focus, select:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: none;
|
||||||
outline-offset: 1px;
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 0.75rem 1rem 1rem;
|
padding: 14px 16px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 14px;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
legend { padding: 0 6px; color: var(--text-dim); font-size: 12px; font-weight: 600; }
|
||||||
legend { padding: 0 0.4rem; color: var(--text-dim); font-size: 0.85rem; }
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 0.55rem 1rem;
|
padding: 8px 15px;
|
||||||
font-size: 0.9rem;
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
transition: background-color 0.12s ease, transform 0.05s ease;
|
||||||
}
|
}
|
||||||
|
button:hover { background: var(--accent-hover); }
|
||||||
|
button:active { transform: translateY(1px); }
|
||||||
|
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
button.secondary {
|
button.secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-strong);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
button.secondary:hover { background: var(--bg-sunken); }
|
||||||
|
|
||||||
button.danger {
|
button.danger {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--danger);
|
border: 1px solid var(--danger);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
button.danger:hover { background: var(--danger-soft); }
|
||||||
|
|
||||||
button:hover { filter: brightness(1.1); }
|
.button-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.button-row {
|
/* ---------------------------------------------------------------- */
|
||||||
display: flex;
|
/* Addon cards, status pills, job log panel */
|
||||||
gap: 0.6rem;
|
/* ---------------------------------------------------------------- */
|
||||||
margin-top: 0.75rem;
|
|
||||||
|
.addon-card { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.addon-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.addon-title { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.addon-icon {
|
||||||
|
width: 34px; height: 34px; border-radius: 9px;
|
||||||
|
background: var(--bg-sunken); display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-dim); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.addon-icon svg { width: 18px; height: 18px; }
|
||||||
|
.addon-name { font-weight: 600; font-size: 14px; }
|
||||||
|
.addon-desc { color: var(--text-faint); font-size: 12px; margin-top: 1px; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 3px 9px; border-radius: 999px;
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; }
|
||||||
|
.pill.installed { background: var(--success-soft); color: var(--success); }
|
||||||
|
.pill.installed::before { background: var(--success); }
|
||||||
|
.pill.not-installed { background: var(--bg-sunken); color: var(--text-faint); }
|
||||||
|
.pill.not-installed::before { background: var(--text-faint); }
|
||||||
|
.pill.unknown { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
.pill.unknown::before { background: var(--warning); }
|
||||||
|
|
||||||
|
.addon-form { display: none; }
|
||||||
|
.addon-form.open { display: flex; padding-top: 4px; border-top: 1px solid var(--border); margin-top: 4px; }
|
||||||
|
|
||||||
|
.job-panel {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.job-panel.open { display: block; }
|
||||||
|
.job-panel-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12.5px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.job-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; }
|
||||||
|
.job-status.running { color: var(--accent); }
|
||||||
|
.job-status.success { color: var(--success); }
|
||||||
|
.job-status.failed { color: var(--danger); }
|
||||||
|
.spinner {
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
border: 2px solid var(--accent-soft); border-top-color: var(--accent);
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.job-log {
|
||||||
|
margin: 0; padding: 12px 14px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 320px; overflow-y: auto;
|
||||||
|
white-space: pre-wrap; word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Site rows (Sites & Page Timing) */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
.site-row-grid {
|
.site-row-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr 1fr;
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
gap: 0.75rem;
|
gap: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.site-row-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
|
@media (max-width: 640px) { .site-row-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
.site-auth {
|
.site-auth {
|
||||||
margin-top: 0.75rem;
|
margin-top: 10px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding-top: 0.75rem;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
.site-auth summary { cursor: pointer; color: var(--text-dim); font-size: 12px; font-weight: 600; }
|
||||||
|
.site-auth[open] summary { margin-bottom: 10px; }
|
||||||
|
.auth-state { color: var(--text-faint); font-weight: normal; }
|
||||||
|
|
||||||
.site-auth summary {
|
/* ---------------------------------------------------------------- */
|
||||||
cursor: pointer;
|
/* Toast banner */
|
||||||
color: var(--text-dim);
|
/* ---------------------------------------------------------------- */
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-auth[open] summary { margin-bottom: 0.6rem; }
|
|
||||||
|
|
||||||
.auth-state { color: var(--text-dim); }
|
|
||||||
|
|
||||||
.remove-site { margin-top: 0.75rem; }
|
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 0.75rem 1rem;
|
padding: 11px 15px;
|
||||||
font-size: 0.9rem;
|
font-size: 13px;
|
||||||
position: sticky;
|
font-weight: 500;
|
||||||
top: 0.75rem;
|
position: fixed;
|
||||||
z-index: 10;
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
max-width: 360px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
.banner.success { background: var(--success-soft); border: 1px solid var(--success); color: var(--success); }
|
||||||
|
.banner.error { background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
.banner.success { background: rgba(63, 184, 127, 0.15); border: 1px solid var(--success); color: var(--success); }
|
@media (max-width: 800px) {
|
||||||
.banner.error { background: rgba(229, 86, 106, 0.15); border: 1px solid var(--error); color: var(--error); }
|
.shell { flex-direction: column; }
|
||||||
|
.sidebar { width: 100%; height: auto; position: static; flex-direction: row; align-items: center; padding: 12px; overflow-x: auto; }
|
||||||
|
.brand { padding: 0 10px 0 0; }
|
||||||
|
.nav { flex-direction: row; }
|
||||||
|
.sidebar-footer { display: none; }
|
||||||
|
.main { padding: 20px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,8 +17,14 @@
|
|||||||
// config.json - so it never needs sudo.
|
// config.json - so it never needs sudo.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { loadConfig, saveConfig } = require('./lib/config');
|
const { loadConfig, saveConfig } = require('./lib/config');
|
||||||
|
const { ACTIONS } = require('./lib/actions');
|
||||||
|
const { startJob, getJob } = require('./lib/jobs');
|
||||||
|
|
||||||
|
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
||||||
|
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '256kb' }));
|
app.use(express.json({ limit: '256kb' }));
|
||||||
@@ -130,6 +136,80 @@ app.put('/api/config', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Addons: install/reconfigure via the allow-listed root helper */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
app.get('/api/actions', (req, res) => {
|
||||||
|
const list = Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, fields: a.fields }));
|
||||||
|
res.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/addons/status', (req, res) => {
|
||||||
|
const cmd = SUDO_CMD || HELPER_PATH;
|
||||||
|
const args = SUDO_CMD ? [HELPER_PATH, 'status_all'] : ['status_all'];
|
||||||
|
execFile(cmd, args, { timeout: 10_000 }, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('status_all failed:', stderr || err.message);
|
||||||
|
return res.status(500).json({ error: 'Could not read addon status' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(JSON.parse(stdout.trim()));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Malformed status response' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/actions/:name/run', (req, res) => {
|
||||||
|
try {
|
||||||
|
const job = startJob(req.params.name, req.body || {});
|
||||||
|
res.json({ jobId: job.id, status: job.status, label: job.label });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(e.status || 500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/actions/jobs/:jobId', (req, res) => {
|
||||||
|
const job = getJob(req.params.jobId);
|
||||||
|
if (!job) return res.status(404).json({ error: 'Unknown job' });
|
||||||
|
res.json({ id: job.id, name: job.name, label: job.label, status: job.status, exitCode: job.exitCode, log: job.log.join('') });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server-Sent Events: replays whatever's already logged, then streams
|
||||||
|
// new lines as they arrive, then a final `done` event - works whether
|
||||||
|
// the client connects before the job starts producing output or
|
||||||
|
// reconnects partway through (e.g. after a page reload).
|
||||||
|
app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
|
||||||
|
const job = getJob(req.params.jobId);
|
||||||
|
if (!job) return res.status(404).end();
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (job.log.length) {
|
||||||
|
res.write(`event: log\ndata: ${JSON.stringify(job.log.join(''))}\n\n`);
|
||||||
|
}
|
||||||
|
if (job.status !== 'running') {
|
||||||
|
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = (text) => {
|
||||||
|
if (text === null) {
|
||||||
|
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
res.write(`event: log\ndata: ${JSON.stringify(text)}\n\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
job.listeners.add(listener);
|
||||||
|
req.on('close', () => job.listeners.delete(listener));
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8090;
|
const PORT = process.env.PORT || 8090;
|
||||||
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
||||||
|
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Variant of fake-helper.sh that also implements status_all with real,
|
||||||
|
# minimally stateful JSON (tracked via marker files alongside itself),
|
||||||
|
# for browser/manual smoke testing of the Addons page's pills/buttons
|
||||||
|
# actually flipping after a real install - not just that a job reports
|
||||||
|
# success. jobs.test.js intentionally uses the plainer fake-helper.sh
|
||||||
|
# instead, to exercise the malformed-status-response error path.
|
||||||
|
STATE_DIR="$(dirname "$0")/.fake-state"
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
|
||||||
|
if [[ "$1" == "status_all" ]]; then
|
||||||
|
state() { [[ -f "$STATE_DIR/$1" ]] && echo true || echo false; }
|
||||||
|
echo "{\"cups\":$(state cups),\"lms\":$(state lms),\"squeezelite\":$(state squeezelite),\"asterisk_intercom\":$(state asterisk_intercom)}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "fake-helper: action=$1"
|
||||||
|
stdin_content=$(cat)
|
||||||
|
echo "fake-helper: stdin-bytes=${#stdin_content}"
|
||||||
|
sleep 0.3
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
action_install_cups | action_reconfigure_cups) touch "$STATE_DIR/cups" ;;
|
||||||
|
action_install_lms) touch "$STATE_DIR/lms" ;;
|
||||||
|
action_install_squeezelite) touch "$STATE_DIR/squeezelite" ;;
|
||||||
|
action_configure_asterisk_intercom) touch "$STATE_DIR/asterisk_intercom" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "fake-helper: done"
|
||||||
|
exit "${FAKE_HELPER_EXIT_CODE:-0}"
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# webui/test/fixtures/fake-helper.sh - stands in for the real, root-owned
|
||||||
|
# kiosk-webui-helper (menus/addon_webui.sh) in webui/test/jobs.test.js,
|
||||||
|
# so the job/SSE system can be tested without real root or a real addon
|
||||||
|
# install. Echoes what it received, sleeps briefly (long enough for the
|
||||||
|
# "another job is already running" test to reliably observe it), then
|
||||||
|
# exits with a controllable code.
|
||||||
|
echo "fake-helper: action=$1"
|
||||||
|
stdin_content=$(cat)
|
||||||
|
echo "fake-helper: stdin-bytes=${#stdin_content}"
|
||||||
|
sleep 0.2
|
||||||
|
echo "fake-helper: done"
|
||||||
|
exit "${FAKE_HELPER_EXIT_CODE:-0}"
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// webui/test/jobs.test.js - integration test for the addon-install job
|
||||||
|
// system (/api/actions/*, /api/addons/status) against a fake helper
|
||||||
|
// script (test/fixtures/fake-helper.sh) instead of the real, root-owned
|
||||||
|
// kiosk-webui-helper - no real root, apt, or system mutation involved,
|
||||||
|
// matching this project's rule of never touching real system state in
|
||||||
|
// tests. Run with: node test/jobs.test.js
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-jobs-test-'));
|
||||||
|
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||||
|
process.env.HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-helper.sh');
|
||||||
|
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
|
||||||
|
|
||||||
|
const app = require('../server');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
async function check(label, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(`PASS: ${label}`);
|
||||||
|
} catch (e) {
|
||||||
|
failures++;
|
||||||
|
console.log(`FAIL: ${label} - ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDone(base, jobId, timeoutMs = 3000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const res = await fetch(`${base}/api/actions/jobs/${jobId}`);
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.status !== 'running') return body;
|
||||||
|
await sleep(20);
|
||||||
|
}
|
||||||
|
throw new Error('timed out waiting for job to finish');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const server = app.listen(0, '127.0.0.1');
|
||||||
|
await new Promise((resolve) => server.once('listening', resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
await check('GET /api/actions lists the allow-listed actions with their fields', async () => {
|
||||||
|
const res = await fetch(`${base}/api/actions`);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
const names = body.map((a) => a.name);
|
||||||
|
assert.ok(names.includes('install_cups'));
|
||||||
|
assert.ok(names.includes('configure_asterisk_intercom'));
|
||||||
|
const asterisk = body.find((a) => a.name === 'configure_asterisk_intercom');
|
||||||
|
assert.ok(asterisk.fields.includes('serverIp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('GET /api/addons/status returns the fake helper\'s status_all JSON', async () => {
|
||||||
|
// fake-helper.sh doesn't implement status_all specially - it just
|
||||||
|
// echoes/exits 0 with non-JSON text, so this exercises the
|
||||||
|
// malformed-response error path rather than a real status shape.
|
||||||
|
const res = await fetch(`${base}/api/addons/status`);
|
||||||
|
assert.strictEqual(res.status, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
let jobId;
|
||||||
|
await check('POST /api/actions/install_cups/run starts a job and returns its id', async () => {
|
||||||
|
const res = await fetch(`${base}/api/actions/install_cups/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.ok(body.jobId);
|
||||||
|
assert.strictEqual(body.status, 'running');
|
||||||
|
jobId = body.jobId;
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('a second action while one is running is rejected with 409', async () => {
|
||||||
|
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('the job completes successfully and the log shows what the helper received', async () => {
|
||||||
|
const body = await waitForDone(base, jobId);
|
||||||
|
assert.strictEqual(body.status, 'success');
|
||||||
|
assert.strictEqual(body.exitCode, 0);
|
||||||
|
assert.ok(body.log.includes('fake-helper: action=action_install_cups'), body.log);
|
||||||
|
assert.ok(body.log.includes('fake-helper: stdin-bytes='), body.log);
|
||||||
|
assert.ok(body.log.includes('fake-helper: done'), body.log);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('after completion, a new action is accepted again (not stuck busy)', async () => {
|
||||||
|
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
await waitForDone(base, body.jobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('unknown action name is rejected with 400, no job created', async () => {
|
||||||
|
const res = await fetch(`${base}/api/actions/definitely_not_real/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('missing required field is rejected with 400 before spawning anything', async () => {
|
||||||
|
const res = await fetch(`${base}/api/actions/configure_asterisk_intercom/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ alreadyInstalled: false, extension: '201', password: 'x' }),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 400);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.ok(/serverIp/.test(body.error), body.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('a failing helper is reflected as status failed with the real exit code', async () => {
|
||||||
|
process.env.FAKE_HELPER_EXIT_CODE = '1';
|
||||||
|
// jobs.js reads process.env.HELPER_PATH/SUDO_CMD once at module
|
||||||
|
// load, but FAKE_HELPER_EXIT_CODE is read fresh by the spawned
|
||||||
|
// shell script every time, so no re-require needed here.
|
||||||
|
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { jobId: failId } = await res.json();
|
||||||
|
const body = await waitForDone(base, failId);
|
||||||
|
assert.strictEqual(body.status, 'failed');
|
||||||
|
assert.strictEqual(body.exitCode, 1);
|
||||||
|
delete process.env.FAKE_HELPER_EXIT_CODE;
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('GET /api/actions/jobs/:id/stream (SSE) replays the log and sends a final done event', async () => {
|
||||||
|
const res = await fetch(`${base}/api/actions/install_lms/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ alreadyInstalled: false }),
|
||||||
|
});
|
||||||
|
const { jobId: streamJobId } = await res.json();
|
||||||
|
|
||||||
|
const streamRes = await fetch(`${base}/api/actions/jobs/${streamJobId}/stream`);
|
||||||
|
assert.strictEqual(streamRes.status, 200);
|
||||||
|
assert.strictEqual(streamRes.headers.get('content-type'), 'text/event-stream');
|
||||||
|
|
||||||
|
const reader = streamRes.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let raw = '';
|
||||||
|
const deadline = Date.now() + 3000;
|
||||||
|
while (!raw.includes('event: done') && Date.now() < deadline) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
raw += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
assert.ok(raw.includes('event: log'), raw);
|
||||||
|
assert.ok(raw.includes('fake-helper: action=action_install_lms'), raw);
|
||||||
|
assert.ok(raw.includes('event: done'), raw);
|
||||||
|
assert.ok(/data: \{"status":"success","exitCode":0\}/.test(raw), raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.log(`${failures} FAILURE(S)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('ALL DONE');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user