Enhance first-boot script and documentation for eMMC provisioning: add structured logging, improve package installation process, and implement one-shot autostart for rotation and wallpaper setup. Update dashboard to manage portal file descriptions and enhance admin interface with new navigation links.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
[Seat:*]
|
||||||
|
user-session=plasmax11
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[greeter]
|
||||||
|
wallpaper=/usr/share/rpd-wallpaper/splash.png
|
||||||
|
wallpaper_mode=crop
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Config files for first-boot (file server)
|
||||||
|
|
||||||
|
first-boot.sh downloads these from `FILE_SERVER` (e.g. `http://10.130.60.141:5000/files/first-boot`) and installs them to the paths below. Upload each file into the **first-boot** subfolder of portal-files (e.g. `/var/lib/cm4-provisioning/portal-files/first-boot/`).
|
||||||
|
|
||||||
|
| File on server | Destination on device |
|
||||||
|
|----------------|------------------------|
|
||||||
|
| 99-wallpaper.conf | /etc/lightdm/lightdm.conf.d/99-wallpaper.conf |
|
||||||
|
| 99-default-session.conf | /etc/lightdm/lightdm.conf.d/99-default-session.conf |
|
||||||
|
| kdeglobals | /home/pi/.config/kdeglobals |
|
||||||
|
| kwinrc | /home/pi/.config/kwinrc |
|
||||||
|
| maliit-keyboard.desktop | /home/pi/.config/autostart/maliit-keyboard.desktop |
|
||||||
|
| set-rotation-once.desktop | /home/pi/.config/autostart/set-rotation-once.desktop (with set-rotation-once.sh) |
|
||||||
|
| set-wallpaper-once.desktop | /home/pi/.config/autostart/set-wallpaper-once.desktop (with set-wallpaper-once.sh) |
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[General]
|
||||||
|
ForceFontDPI=120
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[Windows]
|
||||||
|
BorderlessMaximizedWindows=true
|
||||||
|
[Plugins]
|
||||||
|
touchpointsEnabled=true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Maliit Keyboard
|
||||||
|
Exec=maliit-keyboard -r
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Set rotation once
|
||||||
|
Exec=/home/pi/set-rotation-once.sh
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Set wallpaper once
|
||||||
|
Exec=/home/pi/set-wallpaper-once.sh
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Files for the file server
|
||||||
|
|
||||||
|
first-boot.sh downloads from **`.../files/first-boot/`** (e.g. `http://10.130.60.141:5000/files/first-boot`). Put all first-boot assets in a **first-boot** subfolder of portal-files so provisioning works.
|
||||||
|
|
||||||
|
## Required files (host under `.../files/first-boot/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|--------|
|
||||||
|
| **start-chromium.sh** | Chromium kiosk launcher (executable). |
|
||||||
|
| **chromium-kiosk.desktop** | Autostart entry for the kiosk. |
|
||||||
|
| **splash.png** | Boot splash + login + desktop wallpaper (single image). |
|
||||||
|
| **custom.plymouth** | Plymouth theme config (from `plymouth-custom/`). |
|
||||||
|
| **custom.script** | Plymouth script that displays splash.png (from `plymouth-custom/`). |
|
||||||
|
| **99-wallpaper.conf** | LightDM greeter wallpaper (from `config-files/`). |
|
||||||
|
| **99-default-session.conf** | LightDM default session plasmax11 (from `config-files/`). |
|
||||||
|
| **kdeglobals** | KDE font DPI (from `config-files/`). |
|
||||||
|
| **kwinrc** | KDE window/touch (from `config-files/`). |
|
||||||
|
| **maliit-keyboard.desktop** | Maliit on-screen keyboard autostart (from `config-files/`). |
|
||||||
|
| **set-rotation-once.sh** + **.desktop** | One-shot: DSI-1 rotation at first login. |
|
||||||
|
| **set-wallpaper-once.sh** + **.desktop** | One-shot: desktop wallpaper at first login. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What’s in this folder
|
||||||
|
|
||||||
|
- **plymouth-custom/splash.png** — Example splash image; host as `splash.png`.
|
||||||
|
- **plymouth-custom/custom.plymouth** — Plymouth theme definition; host as `custom.plymouth`.
|
||||||
|
- **plymouth-custom/custom.script** — Plymouth script that draws splash.png; host as `custom.script`.
|
||||||
|
- **lightdm/RPiSystem_dark.png** — Unused; only `splash.png` is used now.
|
||||||
|
- **set-rotation-once.sh** and **set-wallpaper-once.sh** live in `cloud-init/`; copy them into `portal-files/first-boot/` on the file server.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
[Plymouth Theme]
|
||||||
|
Name=Custom Splash
|
||||||
|
Description=Custom boot splash screen
|
||||||
|
ModuleName=script
|
||||||
|
|
||||||
|
[script]
|
||||||
|
ImageDir=/usr/share/plymouth/themes/custom
|
||||||
|
ScriptFile=/usr/share/plymouth/themes/custom/custom.script
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
screen_width = Window.GetWidth();
|
||||||
|
screen_height = Window.GetHeight();
|
||||||
|
|
||||||
|
theme_image = Image("splash.png");
|
||||||
|
image_width = theme_image.GetWidth();
|
||||||
|
image_height = theme_image.GetHeight();
|
||||||
|
|
||||||
|
scale_x = image_width / screen_width;
|
||||||
|
scale_y = image_height / screen_height;
|
||||||
|
|
||||||
|
if (scale_x > 1 || scale_y > 1)
|
||||||
|
{
|
||||||
|
if (scale_x > scale_y)
|
||||||
|
{
|
||||||
|
resized_image = theme_image.Scale(screen_width, image_height / scale_x);
|
||||||
|
image_x = 0;
|
||||||
|
image_y = (screen_height - ((image_height * screen_width) / image_width)) / 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resized_image = theme_image.Scale(image_width / scale_y, screen_height);
|
||||||
|
image_x = (screen_width - ((image_width * screen_height) / image_height)) / 2;
|
||||||
|
image_y = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resized_image = theme_image.Scale(image_width, image_height);
|
||||||
|
image_x = (screen_width - image_width) / 2;
|
||||||
|
image_y = (screen_height - image_height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Plymouth.GetMode() != "shutdown")
|
||||||
|
{
|
||||||
|
sprite = Sprite(resized_image);
|
||||||
|
sprite.SetPosition(image_x, image_y, -100);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun message_callback(text) {
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
@@ -4,6 +4,22 @@ This script runs once on first boot via cloud-init (see `user-data-remote-gnss.e
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Structure (sections)
|
||||||
|
|
||||||
|
1. **Constants** — `FILE_SERVER`, `PI_USER`, paths, log file.
|
||||||
|
2. **Logging** — All output tee’d to `/var/log/first-boot.log`.
|
||||||
|
3. **Helpers** — `install_oneshot(name)` downloads `${name}.sh` from the file server and installs it as a one-shot autostart (runs once at pi’s first login, then deletes itself).
|
||||||
|
4. **Packages** — git, Chromium, wmctrl, SSH, KDE Plasma, kscreen, maliit, xinput-calibrator.
|
||||||
|
5. **Kiosk files** — Download `start-chromium.sh` and `chromium-kiosk.desktop`; create autostart dir.
|
||||||
|
6. **Boot splash and wallpaper** — Download `splash.png`; install Plymouth custom theme; copy image for LightDM and desktop.
|
||||||
|
7. **LightDM** — Download `99-default-session.conf` and `99-wallpaper.conf` to `/etc/lightdm/lightdm.conf.d/`.
|
||||||
|
8. **KDE + Maliit** — Download `kdeglobals`, `kwinrc`, `maliit-keyboard.desktop` from file server to pi’s `.config` and autostart.
|
||||||
|
9. **reTerminal DM drivers** — Seeed repo clone and `reTerminal.sh`.
|
||||||
|
10. **One-shots** — Download `set-rotation-once.sh` + `.desktop` and `set-wallpaper-once.sh` + `.desktop` from file server.
|
||||||
|
11. **Reboot.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Script header and environment
|
## Script header and environment
|
||||||
|
|
||||||
- **`set -e`** — Exit immediately if any command fails.
|
- **`set -e`** — Exit immediately if any command fails.
|
||||||
@@ -11,6 +27,16 @@ This script runs once on first boot via cloud-init (see `user-data-remote-gnss.e
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
All script output (stdout and stderr) is appended to **`/var/log/first-boot.log`** so you can review what ran after first boot (e.g. over SSH: `cat /var/log/first-boot.log`).
|
||||||
|
|
||||||
|
- **`LOGFILE`** — Path of the log file (`/var/log/first-boot.log`).
|
||||||
|
- **`log "..."`** — Prints a timestamped line (ISO format); used for section headers.
|
||||||
|
- **`exec > >(tee -a "$LOGFILE") 2>&1`** — Sends all subsequent stdout and stderr to both the console and the log file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
Installs the software needed for the rest of the script and for the kiosk:
|
Installs the software needed for the rest of the script and for the kiosk:
|
||||||
@@ -22,6 +48,7 @@ Installs the software needed for the rest of the script and for the kiosk:
|
|||||||
| **wmctrl** | Window control; used to force Chromium into fullscreen. |
|
| **wmctrl** | Window control; used to force Chromium into fullscreen. |
|
||||||
| **openssh-server** | SSH access (often also enabled in user-data). |
|
| **openssh-server** | SSH access (often also enabled in user-data). |
|
||||||
| **kde-plasma-desktop** | KDE Plasma desktop (X11 session used for Chromium). |
|
| **kde-plasma-desktop** | KDE Plasma desktop (X11 session used for Chromium). |
|
||||||
|
| **kscreen** | KDE display control; provides `kscreen-doctor` for screen rotation (same as GUI “Right”). |
|
||||||
| **maliit-keyboard** | On-screen keyboard for touch input. |
|
| **maliit-keyboard** | On-screen keyboard for touch input. |
|
||||||
| **xinput-calibrator** | Touchscreen calibration (optional; run manually if needed). |
|
| **xinput-calibrator** | Touchscreen calibration (optional; run manually if needed). |
|
||||||
|
|
||||||
@@ -35,9 +62,9 @@ Creates `/home/pi/.config/autostart` so that `.desktop` files placed there are s
|
|||||||
|
|
||||||
## Chromium kiosk files (from file server)
|
## Chromium kiosk files (from file server)
|
||||||
|
|
||||||
Does **not** create the kiosk files locally; it downloads them from your file server:
|
Downloads from `FILE_SERVER` (no local creation):
|
||||||
|
|
||||||
- **`FILE_SERVER`** — Base URL (default: `http://10.130.60.141:5000/files`). Change this if your server is elsewhere.
|
- **`FILE_SERVER`** — Base URL for first-boot assets (default: `http://10.130.60.141:5000/files/first-boot`). All first-boot files are served from a **first-boot** subfolder on the file server. Change this if your server or path is different.
|
||||||
- **`start-chromium.sh`** — Downloaded to `/home/pi/start-chromium.sh`, made executable (755), owned by `pi`. This script waits for the desktop, starts Chromium in kiosk mode (e.g. `--app=...`), and uses `wmctrl` to force fullscreen.
|
- **`start-chromium.sh`** — Downloaded to `/home/pi/start-chromium.sh`, made executable (755), owned by `pi`. This script waits for the desktop, starts Chromium in kiosk mode (e.g. `--app=...`), and uses `wmctrl` to force fullscreen.
|
||||||
- **`chromium-kiosk.desktop`** — Downloaded to `/home/pi/.config/autostart/chromium-kiosk.desktop`, mode 644, owned by `pi`. This autostart entry runs `start-chromium.sh` when `pi` logs in.
|
- **`chromium-kiosk.desktop`** — Downloaded to `/home/pi/.config/autostart/chromium-kiosk.desktop`, mode 644, owned by `pi`. This autostart entry runs `start-chromium.sh` when `pi` logs in.
|
||||||
|
|
||||||
@@ -45,6 +72,16 @@ Ensure the `.desktop` file on the server has `Exec=/home/pi/start-chromium.sh` (
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Boot splash and wallpaper (single image from file server)
|
||||||
|
|
||||||
|
A **single image** (`splash.png`) is used for the boot splash, login screen, and desktop wallpaper. Host it at **`${FILE_SERVER}/splash.png`** (e.g. `http://10.130.60.141:5000/files/splash.png`).
|
||||||
|
|
||||||
|
- **Plymouth (boot splash):** Downloads `splash.png`, `custom.plymouth`, and `custom.script` from the file server → installs to `/usr/share/plymouth/themes/custom/` → sets `Theme=custom` in `/etc/plymouth/plymouthd.conf` → runs `update-initramfs -u`. If any download fails, logs a warning and continues.
|
||||||
|
- **LightDM (login screen):** Copies the same image to `/usr/share/rpd-wallpaper/splash.png` and writes `/etc/lightdm/lightdm.conf.d/99-wallpaper.conf` with `wallpaper=...` and `wallpaper_mode=crop`.
|
||||||
|
- **Desktop wallpaper:** A one-shot autostart runs when **pi** first logs in and runs `plasma-apply-wallpaperimage /usr/share/rpd-wallpaper/splash.png`. The script then deletes itself. If `plasma-apply-wallpaperimage` is not installed, the step no-ops and the one-shot still removes itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## KDE Plasma: default session (X11)
|
## KDE Plasma: default session (X11)
|
||||||
|
|
||||||
Writes `/etc/lightdm/lightdm.conf.d/99-default-session.conf` so the display manager (LightDM) uses the **Plasma X11** session (`plasmax11`) instead of Wayland. Chromium kiosk is configured for X11, so this is required for it to run correctly.
|
Writes `/etc/lightdm/lightdm.conf.d/99-default-session.conf` so the display manager (LightDM) uses the **Plasma X11** session (`plasmax11`) instead of Wayland. Chromium kiosk is configured for X11, so this is required for it to run correctly.
|
||||||
@@ -92,14 +129,26 @@ These changes take effect after a reboot.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Screen rotation (portrait → landscape, “Right”)
|
||||||
|
|
||||||
|
The reTerminal DM default is portrait. Rotation is set the **same way as in the GUI** (Display settings → Orientation → Right), using KDE’s **kscreen-doctor**, so nothing is written to `/boot/firmware/config.txt`.
|
||||||
|
|
||||||
|
- **One-shot autostart** — A small script runs once when user `pi` first logs into the graphical session:
|
||||||
|
- **`/home/pi/set-rotation-once.sh`** — Waits 5 seconds, then runs `kscreen-doctor output.DSI-1.rotation.right` (only the reTerminal DM DSI-1 display; no other screen is used).
|
||||||
|
- **`/home/pi/.config/autostart/set-rotation-once.desktop`** — Autostart entry that runs the script. Both the script and this desktop file **delete themselves** after a successful run, so rotation is applied only once.
|
||||||
|
- **Effect** — Same as choosing “Right” in System Settings → Display → Orientation. Rotation applies after the first login; no reboot needed for rotation (only for the Seeed drivers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Reboot
|
## Reboot
|
||||||
|
|
||||||
Runs **`reboot`** so the kernel and display stack load the new Seeed drivers. After reboot, the screen and touch should work, and the next login as `pi` will start the Chromium kiosk and Maliit via the autostart entries.
|
Runs **`reboot`** so the kernel and display stack load the new Seeed drivers. After reboot, the screen and touch work; on **pi**’s first login the one-shot sets rotation to “Right” (landscape), and the Chromium kiosk and Maliit start via autostart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Customisation
|
## Customisation
|
||||||
|
|
||||||
- **File server** — Edit `FILE_SERVER` if your `start-chromium.sh` and `chromium-kiosk.desktop` are served from another host/port.
|
- **File server** — Edit `FILE_SERVER` if your assets are served from another host/port. Host all files listed in **`files-from-guard/README.md`** and **`config-files/README.md`** (kiosk, splash, Plymouth, LightDM, KDE, Maliit, one-shots and their .desktop files).
|
||||||
- **Kiosk URL** — The URL Chromium opens is defined in `start-chromium.sh` on your file server (e.g. `--app=http://127.0.0.1:8080`); change it there.
|
- **Kiosk URL** — The URL Chromium opens is defined in `start-chromium.sh` on your file server (e.g. `--app=http://127.0.0.1:8080`); change it there.
|
||||||
- **User** — If you use a user other than `pi`, replace `pi` in this script and in the files on the file server (paths and ownership).
|
- **User** — If you use a user other than `pi`, replace `pi` in this script and in the files on the file server (paths and ownership).
|
||||||
|
- **Screen rotation** — The one-shot runs `kscreen-doctor output.DSI-1.rotation.right`. To use another orientation, edit the script and change `rotation.right` to `rotation.left`, `rotation.inverted`, or `rotation.none`.
|
||||||
|
|||||||
@@ -1,81 +1,96 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# First-boot script: install packages, Chromium kiosk, KDE Plasma + touch.
|
# First-boot: packages, Chromium kiosk, KDE Plasma + touch, reTerminal DM drivers.
|
||||||
# Intended to be downloaded and run by cloud-init (see user-data-remote-gnss.example).
|
# Run by cloud-init (user-data-remote-gnss.example). Run as root.
|
||||||
# Run as root.
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
# Packages
|
# --- Constants ---
|
||||||
|
# All first-boot assets live in portal-files/first-boot/ on the file server.
|
||||||
|
FILE_SERVER="http://10.130.60.141:5000/files/first-boot"
|
||||||
|
PI_USER="pi"
|
||||||
|
PI_HOME="/home/$PI_USER"
|
||||||
|
AUTOSTART="$PI_HOME/.config/autostart"
|
||||||
|
LOGFILE="/var/log/first-boot.log"
|
||||||
|
PLYMOUTH_DIR="/usr/share/plymouth/themes/custom"
|
||||||
|
WALLPAPER_PATH="/usr/share/rpd-wallpaper/splash.png"
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
log() { echo "[$(date -Iseconds)] $*"; }
|
||||||
|
exec > >(tee -a "$LOGFILE") 2>&1
|
||||||
|
log "=== first-boot.sh started ==="
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
# Download script + .desktop from FILE_SERVER and install as one-shot autostart (runs once at pi's first login, then deletes itself).
|
||||||
|
install_oneshot() {
|
||||||
|
local name="$1"
|
||||||
|
log "--- Installing one-shot: $name ---"
|
||||||
|
curl -fsSL "${FILE_SERVER}/${name}.sh" -o "$PI_HOME/${name}.sh" || { log "WARNING: Could not download ${name}.sh"; return 1; }
|
||||||
|
curl -fsSL "${FILE_SERVER}/${name}.desktop" -o "$AUTOSTART/${name}.desktop" || { log "WARNING: Could not download ${name}.desktop"; return 1; }
|
||||||
|
chmod 755 "$PI_HOME/${name}.sh" && chmod 644 "$AUTOSTART/${name}.desktop"
|
||||||
|
chown "$PI_USER:$PI_USER" "$PI_HOME/${name}.sh" "$AUTOSTART/${name}.desktop"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 1. Packages ---
|
||||||
|
log "--- Installing packages ---"
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq \
|
apt-get install -y -qq git chromium-browser wmctrl openssh-server \
|
||||||
git \
|
kde-plasma-desktop kscreen maliit-keyboard xinput-calibrator
|
||||||
chromium-browser \
|
|
||||||
wmctrl \
|
|
||||||
openssh-server \
|
|
||||||
kde-plasma-desktop \
|
|
||||||
maliit-keyboard \
|
|
||||||
xinput-calibrator
|
|
||||||
|
|
||||||
# Autostart dir for user pi
|
# --- 2. Dirs and kiosk files from file server ---
|
||||||
mkdir -p /home/pi/.config/autostart
|
log "--- Kiosk files ---"
|
||||||
|
mkdir -p "$AUTOSTART"
|
||||||
|
curl -fsSL "${FILE_SERVER}/start-chromium.sh" -o "$PI_HOME/start-chromium.sh"
|
||||||
|
curl -fsSL "${FILE_SERVER}/chromium-kiosk.desktop" -o "$AUTOSTART/chromium-kiosk.desktop"
|
||||||
|
chmod 755 "$PI_HOME/start-chromium.sh" && chmod 644 "$AUTOSTART/chromium-kiosk.desktop"
|
||||||
|
chown -R "$PI_USER:$PI_USER" "$PI_HOME/start-chromium.sh" "$AUTOSTART/chromium-kiosk.desktop"
|
||||||
|
|
||||||
# Chromium kiosk files from file server
|
# --- 3. Boot splash and wallpaper (splash.png + Plymouth theme from file server) ---
|
||||||
FILE_SERVER="http://10.130.60.141:5000/files"
|
log "--- Boot splash and wallpaper ---"
|
||||||
curl -fsSL "${FILE_SERVER}/start-chromium.sh" -o /home/pi/start-chromium.sh
|
mkdir -p "$PLYMOUTH_DIR" /usr/share/rpd-wallpaper
|
||||||
chmod 755 /home/pi/start-chromium.sh
|
if curl -fsSL "${FILE_SERVER}/splash.png" -o "$PLYMOUTH_DIR/splash.png"; then
|
||||||
chown pi:pi /home/pi/start-chromium.sh
|
cp "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
|
||||||
curl -fsSL "${FILE_SERVER}/chromium-kiosk.desktop" -o /home/pi/.config/autostart/chromium-kiosk.desktop
|
chmod 644 "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
|
||||||
chmod 644 /home/pi/.config/autostart/chromium-kiosk.desktop
|
if curl -fsSL "${FILE_SERVER}/custom.plymouth" -o "$PLYMOUTH_DIR/custom.plymouth" \
|
||||||
chown pi:pi /home/pi/.config/autostart/chromium-kiosk.desktop
|
&& curl -fsSL "${FILE_SERVER}/custom.script" -o "$PLYMOUTH_DIR/custom.script"; then
|
||||||
|
chmod 644 "$PLYMOUTH_DIR/custom.plymouth" "$PLYMOUTH_DIR/custom.script"
|
||||||
# KDE Plasma: default session (X11 for Chromium)
|
else
|
||||||
|
log "WARNING: Could not download custom.plymouth/custom.script; boot splash theme may be incomplete"
|
||||||
|
fi
|
||||||
|
grep -q '^Theme=custom' /etc/plymouth/plymouthd.conf 2>/dev/null || printf '%s\n' '[Daemon]' 'Theme=custom' >> /etc/plymouth/plymouthd.conf
|
||||||
|
update-initramfs -u -k all 2>/dev/null || true
|
||||||
mkdir -p /etc/lightdm/lightdm.conf.d
|
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||||
cat > /etc/lightdm/lightdm.conf.d/99-default-session.conf << 'LIGHTDM'
|
curl -fsSL "${FILE_SERVER}/99-wallpaper.conf" -o /etc/lightdm/lightdm.conf.d/99-wallpaper.conf 2>/dev/null || log "WARNING: Could not download 99-wallpaper.conf"
|
||||||
[Seat:*]
|
log "Splash and wallpaper set from file server"
|
||||||
user-session=plasmax11
|
else
|
||||||
LIGHTDM
|
log "WARNING: Could not download splash.png"
|
||||||
|
fi
|
||||||
|
|
||||||
# KDE touch-friendly
|
# --- 4. LightDM: KDE Plasma X11 session + configs from file server ---
|
||||||
cat > /home/pi/.config/kdeglobals << 'KDE'
|
log "--- LightDM session ---"
|
||||||
[General]
|
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||||
ForceFontDPI=120
|
curl -fsSL "${FILE_SERVER}/99-default-session.conf" -o /etc/lightdm/lightdm.conf.d/99-default-session.conf 2>/dev/null || log "WARNING: Could not download 99-default-session.conf"
|
||||||
KDE
|
|
||||||
chown pi:pi /home/pi/.config/kdeglobals
|
|
||||||
chmod 644 /home/pi/.config/kdeglobals
|
|
||||||
|
|
||||||
cat > /home/pi/.config/kwinrc << 'KWIN'
|
# --- 5. KDE touch-friendly + Maliit (from file server) ---
|
||||||
[Windows]
|
log "--- KDE and Maliit ---"
|
||||||
BorderlessMaximizedWindows=true
|
mkdir -p "$AUTOSTART" "$PI_HOME/.config"
|
||||||
[Plugins]
|
curl -fsSL "${FILE_SERVER}/kdeglobals" -o "$PI_HOME/.config/kdeglobals" 2>/dev/null || log "WARNING: Could not download kdeglobals"
|
||||||
touchpointsEnabled=true
|
curl -fsSL "${FILE_SERVER}/kwinrc" -o "$PI_HOME/.config/kwinrc" 2>/dev/null || log "WARNING: Could not download kwinrc"
|
||||||
KWIN
|
curl -fsSL "${FILE_SERVER}/maliit-keyboard.desktop" -o "$AUTOSTART/maliit-keyboard.desktop" 2>/dev/null || log "WARNING: Could not download maliit-keyboard.desktop"
|
||||||
chown pi:pi /home/pi/.config/kwinrc
|
chown -R "$PI_USER:$PI_USER" "$PI_HOME/.config"
|
||||||
chmod 644 /home/pi/.config/kwinrc
|
|
||||||
|
|
||||||
# On-screen keyboard (maliit)
|
|
||||||
cat > /home/pi/.config/autostart/maliit-keyboard.desktop << 'MALIIT'
|
|
||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=Maliit Keyboard
|
|
||||||
Exec=maliit-keyboard -r
|
|
||||||
X-GNOME-Autostart-enabled=true
|
|
||||||
MALIIT
|
|
||||||
chown pi:pi /home/pi/.config/autostart/maliit-keyboard.desktop
|
|
||||||
chmod 644 /home/pi/.config/autostart/maliit-keyboard.desktop
|
|
||||||
|
|
||||||
# Ownership for all of pi's config
|
|
||||||
chown -R pi:pi /home/pi/.config
|
|
||||||
|
|
||||||
# Set KDE Plasma (X11) as default session
|
|
||||||
update-alternatives --set x-session-manager /usr/bin/startplasma-x11 2>/dev/null || true
|
update-alternatives --set x-session-manager /usr/bin/startplasma-x11 2>/dev/null || true
|
||||||
|
|
||||||
# reTerminal DM: install Seeed display/touch drivers (screen will work after reboot)
|
# --- 6. reTerminal DM drivers (Seeed) ---
|
||||||
|
log "--- reTerminal DM drivers ---"
|
||||||
REPO_DIR="/tmp/seeed-linux-dtoverlays"
|
REPO_DIR="/tmp/seeed-linux-dtoverlays"
|
||||||
git clone --depth 1 https://github.com/Seeed-Studio/seeed-linux-dtoverlays "$REPO_DIR"
|
git clone --depth 1 https://github.com/Seeed-Studio/seeed-linux-dtoverlays "$REPO_DIR"
|
||||||
"$REPO_DIR/scripts/reTerminal.sh" --device reTerminal-DM
|
"$REPO_DIR/scripts/reTerminal.sh" --device reTerminal-DM
|
||||||
rm -rf "$REPO_DIR"
|
rm -rf "$REPO_DIR"
|
||||||
|
|
||||||
# Reboot so display and touch work
|
# --- 7. One-shots (rotation + wallpaper at first login) ---
|
||||||
|
install_oneshot set-rotation-once || true
|
||||||
|
install_oneshot set-wallpaper-once || true
|
||||||
|
|
||||||
|
# --- 8. Reboot ---
|
||||||
|
log "=== first-boot.sh finished, rebooting ==="
|
||||||
reboot
|
reboot
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
screen_width = Window.GetWidth();
|
||||||
|
screen_height = Window.GetHeight();
|
||||||
|
|
||||||
|
theme_image = Image("splash.png");
|
||||||
|
image_width = theme_image.GetWidth();
|
||||||
|
image_height = theme_image.GetHeight();
|
||||||
|
|
||||||
|
scale_x = image_width / screen_width;
|
||||||
|
scale_y = image_height / screen_height;
|
||||||
|
|
||||||
|
if (scale_x > 1 || scale_y > 1)
|
||||||
|
{
|
||||||
|
if (scale_x > scale_y)
|
||||||
|
{
|
||||||
|
resized_image = theme_image.Scale(screen_width, image_height / scale_x);
|
||||||
|
image_x = 0;
|
||||||
|
image_y = (screen_height - ((image_height * screen_width) / image_width)) / 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resized_image = theme_image.Scale(image_width / scale_y, screen_height);
|
||||||
|
image_x = (screen_width - ((image_width * screen_height) / image_height)) / 2;
|
||||||
|
image_y = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resized_image = theme_image.Scale(image_width, image_height);
|
||||||
|
image_x = (screen_width - image_width) / 2;
|
||||||
|
image_y = (screen_height - image_height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Plymouth.GetMode() != "shutdown")
|
||||||
|
{
|
||||||
|
sprite = Sprite(resized_image);
|
||||||
|
sprite.SetPosition(image_x, image_y, -100);
|
||||||
|
}
|
||||||
|
|
||||||
|
fun message_callback(text) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One-shot: set DSI-1 (reTerminal DM) rotation to Right, then remove self. Runs as user pi at first login.
|
||||||
|
export DISPLAY=:0
|
||||||
|
sleep 5
|
||||||
|
kscreen-doctor output.DSI-1.rotation.right
|
||||||
|
rm -f /home/pi/.config/autostart/set-rotation-once.desktop /home/pi/set-rotation-once.sh
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One-shot: set desktop wallpaper to splash image, then remove self. Runs as user pi at first login.
|
||||||
|
export DISPLAY=:0
|
||||||
|
sleep 8
|
||||||
|
WALLPAPER="/usr/share/rpd-wallpaper/splash.png"
|
||||||
|
[[ -f "$WALLPAPER" ]] && plasma-apply-wallpaperimage "$WALLPAPER" 2>/dev/null || true
|
||||||
|
rm -f /home/pi/.config/autostart/set-wallpaper-once.desktop /home/pi/set-wallpaper-once.sh
|
||||||
@@ -38,6 +38,7 @@ BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR
|
|||||||
SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json")))
|
SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json")))
|
||||||
SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json")))
|
SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json")))
|
||||||
CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json")))
|
CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json")))
|
||||||
|
PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", str(BASE_DIR / "portal_descriptions.json")))
|
||||||
DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db")))
|
DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db")))
|
||||||
|
|
||||||
|
|
||||||
@@ -395,15 +396,32 @@ def admin():
|
|||||||
return render_template("admin.html", username=session.get("username", "Admin"))
|
return render_template("admin.html", username=session.get("username", "Admin"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/portal-files")
|
||||||
|
@require_admin
|
||||||
|
def admin_portal_files():
|
||||||
|
return render_template("portal_files.html", username=session.get("username", "Admin"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/cloudinit-build")
|
||||||
|
@require_admin
|
||||||
|
def admin_cloudinit_build():
|
||||||
|
return render_template("cloudinit_build.html", username=session.get("username", "Admin"))
|
||||||
|
|
||||||
|
|
||||||
# Serve portal files for wget (e.g. cloud-init first boot). No auth.
|
# Serve portal files for wget (e.g. cloud-init first boot). No auth.
|
||||||
|
# Subpaths allowed (e.g. first-boot/splash.png); ".." and "\\" forbidden to prevent traversal.
|
||||||
@app.route("/files/<path:filename>")
|
@app.route("/files/<path:filename>")
|
||||||
def serve_portal_file(filename):
|
def serve_portal_file(filename):
|
||||||
if ".." in filename or "/" in filename or "\\" in filename:
|
if ".." in filename or "\\" in filename:
|
||||||
|
return jsonify({"error": "invalid path"}), 400
|
||||||
|
path = (PORTAL_FILES_DIR / filename).resolve()
|
||||||
|
try:
|
||||||
|
path.relative_to(PORTAL_FILES_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
return jsonify({"error": "invalid path"}), 400
|
return jsonify({"error": "invalid path"}), 400
|
||||||
path = PORTAL_FILES_DIR / filename
|
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
return send_file(path, as_attachment=False, download_name=filename)
|
return send_file(path, as_attachment=False, download_name=filename.split("/")[-1])
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/status")
|
@app.route("/api/status")
|
||||||
@@ -571,20 +589,95 @@ def api_cloudinit_images():
|
|||||||
return jsonify({"images": list_cloudinit_images(), "cloudinit_dir": str(CLOUDINIT_IMAGES_DIR)})
|
return jsonify({"images": list_cloudinit_images(), "cloudinit_dir": str(CLOUDINIT_IMAGES_DIR)})
|
||||||
|
|
||||||
|
|
||||||
|
def _load_portal_descriptions():
|
||||||
|
"""Return dict mapping path -> description (path can be file or folder)."""
|
||||||
|
if not PORTAL_DESCRIPTIONS_FILE.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(PORTAL_DESCRIPTIONS_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_portal_descriptions(descriptions):
|
||||||
|
try:
|
||||||
|
PORTAL_DESCRIPTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(PORTAL_DESCRIPTIONS_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(descriptions, f, indent=2)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/portal-files")
|
@app.route("/api/portal-files")
|
||||||
@require_admin
|
@require_admin
|
||||||
def api_portal_files_list():
|
def api_portal_files_list():
|
||||||
|
"""List one level: root or contents of path=... (folders and files)."""
|
||||||
|
subpath = request.args.get("path", "").strip().strip("/")
|
||||||
|
if ".." in subpath or "\\" in subpath:
|
||||||
|
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": ""})
|
||||||
if not PORTAL_FILES_DIR.is_dir():
|
if not PORTAL_FILES_DIR.is_dir():
|
||||||
return jsonify({"files": [], "base_url": request.host_url.rstrip("/") + "/files/"})
|
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": ""})
|
||||||
files = []
|
list_dir = (PORTAL_FILES_DIR / subpath).resolve() if subpath else PORTAL_FILES_DIR
|
||||||
for p in sorted(PORTAL_FILES_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
||||||
if p.is_file() and ".." not in p.name:
|
|
||||||
try:
|
try:
|
||||||
files.append({"name": p.name, "size": p.stat().st_size, "mtime": p.stat().st_mtime})
|
list_dir.relative_to(PORTAL_FILES_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": subpath})
|
||||||
|
if not list_dir.is_dir():
|
||||||
|
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": subpath})
|
||||||
|
items = []
|
||||||
|
for p in sorted(list_dir.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
||||||
|
if ".." in p.name or p.name.startswith("."):
|
||||||
|
continue
|
||||||
|
rel = (subpath + "/" + p.name) if subpath else p.name
|
||||||
|
try:
|
||||||
|
if p.is_dir():
|
||||||
|
items.append({"type": "folder", "path": rel, "name": p.name})
|
||||||
|
else:
|
||||||
|
items.append({"type": "file", "path": rel, "name": p.name, "size": p.stat().st_size, "mtime": p.stat().st_mtime})
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
descriptions = _load_portal_descriptions()
|
||||||
base = request.host_url.rstrip("/") + "/files/"
|
base = request.host_url.rstrip("/") + "/files/"
|
||||||
return jsonify({"files": files, "base_url": base})
|
return jsonify({"items": items, "base_url": base, "descriptions": descriptions, "current_path": subpath})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/portal-files/descriptions", methods=["GET", "PATCH"])
|
||||||
|
@require_admin
|
||||||
|
def api_portal_descriptions():
|
||||||
|
if request.method == "PATCH":
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
desc = data.get("descriptions")
|
||||||
|
if not isinstance(desc, dict):
|
||||||
|
return jsonify({"ok": False, "error": "descriptions must be a dict"}), 400
|
||||||
|
if not _save_portal_descriptions(desc):
|
||||||
|
return jsonify({"ok": False, "error": "save failed"}), 500
|
||||||
|
admin_log("portal_descriptions", "updated")
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
return jsonify({"descriptions": _load_portal_descriptions()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/portal-files/folder", methods=["POST"])
|
||||||
|
@require_admin
|
||||||
|
def api_portal_folder_create():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
path = (data.get("path") or "").strip().strip("/")
|
||||||
|
if not path:
|
||||||
|
return jsonify({"ok": False, "error": "path required"}), 400
|
||||||
|
if ".." in path or "\\" in path:
|
||||||
|
return jsonify({"ok": False, "error": "invalid path"}), 400
|
||||||
|
full = (PORTAL_FILES_DIR / path).resolve()
|
||||||
|
try:
|
||||||
|
full.relative_to(PORTAL_FILES_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"ok": False, "error": "invalid path"}), 400
|
||||||
|
try:
|
||||||
|
full.mkdir(parents=True, exist_ok=True)
|
||||||
|
admin_log("portal_folder_create", path)
|
||||||
|
return jsonify({"ok": True, "path": path})
|
||||||
|
except OSError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/portal-files/upload", methods=["POST"])
|
@app.route("/api/portal-files/upload", methods=["POST"])
|
||||||
@@ -595,12 +688,20 @@ def api_portal_files_upload():
|
|||||||
f = request.files.get("file") or request.files.get("upload")
|
f = request.files.get("file") or request.files.get("upload")
|
||||||
if not f or not f.filename:
|
if not f or not f.filename:
|
||||||
return jsonify({"ok": False, "error": "no file selected"}), 400
|
return jsonify({"ok": False, "error": "no file selected"}), 400
|
||||||
name = re.sub(r"[^\w\-.]", "_", f.filename)[:120] or "upload"
|
base_name = re.sub(r"[^\w\-./]", "_", f.filename)[:120] or "upload"
|
||||||
if ".." in name or name.startswith("/"):
|
if ".." in base_name or base_name.startswith("/"):
|
||||||
return jsonify({"ok": False, "error": "invalid filename"}), 400
|
return jsonify({"ok": False, "error": "invalid filename"}), 400
|
||||||
path = PORTAL_FILES_DIR / name
|
subpath = (request.form.get("path") or "").strip().strip("/")
|
||||||
|
if subpath and (".." in subpath or "\\" in subpath):
|
||||||
|
return jsonify({"ok": False, "error": "invalid path"}), 400
|
||||||
|
name = (subpath + "/" + base_name) if subpath else base_name
|
||||||
|
path = (PORTAL_FILES_DIR / name).resolve()
|
||||||
try:
|
try:
|
||||||
PORTAL_FILES_DIR.mkdir(parents=True, exist_ok=True)
|
path.relative_to(PORTAL_FILES_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"ok": False, "error": "invalid path"}), 400
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
f.save(str(path))
|
f.save(str(path))
|
||||||
admin_log("portal_upload", name)
|
admin_log("portal_upload", name)
|
||||||
return jsonify({"ok": True, "name": name, "url": request.host_url.rstrip("/") + "/files/" + name})
|
return jsonify({"ok": True, "name": name, "url": request.host_url.rstrip("/") + "/files/" + name})
|
||||||
@@ -613,17 +714,30 @@ def api_portal_files_upload():
|
|||||||
@app.route("/api/portal-files/<path:name>", methods=["DELETE"])
|
@app.route("/api/portal-files/<path:name>", methods=["DELETE"])
|
||||||
@require_admin
|
@require_admin
|
||||||
def api_portal_file_delete(name):
|
def api_portal_file_delete(name):
|
||||||
if ".." in name or "/" in name or "\\" in name:
|
if ".." in name or "\\" in name:
|
||||||
return jsonify({"ok": False, "error": "invalid name"}), 400
|
return jsonify({"ok": False, "error": "invalid name"}), 400
|
||||||
path = PORTAL_FILES_DIR / name
|
path = (PORTAL_FILES_DIR / name).resolve()
|
||||||
if not path.is_file():
|
try:
|
||||||
return jsonify({"ok": False, "error": "not found"}), 404
|
path.relative_to(PORTAL_FILES_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"ok": False, "error": "invalid path"}), 400
|
||||||
|
if path.is_file():
|
||||||
try:
|
try:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
admin_log("portal_delete", name)
|
admin_log("portal_delete", name)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return jsonify({"ok": False, "error": str(e)}), 500
|
return jsonify({"ok": False, "error": str(e)}), 500
|
||||||
|
if path.is_dir():
|
||||||
|
if any(path.iterdir()):
|
||||||
|
return jsonify({"ok": False, "error": "folder not empty"}), 400
|
||||||
|
try:
|
||||||
|
path.rmdir()
|
||||||
|
admin_log("portal_folder_delete", name)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
except OSError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 500
|
||||||
|
return jsonify({"ok": False, "error": "not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/backups/upload", methods=["POST"])
|
@app.route("/api/backups/upload", methods=["POST"])
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
||||||
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
||||||
.header a:hover { color: var(--accent); }
|
.header a:hover { color: var(--accent); }
|
||||||
|
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
||||||
|
.nav a:hover { color: var(--accent); }
|
||||||
|
.nav a.active { color: var(--accent); font-weight: 600; }
|
||||||
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||||
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
||||||
@@ -53,7 +56,14 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
<div>
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
|
<nav class="nav" style="margin-top:0.35rem;">
|
||||||
|
<a href="/admin" class="active">Admin</a>
|
||||||
|
<a href="/admin/portal-files">Portal files</a>
|
||||||
|
<a href="/admin/cloudinit-build">Cloud-init build</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
<span>Logged in as <strong>{{ username }}</strong></span>
|
<span>Logged in as <strong>{{ username }}</strong></span>
|
||||||
<a href="/">Deploy</a>
|
<a href="/">Deploy</a>
|
||||||
<a href="/logout">Log out</a>
|
<a href="/logout">Log out</a>
|
||||||
@@ -91,43 +101,6 @@
|
|||||||
<p id="cloudinitEmpty" class="empty-msg" style="display:none;">No cloud-init images. Build one below.</p>
|
<p id="cloudinitEmpty" class="empty-msg" style="display:none;">No cloud-init images. Build one below.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Build cloud-init (download latest + edit) -->
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Download & build cloud-init image</h2>
|
|
||||||
<p style="font-size:0.9rem; color:var(--text-dim); margin-bottom:0.75rem;">Download latest Raspberry Pi OS (arm64), inject cloud-init. Output goes to <strong>Cloud-init images</strong> above.</p>
|
|
||||||
<div style="margin-bottom:0.5rem;">
|
|
||||||
<label>Variant: </label>
|
|
||||||
<select id="buildVariant"><option value="lite">Lite</option><option value="full">Full</option></select>
|
|
||||||
<span id="buildRaspiosUrl" class="mono" style="margin-left:0.5rem;"></span>
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:0.5rem;"><label><input type="checkbox" id="buildSetGolden" /> Set as golden after build</label></div>
|
|
||||||
<div style="margin-bottom:0.75rem;"><button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download & build</button></div>
|
|
||||||
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
|
|
||||||
<details style="margin-top:0.75rem;">
|
|
||||||
<summary>Edit cloud-init (user-data, meta-data, network-config)</summary>
|
|
||||||
<div style="margin-top:0.75rem;">
|
|
||||||
<p>Templates: <select id="buildTemplateSelect"><option value="">— Load —</option></select>
|
|
||||||
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
|
|
||||||
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save as template…</button></p>
|
|
||||||
<ul id="buildTemplateList" style="list-style:none; padding:0; font-size:0.85rem; margin-bottom:0.5rem;"></ul>
|
|
||||||
<label>user-data (YAML)</label>
|
|
||||||
<textarea id="buildUserData" rows="6" placeholder="#cloud-config..."></textarea>
|
|
||||||
<label style="display:block; margin-top:0.5rem;">meta-data (optional)</label>
|
|
||||||
<textarea id="buildMetaData" rows="2"></textarea>
|
|
||||||
<label style="display:block; margin-top:0.5rem;">network-config (optional)</label>
|
|
||||||
<textarea id="buildNetworkConfig" rows="4"></textarea>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Portal files (wget in cloud-init) -->
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Portal files (for wget in cloud-init) <button type="button" class="btn btn-outline btn-sm" id="uploadPortalBtn">Upload file</button><input type="file" id="uploadPortalInput" style="display:none;" /></h2>
|
|
||||||
<p class="mono" style="font-size:0.8rem; margin-bottom:0.5rem;">Files here are served at <strong id="portalBaseUrl">/files/</strong> — use in user-data e.g. <code>curl -fsSL "http://THIS_SERVER/files/bootstrap.sh" -o /tmp/bootstrap.sh</code></p>
|
|
||||||
<table id="portalTable"><thead><tr><th>File</th><th>Size</th><th>Actions</th></tr></thead><tbody id="portalBody"></tbody></table>
|
|
||||||
<p id="portalEmpty" class="empty-msg" style="display:none;">No files. Upload scripts or configs for first-boot.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Users -->
|
<!-- Users -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">Admin users <button type="button" class="btn btn-outline btn-sm" id="addUserBtn">Add user</button></h2>
|
<h2 class="section-title">Admin users <button type="button" class="btn btn-outline btn-sm" id="addUserBtn">Add user</button></h2>
|
||||||
@@ -227,26 +200,6 @@
|
|||||||
authFetch('/api/cloudinit-images').then(function(r){ return r.json(); }).then(function(d){ renderCloudinit(d.images || []); }).catch(function(){});
|
authFetch('/api/cloudinit-images').then(function(r){ return r.json(); }).then(function(d){ renderCloudinit(d.images || []); }).catch(function(){});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPortal(files, baseUrl) {
|
|
||||||
var tbody = document.getElementById('portalBody');
|
|
||||||
var empty = document.getElementById('portalEmpty');
|
|
||||||
document.getElementById('portalBaseUrl').textContent = baseUrl || '';
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
if (!files || files.length === 0) { empty.style.display = 'block'; return; }
|
|
||||||
empty.style.display = 'none';
|
|
||||||
files.forEach(function(f){
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = '<td><a href="'+escapeHtml(baseUrl+f.name)+'" target="_blank" rel="noopener">'+escapeHtml(f.name)+'</a></td><td class="mono">'+fmtSize(f.size)+'</td><td><button type="button" class="btn btn-outline btn-sm delete-portal" data-name="'+escapeHtml(f.name)+'">Delete</button></td>';
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
tbody.querySelectorAll('.delete-portal').forEach(function(btn){
|
|
||||||
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/portal-files/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchPortal(); else alert(d.error); }); };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function fetchPortal() {
|
|
||||||
authFetch('/api/portal-files').then(function(r){ return r.json(); }).then(function(d){ renderPortal(d.files || [], d.base_url); }).catch(function(){});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
var tbody = document.getElementById('usersBody');
|
var tbody = document.getElementById('usersBody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
@@ -272,48 +225,6 @@
|
|||||||
authFetch('/api/admin/logs').then(function(r){ return r.json(); }).then(function(d){ renderLogs(d.logs); }).catch(function(){});
|
authFetch('/api/admin/logs').then(function(r){ return r.json(); }).then(function(d){ renderLogs(d.logs); }).catch(function(){});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchBuildStatus() {
|
|
||||||
authFetch('/api/build-cloudinit-status').then(function(r){ return r.json(); }).then(function(d){
|
|
||||||
var el = document.getElementById('buildCloudInitStatus');
|
|
||||||
var btn = document.getElementById('buildCloudInitBtn');
|
|
||||||
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
|
|
||||||
if(btn) btn.disabled = busy;
|
|
||||||
if(d.phase === 'idle' && !d.message) el.textContent = '';
|
|
||||||
else if(d.phase === 'done') { el.textContent = 'Done: '+ (d.output_name||'') + ' — see Cloud-init images above.'; fetchCloudinit(); fetchGolden(); }
|
|
||||||
else if(d.phase === 'error') el.textContent = 'Error: ' + (d.error||'');
|
|
||||||
else el.textContent = (d.phase||'') + ': ' + (d.message||'');
|
|
||||||
if(busy) setTimeout(fetchBuildStatus, 5000);
|
|
||||||
}).catch(function(){});
|
|
||||||
}
|
|
||||||
function startBuild() {
|
|
||||||
var btn = document.getElementById('buildCloudInitBtn');
|
|
||||||
if(btn) btn.disabled = true;
|
|
||||||
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
|
|
||||||
var ud = document.getElementById('buildUserData').value.trim();
|
|
||||||
var md = document.getElementById('buildMetaData').value.trim();
|
|
||||||
var nc = document.getElementById('buildNetworkConfig').value.trim();
|
|
||||||
if(ud) body.user_data = ud; if(md) body.meta_data = md; if(nc) body.network_config = nc;
|
|
||||||
authFetch('/api/build-cloudinit', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r){ return r.json(); }).then(function(d){
|
|
||||||
if(d.ok) { document.getElementById('buildCloudInitStatus').textContent = 'Build started…'; setTimeout(fetchBuildStatus, 2000); }
|
|
||||||
else { alert(d.error); if(btn) btn.disabled = false; }
|
|
||||||
}).catch(function(){ if(btn) btn.disabled = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchTemplates() {
|
|
||||||
authFetch('/api/cloudinit-templates').then(function(r){ return r.json(); }).then(function(d){
|
|
||||||
var list = d.templates || [];
|
|
||||||
var sel = document.getElementById('buildTemplateSelect');
|
|
||||||
sel.innerHTML = '<option value="">— Load —</option>';
|
|
||||||
list.forEach(function(t){ var o = document.createElement('option'); o.value = t.id; o.textContent = t.name; sel.appendChild(o); });
|
|
||||||
var ul = document.getElementById('buildTemplateList');
|
|
||||||
ul.innerHTML = list.map(function(t){ return '<li>'+escapeHtml(t.name)+' <button type="button" class="btn btn-outline btn-sm load-tpl" data-id="'+t.id+'">Load</button> <button type="button" class="btn btn-outline btn-sm del-tpl" data-id="'+t.id+'">Delete</button></li>'; }).join('') || '<li>No templates</li>';
|
|
||||||
ul.querySelectorAll('.load-tpl').forEach(function(b){ b.onclick = function(){ authFetch('/api/cloudinit-templates/'+b.getAttribute('data-id')).then(function(r){ return r.json(); }).then(function(t){ document.getElementById('buildUserData').value = t.user_data||''; document.getElementById('buildMetaData').value = t.meta_data||''; document.getElementById('buildNetworkConfig').value = t.network_config||''; }); }; });
|
|
||||||
ul.querySelectorAll('.del-tpl').forEach(function(b){ b.onclick = function(){ if(!confirm('Delete template?')) return; authFetch('/api/cloudinit-templates/'+b.getAttribute('data-id'), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchTemplates(); }); }; });
|
|
||||||
}).catch(function(){});
|
|
||||||
}
|
|
||||||
function loadTemplateFromSelect() { var s = document.getElementById('buildTemplateSelect'); if(s && s.value) authFetch('/api/cloudinit-templates/'+s.value).then(function(r){ return r.json(); }).then(function(t){ document.getElementById('buildUserData').value = t.user_data||''; document.getElementById('buildMetaData').value = t.meta_data||''; document.getElementById('buildNetworkConfig').value = t.network_config||''; }); }
|
|
||||||
function saveTemplate() { var name = prompt('Template name'); if(!name || !name.trim()) return; var body = { name: name.trim(), user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value }; authFetch('/api/cloudinit-templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchTemplates(); alert('Saved'); } else alert(d.error); }); }
|
|
||||||
|
|
||||||
document.getElementById('refreshBackupsBtn').onclick = fetchBackups;
|
document.getElementById('refreshBackupsBtn').onclick = fetchBackups;
|
||||||
document.getElementById('uploadBackupBtn').onclick = function(){ document.getElementById('uploadBackupInput').click(); };
|
document.getElementById('uploadBackupBtn').onclick = function(){ document.getElementById('uploadBackupInput').click(); };
|
||||||
document.getElementById('uploadBackupInput').onchange = function(){
|
document.getElementById('uploadBackupInput').onchange = function(){
|
||||||
@@ -324,18 +235,6 @@
|
|||||||
this.value = '';
|
this.value = '';
|
||||||
};
|
};
|
||||||
document.getElementById('refreshCloudinitBtn').onclick = fetchCloudinit;
|
document.getElementById('refreshCloudinitBtn').onclick = fetchCloudinit;
|
||||||
document.getElementById('buildCloudInitBtn').onclick = startBuild;
|
|
||||||
document.getElementById('buildVariant').onchange = function(){ authFetch('/api/raspios-latest-url?variant='+encodeURIComponent(document.getElementById('buildVariant').value)).then(function(r){ return r.json(); }).then(function(d){ document.getElementById('buildRaspiosUrl').textContent = d.ok && d.filename ? d.filename : (d.error||''); }); };
|
|
||||||
document.getElementById('buildTemplateLoad').onclick = loadTemplateFromSelect;
|
|
||||||
document.getElementById('buildTemplateSave').onclick = saveTemplate;
|
|
||||||
document.getElementById('uploadPortalBtn').onclick = function(){ document.getElementById('uploadPortalInput').click(); };
|
|
||||||
document.getElementById('uploadPortalInput').onchange = function(){
|
|
||||||
var f = this.files && this.files[0];
|
|
||||||
if(!f) return;
|
|
||||||
var fd = new FormData(); fd.append('file', f);
|
|
||||||
authFetch('/api/portal-files/upload', { method: 'POST', body: fd }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchPortal(); alert('Uploaded. URL: '+d.url); } else alert(d.error); }).catch(function(){});
|
|
||||||
this.value = '';
|
|
||||||
};
|
|
||||||
document.getElementById('refreshLogsBtn').onclick = fetchLogs;
|
document.getElementById('refreshLogsBtn').onclick = fetchLogs;
|
||||||
document.getElementById('addUserBtn').onclick = function(){ document.getElementById('addUserForm').style.display = 'block'; };
|
document.getElementById('addUserBtn').onclick = function(){ document.getElementById('addUserForm').style.display = 'block'; };
|
||||||
document.getElementById('addUserCancel').onclick = function(){ document.getElementById('addUserForm').style.display = 'none'; };
|
document.getElementById('addUserCancel').onclick = function(){ document.getElementById('addUserForm').style.display = 'none'; };
|
||||||
@@ -347,9 +246,7 @@
|
|||||||
authFetch('/api/admin/users', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username: username, password: password}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { document.getElementById('addUserForm').style.display = 'none'; document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; fetchUsers(); } else alert(d.error); });
|
authFetch('/api/admin/users', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username: username, password: password}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { document.getElementById('addUserForm').style.display = 'none'; document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; fetchUsers(); } else alert(d.error); });
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchBackups(); fetchCloudinit(); fetchPortal(); fetchUsers(); fetchLogs(); fetchGolden(); fetchBuildStatus(); fetchTemplates();
|
fetchBackups(); fetchCloudinit(); fetchUsers(); fetchLogs(); fetchGolden();
|
||||||
authFetch('/api/raspios-latest-url').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('buildRaspiosUrl').textContent = d.ok && d.filename ? d.filename : (d.error||''); });
|
|
||||||
setInterval(fetchBuildStatus, 15000);
|
|
||||||
setInterval(fetchLogs, 30000);
|
setInterval(fetchLogs, 30000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Cloud-init build · Admin · CM4 Provisioning</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
|
||||||
|
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
|
||||||
|
--border: #2d333b; --radius: 10px; --radius-sm: 6px;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
||||||
|
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.header h1 { font-size: 1.25rem; font-weight: 700; }
|
||||||
|
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
||||||
|
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
||||||
|
.nav a:hover { color: var(--accent); }
|
||||||
|
.nav a.active { color: var(--accent); font-weight: 600; }
|
||||||
|
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
||||||
|
.header a:hover { color: var(--accent); }
|
||||||
|
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||||
|
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; }
|
||||||
|
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
||||||
|
.btn-primary:hover { background: var(--accent-dim); }
|
||||||
|
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
.tabs { display: flex; gap: 0.25rem; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.tabs button { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-dim); cursor: pointer; font: inherit; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||||
|
.tabs button:hover { color: var(--text); }
|
||||||
|
.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
.tab-pane { display: none; }
|
||||||
|
.tab-pane.active { display: block; }
|
||||||
|
textarea { width: 100%; min-height: 220px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.5rem; border-radius: 4px; }
|
||||||
|
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
|
||||||
|
input[type="text"], select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
|
||||||
|
label { display: inline-block; margin-right: 0.5rem; }
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
ul li { margin-bottom: 0.35rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="header">
|
||||||
|
<div>
|
||||||
|
<h1>Cloud-init build</h1>
|
||||||
|
<nav class="nav" style="margin-top:0.35rem;">
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
<a href="/admin/portal-files">Portal files</a>
|
||||||
|
<a href="/admin/cloudinit-build" class="active">Cloud-init build</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<span>Logged in as <strong>{{ username }}</strong></span>
|
||||||
|
<a href="/">Deploy</a>
|
||||||
|
<a href="/logout">Log out</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Download & build cloud-init image</h2>
|
||||||
|
<p style="font-size:0.9rem; color:var(--text-dim); margin-bottom:0.75rem;">Download latest Raspberry Pi OS (arm64), inject cloud-init. Output goes to <strong>Cloud-init images</strong> on the Admin page.</p>
|
||||||
|
<div style="margin-bottom:0.5rem;">
|
||||||
|
<label>Variant:</label>
|
||||||
|
<select id="buildVariant"><option value="lite">Lite</option><option value="full">Full</option></select>
|
||||||
|
<span id="buildRaspiosUrl" class="mono" style="margin-left:0.5rem;"></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:0.5rem;"><label><input type="checkbox" id="buildSetGolden" /> Set as golden after build</label></div>
|
||||||
|
<div style="margin-bottom:0.75rem;"><button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download & build</button></div>
|
||||||
|
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Edit cloud-init files (templates)</h2>
|
||||||
|
<p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Choose a file to edit below. These contents are injected into the image when you run the build.</p>
|
||||||
|
<div class="tabs">
|
||||||
|
<button type="button" class="tab-btn active" data-tab="user-data">user-data</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="meta-data">meta-data</button>
|
||||||
|
<button type="button" class="tab-btn" data-tab="network-config">network-config</button>
|
||||||
|
</div>
|
||||||
|
<div id="pane-user-data" class="tab-pane active">
|
||||||
|
<label>user-data (YAML, #cloud-config)</label>
|
||||||
|
<textarea id="buildUserData" rows="12" placeholder="#cloud-config..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="pane-meta-data" class="tab-pane">
|
||||||
|
<label>meta-data (optional)</label>
|
||||||
|
<textarea id="buildMetaData" rows="8" placeholder="instance-id: ..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="pane-network-config" class="tab-pane">
|
||||||
|
<label>network-config (optional)</label>
|
||||||
|
<textarea id="buildNetworkConfig" rows="8" placeholder="version: 2..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:0.75rem;">
|
||||||
|
<span>Templates:</span>
|
||||||
|
<select id="buildTemplateSelect"><option value="">— Load —</option></select>
|
||||||
|
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
|
||||||
|
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save as template…</button>
|
||||||
|
</div>
|
||||||
|
<ul id="buildTemplateList" style="margin-top:0.5rem; font-size:0.85rem;"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function authFetch(url, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
return fetch(url, opts).then(function(r) {
|
||||||
|
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
||||||
|
btn.onclick = function() {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(function(p) { p.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
var tab = btn.getAttribute('data-tab');
|
||||||
|
var pane = document.getElementById('pane-' + tab);
|
||||||
|
if (pane) pane.classList.add('active');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function fetchBuildStatus() {
|
||||||
|
authFetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
var el = document.getElementById('buildCloudInitStatus');
|
||||||
|
var btn = document.getElementById('buildCloudInitBtn');
|
||||||
|
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
|
||||||
|
if (btn) btn.disabled = busy;
|
||||||
|
if (d.phase === 'idle' && !d.message) el.textContent = '';
|
||||||
|
else if (d.phase === 'done') { el.textContent = 'Done: ' + (d.output_name || '') + ' — see Admin page Cloud-init images.'; }
|
||||||
|
else if (d.phase === 'error') el.textContent = 'Error: ' + (d.error || '');
|
||||||
|
else el.textContent = (d.phase || '') + ': ' + (d.message || '');
|
||||||
|
if (busy) setTimeout(fetchBuildStatus, 5000);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
function startBuild() {
|
||||||
|
var btn = document.getElementById('buildCloudInitBtn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
|
||||||
|
body.user_data = document.getElementById('buildUserData').value.trim();
|
||||||
|
body.meta_data = document.getElementById('buildMetaData').value.trim();
|
||||||
|
body.network_config = document.getElementById('buildNetworkConfig').value.trim();
|
||||||
|
authFetch('/api/build-cloudinit', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
if (d.ok) { document.getElementById('buildCloudInitStatus').textContent = 'Build started…'; setTimeout(fetchBuildStatus, 2000); }
|
||||||
|
else { alert(d.error); if (btn) btn.disabled = false; }
|
||||||
|
}).catch(function() { if (btn) btn.disabled = false; });
|
||||||
|
}
|
||||||
|
function fetchTemplates() {
|
||||||
|
authFetch('/api/cloudinit-templates').then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
var list = d.templates || [];
|
||||||
|
var sel = document.getElementById('buildTemplateSelect');
|
||||||
|
sel.innerHTML = '<option value="">— Load —</option>';
|
||||||
|
list.forEach(function(t) { var o = document.createElement('option'); o.value = t.id; o.textContent = t.name; sel.appendChild(o); });
|
||||||
|
var ul = document.getElementById('buildTemplateList');
|
||||||
|
ul.innerHTML = list.map(function(t) {
|
||||||
|
return '<li>' + escapeHtml(t.name) + ' <button type="button" class="btn btn-outline btn-sm load-tpl" data-id="' + t.id + '">Load</button> <button type="button" class="btn btn-outline btn-sm del-tpl" data-id="' + t.id + '">Delete</button></li>';
|
||||||
|
}).join('') || '<li>No templates</li>';
|
||||||
|
ul.querySelectorAll('.load-tpl').forEach(function(b) {
|
||||||
|
b.onclick = function() {
|
||||||
|
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id')).then(function(r) { return r.json(); }).then(function(t) {
|
||||||
|
document.getElementById('buildUserData').value = t.user_data || '';
|
||||||
|
document.getElementById('buildMetaData').value = t.meta_data || '';
|
||||||
|
document.getElementById('buildNetworkConfig').value = t.network_config || '';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
ul.querySelectorAll('.del-tpl').forEach(function(b) {
|
||||||
|
b.onclick = function() {
|
||||||
|
if (!confirm('Delete template?')) return;
|
||||||
|
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id'), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) fetchTemplates(); });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
function loadTemplateFromSelect() {
|
||||||
|
var s = document.getElementById('buildTemplateSelect');
|
||||||
|
if (s && s.value) authFetch('/api/cloudinit-templates/' + s.value).then(function(r) { return r.json(); }).then(function(t) {
|
||||||
|
document.getElementById('buildUserData').value = t.user_data || '';
|
||||||
|
document.getElementById('buildMetaData').value = t.meta_data || '';
|
||||||
|
document.getElementById('buildNetworkConfig').value = t.network_config || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function saveTemplate() {
|
||||||
|
var name = prompt('Template name');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
var body = { name: name.trim(), user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value };
|
||||||
|
authFetch('/api/cloudinit-templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) { fetchTemplates(); alert('Saved'); } else alert(d.error); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('buildCloudInitBtn').onclick = startBuild;
|
||||||
|
document.getElementById('buildVariant').onchange = function() {
|
||||||
|
authFetch('/api/raspios-latest-url?variant=' + encodeURIComponent(document.getElementById('buildVariant').value)).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById('buildTemplateLoad').onclick = loadTemplateFromSelect;
|
||||||
|
document.getElementById('buildTemplateSave').onclick = saveTemplate;
|
||||||
|
|
||||||
|
fetchBuildStatus();
|
||||||
|
fetchTemplates();
|
||||||
|
authFetch('/api/raspios-latest-url').then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
|
||||||
|
});
|
||||||
|
setInterval(fetchBuildStatus, 15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Portal files · Admin · CM4 Provisioning</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
|
||||||
|
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
|
||||||
|
--border: #2d333b; --danger: #f87171; --radius: 10px; --radius-sm: 6px;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
||||||
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.header h1 { font-size: 1.25rem; font-weight: 700; }
|
||||||
|
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
||||||
|
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
||||||
|
.nav a:hover { color: var(--accent); }
|
||||||
|
.nav a.active { color: var(--accent); font-weight: 600; }
|
||||||
|
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
||||||
|
.header a:hover { color: var(--accent); }
|
||||||
|
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||||
|
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
||||||
|
.btn-primary:hover { background: var(--accent-dim); }
|
||||||
|
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
|
||||||
|
.btn-danger:hover { background: rgba(248,113,113,0.3); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
|
||||||
|
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
|
||||||
|
.empty-msg { text-align: center; padding: 1.5rem; font-size: 0.9rem; color: var(--text-muted); }
|
||||||
|
.breadcrumb { margin-bottom: 0.75rem; font-size: 0.85rem; }
|
||||||
|
.breadcrumb a { color: var(--text-dim); text-decoration: none; }
|
||||||
|
.breadcrumb a:hover { color: var(--accent); }
|
||||||
|
.breadcrumb span { color: var(--text-muted); margin: 0 0.25rem; }
|
||||||
|
.desc-input { width: 100%; max-width: 280px; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.3rem 0.5rem; border-radius: 4px; }
|
||||||
|
.folder-name { font-weight: 500; }
|
||||||
|
input[type="text"] { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="header">
|
||||||
|
<div>
|
||||||
|
<h1>Portal files</h1>
|
||||||
|
<nav class="nav" style="margin-top:0.35rem;">
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
<a href="/admin/portal-files" class="active">Portal files</a>
|
||||||
|
<a href="/admin/cloudinit-build">Cloud-init build</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<span>Logged in as <strong>{{ username }}</strong></span>
|
||||||
|
<a href="/">Deploy</a>
|
||||||
|
<a href="/logout">Log out</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span id="breadcrumb">Portal files</span>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" id="newFolderBtn">New folder</button>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" id="uploadBtn">Upload file</button>
|
||||||
|
<input type="file" id="uploadInput" style="display:none;" />
|
||||||
|
</div>
|
||||||
|
<p class="mono" style="font-size:0.8rem; margin-bottom:0.75rem;">Served at <strong id="baseUrl">/files/</strong> — use in cloud-init e.g. <code>curl -fsSL "http://SERVER/files/first-boot/splash.png" -o /tmp/splash.png</code></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Description</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="portalBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<p id="portalEmpty" class="empty-msg" style="display:none;">No files or folders. Create a folder or upload a file.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function authFetch(url, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
return fetch(url, opts).then(function(r) {
|
||||||
|
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||||
|
function fmtSize(n) { if (!n) return '—'; if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
|
||||||
|
function fmtDate(ts) { return ts ? new Date(ts*1000).toLocaleString() : '—'; }
|
||||||
|
|
||||||
|
var currentPath = '';
|
||||||
|
var descriptions = {};
|
||||||
|
|
||||||
|
function buildBreadcrumb() {
|
||||||
|
var el = document.getElementById('breadcrumb');
|
||||||
|
var parts = currentPath ? currentPath.split('/') : [];
|
||||||
|
var html = '<a href="#" data-path="">Portal files</a>';
|
||||||
|
parts.forEach(function(p, i) {
|
||||||
|
var path = parts.slice(0, i+1).join('/');
|
||||||
|
html += ' <span>/</span> <a href="#" data-path="' + escapeHtml(path) + '">' + escapeHtml(p) + '</a>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.querySelectorAll('a[data-path]').forEach(function(a) {
|
||||||
|
a.onclick = function(e) { e.preventDefault(); currentPath = a.getAttribute('data-path') || ''; fetchPortal(); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDescription(path, desc) {
|
||||||
|
descriptions[path] = desc;
|
||||||
|
authFetch('/api/portal-files/descriptions', { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ descriptions: descriptions }) }).then(function(r) { return r.json(); }).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPortal(data) {
|
||||||
|
var items = data.items || [];
|
||||||
|
var baseUrl = data.base_url || '';
|
||||||
|
descriptions = data.descriptions || {};
|
||||||
|
currentPath = data.current_path || '';
|
||||||
|
document.getElementById('baseUrl').textContent = baseUrl + (currentPath ? currentPath + '/' : '');
|
||||||
|
buildBreadcrumb();
|
||||||
|
|
||||||
|
var tbody = document.getElementById('portalBody');
|
||||||
|
var empty = document.getElementById('portalEmpty');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (items.length === 0) { empty.style.display = 'block'; return; }
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
items.forEach(function(it) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var desc = descriptions[it.path] || '';
|
||||||
|
var typeLabel = it.type === 'folder' ? 'Folder' : 'File';
|
||||||
|
var sizeCell = it.type === 'file' ? '<td class="mono">' + fmtSize(it.size) + '</td>' : '<td class="mono">—</td>';
|
||||||
|
var descCell = '<td><input type="text" class="desc-input" data-path="' + escapeHtml(it.path) + '" value="' + escapeHtml(desc) + '" placeholder="Comment…" /></td>';
|
||||||
|
var actions = '';
|
||||||
|
if (it.type === 'folder') {
|
||||||
|
actions = '<button type="button" class="btn btn-outline btn-sm open-folder" data-path="' + escapeHtml(it.path) + '">Open</button> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="folder">Delete</button>';
|
||||||
|
} else {
|
||||||
|
actions = '<a href="' + escapeHtml(baseUrl + it.path) + '" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open</a> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="file">Delete</button>';
|
||||||
|
}
|
||||||
|
tr.innerHTML = '<td class="' + (it.type === 'folder' ? 'folder-name' : '') + '">' + escapeHtml(it.name) + (it.type === 'folder' ? ' /' : '') + '</td><td>' + typeLabel + '</td>' + sizeCell + descCell + '<td class="actions-cell">' + actions + '</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.desc-input').forEach(function(inp) {
|
||||||
|
inp.onblur = function() { saveDescription(inp.getAttribute('data-path'), inp.value.trim()); };
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('.open-folder').forEach(function(btn) {
|
||||||
|
btn.onclick = function() { currentPath = btn.getAttribute('data-path'); fetchPortal(); };
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('.delete-item').forEach(function(btn) {
|
||||||
|
btn.onclick = function() {
|
||||||
|
var path = btn.getAttribute('data-path');
|
||||||
|
var type = btn.getAttribute('data-type');
|
||||||
|
if (!confirm('Delete ' + type + ' “‘ + path + '”?')) return;
|
||||||
|
authFetch('/api/portal-files/' + encodeURIComponent(path), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
if (d.ok) fetchPortal(); else alert(d.error || 'Delete failed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPortal() {
|
||||||
|
var url = '/api/portal-files';
|
||||||
|
if (currentPath) url += '?path=' + encodeURIComponent(currentPath);
|
||||||
|
authFetch(url).then(function(r) { return r.json(); }).then(renderPortal).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('newFolderBtn').onclick = function() {
|
||||||
|
var name = prompt('Folder name (no slashes)');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
name = name.trim().replace(/[/\\]/g, '');
|
||||||
|
var path = currentPath ? currentPath + '/' + name : name;
|
||||||
|
authFetch('/api/portal-files/folder', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ path: path }) }).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
if (d.ok) fetchPortal(); else alert(d.error || 'Failed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById('uploadBtn').onclick = function() { document.getElementById('uploadInput').click(); };
|
||||||
|
document.getElementById('uploadInput').onchange = function() {
|
||||||
|
var f = this.files && this.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', f);
|
||||||
|
if (currentPath) fd.append('path', currentPath);
|
||||||
|
authFetch('/api/portal-files/upload', { method: 'POST', body: fd }).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
if (d.ok) { fetchPortal(); alert('Uploaded. URL: ' + d.url); } else alert(d.error || 'Upload failed');
|
||||||
|
});
|
||||||
|
this.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPortal();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user