Implement automatic page scaling feature with viewport adjustments
This commit is contained in:
8
chromium-setup/emmc-provisioning/90-cm4-boot-mode.rules
Normal file
8
chromium-setup/emmc-provisioning/90-cm4-boot-mode.rules
Normal file
@@ -0,0 +1,8 @@
|
||||
# When reTerminal (CM4) is connected in USB boot mode (eMMC disable jumper),
|
||||
# Raspberry Pi Foundation USB device appears (vendor 2b8e). Trigger auto-flash.
|
||||
# Install: sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
|
||||
# sudo udevadm control --reload-rules
|
||||
# The trigger script starts the actual flash via systemd so udev does not block.
|
||||
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="2b8e", ACTION=="add", \
|
||||
RUN+="/usr/local/bin/cm4-flash-trigger.sh"
|
||||
165
chromium-setup/emmc-provisioning/EMMC-PROVISIONING-GUIDE.md
Normal file
165
chromium-setup/emmc-provisioning/EMMC-PROVISIONING-GUIDE.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Automatic eMMC provisioning for reTerminal DM4 (CM4)
|
||||
|
||||
This guide covers:
|
||||
|
||||
1. **Auto-flash**: When the reTerminal is switched to boot mode (eMMC disable jumper) and connected via USB to a provisioning host, the host automatically deploys a golden image to the CM4 eMMC.
|
||||
2. **Backup**: When a device is detected (USB or network), the dashboard asks you to choose **Backup** or **Deploy**. Backup saves the device eMMC to a timestamped file in `backups/`.
|
||||
3. **Network**: If the device boots over the network and runs the **provisioning client** (see `network-client/`), it registers with the dashboard and appears as "Device (Network)"; you then choose Backup or Deploy. Deploy streams the golden image to the device; Backup uploads the device eMMC to the server.
|
||||
4. **Cloud-init**: The golden image includes cloud-init so each device configures itself on first boot (hostname, network, packages, kiosk setup).
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Auto-flash when reTerminal is in boot mode
|
||||
|
||||
### How it works
|
||||
|
||||
- reTerminal has an **eMMC disable** jumper (see reTerminal docs; often “J2” or “nRPIBOOT”). When the jumper is fitted, the CM4 boots in **USB device mode** and waits for `rpiboot` from the host.
|
||||
- You connect the reTerminal’s **USB slave** port to a **provisioning PC** (Linux).
|
||||
- **udev** detects the Raspberry Pi Foundation USB device (vendor `2b8e`) and runs a trigger script.
|
||||
- The trigger starts a **flash job** that:
|
||||
1. Runs **rpiboot** (from the `usbboot` project). The CM4 then exposes its eMMC as a USB mass-storage device.
|
||||
2. After `rpiboot` exits, finds the new block device (eMMC) and writes your **golden image** to it with `dd`.
|
||||
- You remove the jumper and power cycle; the reTerminal boots from eMMC and runs **cloud-init** on first boot.
|
||||
|
||||
### Provisioning host setup (Linux)
|
||||
|
||||
#### 1. Build and install usbboot (rpiboot)
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libusb-1.0-0-dev
|
||||
git clone --depth=1 https://github.com/raspberrypi/usbboot
|
||||
cd usbboot
|
||||
make
|
||||
sudo mkdir -p /opt/usbboot
|
||||
sudo cp rpiboot /opt/usbboot/
|
||||
```
|
||||
|
||||
#### 2. Create golden image and config directory
|
||||
|
||||
- Build your golden image (see Part 2) and place it where the script will find it, e.g.:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/lib/cm4-provisioning
|
||||
sudo cp /path/to/your/golden-reterminal.img /var/lib/cm4-provisioning/golden.img
|
||||
```
|
||||
|
||||
- Or use a different path and set `GOLDEN_IMAGE` when installing the script (see below).
|
||||
|
||||
#### 3. Install the auto-flash script and trigger
|
||||
|
||||
```bash
|
||||
# From this repo (chromium-setup/emmc-provisioning/)
|
||||
SCRIPT_DIR="$(pwd)"
|
||||
|
||||
sudo mkdir -p /opt/cm4-provisioning
|
||||
sudo cp flash-emmc-on-connect.sh /opt/cm4-provisioning/
|
||||
sudo chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
|
||||
# Optional: override paths via environment (create env file)
|
||||
echo 'GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img' | sudo tee /opt/cm4-provisioning/env
|
||||
echo 'RPIBOOT_DIR=/opt/usbboot' | sudo tee -a /opt/cm4-provisioning/env
|
||||
echo 'EMMC_SIZE_BYTES=8589934592' | sudo tee -a /opt/cm4-provisioning/env # 8GB; use 17179869184 for 16GB
|
||||
|
||||
sudo cp cm4-flash-trigger.sh /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
||||
```
|
||||
|
||||
If your golden image path or rpiboot path is different, set `GOLDEN_IMAGE`, `RPIBOOT_DIR`, and optionally `EMMC_SIZE_BYTES` in `/opt/cm4-provisioning/env` and source it from the script, or pass them into the systemd-run call in the trigger (e.g. by making the trigger source the env file and export variables before `systemd-run`).
|
||||
|
||||
#### 4. Install udev rule
|
||||
|
||||
```bash
|
||||
sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
#### 5. Enable provisioning (safety)
|
||||
|
||||
Provisioning runs only if the “enabled” file exists:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/cm4-provisioning
|
||||
sudo touch /etc/cm4-provisioning/enabled
|
||||
```
|
||||
|
||||
To disable auto-flash, remove that file: `sudo rm /etc/cm4-provisioning/enabled`.
|
||||
|
||||
#### 6. Optional: pass environment into the flash job
|
||||
|
||||
If you use `/opt/cm4-provisioning/env`, update the trigger so the flash script sees those variables. For example change `/usr/local/bin/cm4-flash-trigger.sh` to:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -a
|
||||
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||
set +a
|
||||
export GOLDEN_IMAGE RPIBOOT_DIR EMMC_SIZE_BYTES
|
||||
FLASH_SCRIPT="${CM4_FLASH_SCRIPT:-/opt/cm4-provisioning/flash-emmc-on-connect.sh}"
|
||||
exec systemd-run --no-block --unit=cm4-flash-once --property=Environment="GOLDEN_IMAGE=$GOLDEN_IMAGE" ...
|
||||
```
|
||||
|
||||
Or keep it simple and edit the defaults inside `flash-emmc-on-connect.sh` (e.g. `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES`).
|
||||
|
||||
### Usage
|
||||
|
||||
1. Fit the **eMMC disable** jumper on the reTerminal.
|
||||
2. Connect the reTerminal **USB slave** port to the provisioning PC.
|
||||
3. Power the reTerminal (or apply power after USB).
|
||||
4. On the host, `rpiboot` will run automatically; when it exits, the script will `dd` the golden image to the eMMC. Watch logs: `journalctl -u cm4-flash-once -f` or `journalctl -t cm4-flash -f`.
|
||||
5. When done, remove the jumper and power cycle the reTerminal. It will boot from eMMC; cloud-init will run on first boot.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Golden image with cloud-init
|
||||
|
||||
Raspberry Pi OS (recent versions) supports **cloud-init** using the **NoCloud** datasource: it reads `user-data`, `meta-data`, and optionally `network-config` from the **boot** (FAT32) partition.
|
||||
|
||||
### Creating the golden image
|
||||
|
||||
1. **Flash Raspberry Pi OS** (or your base image) to a spare SD card or a loop file.
|
||||
2. **Mount the boot partition** (first partition, FAT32). On the image file it might be at an offset; use `losetup -P` or mount the SD’s partition.
|
||||
3. **Add cloud-init NoCloud files** on the boot partition (same level as `config.txt`, not in a subfolder for default NoCloud):
|
||||
- `user-data` – main config (packages, runcmd, etc.)
|
||||
- `meta-data` – optional (instance-id, local-hostname)
|
||||
- `network-config` – optional (network config in netplan format)
|
||||
|
||||
You can use the examples in this repo:
|
||||
|
||||
```bash
|
||||
# After mounting boot partition at e.g. /mnt/boot
|
||||
# (On Raspberry Pi OS, boot is often /boot/firmware on the running system, or the first FAT partition of the image)
|
||||
cp emmc-provisioning/cloud-init/user-data /mnt/boot/
|
||||
cp emmc-provisioning/cloud-init/meta-data /mnt/boot/
|
||||
cp emmc-provisioning/cloud-init/network-config /mnt/boot/
|
||||
```
|
||||
|
||||
4. **Customise** `user-data` and `network-config` (hostname, WiFi, packages, Chromium kiosk, etc.).
|
||||
5. **Copy your kiosk/Chromium scripts** into the image rootfs if needed (e.g. under `/home/pi/` or `/opt/`) and reference them from `user-data` `runcmd` or a systemd unit.
|
||||
6. **Unmount**, then create a **golden image** from the SD or loop device (e.g. `dd` or `dd` of the whole block device). Use that as `golden.img` on the provisioning host.
|
||||
|
||||
### Cloud-init file locations on the Pi
|
||||
|
||||
- **NoCloud**: Boot partition root – `user-data`, `meta-data`, `network-config`.
|
||||
- Some images expect them in a subfolder `cloud-init/` or on a separate vfat partition labeled `cidata`; check your OS docs. Standard Raspberry Pi OS NoCloud uses the boot partition root.
|
||||
|
||||
### Per-device config (optional)
|
||||
|
||||
NoCloud can also use a **seed** partition or **config drive**. For per-device hostname/settings you can:
|
||||
|
||||
- Use **meta-data** `instance-id` and `local-hostname` and generate different `meta-data` per device when imaging (e.g. script that writes `meta-data` before flashing), or
|
||||
- Use a first-boot script that calls a provisioning server (e.g. by serial number) and applies device-specific config; cloud-init can launch that script from `runcmd`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Step | Action |
|
||||
|------|--------|
|
||||
| 1 | Build `usbboot`, install `rpiboot` on provisioning host. |
|
||||
| 2 | Create golden image with cloud-init `user-data`, `meta-data`, `network-config` on boot partition. |
|
||||
| 3 | Install `flash-emmc-on-connect.sh`, `cm4-flash-trigger.sh`, and udev rule; set `GOLDEN_IMAGE` and enable file. |
|
||||
| 4 | Put reTerminal in boot mode (jumper), connect USB to host; image is written automatically. |
|
||||
| 5 | Remove jumper, power cycle; device boots from eMMC and cloud-init runs on first boot. |
|
||||
|
||||
This gives you automatic deployment of the golden image to eMMC when the reTerminal is in boot mode, plus first-boot configuration via cloud-init.
|
||||
292
chromium-setup/emmc-provisioning/PORTAL_STYLING_GUIDE.md
Normal file
292
chromium-setup/emmc-provisioning/PORTAL_STYLING_GUIDE.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Portal Styling Guide (Template)
|
||||
|
||||
Use this document when building new portals so they match the visual and UX style of the reference portal (e.g. FreePBX / TM VOIP Extensions Portal). Replace placeholders like `[Portal Name]` with your portal’s name where relevant.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design philosophy
|
||||
|
||||
- **Dark theme:** Dark backgrounds with light text; no light-mode variant in this guide.
|
||||
- **Accent:** Single accent (teal/cyan gradient) for primary actions, links, and highlights.
|
||||
- **Clarity:** Clear hierarchy (cards, sections, labels), consistent spacing, readable typography.
|
||||
- **Consistency:** Same tokens, components, and patterns across all pages (login, app, modals).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design tokens (CSS variables)
|
||||
|
||||
Define these in `:root` (or in a shared CSS file) and use them everywhere instead of hard-coded colors.
|
||||
|
||||
### Colors
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|--------|--------|
|
||||
| `--bg-primary` | `#0a0e14` | Page background |
|
||||
| `--bg-secondary` | `#11151c` | Header, secondary surfaces |
|
||||
| `--bg-tertiary` | `#1a1f2b` | Inputs, table header, hover states |
|
||||
| `--bg-card` | `#151a24` | Cards, modals |
|
||||
| `--accent-primary` | `#00d4aa` | Primary accent (teal) |
|
||||
| `--accent-secondary` | `#00b894` | Accent variant, secondary accent |
|
||||
| `--accent-glow` | `rgba(0, 212, 170, 0.15)` | Focus rings, subtle highlights |
|
||||
| `--text-primary` | `#e6e8eb` | Main text |
|
||||
| `--text-secondary` | `#8b949e` | Labels, secondary text |
|
||||
| `--text-muted` | `#5c6370` | Placeholders, disabled, hints |
|
||||
| `--border-color` | `#2d333b` | Borders (cards, inputs, tables) |
|
||||
| `--danger` | `#ff6b6b` | Errors, delete, destructive actions |
|
||||
| `--danger-glow` | `rgba(255, 107, 107, 0.15)` | Danger focus/hover background |
|
||||
| `--warning` | `#ffd93d` | Warnings |
|
||||
| `--success` | `#00d4aa` | Success (can match accent) |
|
||||
| `--gradient-accent` | `linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%)` | Primary buttons, logo text |
|
||||
|
||||
### Example `:root` block
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg-primary: #0a0e14;
|
||||
--bg-secondary: #11151c;
|
||||
--bg-tertiary: #1a1f2b;
|
||||
--bg-card: #151a24;
|
||||
--accent-primary: #00d4aa;
|
||||
--accent-secondary: #00b894;
|
||||
--accent-glow: rgba(0, 212, 170, 0.15);
|
||||
--text-primary: #e6e8eb;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #5c6370;
|
||||
--border-color: #2d333b;
|
||||
--danger: #ff6b6b;
|
||||
--danger-glow: rgba(255, 107, 107, 0.15);
|
||||
--warning: #ffd93d;
|
||||
--success: #00d4aa;
|
||||
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Typography
|
||||
|
||||
- **Body / UI font:** `'Outfit', -apple-system, BlinkMacSystemFont, sans-serif`
|
||||
- **Monospace (data, code, IDs):** `'JetBrains Mono', monospace`
|
||||
|
||||
Load from Google Fonts:
|
||||
|
||||
```html
|
||||
<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;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
- **Body:** `color: var(--text-primary);` `line-height: 1.6;`
|
||||
- **Labels:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional `text-transform: uppercase;` `letter-spacing: 0.5px;`
|
||||
- **Card/section titles:** `font-size: 1.1rem;` `font-weight: 600;`
|
||||
- **Table header:** `font-size: 0.75rem–0.8rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;` `color: var(--text-secondary);`
|
||||
- **Table body:** `font-size: 0.9rem;` monospace for IDs/codes
|
||||
|
||||
---
|
||||
|
||||
## 4. Page layout
|
||||
|
||||
### Global
|
||||
|
||||
- **Reset:** `* { margin: 0; padding: 0; box-sizing: border-box; }`
|
||||
- **Body:** `background: var(--bg-primary);` `color: var(--text-primary);` `min-height: 100vh;` `font-family: 'Outfit', ...`
|
||||
|
||||
### Background treatment (optional)
|
||||
|
||||
Subtle gradient overlay for depth:
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
```
|
||||
|
||||
### Header (fixed)
|
||||
|
||||
- **Container:** `background: var(--bg-secondary);` `border-bottom: 1px solid var(--border-color);` `position: fixed; top: 0; left: 0; right: 0; z-index: 1000;` optional `backdrop-filter: blur(10px);`
|
||||
- **Top row:** Logo left; status/user/actions right; `padding: 1rem 2rem;` `display: flex; align-items: center; justify-content: space-between;`
|
||||
- **Tabs row:** Under the top row; `background: var(--bg-tertiary);` `padding: 0.5rem 2rem;` `border-top: 1px solid var(--border-color);` horizontal flex, gap, overflow-x auto for small screens
|
||||
|
||||
### Main content
|
||||
|
||||
- **Container:** `max-width: 1400px;` `margin: 0 auto;` `padding: 2rem;` `padding-top: calc(2rem + 140px);` (offset for fixed header + tabs). On mobile reduce padding and increase top offset if header stacks.
|
||||
|
||||
---
|
||||
|
||||
## 5. Logo
|
||||
|
||||
- **Wrapper:** flex, `align-items: center;` `gap: 0.75rem;`
|
||||
- **Icon:** Square (e.g. 40×40px), `background: var(--gradient-accent);` `border-radius: 10px;` optional `box-shadow: 0 4px 20px var(--accent-glow);` emoji or icon inside.
|
||||
- **Title (h1):** `font-size: 1.5rem;` `font-weight: 600;` `background: var(--gradient-accent);` `-webkit-background-clip: text;` `background-clip: text;` `-webkit-text-fill-color: transparent;`
|
||||
|
||||
---
|
||||
|
||||
## 6. Tabs (main navigation)
|
||||
|
||||
- **Tab button (default):** `padding: 0.75rem 1.5rem;` `background: transparent;` `border: none;` `color: var(--text-secondary);` `font-size: 0.95rem;` `font-weight: 500;` `border-radius: 8px;` flex with icon + label, `gap: 0.5rem;`
|
||||
- **Hover:** `color: var(--text-primary);` `background: var(--bg-tertiary);`
|
||||
- **Active:** `background: var(--gradient-accent);` `color: var(--bg-primary);`
|
||||
- **Tab content:** `display: none;` by default; `.tab-content.active { display: block; }` optional fade-in animation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cards
|
||||
|
||||
- **Base:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 1.5rem;` `margin-bottom: 1.5rem;`
|
||||
- **Card header:** flex, `justify-content: space-between;` `align-items: center;` `margin-bottom: 1.5rem;` `padding-bottom: 1rem;` `border-bottom: 1px solid var(--border-color);`
|
||||
- **Card title:** `font-size: 1.1rem;` `font-weight: 600;` flex with icon + text, `gap: 0.5rem;`
|
||||
|
||||
---
|
||||
|
||||
## 8. Buttons
|
||||
|
||||
- **Base:** `padding: 0.75rem 1.5rem;` `border-radius: 8px;` `font-size: 0.9rem;` `font-weight: 500;` inline-flex, `align-items: center;` `gap: 0.5rem;` `transition: all 0.2s ease;`
|
||||
- **Primary:** `background: var(--gradient-accent);` `color: var(--bg-primary);` `border: none;` hover: slight `translateY(-2px);` `box-shadow: 0 4px 20px var(--accent-glow);`
|
||||
- **Secondary:** `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `color: var(--text-primary);` hover: `border-color: var(--accent-primary);`
|
||||
- **Danger:** `background: transparent;` `border: 1px solid var(--danger);` `color: var(--danger);` hover: `background: var(--danger);` `color: white;`
|
||||
- **Disabled:** `opacity: 0.5;` `cursor: not-allowed;`
|
||||
- **Icon-only (small):** e.g. 28×28px, `border-radius: 6px;` same semantic colors (e.g. `.btn-remove` with `--danger`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Forms
|
||||
|
||||
- **Grid:** `display: grid;` `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));` `gap: 1rem;`
|
||||
- **Form group:** flex column, `gap: 0.5rem;`
|
||||
- **Label:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional uppercase + letter-spacing
|
||||
- **Input / select:** `padding: 0.75rem 1rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 8px;` `color: var(--text-primary);` `font-size: 0.9rem;` monospace for IDs/codes
|
||||
- **Focus:** `outline: none;` `border-color: var(--accent-primary);` `box-shadow: 0 0 0 3px var(--accent-glow);`
|
||||
- **Placeholder:** `color: var(--text-muted);`
|
||||
- **Read-only:** `background: var(--bg-secondary);` `cursor: default;`
|
||||
- **Checkbox:** `accent-color: var(--accent-primary);` (or custom size e.g. 18×18px)
|
||||
- **Password field:** wrapper with toggle button; input `padding-right: 3rem;` so toggle doesn’t overlap text.
|
||||
|
||||
---
|
||||
|
||||
## 10. Tables
|
||||
|
||||
- **Container:** `overflow-x: auto;` `border-radius: 12px;` `border: 1px solid var(--border-color);`
|
||||
- **Table:** `width: 100%;` `border-collapse: collapse;`
|
||||
- **th / td:** `padding: 0.6rem 0.75rem;` `text-align: left;` `border-bottom: 1px solid var(--border-color);` `color: var(--text-primary);`
|
||||
- **th:** `background: var(--bg-tertiary);` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;`
|
||||
- **tbody tr hover:** `background-color: var(--bg-tertiary);`
|
||||
- **Last row:** `tr:last-child td { border-bottom: none; }`
|
||||
- **Data cells:** `font-family: 'JetBrains Mono', monospace;` `font-size: 0.9rem;`
|
||||
- **Actions column:** right-aligned; min-width for action buttons; `.table-actions { display: flex; gap: 1rem; flex-wrap: wrap; }`
|
||||
- **Data table variant:** `.data-table` with alternating row background (e.g. `nth-child(even)` subtle `rgba(255,255,255,0.02)`) and same hover.
|
||||
|
||||
---
|
||||
|
||||
## 11. Badges and status
|
||||
|
||||
- **Status badge (e.g. connection):** flex, `align-items: center;` `gap: 0.5rem;` `padding: 0.5rem 1rem;` `background: var(--bg-tertiary);` `border-radius: 20px;` `font-size: 0.85rem;` `border: 1px solid var(--border-color);`
|
||||
- **Status dot:** 8×8px circle; `.connected { background: var(--success); }` `.error { background: var(--danger); }` optional pulse animation
|
||||
- **Pill badge (e.g. extension ID):** `padding: 0.25rem 0.75rem;` `background: var(--accent-glow);` `color: var(--accent-primary);` `border-radius: 20px;` `font-weight: 500;`
|
||||
- **Tech badge (e.g. PJSIP/SIP):** small, `border-radius: 4px;` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` distinct colors per type (e.g. PJSIP blue, SIP purple)
|
||||
|
||||
---
|
||||
|
||||
## 12. Search and filters
|
||||
|
||||
- **Search box:** wrapper relative; input `padding: 0.6rem 1rem 0.6rem 2.5rem;` `width: 250px;` same colors as form inputs; optional `::before` search icon (e.g. 🔍) `left: 0.75rem;`
|
||||
- **Filter checkbox:** inline-flex, `align-items: center;` `gap: 0.4rem;` `color: var(--text-secondary);` `accent-color: var(--accent-primary);`
|
||||
|
||||
---
|
||||
|
||||
## 13. Empty and loading states
|
||||
|
||||
- **Empty state:** `text-align: center;` `padding: 3rem;` `color: var(--text-muted);`
|
||||
- **No results:** same idea, `padding: 2rem;`
|
||||
- **Loading:** flex center, `padding: 2rem;` spinner (e.g. 32×32px border, `border-top-color: var(--accent-primary);` `animation: spin 1s linear infinite;`)
|
||||
|
||||
---
|
||||
|
||||
## 14. Modals
|
||||
|
||||
- **Overlay:** `position: fixed;` `inset: 0;` `background: rgba(0,0,0,0.7);` `z-index: 1000;` flex center; `display: none;` `.active { display: flex; }`
|
||||
- **Dialog:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 2rem;` `max-width: 500px;` `width: 90%;` optional scale-in animation
|
||||
- **Modal header:** flex, `align-items: center;` `gap: 1rem;` `margin-bottom: 1.5rem;`
|
||||
- **Modal icon:** e.g. 48×48px, `background: var(--accent-glow);` `border-radius: 12px;` centered content
|
||||
- **Modal title:** `font-size: 1.25rem;` `font-weight: 600;`
|
||||
- **Detail sections:** `margin-bottom: 2rem;` section title `font-size: 1.1rem;` `color: var(--accent-primary);` `border-bottom: 1px solid var(--border-color);` `padding-bottom: 0.5rem;`
|
||||
- **Details grid:** `grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));` `gap: 1rem;` each item: label (small, secondary) + value (primary)
|
||||
|
||||
---
|
||||
|
||||
## 15. Toasts (notifications)
|
||||
|
||||
- **Container:** `position: fixed;` `top: 1rem;` `right: 1rem;` `z-index: 1100;` flex column, `gap: 0.5rem;`
|
||||
- **Toast:** `padding: 1rem 1.5rem;` `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 8px;` flex, `align-items: center;` `gap: 0.75rem;` `max-width: 400px;` slide-in animation
|
||||
- **Success:** `border-left: 3px solid var(--success);`
|
||||
- **Error:** `border-left: 3px solid var(--danger);`
|
||||
|
||||
---
|
||||
|
||||
## 16. Pagination
|
||||
|
||||
- **Wrapper:** flex, `justify-content: center;` `align-items: center;` `gap: 0.5rem;` `padding: 1rem;` `border-top: 1px solid var(--border-color);`
|
||||
- **Button:** `padding: 0.5rem 0.75rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 6px;` `color: var(--text-primary);` `font-size: 0.85rem;` `min-width: 36px;`
|
||||
- **Hover:** `background: var(--accent-glow);` `border-color: var(--accent-primary);` `color: var(--accent-primary);`
|
||||
- **Active page:** `background: var(--accent-primary);` `color: var(--bg-primary);`
|
||||
- **Disabled:** `opacity: 0.4;` `cursor: not-allowed;`
|
||||
- **Info text:** `color: var(--text-secondary);` `font-size: 0.85rem;` between prev/next
|
||||
|
||||
---
|
||||
|
||||
## 17. Login page
|
||||
|
||||
- **Layout:** full viewport, flex center; `padding: 2rem;`
|
||||
- **Card:** same tokens as app cards; `max-width: 400px;` `padding: 2.5rem;` `border-radius: 16px;` `box-shadow: 0 8px 32px rgba(0,0,0,0.3);`
|
||||
- **Logo:** centered; icon (e.g. 64×64px) with gradient + glow; title with gradient text
|
||||
- **Form:** same form-group and input styles as app; full-width primary submit button
|
||||
- **Error message:** `color: var(--danger);` `font-size: 0.9rem;` above or below form
|
||||
- Use the same `:root` variables and body background so login and app feel like one product.
|
||||
|
||||
---
|
||||
|
||||
## 18. Responsive
|
||||
|
||||
- **Breakpoint:** e.g. `@media (max-width: 768px)`
|
||||
- **Header top:** `flex-direction: column;` `gap: 1rem;` `text-align: center;` reduce padding
|
||||
- **Tabs:** reduce horizontal padding; allow horizontal scroll if needed
|
||||
- **Container:** reduce padding; increase `padding-top` if header height grows (e.g. `calc(1rem + 180px)`)
|
||||
- **Form grid:** `grid-template-columns: 1fr;` for single column on small screens
|
||||
|
||||
---
|
||||
|
||||
## 19. Checklist for a new portal
|
||||
|
||||
- [ ] Copy or recreate the `:root` design tokens.
|
||||
- [ ] Load **Outfit** and **JetBrains Mono** (or same weights).
|
||||
- [ ] Use the same header structure: logo + status/user + actions, then tabs.
|
||||
- [ ] Use `.card`, `.card-header`, `.card-title` for sections.
|
||||
- [ ] Use `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger` for actions.
|
||||
- [ ] Use `.form-grid`, `.form-group`, and input/select styles for forms.
|
||||
- [ ] Use `.table-container`, table, `.data-table` and th/td styles for lists.
|
||||
- [ ] Use same modal overlay/dialog and toast styles.
|
||||
- [ ] Use same empty, loading, and error states.
|
||||
- [ ] Apply same login page layout and token usage.
|
||||
- [ ] Test at 768px width for basic responsive behavior.
|
||||
- [ ] Replace [Portal Name] and any product-specific labels in this guide for your portal.
|
||||
|
||||
---
|
||||
|
||||
## 20. Reference files (this repo)
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| `static/css/main.css` | Full implementation of tokens, layout, components |
|
||||
| `app/templates/base.html` | App shell: fonts, header, tabs, container, toasts |
|
||||
| `app/templates/login.html` | Login layout and inline tokens (can be moved to main.css) |
|
||||
| `app/templates/tabs/_dashboard.html` | Example: cards, stats, table container |
|
||||
| `app/templates/tabs/_users.html` | Example: card, form-grid, form-group, buttons, table |
|
||||
|
||||
For a new portal, you can copy `main.css` and adapt it (e.g. change `:root` if you need a different accent), then build your base template and pages to use the same class names and structure described above.
|
||||
146
chromium-setup/emmc-provisioning/PROXMOX-LXC-DEPLOYMENT.md
Normal file
146
chromium-setup/emmc-provisioning/PROXMOX-LXC-DEPLOYMENT.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# CM4 eMMC provisioning on Proxmox (LXC + host)
|
||||
|
||||
The auto-flash **runs on the Proxmox host** (where the USB device appears). The **LXC** holds the same scripts and shares the **golden image** directory with the host so you can manage the image from the container.
|
||||
|
||||
## What is deployed
|
||||
|
||||
| Where | What |
|
||||
|-------|-----|
|
||||
| **Proxmox host** | udev rule, trigger script, flash script, rpiboot (after you run the install script), `/var/lib/cm4-provisioning/` (golden image dir), `/etc/cm4-provisioning/enabled` |
|
||||
| **LXC 201 (cm4-provisioning)** | Same scripts in `/opt/cm4-provisioning/`, same env; `/var/lib/cm4-provisioning/` is a **bind mount** from the host (shared storage for the golden image) |
|
||||
|
||||
When you plug the reTerminal in boot mode into the **host**, udev on the host runs the flash (rpiboot + dd). The golden image is read from `/var/lib/cm4-provisioning/golden.img` on the host (same path visible in the LXC).
|
||||
|
||||
---
|
||||
|
||||
## Deployment that was done
|
||||
|
||||
1. **LXC 201** created on Proxmox `10.130.60.224`:
|
||||
- Hostname: `cm4-provisioning`
|
||||
- Debian 12, 1 GB RAM, 8 GB rootfs
|
||||
- Bind mount: host `/var/lib/cm4-provisioning` → container `/var/lib/cm4-provisioning`
|
||||
|
||||
2. **On the host**:
|
||||
- `/opt/cm4-provisioning/flash-emmc-on-connect.sh` – flash script
|
||||
- `/usr/local/bin/cm4-flash-trigger.sh` – started by udev
|
||||
- `/etc/udev/rules.d/90-cm4-boot-mode.rules` – run trigger when USB vendor `2b8e` is added
|
||||
- `/opt/cm4-provisioning/env` – `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES`
|
||||
- `/etc/cm4-provisioning/enabled` – safety switch (remove to disable auto-flash)
|
||||
|
||||
3. **Inside LXC 201**:
|
||||
- Same scripts in `/opt/cm4-provisioning/` and env (for reference/backup)
|
||||
- Golden image path: `/var/lib/cm4-provisioning/golden.img` (bind-mounted from host)
|
||||
- **Dashboard** (optional): Flask app in `/opt/cm4-provisioning/dashboard/` to monitor deployment and show connection steps; see below.
|
||||
|
||||
4. **usbboot (rpiboot)** was **not** built on the host (no outbound DNS during deploy). You must install it when the host has internet.
|
||||
|
||||
---
|
||||
|
||||
## What you need to do
|
||||
|
||||
### 1. Build and install rpiboot on the Proxmox host (when it has internet)
|
||||
|
||||
On your machine (repo already synced to the host):
|
||||
|
||||
```bash
|
||||
# From your repo
|
||||
scp chromium-setup/emmc-provisioning/scripts/install-usbboot-on-host.sh root@10.130.60.224:/tmp/
|
||||
ssh root@10.130.60.224 "bash /tmp/install-usbboot-on-host.sh"
|
||||
```
|
||||
|
||||
Or on the host (if the deploy folder is still there):
|
||||
|
||||
```bash
|
||||
ssh root@10.130.60.224
|
||||
bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
|
||||
```
|
||||
|
||||
This installs dependencies, clones usbboot, builds it, and copies `rpiboot` to `/opt/usbboot/`.
|
||||
|
||||
### 2. Put the golden image on the host (or in the LXC)
|
||||
|
||||
The image must be at **`/var/lib/cm4-provisioning/golden.img`** on the **host**. Because that directory is bind-mounted into the LXC, you can use either:
|
||||
|
||||
- **From the host:**
|
||||
```bash
|
||||
scp your-golden.img root@10.130.60.224:/var/lib/cm4-provisioning/golden.img
|
||||
```
|
||||
|
||||
- **From the LXC** (e.g. after copying the image into the container elsewhere first):
|
||||
```bash
|
||||
pct exec 201 -- ls -la /var/lib/cm4-provisioning/
|
||||
# Copy to that path inside the container; it's the same as the host path.
|
||||
```
|
||||
|
||||
### 3. Run the provisioning dashboard (optional)
|
||||
|
||||
The dashboard shows **connection steps** and **live deployment status** (idle / connecting / flashing / done / error) and a recent flash log. It reads the same `status.json` and `flash.log` that the host’s flash script writes (via the bind-mounted `/var/lib/cm4-provisioning`).
|
||||
|
||||
**Inside LXC 201:**
|
||||
|
||||
```bash
|
||||
# Copy dashboard into the container (from host, if you have the repo there)
|
||||
# Or from your workstation:
|
||||
# rsync -a chromium-setup/emmc-provisioning/dashboard/ root@10.130.60.224:/tmp/dashboard/
|
||||
# ssh root@10.130.60.224 "pct push 201 /tmp/dashboard/app.py /opt/cm4-provisioning/dashboard/ && pct push 201 /tmp/dashboard/cm4-dashboard.service /opt/cm4-provisioning/dashboard/ && pct exec 201 -- mkdir -p /opt/cm4-provisioning/dashboard/templates && ..."
|
||||
|
||||
# Inside the LXC (pct exec 201 -- bash):
|
||||
apt-get update && apt-get install -y python3-flask
|
||||
mkdir -p /opt/cm4-provisioning/dashboard/templates
|
||||
# Copy app.py, templates/index.html, cm4-dashboard.service into the container (see dashboard/README.md)
|
||||
|
||||
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cm4-dashboard
|
||||
```
|
||||
|
||||
Then open **http://<LXC-201-IP>:5000** (get the IP with `pct exec 201 -- hostname -I`). If the LXC is on a private network, set up port forwarding on the Proxmox host or use a reverse proxy so you can reach the dashboard from your browser.
|
||||
|
||||
### 4. Optional: disable or enable auto-flash
|
||||
|
||||
- **Disable:**
|
||||
`ssh root@10.130.60.224 "rm /etc/cm4-provisioning/enabled"`
|
||||
|
||||
- **Enable again:**
|
||||
`ssh root@10.130.60.224 "touch /etc/cm4-provisioning/enabled"`
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Place the reTerminal in **boot mode** (eMMC disable jumper).
|
||||
2. Connect its **USB slave** port to the **Proxmox host** (not to the LXC).
|
||||
3. Power the reTerminal (or connect after power).
|
||||
4. On the host, udev will run the trigger and then the flash script (rpiboot, then dd). Watch logs:
|
||||
```bash
|
||||
ssh root@10.130.60.224 "journalctl -u cm4-flash-once -f"
|
||||
# or
|
||||
ssh root@10.130.60.224 "journalctl -t cm4-flash -f"
|
||||
```
|
||||
5. When flashing finishes, remove the jumper and power cycle the reTerminal so it boots from eMMC.
|
||||
|
||||
---
|
||||
|
||||
## Redeploy / update scripts
|
||||
|
||||
From your repo (e.g. after changing scripts):
|
||||
|
||||
```bash
|
||||
./chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh root@10.130.60.224
|
||||
```
|
||||
|
||||
That script syncs the repo to the host and reinstalls scripts on both the host and LXC 201. It does **not** overwrite `/opt/cm4-provisioning/env` or `/etc/cm4-provisioning/enabled` if you’ve changed them; adjust the script if you want that. It also does **not** build usbboot; run `install-usbboot-on-host.sh` on the host when needed.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Item | Location |
|
||||
|------|----------|
|
||||
| LXC | 201, hostname `cm4-provisioning`, Proxmox `10.130.60.224` |
|
||||
| Golden image | `/var/lib/cm4-provisioning/golden.img` (host and LXC see the same file) |
|
||||
| Flash runs on | Proxmox **host** (udev + rpiboot + dd) |
|
||||
| Build rpiboot on host | Run `scripts/install-usbboot-on-host.sh` on the host when it has internet |
|
||||
| Dashboard | Flask app in LXC at `http://<LXC-IP>:5000`; switch Flash/Backup mode, list and download backups; see **dashboard/README.md** and section 3 above |
|
||||
| Backups | Saved under `/var/lib/cm4-provisioning/backups/`. When a device is detected (USB or network), choose **Backup** or **Deploy** in the dashboard. |
|
||||
| Network deploy/backup | Network-booted devices run **network-client/provisioning-client.sh** and register with the dashboard; they then appear under "Device detected (Network)" and you choose Backup or Deploy. See **network-client/README.md**. |
|
||||
17
chromium-setup/emmc-provisioning/README.md
Normal file
17
chromium-setup/emmc-provisioning/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# reTerminal DM4 eMMC auto-provisioning
|
||||
|
||||
Automatically flash a **golden image** to the CM4 eMMC when the reTerminal is connected in **boot mode** (eMMC disable jumper). Optional **backup** mode saves the current eMMC to a timestamped image file instead. Uses **cloud-init** for first-boot configuration.
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| **EMMC-PROVISIONING-GUIDE.md** | Full setup and usage guide – read this first. |
|
||||
| **flash-emmc-on-connect.sh** | Script that runs `rpiboot` then either flashes the golden image to eMMC or backs up eMMC to a file (mode set via dashboard or `mode` file). |
|
||||
| **cm4-flash-trigger.sh** | Called by udev when CM4 in boot mode is connected; starts the flash job. |
|
||||
| **90-cm4-boot-mode.rules** | udev rule: when USB device 2b8e is added, run the trigger script. |
|
||||
| **cloud-init/** | Example NoCloud files (`user-data`, `meta-data`, `network-config`) for the golden image. |
|
||||
| **dashboard/** | Flask web UI: auto-detect device (USB or network), prompt **Backup or Deploy**, show status and connection steps. See **dashboard/README.md**. |
|
||||
| **network-client/** | Script for network-booted devices: register with the dashboard and perform Deploy (pull image, write eMMC) or Backup (upload eMMC). See **network-client/README.md**. |
|
||||
|
||||
Quick start: see **EMMC-PROVISIONING-GUIDE.md**.
|
||||
|
||||
**Proxmox:** LXC 201 + host setup is documented in **PROXMOX-LXC-DEPLOYMENT.md**. Use **scripts/deploy-to-proxmox.sh** to deploy to a Proxmox host; flash runs on the host, golden image is in a bind-mounted dir shared with the LXC.
|
||||
5
chromium-setup/emmc-provisioning/cloud-init/meta-data
Normal file
5
chromium-setup/emmc-provisioning/cloud-init/meta-data
Normal file
@@ -0,0 +1,5 @@
|
||||
# NoCloud meta-data: enables cloud-init. Optional instance-id for multi-device.
|
||||
# Copy to the boot (FAT32) partition of your image as 'meta-data'.
|
||||
|
||||
instance-id: reterminal-01
|
||||
# local-hostname: reterminal-01 # optional override
|
||||
21
chromium-setup/emmc-provisioning/cloud-init/network-config
Normal file
21
chromium-setup/emmc-provisioning/cloud-init/network-config
Normal file
@@ -0,0 +1,21 @@
|
||||
# Cloud-init network-config (NoCloud). Copy to boot partition as 'network-config'.
|
||||
# Adjust for your LAN: DHCP or static.
|
||||
|
||||
version: 2
|
||||
ethernets:
|
||||
eth0:
|
||||
dhcp4: true
|
||||
# eth0:
|
||||
# addresses:
|
||||
# - 192.168.1.100/24
|
||||
# gateway4: 192.168.1.1
|
||||
# nameservers:
|
||||
# addresses:
|
||||
# - 8.8.8.8
|
||||
|
||||
# Optional WiFi (uncomment and set your SSID/password)
|
||||
# wlan0:
|
||||
# dhcp4: true
|
||||
# access-points:
|
||||
# "YourSSID":
|
||||
# password: "YourPassword"
|
||||
34
chromium-setup/emmc-provisioning/cloud-init/user-data
Normal file
34
chromium-setup/emmc-provisioning/cloud-init/user-data
Normal file
@@ -0,0 +1,34 @@
|
||||
#cloud-config
|
||||
# Cloud-init user-data for reTerminal DM4 golden image (eMMC).
|
||||
# Copy to the boot (FAT32) partition of your image as 'user-data'.
|
||||
# Raspberry Pi OS uses NoCloud: meta-data, user-data, network-config on boot partition.
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
|
||||
packages:
|
||||
- chromium-browser
|
||||
- wmctrl
|
||||
# - python3-pip # uncomment if you need Flask/other apps
|
||||
|
||||
# Optional: set hostname from serial or leave default
|
||||
# hostname: reterminal-%s # %s = first column of meta-data instance-id if set
|
||||
|
||||
# Optional: enable I2C/SPI for reTerminal peripherals (LED, buzzer, etc.)
|
||||
# Uncomment if your image does not already enable these:
|
||||
# write_files:
|
||||
# - path: /boot/firmware/config.txt.d/99-reterminal.txt
|
||||
# content: |
|
||||
# dtparam=i2c_arm=on
|
||||
# dtparam=spi=on
|
||||
|
||||
# Run once on first boot (e.g. copy kiosk scripts, start Chromium on boot)
|
||||
runcmd:
|
||||
# Example: ensure Chromium kiosk autostart
|
||||
# - systemctl enable chromium-kiosk
|
||||
- cloud-init single --name cc_final_message
|
||||
|
||||
# Power state after first boot (optional)
|
||||
# power_state:
|
||||
# mode: reboot
|
||||
# delay: 1
|
||||
6
chromium-setup/emmc-provisioning/cm4-flash-trigger.sh
Normal file
6
chromium-setup/emmc-provisioning/cm4-flash-trigger.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Called by udev when CM4 in boot mode is connected. Starts the flash job in the background
|
||||
# so udev can return immediately. Install to /usr/local/bin/cm4-flash-trigger.sh
|
||||
|
||||
FLASH_SCRIPT="${CM4_FLASH_SCRIPT:-/opt/cm4-provisioning/flash-emmc-on-connect.sh}"
|
||||
exec systemd-run --no-block --unit=cm4-flash-once "$FLASH_SCRIPT"
|
||||
38
chromium-setup/emmc-provisioning/dashboard/README.md
Normal file
38
chromium-setup/emmc-provisioning/dashboard/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# CM4 Provisioning Dashboard
|
||||
|
||||
Flask web UI to monitor the eMMC deployment process and show device connection steps.
|
||||
|
||||
- **Connection steps**: Numbered instructions for putting the reTerminal in boot mode and connecting it.
|
||||
- **Live status**: Idle / Connecting (rpiboot) / Flashing / Backup / Done / Error, with optional progress.
|
||||
- **Backup / Restore**: Toggle between **Flash** (deploy golden image) and **Backup** (save eMMC to a timestamped file when device is connected in boot mode). List and download saved backups.
|
||||
- **Recent log**: Tail of the flash log (from the host, via the shared bind mount).
|
||||
|
||||
The dashboard reads `/var/lib/cm4-provisioning/status.json` and `flash.log`, which the flash script (running on the Proxmox host) updates. When the dashboard runs inside the LXC, that directory is bind-mounted from the host, so it sees the same files.
|
||||
|
||||
## Run locally (development)
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
pip install flask # or use venv
|
||||
python3 app.py
|
||||
# Open http://localhost:5000
|
||||
```
|
||||
|
||||
## Run in LXC (Proxmox)
|
||||
|
||||
1. Copy the dashboard into the container (e.g. to `/opt/cm4-provisioning/dashboard`).
|
||||
2. Install Flask if needed: `apt install -y python3-flask` or `pip install flask`.
|
||||
3. Install the systemd unit and enable it:
|
||||
|
||||
```bash
|
||||
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cm4-dashboard
|
||||
```
|
||||
|
||||
4. Open `http://<LXC-IP>:5000` (or port-forward from the Proxmox host).
|
||||
|
||||
## Environment (optional)
|
||||
|
||||
- `CM4_STATUS_FILE` – path to status JSON (default: `/var/lib/cm4-provisioning/status.json`).
|
||||
- `CM4_LOG_FILE` – path to flash log (default: `/var/lib/cm4-provisioning/flash.log`).
|
||||
245
chromium-setup/emmc-provisioning/dashboard/app.py
Normal file
245
chromium-setup/emmc-provisioning/dashboard/app.py
Normal file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Flask dashboard for CM4 eMMC provisioning.
|
||||
Monitors deployment status, shows device connection steps, backup/restore.
|
||||
Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register, then Backup/Deploy).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, render_template, jsonify, request, send_file, Response
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
BASE_DIR = Path(os.environ.get("CM4_PROVISIONING_DIR", "/var/lib/cm4-provisioning"))
|
||||
STATUS_FILE = os.environ.get("CM4_STATUS_FILE", str(BASE_DIR / "status.json"))
|
||||
LOG_FILE = os.environ.get("CM4_LOG_FILE", str(BASE_DIR / "flash.log"))
|
||||
ACTION_REQUEST_FILE = os.environ.get("CM4_ACTION_REQUEST_FILE", str(BASE_DIR / "action_request"))
|
||||
DEVICE_SOURCE_FILE = os.environ.get("CM4_DEVICE_SOURCE_FILE", str(BASE_DIR / "device_source"))
|
||||
BACKUPS_DIR = Path(os.environ.get("CM4_BACKUPS_DIR", str(BASE_DIR / "backups")))
|
||||
GOLDEN_IMAGE = Path(os.environ.get("CM4_GOLDEN_IMAGE", str(BASE_DIR / "golden.img")))
|
||||
NETWORK_DEVICES_FILE = Path(os.environ.get("CM4_NETWORK_DEVICES_FILE", str(BASE_DIR / "network_devices.json")))
|
||||
|
||||
DEFAULT_STATUS = {
|
||||
"phase": "idle",
|
||||
"message": "Waiting for reTerminal in boot mode or network.",
|
||||
"progress": None,
|
||||
"updated": None,
|
||||
}
|
||||
|
||||
|
||||
def read_status():
|
||||
try:
|
||||
with open(STATUS_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
out = {**DEFAULT_STATUS, **data}
|
||||
if out.get("phase") == "waiting_choice":
|
||||
try:
|
||||
with open(DEVICE_SOURCE_FILE, "r") as sf:
|
||||
out["device_source"] = (sf.read() or "").strip() or "usb"
|
||||
except (FileNotFoundError, OSError):
|
||||
out["device_source"] = "usb"
|
||||
return out
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return DEFAULT_STATUS
|
||||
|
||||
|
||||
def read_log_tail(lines=50):
|
||||
try:
|
||||
with open(LOG_FILE, "r") as f:
|
||||
all_lines = f.readlines()
|
||||
return "".join(all_lines[-lines:]).strip() if all_lines else ""
|
||||
except (FileNotFoundError, PermissionError):
|
||||
return ""
|
||||
|
||||
|
||||
def _load_network_devices():
|
||||
try:
|
||||
if NETWORK_DEVICES_FILE.is_file():
|
||||
with open(NETWORK_DEVICES_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {"devices": []}
|
||||
|
||||
|
||||
def _save_network_devices(data):
|
||||
try:
|
||||
os.makedirs(NETWORK_DEVICES_FILE.parent, exist_ok=True)
|
||||
with open(NETWORK_DEVICES_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return True
|
||||
except (PermissionError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def list_backups():
|
||||
if not BACKUPS_DIR.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
if p.is_file() and p.suffix in (".img", ".img.gz"):
|
||||
try:
|
||||
st = p.stat()
|
||||
out.append({"name": p.name, "size": st.st_size, "mtime": st.st_mtime})
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
return jsonify(read_status())
|
||||
|
||||
|
||||
@app.route("/api/log")
|
||||
def api_log():
|
||||
return jsonify({"log": read_log_tail()})
|
||||
|
||||
|
||||
@app.route("/api/pending-devices")
|
||||
def api_pending_devices():
|
||||
"""Returns USB (if waiting_choice) and registered network devices so the UI can show Backup/Deploy."""
|
||||
st = read_status()
|
||||
usb = None
|
||||
if st.get("phase") == "waiting_choice":
|
||||
usb = {"source": "usb", "message": st.get("message", "Device connected (USB). Choose action.")}
|
||||
data = _load_network_devices()
|
||||
network = [d for d in data.get("devices", []) if d.get("action") in (None, "wait")]
|
||||
return jsonify({"usb": usb, "network": network})
|
||||
|
||||
|
||||
@app.route("/api/device-action", methods=["POST"])
|
||||
def api_device_action():
|
||||
"""User chose Backup or Deploy for a device. source=usb | network; for network pass mac=."""
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
source = (body.get("source") or "").strip().lower()
|
||||
action = (body.get("action") or "").strip().lower()
|
||||
if action not in ("backup", "deploy"):
|
||||
return jsonify({"ok": False, "error": "action must be 'backup' or 'deploy'"}), 400
|
||||
if source == "usb":
|
||||
try:
|
||||
os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True)
|
||||
with open(ACTION_REQUEST_FILE, "w") as f:
|
||||
f.write(action)
|
||||
return jsonify({"ok": True})
|
||||
except (PermissionError, OSError):
|
||||
return jsonify({"ok": False, "error": "Could not write action file"}), 500
|
||||
if source == "network":
|
||||
mac = (body.get("mac") or "").strip()
|
||||
if not mac:
|
||||
return jsonify({"ok": False, "error": "mac required for network device"}), 400
|
||||
data = _load_network_devices()
|
||||
for d in data.get("devices", []):
|
||||
if (d.get("mac") or "").lower() == mac.lower():
|
||||
d["action"] = action
|
||||
d["action_at"] = time.time()
|
||||
_save_network_devices(data)
|
||||
return jsonify({"ok": True})
|
||||
return jsonify({"ok": False, "error": "Device not found"}), 404
|
||||
return jsonify({"ok": False, "error": "source must be 'usb' or 'network'"}), 400
|
||||
|
||||
|
||||
@app.route("/api/register-device", methods=["POST"])
|
||||
def api_register_device():
|
||||
"""Called by a network-booted device to register (mac, ip)."""
|
||||
body = request.get_json(force=True, silent=True) or request.form
|
||||
mac = (body.get("mac") or "").strip()
|
||||
ip = (body.get("ip") or request.remote_addr or "").strip()
|
||||
if not mac:
|
||||
return jsonify({"ok": False, "error": "mac required"}), 400
|
||||
data = _load_network_devices()
|
||||
devices = data.get("devices", [])
|
||||
for d in devices:
|
||||
if (d.get("mac") or "").lower() == mac.lower():
|
||||
d["ip"] = ip
|
||||
d["registered_at"] = time.time()
|
||||
d["action"] = d.get("action") or "wait"
|
||||
_save_network_devices(data)
|
||||
return jsonify({"ok": True, "message": "registered"})
|
||||
devices.append({"mac": mac, "ip": ip, "registered_at": time.time(), "action": "wait"})
|
||||
data["devices"] = devices
|
||||
_save_network_devices(data)
|
||||
return jsonify({"ok": True, "message": "registered"})
|
||||
|
||||
|
||||
@app.route("/api/device-action-poll")
|
||||
def api_device_action_poll():
|
||||
"""Network device polls this to get its assigned action (deploy/backup) and URL."""
|
||||
mac = (request.args.get("mac") or "").strip()
|
||||
if not mac:
|
||||
return jsonify({"action": "wait"}), 200
|
||||
data = _load_network_devices()
|
||||
base = request.host_url.rstrip("/")
|
||||
for d in data.get("devices", []):
|
||||
if (d.get("mac") or "").lower() == mac.lower():
|
||||
action = d.get("action") or "wait"
|
||||
if action == "deploy":
|
||||
return jsonify({"action": "deploy", "url": f"{base}/api/golden-image"})
|
||||
if action == "backup":
|
||||
return jsonify({"action": "backup", "upload_url": f"{base}/api/backup-upload?mac={mac}"})
|
||||
return jsonify({"action": "wait"})
|
||||
return jsonify({"action": "wait"})
|
||||
|
||||
|
||||
@app.route("/api/golden-image")
|
||||
def api_golden_image():
|
||||
"""Stream the golden image for network deploy (device pulls and writes to eMMC)."""
|
||||
if not GOLDEN_IMAGE.is_file():
|
||||
return jsonify({"error": "Golden image not found"}), 404
|
||||
return send_file(
|
||||
GOLDEN_IMAGE,
|
||||
mimetype="application/octet-stream",
|
||||
as_attachment=True,
|
||||
download_name="golden.img",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/backup-upload", methods=["POST"])
|
||||
def api_backup_upload():
|
||||
"""Network device uploads its eMMC backup (raw body)."""
|
||||
mac = (request.args.get("mac") or "").strip().replace(":", "-")[:20]
|
||||
if not mac:
|
||||
return jsonify({"error": "mac query param required"}), 400
|
||||
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"backup-net-{mac}-{int(time.time())}.img"
|
||||
path = BACKUPS_DIR / name
|
||||
try:
|
||||
with open(path, "wb") as f:
|
||||
while True:
|
||||
chunk = request.stream.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
return jsonify({"ok": True, "file": name})
|
||||
except (OSError, IOError) as e:
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/backups")
|
||||
def api_backups():
|
||||
return jsonify({"backups": list_backups()})
|
||||
|
||||
|
||||
@app.route("/api/backups/<path:name>")
|
||||
def api_backup_download(name):
|
||||
if ".." in name or "/" in name or "\\" in name:
|
||||
return jsonify({"error": "invalid name"}), 400
|
||||
path = BACKUPS_DIR / name
|
||||
if not path.is_file():
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return send_file(path, as_attachment=True, download_name=name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=CM4 eMMC Provisioning Dashboard
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/cm4-provisioning/dashboard
|
||||
ExecStart=/usr/bin/python3 -m flask --app app run --host=0.0.0.0 --port=5000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=FLASK_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
520
chromium-setup/emmc-provisioning/dashboard/templates/index.html
Normal file
520
chromium-setup/emmc-provisioning/dashboard/templates/index.html
Normal file
@@ -0,0 +1,520 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CM4 eMMC 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;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Portal Styling Guide tokens */
|
||||
:root {
|
||||
--bg-primary: #0a0e14;
|
||||
--bg-secondary: #11151c;
|
||||
--bg-tertiary: #1a1f2b;
|
||||
--bg-card: #151a24;
|
||||
--accent-primary: #00d4aa;
|
||||
--accent-secondary: #00b894;
|
||||
--accent-glow: rgba(0, 212, 170, 0.15);
|
||||
--text-primary: #e6e8eb;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #5c6370;
|
||||
--border-color: #2d333b;
|
||||
--danger: #ff6b6b;
|
||||
--danger-glow: rgba(255, 107, 107, 0.15);
|
||||
--warning: #ffd93d;
|
||||
--success: #00d4aa;
|
||||
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--gradient-accent);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 20px var(--accent-glow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.logo-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding-top: calc(2rem + 72px);
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card-title .label {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.subtitle { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1.5rem; }
|
||||
.steps { list-style: none; }
|
||||
.steps li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.steps li:last-child { border-bottom: none; }
|
||||
.step-num {
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.step-num.done { background: var(--gradient-accent); color: var(--bg-primary); }
|
||||
.status-box {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.status-box.idle { background: var(--accent-glow); border-color: var(--accent-primary); }
|
||||
.status-box.rpiboot,
|
||||
.status-box.flashing,
|
||||
.status-box.backup,
|
||||
.status-box.waiting_choice { background: rgba(255, 217, 61, 0.1); border-color: var(--warning); }
|
||||
.status-box.done { background: rgba(0, 212, 170, 0.12); border-color: var(--success); }
|
||||
.status-box.error { background: var(--danger-glow); border-color: var(--danger); }
|
||||
.status-phase {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.status-box.idle .status-phase { color: var(--accent-primary); }
|
||||
.status-box.rpiboot .status-phase,
|
||||
.status-box.flashing .status-phase,
|
||||
.status-box.backup .status-phase,
|
||||
.status-box.waiting_choice .status-phase { color: var(--warning); }
|
||||
.status-box.done .status-phase { color: var(--success); }
|
||||
.status-box.error .status-phase { color: var(--danger); }
|
||||
.status-message { color: var(--text-primary); font-size: 0.95rem; }
|
||||
.status-error { color: var(--danger); font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
.status-updated { color: var(--text-muted); font-size: 0.75rem; margin-top: 0.5rem; }
|
||||
.progress-wrap {
|
||||
margin-top: 0.75rem;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--gradient-accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.progress-bar.indeterminate {
|
||||
width: 40%;
|
||||
animation: indeterminate 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(350%); }
|
||||
}
|
||||
.log-box {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.log-box:empty::before { content: 'No flash log yet.'; color: var(--text-muted); }
|
||||
.backups-list { list-style: none; }
|
||||
.backups-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.backups-list li:last-child { border-bottom: none; }
|
||||
.backups-list a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.backups-list a:hover { text-decoration: underline; }
|
||||
.backups-meta { color: var(--text-muted); font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
|
||||
.pending-device {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pending-device .label { font-weight: 500; color: var(--text-primary); }
|
||||
.pending-device .actions { display: flex; gap: 0.5rem; }
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--gradient-accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px var(--accent-glow);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
.pending-device .btn { padding: 0.5rem 1rem; font-size: 0.85rem; }
|
||||
code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.header { padding: 1rem; flex-direction: column; gap: 0.5rem; text-align: center; }
|
||||
.main { padding: 1rem; padding-top: calc(1rem + 100px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">⚡</div>
|
||||
<h1 class="logo-title">CM4 eMMC Provisioning</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main">
|
||||
<p class="subtitle">reTerminal DM4 — deploy or backup via USB boot mode or network boot</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title"><span class="label">Connect the device</span></h2>
|
||||
</div>
|
||||
<p style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.75rem;">Choose one:</p>
|
||||
<p style="font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">USB boot mode</p>
|
||||
<ol class="steps">
|
||||
<li><span class="step-num">1</span> Set the reTerminal to <strong>boot mode</strong>: fit the <strong>eMMC disable</strong> jumper (e.g. J2 / nRPIBOOT).</li>
|
||||
<li><span class="step-num">2</span> Connect the reTerminal’s <strong>USB slave</strong> port to the Proxmox host. Power on. The device will appear in “Device detected” below; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
|
||||
<li><span class="step-num">3</span> When done, remove the jumper and power cycle so it boots from eMMC.</li>
|
||||
</ol>
|
||||
<p style="font-size: 0.9rem; font-weight: 600; margin: 1rem 0 0.25rem 0;">Network boot</p>
|
||||
<ol class="steps">
|
||||
<li><span class="step-num">1</span> Enable network boot on the CM4 (e.g. <code>BOOT_ORDER=0x21</code>) and ensure it can reach this server.</li>
|
||||
<li><span class="step-num">2</span> Boot the device over the network with an environment that runs the <strong>provisioning client</strong> (register + poll for action). It will show under “Device detected (Network)”. Choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title"><span class="label">Device detected — choose action</span></h2>
|
||||
</div>
|
||||
<p id="pendingHint" style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.75rem;">When a device is detected (USB boot mode or network boot), it will appear below. Choose <strong>Backup</strong> to save its eMMC to a file, or <strong>Deploy</strong> to write the golden image to it.</p>
|
||||
<div id="pendingDevices"></div>
|
||||
<p id="noPending" class="empty-state" style="display: none;">No device waiting. Connect a reTerminal in USB boot mode, or ensure a network-booted device has registered.</p>
|
||||
<h3 style="font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin: 1rem 0 0.5rem 0;">Saved backups</h3>
|
||||
<ul id="backupsList" class="backups-list"></ul>
|
||||
<p id="backupsEmpty" class="empty-state" style="display: none;">No backups yet.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title"><span class="label">Deployment status</span></h2>
|
||||
</div>
|
||||
<div id="status" class="status-box idle">
|
||||
<div class="status-phase">Idle</div>
|
||||
<div class="status-message">Waiting for reTerminal in boot mode.</div>
|
||||
<div id="statusError" class="status-error" style="display:none;"></div>
|
||||
<div id="statusUpdated" class="status-updated"></div>
|
||||
<div id="progressWrap" class="progress-wrap" style="display:none;">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<details style="margin-top: 0.75rem;">
|
||||
<summary>Recent flash log</summary>
|
||||
<div id="log" class="log-box" style="margin-top: 0.5rem;"></div>
|
||||
</details>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const statusEl = document.getElementById('status');
|
||||
const phaseEl = statusEl.querySelector('.status-phase');
|
||||
const messageEl = statusEl.querySelector('.status-message');
|
||||
const errorEl = document.getElementById('statusError');
|
||||
const updatedEl = document.getElementById('statusUpdated');
|
||||
const progressWrap = document.getElementById('progressWrap');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const logEl = document.getElementById('log');
|
||||
|
||||
const phaseLabels = {
|
||||
idle: 'Idle',
|
||||
rpiboot: 'Connecting',
|
||||
waiting_choice: 'Choose action',
|
||||
flashing: 'Flashing',
|
||||
backup: 'Backing up',
|
||||
done: 'Done',
|
||||
error: 'Error'
|
||||
};
|
||||
|
||||
function renderStatus(data) {
|
||||
const phase = data.phase || 'idle';
|
||||
statusEl.className = 'status-box ' + phase;
|
||||
phaseEl.textContent = phaseLabels[phase] || phase;
|
||||
messageEl.textContent = data.message || '';
|
||||
|
||||
if (data.error) {
|
||||
errorEl.textContent = data.error;
|
||||
errorEl.style.display = 'block';
|
||||
} else {
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data.updated) {
|
||||
updatedEl.textContent = 'Updated: ' + data.updated;
|
||||
updatedEl.style.display = 'block';
|
||||
} else {
|
||||
updatedEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const progress = data.progress;
|
||||
const inProgress = phase === 'rpiboot' || phase === 'flashing' || phase === 'backup';
|
||||
const showProgress = progress != null && (inProgress || phase === 'done');
|
||||
if (showProgress) {
|
||||
progressWrap.style.display = 'block';
|
||||
progressBar.classList.remove('indeterminate');
|
||||
progressBar.style.width = (progress === null ? 0 : progress) + '%';
|
||||
if (progress === null && inProgress) {
|
||||
progressBar.classList.add('indeterminate');
|
||||
progressBar.style.width = '40%';
|
||||
}
|
||||
} else if (inProgress) {
|
||||
progressWrap.style.display = 'block';
|
||||
progressBar.classList.add('indeterminate');
|
||||
progressBar.style.width = '40%';
|
||||
} else {
|
||||
progressWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPendingDevices(usb, network) {
|
||||
const container = document.getElementById('pendingDevices');
|
||||
const noPending = document.getElementById('noPending');
|
||||
container.innerHTML = '';
|
||||
let hasAny = false;
|
||||
if (usb) {
|
||||
hasAny = true;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'pending-device';
|
||||
div.innerHTML = '<span class="label">Device connected (USB boot mode)</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></span>';
|
||||
container.appendChild(div);
|
||||
}
|
||||
(network || []).forEach(function(d) {
|
||||
hasAny = true;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'pending-device';
|
||||
div.innerHTML = '<span class="label">Device (Network): ' + escapeHtml(d.ip || '') + ' — ' + escapeHtml(d.mac || '') + '</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></span>';
|
||||
container.appendChild(div);
|
||||
});
|
||||
noPending.style.display = hasAny ? 'none' : 'block';
|
||||
container.querySelectorAll('button[data-action]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
const source = btn.getAttribute('data-source');
|
||||
const action = btn.getAttribute('data-action');
|
||||
const mac = btn.getAttribute('data-mac');
|
||||
const body = { source: source, action: action };
|
||||
if (mac) body.mac = mac;
|
||||
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) { fetchPending(); fetchStatus(); }
|
||||
else alert(data.error || 'Failed');
|
||||
})
|
||||
.catch(function() { alert('Request failed'); });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fetchPending() {
|
||||
fetch('/api/pending-devices').then(function(r) { return r.json(); }).then(function(data) {
|
||||
renderPendingDevices(data.usb || null, data.network || []);
|
||||
}).catch(function() { renderPendingDevices(null, []); });
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
fetch('/api/status')
|
||||
.then(r => r.json())
|
||||
.then(renderStatus)
|
||||
.catch(() => renderStatus({ phase: 'error', message: 'Could not load status.' }));
|
||||
}
|
||||
|
||||
function fetchLog() {
|
||||
fetch('/api/log')
|
||||
.then(r => r.json())
|
||||
.then(data => { logEl.textContent = data.log || ''; })
|
||||
.catch(() => { logEl.textContent = ''; });
|
||||
}
|
||||
|
||||
function fmtSize(n) {
|
||||
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 new Date(ts * 1000).toLocaleString(); }
|
||||
|
||||
function fetchBackups() {
|
||||
fetch('/api/backups')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const list = document.getElementById('backupsList');
|
||||
const empty = document.getElementById('backupsEmpty');
|
||||
list.innerHTML = '';
|
||||
const backups = data.backups || [];
|
||||
if (backups.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
backups.forEach(b => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = '<a href="/api/backups/' + encodeURIComponent(b.name) + '" download>' + escapeHtml(b.name) + '</a>' +
|
||||
'<span class="backups-meta">' + fmtSize(b.size) + ' · ' + fmtDate(b.mtime) + '</span>';
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
fetchLog();
|
||||
fetchPending();
|
||||
fetchBackups();
|
||||
setInterval(fetchStatus, 2000);
|
||||
setInterval(fetchLog, 4000);
|
||||
setInterval(fetchPending, 2000);
|
||||
setInterval(fetchBackups, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
147
chromium-setup/emmc-provisioning/flash-emmc-on-connect.sh
Normal file
147
chromium-setup/emmc-provisioning/flash-emmc-on-connect.sh
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-flash CM4 eMMC when reTerminal is connected in boot mode (eMMC disable jumper).
|
||||
# Run this from udev or a systemd service when Raspberry Pi USB boot device (2b8e) is detected.
|
||||
# Requires: usbboot (rpiboot) built, golden image at GOLDEN_IMAGE path.
|
||||
|
||||
set -e
|
||||
|
||||
# Load overrides from env file if present
|
||||
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||
|
||||
# Configuration - adjust paths and size for your setup
|
||||
RPIBOOT_DIR="${RPIBOOT_DIR:-/opt/usbboot}"
|
||||
GOLDEN_IMAGE="${GOLDEN_IMAGE:-/var/lib/cm4-provisioning/golden.img}"
|
||||
# Expected eMMC size in bytes (reTerminal CM4 often 8GB or 16GB). Used to identify the correct block device.
|
||||
EMMC_SIZE_BYTES="${EMMC_SIZE_BYTES:-$(( 8 * 1024 * 1024 * 1024 ))}"
|
||||
LOG_TAG="cm4-flash"
|
||||
STATUS_FILE="${STATUS_FILE:-/var/lib/cm4-provisioning/status.json}"
|
||||
LOG_FILE="${LOG_FILE:-/var/lib/cm4-provisioning/flash.log}"
|
||||
|
||||
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*"; echo "[$(date -Iseconds)] $*" >> "$LOG_FILE" 2>/dev/null || true; }
|
||||
|
||||
write_status() {
|
||||
local phase="$1" message="$2" progress="${3:-null}" error="${4:-}"
|
||||
local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
message="${message//\"/\\\"}"; error="${error//\"/\\\"}"
|
||||
if [[ -n "$error" ]]; then
|
||||
printf '{"phase":"%s","message":"%s","progress":%s,"error":"%s","updated":"%s"}\n' \
|
||||
"$phase" "$message" "$progress" "$error" "$ts" > "$STATUS_FILE" 2>/dev/null || true
|
||||
else
|
||||
printf '{"phase":"%s","message":"%s","progress":%s,"updated":"%s"}\n' \
|
||||
"$phase" "$message" "$progress" "$ts" > "$STATUS_FILE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Optional: only run if this file exists (safety)
|
||||
ENABLE_FILE="${ENABLE_FILE:-/etc/cm4-provisioning/enabled}"
|
||||
if [[ -n "$ENABLE_FILE" && ! -f "$ENABLE_FILE" ]]; then
|
||||
log "Skipping: $ENABLE_FILE not present"
|
||||
write_status "idle" "Provisioning disabled (remove /etc/cm4-provisioning/enabled to enable)" "null" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# When a device is detected we ask the user (dashboard): Backup or Deploy?
|
||||
# These files are used to wait for the user's choice (written by dashboard, read by this script).
|
||||
BACKUPS_DIR="${BACKUPS_DIR:-/var/lib/cm4-provisioning/backups}"
|
||||
ACTION_REQUEST_FILE="${ACTION_REQUEST_FILE:-/var/lib/cm4-provisioning/action_request}"
|
||||
CURRENT_DEVICE_FILE="${CURRENT_DEVICE_FILE:-/var/lib/cm4-provisioning/current_device}"
|
||||
DEVICE_SOURCE_FILE="${DEVICE_SOURCE_FILE:-/var/lib/cm4-provisioning/device_source}"
|
||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-600}"
|
||||
# Golden image required for deploy
|
||||
if [[ ! -f "$GOLDEN_IMAGE" ]]; then
|
||||
log "Golden image not found (required for deploy): $GOLDEN_IMAGE"
|
||||
write_status "error" "Golden image not found" "null" "Golden image not found. Add golden.img for deploy."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RPIBOOT_BIN="$RPIBOOT_DIR/rpiboot"
|
||||
if [[ ! -x "$RPIBOOT_BIN" ]]; then
|
||||
log "rpiboot not found: $RPIBOOT_BIN (build usbboot and set RPIBOOT_DIR)"
|
||||
write_status "error" "rpiboot not installed" "null" "rpiboot not found. Run install-usbboot-on-host.sh on the host."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure status dir exists and start with running state
|
||||
mkdir -p "$(dirname "$STATUS_FILE")" "$BACKUPS_DIR" 2>/dev/null || true
|
||||
write_status "rpiboot" "Connecting to CM4 in boot mode…" "0"
|
||||
|
||||
# Block devices before rpiboot (so we can detect new one after)
|
||||
before_devs=$(lsblk -nd -o NAME 2>/dev/null | sort)
|
||||
|
||||
log "Starting rpiboot to expose CM4 eMMC as mass storage..."
|
||||
if ! "$RPIBOOT_BIN"; then
|
||||
log "rpiboot failed or no device connected"
|
||||
write_status "error" "rpiboot failed" "null" "rpiboot failed or no device connected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# rpiboot exits when mass storage appears; give udev a moment to create /dev/sdX
|
||||
sleep 3
|
||||
|
||||
# Find new block device (prefer one matching expected eMMC size)
|
||||
target_dev=""
|
||||
for dev in /dev/sd[a-z] /dev/sd[a-z][a-z]; do
|
||||
[[ -b "$dev" ]] || continue
|
||||
# Skip partitions
|
||||
[[ "$dev" =~ [0-9]$ ]] && continue
|
||||
size=$(blockdev --getsize64 "$dev" 2>/dev/null || true)
|
||||
if [[ -n "$size" ]]; then
|
||||
# Allow 5% tolerance on size
|
||||
if (( size >= EMMC_SIZE_BYTES * 95 / 100 && size <= EMMC_SIZE_BYTES * 105 / 100 )); then
|
||||
target_dev=$dev
|
||||
break
|
||||
fi
|
||||
# Otherwise take first new disk that appeared (fallback)
|
||||
if [[ -z "$target_dev" && "$before_devs" != *"${dev#/dev/}"* ]]; then
|
||||
target_dev=$dev
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$target_dev" ]]; then
|
||||
log "No suitable block device found after rpiboot (expected ~${EMMC_SIZE_BYTES} bytes)"
|
||||
write_status "error" "No eMMC device found" "null" "No suitable block device after rpiboot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ask user (dashboard): Backup or Deploy?
|
||||
write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup or Deploy in the dashboard." "null"
|
||||
echo "usb" > "$DEVICE_SOURCE_FILE" 2>/dev/null || true
|
||||
echo "$target_dev" > "$CURRENT_DEVICE_FILE" 2>/dev/null || true
|
||||
log "Waiting for user choice (Backup or Deploy) in dashboard; timeout ${WAIT_TIMEOUT}s..."
|
||||
for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
|
||||
sleep 2
|
||||
if [[ -f "$ACTION_REQUEST_FILE" ]]; then
|
||||
action=$(cat "$ACTION_REQUEST_FILE" 2>/dev/null | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
rm -f "$ACTION_REQUEST_FILE" 2>/dev/null || true
|
||||
if [[ "$action" == "backup" ]]; then
|
||||
backup_name="backup-$(date +%Y%m%d-%H%M%S).img"
|
||||
backup_path="$BACKUPS_DIR/$backup_name"
|
||||
write_status "backup" "Creating backup…" "null"
|
||||
log "Backing up $target_dev to $backup_path..."
|
||||
if dd if="$target_dev" of="$backup_path" bs=4M status=progress conv=fsync 2>>"$LOG_FILE"; then
|
||||
log "Backup complete: $backup_path"
|
||||
write_status "done" "Backup complete: $backup_name" "100"
|
||||
else
|
||||
write_status "error" "Backup failed" "null" "dd failed"
|
||||
fi
|
||||
elif [[ "$action" == "deploy" ]]; then
|
||||
write_status "flashing" "Writing golden image…" "null"
|
||||
log "Flashing $GOLDEN_IMAGE to $target_dev..."
|
||||
if dd if="$GOLDEN_IMAGE" of="$target_dev" bs=4M status=progress conv=fsync 2>>"$LOG_FILE"; then
|
||||
log "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal."
|
||||
write_status "done" "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal." "100"
|
||||
else
|
||||
write_status "error" "Flash failed" "null" "dd failed"
|
||||
fi
|
||||
else
|
||||
write_status "error" "Unknown action" "null" "action_request must be 'backup' or 'deploy'"
|
||||
fi
|
||||
rm -f "$CURRENT_DEVICE_FILE" "$DEVICE_SOURCE_FILE" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
log "Timeout waiting for user choice"
|
||||
write_status "idle" "Timeout waiting for choice. Connect the device again to retry." "null"
|
||||
rm -f "$CURRENT_DEVICE_FILE" "$DEVICE_SOURCE_FILE" 2>/dev/null || true
|
||||
exit 0
|
||||
27
chromium-setup/emmc-provisioning/network-client/README.md
Normal file
27
chromium-setup/emmc-provisioning/network-client/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Network provisioning client
|
||||
|
||||
When a reTerminal (or any Pi) has **network boot** enabled and boots over the network, it can register with the provisioning dashboard and then perform **Deploy** (pull golden image and write to eMMC) or **Backup** (read eMMC and upload to the server) when you choose the action in the dashboard.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Device boots (e.g. from NFS or a minimal netboot root).
|
||||
2. Run **provisioning-client.sh** with `PROVISIONING_SERVER` set to the dashboard URL (e.g. `http://<LXC-IP>:5000`).
|
||||
3. The script registers (MAC + IP) and polls `GET /api/device-action-poll?mac=...`.
|
||||
4. In the dashboard, the device appears under "Device detected (Network)". You click **Backup** or **Deploy**.
|
||||
5. The device gets the action from the next poll: **Deploy** → it downloads `GET /api/golden-image` and runs `dd of=/dev/mmcblk0`. **Backup** → it runs `dd if=/dev/mmcblk0` and POSTs to `POST /api/backup-upload?mac=...`.
|
||||
|
||||
## Usage on the device
|
||||
|
||||
```bash
|
||||
export PROVISIONING_SERVER=http://192.168.1.10:5000 # dashboard URL
|
||||
./provisioning-client.sh
|
||||
```
|
||||
|
||||
- **EMMC_DEV**: override eMMC block device (default `/dev/mmcblk0`).
|
||||
|
||||
The device must have network access to the dashboard and (for deploy) the dashboard must have `golden.img` in its provisioning directory.
|
||||
|
||||
## Integrating into a netboot environment
|
||||
|
||||
- Add this script to your netboot root (e.g. NFS-mounted filesystem or initramfs).
|
||||
- Run it from a first-boot or default login script so that when the device boots from the network it registers and waits for an action. Optionally run it as a systemd service that starts after network is up.
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run this script on a reTerminal that has booted over the network (or any Pi with network).
|
||||
# It registers with the provisioning dashboard and waits for Backup or Deploy, then performs the action.
|
||||
# Usage: PROVISIONING_SERVER=http://192.168.1.10:5000 ./provisioning-client.sh
|
||||
# Requires: curl, and for deploy/backup: enough space and write access to eMMC (e.g. /dev/mmcblk0).
|
||||
|
||||
set -e
|
||||
BASE_URL="${PROVISIONING_SERVER:-http://192.168.1.10:5000}"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
EMMC_DEV="${EMMC_DEV:-/dev/mmcblk0}"
|
||||
MAC=$(cat /sys/class/net/eth0/address 2>/dev/null || cat /sys/class/net/enp*/address 2>/dev/null | head -1 || echo "unknown")
|
||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "")
|
||||
|
||||
echo "Registering with $BASE_URL (MAC=$MAC IP=$IP)..."
|
||||
curl -s -X POST -H "Content-Type: application/json" -d "{\"mac\":\"$MAC\",\"ip\":\"$IP\"}" "$BASE_URL/api/register-device" || true
|
||||
|
||||
echo "Polling for action (Backup or Deploy)..."
|
||||
while true; do
|
||||
resp=$(curl -s "$BASE_URL/api/device-action-poll?mac=$MAC" 2>/dev/null || echo '{"action":"wait"}')
|
||||
action=$(echo "$resp" | grep -o '"action":"[^"]*"' | cut -d'"' -f4)
|
||||
url=$(echo "$resp" | grep -o '"url":"[^"]*"' | cut -d'"' -f4)
|
||||
upload_url=$(echo "$resp" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [[ "$action" == "deploy" && -n "$url" ]]; then
|
||||
echo "Deploy: downloading image and writing to $EMMC_DEV..."
|
||||
if [[ ! -b "$EMMC_DEV" ]]; then
|
||||
echo "Error: $EMMC_DEV not found"
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
curl -sL "$url" | dd of="$EMMC_DEV" bs=4M status=progress conv=fsync
|
||||
echo "Deploy done. Reboot to run from eMMC."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$action" == "backup" && -n "$upload_url" ]]; then
|
||||
echo "Backup: reading $EMMC_DEV and uploading..."
|
||||
if [[ ! -b "$EMMC_DEV" ]]; then
|
||||
echo "Error: $EMMC_DEV not found"
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
dd if="$EMMC_DEV" bs=4M status=progress 2>/dev/null | curl -s -X POST -T - "$upload_url"
|
||||
echo "Backup done."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy CM4 eMMC provisioning to a Proxmox host (creates LXC 201, installs scripts on host and in LXC).
|
||||
# Usage: ./deploy-to-proxmox.sh [proxmox_host]
|
||||
# Example: ./deploy-to-proxmox.sh root@10.130.60.224
|
||||
# Requires: ssh key access to root@<host>
|
||||
|
||||
set -e
|
||||
PROXMOX="${1:-root@10.130.60.224}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "Deploying to $PROXMOX ..."
|
||||
rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh'
|
||||
|
||||
ssh "$PROXMOX" bash -s << 'REMOTE'
|
||||
set -e
|
||||
DEPLOY=/tmp/emmc-provisioning-deploy
|
||||
|
||||
# Ensure LXC 201 exists (create if not)
|
||||
if ! pct status 201 &>/dev/null; then
|
||||
echo "Creating LXC 201 (cm4-provisioning)..."
|
||||
pct create 201 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
|
||||
--hostname cm4-provisioning --memory 1024 --swap 0 --cores 1 \
|
||||
--rootfs local-zfs:8 --net0 name=eth0,bridge=vmbr0,ip=dhcp \
|
||||
--unprivileged 0 --features nesting=1 -tag cm4-provisioning
|
||||
mkdir -p /var/lib/cm4-provisioning
|
||||
pct set 201 -mp0 /var/lib/cm4-provisioning,mp=/var/lib/cm4-provisioning
|
||||
fi
|
||||
|
||||
# Host: install scripts and udev
|
||||
mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
|
||||
cp "$DEPLOY/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
|
||||
chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
cp "$DEPLOY/cm4-flash-trigger.sh" /usr/local/bin/
|
||||
chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
||||
cp "$DEPLOY/90-cm4-boot-mode.rules" /etc/udev/rules.d/
|
||||
udevadm control --reload-rules
|
||||
|
||||
cat > /opt/cm4-provisioning/env << 'ENV'
|
||||
GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img
|
||||
RPIBOOT_DIR=/opt/usbboot
|
||||
EMMC_SIZE_BYTES=8589934592
|
||||
ENV
|
||||
touch /etc/cm4-provisioning/enabled
|
||||
mkdir -p /var/lib/cm4-provisioning/backups
|
||||
|
||||
# Start LXC if stopped
|
||||
pct start 201 2>/dev/null || true
|
||||
|
||||
# LXC: install scripts
|
||||
pct exec 201 -- mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
|
||||
pct push 201 "$DEPLOY/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
|
||||
pct exec 201 -- chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||
pct push 201 "$DEPLOY/cm4-flash-trigger.sh" /usr/local/bin/cm4-flash-trigger.sh
|
||||
pct exec 201 -- chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
||||
pct exec 201 -- bash -c 'echo -e "GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img\nRPIBOOT_DIR=/opt/usbboot\nEMMC_SIZE_BYTES=8589934592" > /opt/cm4-provisioning/env'
|
||||
|
||||
# LXC: install dashboard
|
||||
pct exec 201 -- mkdir -p /opt/cm4-provisioning/dashboard/templates
|
||||
pct push 201 "$DEPLOY/dashboard/app.py" /opt/cm4-provisioning/dashboard/
|
||||
pct push 201 "$DEPLOY/dashboard/templates/index.html" /opt/cm4-provisioning/dashboard/templates/
|
||||
pct push 201 "$DEPLOY/dashboard/cm4-dashboard.service" /opt/cm4-provisioning/dashboard/
|
||||
|
||||
echo "Deploy done. Install usbboot on host when online: ssh $PROXMOX 'bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh'"
|
||||
echo "To enable the dashboard in LXC 201: pct exec 201 -- bash -c 'apt-get install -y python3-flask; cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/; systemctl daemon-reload; systemctl enable --now cm4-dashboard'"
|
||||
REMOTE
|
||||
|
||||
echo "Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/)."
|
||||
echo "When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh"
|
||||
echo "Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see PROXMOX-LXC-DEPLOYMENT.md)."
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Proxmox HOST (root) when the host has internet.
|
||||
# Builds usbboot (rpiboot) and installs to /opt/usbboot so the auto-flash can run.
|
||||
|
||||
set -e
|
||||
apt-get update
|
||||
apt-get install -y libusb-1.0-0-dev git
|
||||
cd /tmp
|
||||
rm -rf usbboot
|
||||
git clone --depth=1 https://github.com/raspberrypi/usbboot
|
||||
cd usbboot
|
||||
make
|
||||
mkdir -p /opt/usbboot
|
||||
cp rpiboot /opt/usbboot/
|
||||
echo "usbboot installed at /opt/usbboot/rpiboot"
|
||||
Reference in New Issue
Block a user