Add web management UI, offline packages, WinPE consolidation, and docs
- webapp/: Flask web management app with:
- Dashboard showing image types and service status
- USB import page for WinPE deployment content
- Unattend.xml visual editor (driver paths, specialize commands,
OOBE settings, first logon commands, raw XML view)
- API endpoints for services and image management
- SETUP.md: Complete setup documentation for streamlined process
- build-usb.sh: Now copies webapp and optional WinPE images to USB
- playbook: Added webapp deployment (systemd service, Apache reverse
proxy), offline package verification, WinPE auto-import from USB
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -26,5 +26,11 @@ offline-packages/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
venv/
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
secrets.md
|
secrets.md
|
||||||
|
|||||||
132
SETUP.md
Normal file
132
SETUP.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# PXE Server Setup Guide
|
||||||
|
|
||||||
|
Automated build process for deploying an Ubuntu-based PXE boot server that hosts GE Aerospace Windows PE images. The entire setup is air-gapped — no internet required on the target machine.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client PXE boot
|
||||||
|
-> Broadcom signed iPXE (Secure Boot)
|
||||||
|
-> wimboot (HTTP from Apache)
|
||||||
|
-> WinPE (boot.wim)
|
||||||
|
-> startnet.cmd maps Samba shares
|
||||||
|
-> GE Aerospace image deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services on the PXE Server
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|----------|----------|--------------------------------------|
|
||||||
|
| dnsmasq | 67/udp | DHCP (10.9.100.10–100) |
|
||||||
|
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
|
||||||
|
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files) |
|
||||||
|
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
|
||||||
|
| Samba | 445/tcp | Deployment content shares |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Hardware
|
||||||
|
- Server or PC with >= 8 GB RAM, >= 250 GB disk, one wired NIC
|
||||||
|
- USB thumb drive >= 8 GB (32+ GB if bundling WinPE images)
|
||||||
|
|
||||||
|
### Software (on your workstation)
|
||||||
|
- Ubuntu Server 24.04 ISO — https://ubuntu.com/download/server
|
||||||
|
- Docker (for downloading offline packages)
|
||||||
|
- GE Aerospace Media Creator LITE (for caching WinPE images)
|
||||||
|
|
||||||
|
### GE Access Packages
|
||||||
|
- EPM Rufus Exception Request
|
||||||
|
- EPM DT Functions
|
||||||
|
- DLP - Encrypted Removable (USB) Long Term Access
|
||||||
|
|
||||||
|
## Setup Process
|
||||||
|
|
||||||
|
### Step 1: Download Offline Packages (one-time, requires internet + Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./download-packages.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs an Ubuntu 24.04 Docker container to download all .deb packages (ansible, dnsmasq, apache2, samba, etc.) into `offline-packages/`. ~102 MB total.
|
||||||
|
|
||||||
|
### Step 2: Build the USB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic — server only (import WinPE images later)
|
||||||
|
sudo ./build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
|
||||||
|
|
||||||
|
# With WinPE images bundled (single USB, larger drive needed)
|
||||||
|
sudo ./build-usb.sh /dev/sdX /path/to/ubuntu-24.04.iso /path/to/winpe-images
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a bootable USB with:
|
||||||
|
- Partition 1: Ubuntu Server installer
|
||||||
|
- Partition 2: CIDATA (autoinstall config, offline .debs, Ansible playbook, optional WinPE images)
|
||||||
|
|
||||||
|
### Step 3: Boot the Target Machine
|
||||||
|
|
||||||
|
1. Insert the USB into the target machine
|
||||||
|
2. Press F12 (or vendor boot key) and select the USB
|
||||||
|
3. Ubuntu auto-installs — no interaction needed
|
||||||
|
4. After reboot, the first-boot script installs all .deb packages and runs the Ansible playbook
|
||||||
|
5. PXE services (dnsmasq, Apache, Samba) are configured automatically
|
||||||
|
|
||||||
|
### Step 4: Connect to Isolated Network
|
||||||
|
|
||||||
|
Move the server's wired NIC to the isolated switch for PXE clients.
|
||||||
|
|
||||||
|
### Step 5: Import WinPE Content (if not bundled in Step 2)
|
||||||
|
|
||||||
|
Insert the Media Creator LITE USB and copy content to the Samba share:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /mnt/usb2
|
||||||
|
sudo mount /dev/sdb2 /mnt/usb2
|
||||||
|
sudo cp -r /mnt/usb2/. /srv/samba/winpeapps/standard
|
||||||
|
sudo umount /mnt/usb2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Connect a test workstation to the isolated switch
|
||||||
|
2. Set Network Boot (PXE) as first boot in BIOS/UEFI
|
||||||
|
3. Boot — the client should pull an IP from 10.9.100.x
|
||||||
|
4. iPXE loads, fetches the boot script from port 4433
|
||||||
|
5. WinPE boots via wimboot + boot.wim over HTTP
|
||||||
|
6. WinPE maps Samba shares and begins image deployment
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pxe-server/
|
||||||
|
├── autoinstall/
|
||||||
|
│ ├── user-data # Cloud-init autoinstall (Ubuntu config, first-boot script)
|
||||||
|
│ └── meta-data # Cloud-init metadata (empty, required)
|
||||||
|
├── playbook/
|
||||||
|
│ ├── pxe_server_setup.yml # Ansible: dnsmasq, Apache, Samba, iPXE, firewall, netplan
|
||||||
|
│ └── inventory.ini # Ansible inventory
|
||||||
|
├── unattend/
|
||||||
|
│ └── FlatUnattendW10.xml # Windows unattend.xml sample
|
||||||
|
├── offline-packages/ # .deb files (gitignored, built by download-packages.sh)
|
||||||
|
├── build-usb.sh # Builds the installer USB
|
||||||
|
├── download-packages.sh # Downloads offline .debs via Docker
|
||||||
|
└── setup-guide-original.txt # Original manual setup doc (reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image Types
|
||||||
|
|
||||||
|
| Image Type | Domain |
|
||||||
|
|-------------------|-----------------|
|
||||||
|
| geastandardpbr | geaerospace.com |
|
||||||
|
| geaengineerpbr | geaerospace.com |
|
||||||
|
| geashopfloorpbr | geaerospace.com |
|
||||||
|
| gestandardlegacy | ge.com |
|
||||||
|
| geengineerlegacy | ge.com |
|
||||||
|
| geshopfloorlegacy | ge.com |
|
||||||
|
|
||||||
|
## Network Configuration
|
||||||
|
|
||||||
|
- PXE server static IP: `10.9.100.1/24`
|
||||||
|
- DHCP range: `10.9.100.10` - `10.9.100.100`
|
||||||
|
- Lease time: 12 hours
|
||||||
|
- DNS: `8.8.8.8` (passed to clients, not used by server)
|
||||||
29
build-usb.sh
29
build-usb.sh
@@ -23,8 +23,12 @@ PLAYBOOK_DIR="$SCRIPT_DIR/playbook"
|
|||||||
OFFLINE_PKG_DIR="$SCRIPT_DIR/offline-packages"
|
OFFLINE_PKG_DIR="$SCRIPT_DIR/offline-packages"
|
||||||
|
|
||||||
# --- Validate arguments ---
|
# --- Validate arguments ---
|
||||||
if [ $# -ne 2 ]; then
|
if [ $# -lt 2 ]; then
|
||||||
echo "Usage: sudo $0 /dev/sdX /path/to/ubuntu-24.04.iso"
|
echo "Usage: sudo $0 /dev/sdX /path/to/ubuntu-24.04.iso [/path/to/winpe-images]"
|
||||||
|
echo ""
|
||||||
|
echo " The optional third argument is the path to WinPE deployment content"
|
||||||
|
echo " (e.g., the mounted Media Creator LITE USB). If provided, the images"
|
||||||
|
echo " will be bundled onto the CIDATA partition for automatic import."
|
||||||
echo ""
|
echo ""
|
||||||
echo "Available removable devices:"
|
echo "Available removable devices:"
|
||||||
lsblk -d -o NAME,SIZE,TRAN,RM | grep -E '^\S+\s+\S+\s+(usb)\s+1'
|
lsblk -d -o NAME,SIZE,TRAN,RM | grep -E '^\S+\s+\S+\s+(usb)\s+1'
|
||||||
@@ -33,6 +37,7 @@ fi
|
|||||||
|
|
||||||
USB_DEV="$1"
|
USB_DEV="$1"
|
||||||
ISO_PATH="$2"
|
ISO_PATH="$2"
|
||||||
|
WINPE_SOURCE="${3:-}"
|
||||||
|
|
||||||
# Safety checks
|
# Safety checks
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
@@ -171,6 +176,26 @@ echo " Copied $DEB_COUNT .deb packages to packages/"
|
|||||||
cp -r "$PLAYBOOK_DIR" "$MOUNT_POINT/playbook"
|
cp -r "$PLAYBOOK_DIR" "$MOUNT_POINT/playbook"
|
||||||
echo " Copied playbook/"
|
echo " Copied playbook/"
|
||||||
|
|
||||||
|
# Copy webapp
|
||||||
|
WEBAPP_DIR="$SCRIPT_DIR/webapp"
|
||||||
|
if [ -d "$WEBAPP_DIR" ]; then
|
||||||
|
mkdir -p "$MOUNT_POINT/webapp"
|
||||||
|
cp -r "$WEBAPP_DIR/app.py" "$WEBAPP_DIR/requirements.txt" "$MOUNT_POINT/webapp/"
|
||||||
|
cp -r "$WEBAPP_DIR/templates" "$WEBAPP_DIR/static" "$MOUNT_POINT/webapp/"
|
||||||
|
echo " Copied webapp/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optionally copy WinPE deployment images
|
||||||
|
if [ -n "$WINPE_SOURCE" ] && [ -d "$WINPE_SOURCE" ]; then
|
||||||
|
echo " Copying WinPE deployment content from $WINPE_SOURCE..."
|
||||||
|
mkdir -p "$MOUNT_POINT/images"
|
||||||
|
cp -r "$WINPE_SOURCE"/* "$MOUNT_POINT/images/" 2>/dev/null || true
|
||||||
|
IMG_SIZE=$(du -sh "$MOUNT_POINT/images" | cut -f1)
|
||||||
|
echo " Copied WinPE images ($IMG_SIZE)"
|
||||||
|
elif [ -n "$WINPE_SOURCE" ]; then
|
||||||
|
echo " WARNING: WinPE source path not found: $WINPE_SOURCE (skipping)"
|
||||||
|
fi
|
||||||
|
|
||||||
# List what's on CIDATA
|
# List what's on CIDATA
|
||||||
echo ""
|
echo ""
|
||||||
echo " CIDATA contents:"
|
echo " CIDATA contents:"
|
||||||
|
|||||||
@@ -219,6 +219,17 @@
|
|||||||
loop:
|
loop:
|
||||||
- ipxe.efi
|
- ipxe.efi
|
||||||
|
|
||||||
|
- name: "Check for WinPE deployment content on USB"
|
||||||
|
stat:
|
||||||
|
path: "{{ usb_mount }}/images"
|
||||||
|
register: usb_images_dir
|
||||||
|
|
||||||
|
- name: "Import WinPE deployment content from USB (if present)"
|
||||||
|
shell: >
|
||||||
|
cp -rn "{{ usb_mount }}/images/{{ item }}/"* "{{ samba_share }}/{{ item }}/" 2>/dev/null || true
|
||||||
|
loop: "{{ image_types }}"
|
||||||
|
when: usb_images_dir.stat.exists
|
||||||
|
|
||||||
- name: "Restart and enable services"
|
- name: "Restart and enable services"
|
||||||
systemd:
|
systemd:
|
||||||
name: "{{ item }}"
|
name: "{{ item }}"
|
||||||
@@ -253,6 +264,82 @@
|
|||||||
special_time: "reboot"
|
special_time: "reboot"
|
||||||
job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service"
|
job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service"
|
||||||
|
|
||||||
|
# --- Web Management App (Flask) ---
|
||||||
|
- name: "Install pip for Python package management"
|
||||||
|
command: apt-get install -y python3-pip python3-venv
|
||||||
|
args:
|
||||||
|
creates: /usr/bin/pip3
|
||||||
|
|
||||||
|
- name: "Create webapp directory"
|
||||||
|
file:
|
||||||
|
path: /opt/pxe-webapp
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: "Copy webapp from USB"
|
||||||
|
shell: >
|
||||||
|
cp -r "{{ usb_mount }}/../webapp/"* /opt/pxe-webapp/ 2>/dev/null ||
|
||||||
|
cp -r "{{ usb_mount }}/webapp/"* /opt/pxe-webapp/ 2>/dev/null || true
|
||||||
|
args:
|
||||||
|
creates: /opt/pxe-webapp/app.py
|
||||||
|
|
||||||
|
- name: "Create Python virtual environment for webapp"
|
||||||
|
command: python3 -m venv /opt/pxe-webapp/venv
|
||||||
|
args:
|
||||||
|
creates: /opt/pxe-webapp/venv/bin/python
|
||||||
|
|
||||||
|
- name: "Install webapp Python dependencies"
|
||||||
|
pip:
|
||||||
|
requirements: /opt/pxe-webapp/requirements.txt
|
||||||
|
virtualenv: /opt/pxe-webapp/venv
|
||||||
|
|
||||||
|
- name: "Create systemd service for PXE webapp"
|
||||||
|
copy:
|
||||||
|
dest: /etc/systemd/system/pxe-webapp.service
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=PXE Server Web Management
|
||||||
|
After=network.target apache2.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/pxe-webapp
|
||||||
|
Environment=SAMBA_SHARE={{ samba_share }}
|
||||||
|
ExecStart=/opt/pxe-webapp/venv/bin/python app.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
- name: "Enable and start PXE webapp service"
|
||||||
|
systemd:
|
||||||
|
name: pxe-webapp
|
||||||
|
state: started
|
||||||
|
enabled: yes
|
||||||
|
daemon_reload: yes
|
||||||
|
|
||||||
|
- name: "Configure Apache reverse proxy for webapp"
|
||||||
|
copy:
|
||||||
|
dest: /etc/apache2/sites-available/pxe-webapp.conf
|
||||||
|
content: |
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass /manage http://127.0.0.1:5000/
|
||||||
|
ProxyPassReverse /manage http://127.0.0.1:5000/
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
- name: "Enable Apache proxy modules"
|
||||||
|
command: a2enmod proxy proxy_http
|
||||||
|
args:
|
||||||
|
creates: /etc/apache2/mods-enabled/proxy.load
|
||||||
|
|
||||||
|
- name: "Enable webapp Apache site"
|
||||||
|
command: a2ensite pxe-webapp.conf
|
||||||
|
args:
|
||||||
|
creates: /etc/apache2/sites-enabled/pxe-webapp.conf
|
||||||
|
|
||||||
- name: "Configure static IP for PXE interface"
|
- name: "Configure static IP for PXE interface"
|
||||||
copy:
|
copy:
|
||||||
dest: /etc/netplan/50-cloud-init.yaml
|
dest: /etc/netplan/50-cloud-init.yaml
|
||||||
|
|||||||
631
webapp/app.py
Normal file
631
webapp/app.py
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Flask web application for managing a GE Aerospace PXE server."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
flash,
|
||||||
|
jsonify,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SAMBA_SHARE = os.environ.get("SAMBA_SHARE", "/srv/samba/winpeapps")
|
||||||
|
|
||||||
|
IMAGE_TYPES = [
|
||||||
|
"geastandardpbr",
|
||||||
|
"geaengineerpbr",
|
||||||
|
"geashopfloorpbr",
|
||||||
|
"gestandardlegacy",
|
||||||
|
"geengineerlegacy",
|
||||||
|
"geshopfloorlegacy",
|
||||||
|
]
|
||||||
|
|
||||||
|
FRIENDLY_NAMES = {
|
||||||
|
"geastandardpbr": "GEA Standard PBR",
|
||||||
|
"geaengineerpbr": "GEA Engineer PBR",
|
||||||
|
"geashopfloorpbr": "GEA Shop Floor PBR",
|
||||||
|
"gestandardlegacy": "GE Standard Legacy",
|
||||||
|
"geengineerlegacy": "GE Engineer Legacy",
|
||||||
|
"geshopfloorlegacy": "GE Shop Floor Legacy",
|
||||||
|
}
|
||||||
|
|
||||||
|
NS = "urn:schemas-microsoft-com:unattend"
|
||||||
|
WCM = "http://schemas.microsoft.com/WMIConfig/2002/State"
|
||||||
|
NSMAP = {None: NS, "wcm": WCM}
|
||||||
|
|
||||||
|
# Convenience qualified-name helpers
|
||||||
|
def qn(tag):
|
||||||
|
"""Return a tag qualified with the default unattend namespace."""
|
||||||
|
return f"{{{NS}}}{tag}"
|
||||||
|
|
||||||
|
def qwcm(attr):
|
||||||
|
"""Return an attribute qualified with the wcm namespace."""
|
||||||
|
return f"{{{WCM}}}{attr}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Utility helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def deploy_path(image_type):
|
||||||
|
"""Return the Deploy directory for an image type."""
|
||||||
|
return os.path.join(SAMBA_SHARE, image_type, "Deploy")
|
||||||
|
|
||||||
|
|
||||||
|
def unattend_path(image_type):
|
||||||
|
"""Return the unattend.xml path for an image type."""
|
||||||
|
return os.path.join(deploy_path(image_type), "Control", "unattend.xml")
|
||||||
|
|
||||||
|
|
||||||
|
def image_status(image_type):
|
||||||
|
"""Return a dict describing the state of an image type."""
|
||||||
|
dp = deploy_path(image_type)
|
||||||
|
up = unattend_path(image_type)
|
||||||
|
has_content = os.path.isdir(dp) and any(os.scandir(dp)) if os.path.isdir(dp) else False
|
||||||
|
has_unattend = os.path.isfile(up)
|
||||||
|
return {
|
||||||
|
"image_type": image_type,
|
||||||
|
"friendly_name": FRIENDLY_NAMES.get(image_type, image_type),
|
||||||
|
"deploy_path": dp,
|
||||||
|
"has_content": has_content,
|
||||||
|
"has_unattend": has_unattend,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def service_status(service_name):
|
||||||
|
"""Check whether a systemd service is active."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "is-active", service_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
state = result.stdout.strip()
|
||||||
|
return {"name": service_name, "active": state == "active", "state": state}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"name": service_name, "active": False, "state": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def find_usb_mounts():
|
||||||
|
"""Return a list of mount-point paths that look like removable media."""
|
||||||
|
mounts = []
|
||||||
|
try:
|
||||||
|
with open("/proc/mounts", "r") as fh:
|
||||||
|
for line in fh:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
mount_point = parts[1]
|
||||||
|
if mount_point.startswith(("/mnt/", "/media/")):
|
||||||
|
if os.path.isdir(mount_point):
|
||||||
|
mounts.append(mount_point)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return sorted(set(mounts))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# XML helpers — parse / build unattend.xml
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UNATTEND_TEMPLATE = """\
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend"
|
||||||
|
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||||
|
<settings pass="windowsPE" />
|
||||||
|
<settings pass="offlineServicing" />
|
||||||
|
<settings pass="specialize" />
|
||||||
|
<settings pass="oobeSystem" />
|
||||||
|
</unattend>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_or_create(parent, tag):
|
||||||
|
"""Find the first child with *tag* or create it."""
|
||||||
|
el = parent.find(tag, namespaces={"": NS})
|
||||||
|
if el is None:
|
||||||
|
el = etree.SubElement(parent, qn(tag.split("}")[-1]) if "}" not in tag else tag)
|
||||||
|
return el
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_pass(root, pass_name):
|
||||||
|
"""Return the <settings pass="..."> element, creating if needed."""
|
||||||
|
for s in root.findall(qn("settings")):
|
||||||
|
if s.get("pass") == pass_name:
|
||||||
|
return s
|
||||||
|
s = etree.SubElement(root, qn("settings"))
|
||||||
|
s.set("pass", pass_name)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def parse_unattend(xml_path):
|
||||||
|
"""Parse an unattend.xml and return a dict of editable data."""
|
||||||
|
data = {
|
||||||
|
"driver_paths": [],
|
||||||
|
"computer_name": "",
|
||||||
|
"registered_organization": "",
|
||||||
|
"registered_owner": "",
|
||||||
|
"time_zone": "",
|
||||||
|
"specialize_commands": [],
|
||||||
|
"oobe": {
|
||||||
|
"HideEULAPage": "true",
|
||||||
|
"HideOEMRegistrationScreen": "true",
|
||||||
|
"HideOnlineAccountScreens": "true",
|
||||||
|
"HideWirelessSetupInOOBE": "true",
|
||||||
|
"HideLocalAccountScreen": "true",
|
||||||
|
"NetworkLocation": "Work",
|
||||||
|
"ProtectYourPC": "3",
|
||||||
|
"SkipUserOOBE": "true",
|
||||||
|
"SkipMachineOOBE": "true",
|
||||||
|
},
|
||||||
|
"firstlogon_commands": [],
|
||||||
|
"raw_xml": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
data["raw_xml"] = UNATTEND_TEMPLATE
|
||||||
|
return data
|
||||||
|
|
||||||
|
raw = open(xml_path, "r", encoding="utf-8").read()
|
||||||
|
data["raw_xml"] = raw
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = etree.fromstring(raw.encode("utf-8"))
|
||||||
|
except etree.XMLSyntaxError:
|
||||||
|
return data
|
||||||
|
|
||||||
|
ns = {"u": NS}
|
||||||
|
|
||||||
|
# --- offlineServicing: DriverPaths ---
|
||||||
|
for dp_el in root.xpath(
|
||||||
|
"u:settings[@pass='offlineServicing']//u:PathAndCredentials/u:Path",
|
||||||
|
namespaces=ns,
|
||||||
|
):
|
||||||
|
if dp_el.text:
|
||||||
|
data["driver_paths"].append(dp_el.text.strip())
|
||||||
|
|
||||||
|
# --- specialize: Shell-Setup ---
|
||||||
|
for comp in root.xpath(
|
||||||
|
"u:settings[@pass='specialize']/u:component",
|
||||||
|
namespaces=ns,
|
||||||
|
):
|
||||||
|
comp_name = comp.get("name", "")
|
||||||
|
if "Shell-Setup" in comp_name:
|
||||||
|
for tag, key in [
|
||||||
|
("ComputerName", "computer_name"),
|
||||||
|
("RegisteredOrganization", "registered_organization"),
|
||||||
|
("RegisteredOwner", "registered_owner"),
|
||||||
|
("TimeZone", "time_zone"),
|
||||||
|
]:
|
||||||
|
el = comp.find(qn(tag))
|
||||||
|
if el is not None and el.text:
|
||||||
|
data[key] = el.text.strip()
|
||||||
|
|
||||||
|
# --- specialize: RunSynchronous commands ---
|
||||||
|
for cmd in root.xpath(
|
||||||
|
"u:settings[@pass='specialize']//u:RunSynchronousCommand",
|
||||||
|
namespaces=ns,
|
||||||
|
):
|
||||||
|
order_el = cmd.find(qn("Order"))
|
||||||
|
path_el = cmd.find(qn("Path"))
|
||||||
|
desc_el = cmd.find(qn("Description"))
|
||||||
|
data["specialize_commands"].append({
|
||||||
|
"order": order_el.text.strip() if order_el is not None and order_el.text else "",
|
||||||
|
"path": path_el.text.strip() if path_el is not None and path_el.text else "",
|
||||||
|
"description": desc_el.text.strip() if desc_el is not None and desc_el.text else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- oobeSystem ---
|
||||||
|
for comp in root.xpath(
|
||||||
|
"u:settings[@pass='oobeSystem']/u:component",
|
||||||
|
namespaces=ns,
|
||||||
|
):
|
||||||
|
comp_name = comp.get("name", "")
|
||||||
|
if "OOBE" in comp_name or "Shell-Setup" in comp_name:
|
||||||
|
oobe_el = comp.find(qn("OOBE"))
|
||||||
|
if oobe_el is not None:
|
||||||
|
for child in oobe_el:
|
||||||
|
local = etree.QName(child).localname
|
||||||
|
if local in data["oobe"] and child.text:
|
||||||
|
data["oobe"][local] = child.text.strip()
|
||||||
|
|
||||||
|
# FirstLogonCommands
|
||||||
|
flc = comp.find(qn("FirstLogonCommands"))
|
||||||
|
if flc is not None:
|
||||||
|
for sync in flc.findall(qn("SynchronousCommand")):
|
||||||
|
order_el = sync.find(qn("Order"))
|
||||||
|
cl_el = sync.find(qn("CommandLine"))
|
||||||
|
desc_el = sync.find(qn("Description"))
|
||||||
|
data["firstlogon_commands"].append({
|
||||||
|
"order": order_el.text.strip() if order_el is not None and order_el.text else "",
|
||||||
|
"commandline": cl_el.text.strip() if cl_el is not None and cl_el.text else "",
|
||||||
|
"description": desc_el.text.strip() if desc_el is not None and desc_el.text else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def build_unattend_xml(form_data):
|
||||||
|
"""Build a complete unattend.xml string from form data dict."""
|
||||||
|
root = etree.Element(qn("unattend"), nsmap=NSMAP)
|
||||||
|
|
||||||
|
# --- windowsPE (empty) ---
|
||||||
|
_settings_pass(root, "windowsPE")
|
||||||
|
|
||||||
|
# --- offlineServicing: DriverPaths ---
|
||||||
|
offline = _settings_pass(root, "offlineServicing")
|
||||||
|
driver_paths = form_data.get("driver_paths", [])
|
||||||
|
if driver_paths:
|
||||||
|
comp = etree.SubElement(offline, qn("component"))
|
||||||
|
comp.set("name", "Microsoft-Windows-PnpCustomizationsNonWinPE")
|
||||||
|
comp.set("processorArchitecture", "amd64")
|
||||||
|
comp.set("publicKeyToken", "31bf3856ad364e35")
|
||||||
|
comp.set("language", "neutral")
|
||||||
|
comp.set("versionScope", "nonSxS")
|
||||||
|
dp_container = etree.SubElement(comp, qn("DriverPaths"))
|
||||||
|
for idx, dp in enumerate(driver_paths, start=1):
|
||||||
|
if not dp.strip():
|
||||||
|
continue
|
||||||
|
pac = etree.SubElement(dp_container, qn("PathAndCredentials"))
|
||||||
|
pac.set(qwcm("action"), "add")
|
||||||
|
pac.set(qwcm("keyValue"), str(idx))
|
||||||
|
path_el = etree.SubElement(pac, qn("Path"))
|
||||||
|
path_el.text = dp.strip()
|
||||||
|
|
||||||
|
# --- specialize ---
|
||||||
|
spec = _settings_pass(root, "specialize")
|
||||||
|
|
||||||
|
# Shell-Setup component
|
||||||
|
shell_comp = etree.SubElement(spec, qn("component"))
|
||||||
|
shell_comp.set("name", "Microsoft-Windows-Shell-Setup")
|
||||||
|
shell_comp.set("processorArchitecture", "amd64")
|
||||||
|
shell_comp.set("publicKeyToken", "31bf3856ad364e35")
|
||||||
|
shell_comp.set("language", "neutral")
|
||||||
|
shell_comp.set("versionScope", "nonSxS")
|
||||||
|
|
||||||
|
for tag, key in [
|
||||||
|
("ComputerName", "computer_name"),
|
||||||
|
("RegisteredOrganization", "registered_organization"),
|
||||||
|
("RegisteredOwner", "registered_owner"),
|
||||||
|
("TimeZone", "time_zone"),
|
||||||
|
]:
|
||||||
|
val = form_data.get(key, "").strip()
|
||||||
|
if val:
|
||||||
|
el = etree.SubElement(shell_comp, qn(tag))
|
||||||
|
el.text = val
|
||||||
|
|
||||||
|
# Deployment / RunSynchronous commands
|
||||||
|
spec_cmds = form_data.get("specialize_commands", [])
|
||||||
|
if spec_cmds:
|
||||||
|
deploy_comp = etree.SubElement(spec, qn("component"))
|
||||||
|
deploy_comp.set("name", "Microsoft-Windows-Deployment")
|
||||||
|
deploy_comp.set("processorArchitecture", "amd64")
|
||||||
|
deploy_comp.set("publicKeyToken", "31bf3856ad364e35")
|
||||||
|
deploy_comp.set("language", "neutral")
|
||||||
|
deploy_comp.set("versionScope", "nonSxS")
|
||||||
|
rs = etree.SubElement(deploy_comp, qn("RunSynchronous"))
|
||||||
|
for idx, cmd in enumerate(spec_cmds, start=1):
|
||||||
|
if not cmd.get("path", "").strip():
|
||||||
|
continue
|
||||||
|
rsc = etree.SubElement(rs, qn("RunSynchronousCommand"))
|
||||||
|
rsc.set(qwcm("action"), "add")
|
||||||
|
order_el = etree.SubElement(rsc, qn("Order"))
|
||||||
|
order_el.text = str(idx)
|
||||||
|
path_el = etree.SubElement(rsc, qn("Path"))
|
||||||
|
path_el.text = cmd["path"].strip()
|
||||||
|
desc_el = etree.SubElement(rsc, qn("Description"))
|
||||||
|
desc_el.text = cmd.get("description", "").strip()
|
||||||
|
|
||||||
|
# --- oobeSystem ---
|
||||||
|
oobe_settings = _settings_pass(root, "oobeSystem")
|
||||||
|
oobe_comp = etree.SubElement(oobe_settings, qn("component"))
|
||||||
|
oobe_comp.set("name", "Microsoft-Windows-Shell-Setup")
|
||||||
|
oobe_comp.set("processorArchitecture", "amd64")
|
||||||
|
oobe_comp.set("publicKeyToken", "31bf3856ad364e35")
|
||||||
|
oobe_comp.set("language", "neutral")
|
||||||
|
oobe_comp.set("versionScope", "nonSxS")
|
||||||
|
|
||||||
|
oobe_el = etree.SubElement(oobe_comp, qn("OOBE"))
|
||||||
|
oobe_data = form_data.get("oobe", {})
|
||||||
|
for key in [
|
||||||
|
"HideEULAPage",
|
||||||
|
"HideOEMRegistrationScreen",
|
||||||
|
"HideOnlineAccountScreens",
|
||||||
|
"HideWirelessSetupInOOBE",
|
||||||
|
"HideLocalAccountScreen",
|
||||||
|
"NetworkLocation",
|
||||||
|
"ProtectYourPC",
|
||||||
|
"SkipUserOOBE",
|
||||||
|
"SkipMachineOOBE",
|
||||||
|
]:
|
||||||
|
val = oobe_data.get(key, "")
|
||||||
|
if val:
|
||||||
|
child = etree.SubElement(oobe_el, qn(key))
|
||||||
|
child.text = str(val)
|
||||||
|
|
||||||
|
# FirstLogonCommands
|
||||||
|
fl_cmds = form_data.get("firstlogon_commands", [])
|
||||||
|
if fl_cmds:
|
||||||
|
flc = etree.SubElement(oobe_comp, qn("FirstLogonCommands"))
|
||||||
|
for idx, cmd in enumerate(fl_cmds, start=1):
|
||||||
|
if not cmd.get("commandline", "").strip():
|
||||||
|
continue
|
||||||
|
sc = etree.SubElement(flc, qn("SynchronousCommand"))
|
||||||
|
sc.set(qwcm("action"), "add")
|
||||||
|
order_el = etree.SubElement(sc, qn("Order"))
|
||||||
|
order_el.text = str(idx)
|
||||||
|
cl_el = etree.SubElement(sc, qn("CommandLine"))
|
||||||
|
cl_el.text = cmd["commandline"].strip()
|
||||||
|
desc_el = etree.SubElement(sc, qn("Description"))
|
||||||
|
desc_el.text = cmd.get("description", "").strip()
|
||||||
|
|
||||||
|
xml_bytes = etree.tostring(
|
||||||
|
root,
|
||||||
|
pretty_print=True,
|
||||||
|
xml_declaration=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return xml_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_form_data(form):
|
||||||
|
"""Pull structured data from the submitted form."""
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# Driver paths
|
||||||
|
dp_list = form.getlist("driver_path[]")
|
||||||
|
data["driver_paths"] = [p for p in dp_list if p.strip()]
|
||||||
|
|
||||||
|
# Machine settings
|
||||||
|
data["computer_name"] = form.get("computer_name", "")
|
||||||
|
data["registered_organization"] = form.get("registered_organization", "")
|
||||||
|
data["registered_owner"] = form.get("registered_owner", "")
|
||||||
|
data["time_zone"] = form.get("time_zone", "")
|
||||||
|
|
||||||
|
# Specialize commands
|
||||||
|
spec_paths = form.getlist("spec_cmd_path[]")
|
||||||
|
spec_descs = form.getlist("spec_cmd_desc[]")
|
||||||
|
data["specialize_commands"] = []
|
||||||
|
for i in range(len(spec_paths)):
|
||||||
|
if spec_paths[i].strip():
|
||||||
|
data["specialize_commands"].append({
|
||||||
|
"path": spec_paths[i],
|
||||||
|
"description": spec_descs[i] if i < len(spec_descs) else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# OOBE settings
|
||||||
|
data["oobe"] = {}
|
||||||
|
for key in [
|
||||||
|
"HideEULAPage",
|
||||||
|
"HideOEMRegistrationScreen",
|
||||||
|
"HideOnlineAccountScreens",
|
||||||
|
"HideWirelessSetupInOOBE",
|
||||||
|
"HideLocalAccountScreen",
|
||||||
|
"SkipUserOOBE",
|
||||||
|
"SkipMachineOOBE",
|
||||||
|
]:
|
||||||
|
data["oobe"][key] = form.get(f"oobe_{key}", "false")
|
||||||
|
data["oobe"]["NetworkLocation"] = form.get("oobe_NetworkLocation", "Work")
|
||||||
|
data["oobe"]["ProtectYourPC"] = form.get("oobe_ProtectYourPC", "3")
|
||||||
|
|
||||||
|
# First logon commands
|
||||||
|
fl_cls = form.getlist("fl_cmd_commandline[]")
|
||||||
|
fl_descs = form.getlist("fl_cmd_desc[]")
|
||||||
|
data["firstlogon_commands"] = []
|
||||||
|
for i in range(len(fl_cls)):
|
||||||
|
if fl_cls[i].strip():
|
||||||
|
data["firstlogon_commands"].append({
|
||||||
|
"commandline": fl_cls[i],
|
||||||
|
"description": fl_descs[i] if i < len(fl_descs) else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — pages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def dashboard():
|
||||||
|
images = [image_status(it) for it in IMAGE_TYPES]
|
||||||
|
services = [service_status(s) for s in ("dnsmasq", "apache2", "smbd")]
|
||||||
|
return render_template(
|
||||||
|
"dashboard.html",
|
||||||
|
images=images,
|
||||||
|
services=services,
|
||||||
|
image_types=IMAGE_TYPES,
|
||||||
|
friendly_names=FRIENDLY_NAMES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/images/import", methods=["GET", "POST"])
|
||||||
|
def images_import():
|
||||||
|
usb_mounts = find_usb_mounts()
|
||||||
|
images = [image_status(it) for it in IMAGE_TYPES]
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
source = request.form.get("source", "")
|
||||||
|
target = request.form.get("target", "")
|
||||||
|
|
||||||
|
if not source or not target:
|
||||||
|
flash("Please select both a source and a target image type.", "danger")
|
||||||
|
return redirect(url_for("images_import"))
|
||||||
|
|
||||||
|
if target not in IMAGE_TYPES:
|
||||||
|
flash("Invalid target image type.", "danger")
|
||||||
|
return redirect(url_for("images_import"))
|
||||||
|
|
||||||
|
if not os.path.isdir(source):
|
||||||
|
flash(f"Source path does not exist: {source}", "danger")
|
||||||
|
return redirect(url_for("images_import"))
|
||||||
|
|
||||||
|
dest = deploy_path(target)
|
||||||
|
try:
|
||||||
|
os.makedirs(dest, exist_ok=True)
|
||||||
|
# Use rsync-style copy: copy contents of source into dest
|
||||||
|
for item in os.listdir(source):
|
||||||
|
src_item = os.path.join(source, item)
|
||||||
|
dst_item = os.path.join(dest, item)
|
||||||
|
if os.path.isdir(src_item):
|
||||||
|
if os.path.exists(dst_item):
|
||||||
|
shutil.rmtree(dst_item)
|
||||||
|
shutil.copytree(src_item, dst_item)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_item, dst_item)
|
||||||
|
flash(
|
||||||
|
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
flash(f"Import failed: {exc}", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for("images_import"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"import.html",
|
||||||
|
usb_mounts=usb_mounts,
|
||||||
|
images=images,
|
||||||
|
image_types=IMAGE_TYPES,
|
||||||
|
friendly_names=FRIENDLY_NAMES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/images/<image_type>/unattend", methods=["GET", "POST"])
|
||||||
|
def unattend_editor(image_type):
|
||||||
|
if image_type not in IMAGE_TYPES:
|
||||||
|
flash("Unknown image type.", "danger")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
xml_file = unattend_path(image_type)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
save_mode = request.form.get("save_mode", "form")
|
||||||
|
|
||||||
|
if save_mode == "raw":
|
||||||
|
raw_xml = request.form.get("raw_xml", "")
|
||||||
|
# Validate the XML before saving
|
||||||
|
try:
|
||||||
|
etree.fromstring(raw_xml.encode("utf-8"))
|
||||||
|
except etree.XMLSyntaxError as exc:
|
||||||
|
flash(f"Invalid XML: {exc}", "danger")
|
||||||
|
data = parse_unattend(xml_file)
|
||||||
|
data["raw_xml"] = raw_xml
|
||||||
|
return render_template(
|
||||||
|
"unattend_editor.html",
|
||||||
|
image_type=image_type,
|
||||||
|
friendly_name=FRIENDLY_NAMES.get(image_type, image_type),
|
||||||
|
data=data,
|
||||||
|
image_types=IMAGE_TYPES,
|
||||||
|
friendly_names=FRIENDLY_NAMES,
|
||||||
|
)
|
||||||
|
xml_content = raw_xml
|
||||||
|
else:
|
||||||
|
form_data = _extract_form_data(request.form)
|
||||||
|
xml_content = build_unattend_xml(form_data)
|
||||||
|
|
||||||
|
# Write to disk
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
||||||
|
with open(xml_file, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(xml_content)
|
||||||
|
flash("unattend.xml saved successfully.", "success")
|
||||||
|
except Exception as exc:
|
||||||
|
flash(f"Failed to save: {exc}", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for("unattend_editor", image_type=image_type))
|
||||||
|
|
||||||
|
data = parse_unattend(xml_file)
|
||||||
|
return render_template(
|
||||||
|
"unattend_editor.html",
|
||||||
|
image_type=image_type,
|
||||||
|
friendly_name=FRIENDLY_NAMES.get(image_type, image_type),
|
||||||
|
data=data,
|
||||||
|
image_types=IMAGE_TYPES,
|
||||||
|
friendly_names=FRIENDLY_NAMES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route("/api/services")
|
||||||
|
def api_services():
|
||||||
|
services = {s: service_status(s) for s in ("dnsmasq", "apache2", "smbd")}
|
||||||
|
return jsonify(services)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/images")
|
||||||
|
def api_images():
|
||||||
|
images = [image_status(it) for it in IMAGE_TYPES]
|
||||||
|
return jsonify(images)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/images/<image_type>/unattend", methods=["POST"])
|
||||||
|
def api_save_unattend(image_type):
|
||||||
|
if image_type not in IMAGE_TYPES:
|
||||||
|
return jsonify({"error": "Unknown image type"}), 404
|
||||||
|
|
||||||
|
xml_file = unattend_path(image_type)
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return jsonify({"error": "No JSON body provided"}), 400
|
||||||
|
|
||||||
|
if "raw_xml" in payload:
|
||||||
|
raw_xml = payload["raw_xml"]
|
||||||
|
try:
|
||||||
|
etree.fromstring(raw_xml.encode("utf-8"))
|
||||||
|
except etree.XMLSyntaxError as exc:
|
||||||
|
return jsonify({"error": f"Invalid XML: {exc}"}), 400
|
||||||
|
xml_content = raw_xml
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
xml_content = build_unattend_xml(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": f"Failed to build XML: {exc}"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
||||||
|
with open(xml_file, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(xml_content)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": f"Failed to write file: {exc}"}), 500
|
||||||
|
|
||||||
|
return jsonify({"status": "ok", "path": xml_file})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Context processor — make data available to all templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
return {
|
||||||
|
"all_image_types": IMAGE_TYPES,
|
||||||
|
"all_friendly_names": FRIENDLY_NAMES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||||
2
webapp/requirements.txt
Normal file
2
webapp/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask
|
||||||
|
lxml
|
||||||
298
webapp/static/app.js
Normal file
298
webapp/static/app.js
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* PXE Server Manager - Frontend JavaScript
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Add/remove rows in driver paths, specialize commands, first-logon commands
|
||||||
|
* - Drag-to-reorder and up/down buttons for command lists
|
||||||
|
* - Switching between Form editor and Raw XML views
|
||||||
|
* - AJAX and form-based save
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Utility: renumber the "Order" column in a table body
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function renumberRows(tbody) {
|
||||||
|
var rows = tbody.querySelectorAll('tr');
|
||||||
|
rows.forEach(function (row, idx) {
|
||||||
|
var orderCell = row.querySelector('.order-num');
|
||||||
|
if (orderCell) {
|
||||||
|
orderCell.textContent = idx + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Utility: hide "empty" message if rows exist, show if none
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function toggleEmpty(tableId, emptyId) {
|
||||||
|
var tbody = document.querySelector('#' + tableId + ' tbody');
|
||||||
|
var emptyEl = document.getElementById(emptyId);
|
||||||
|
if (!tbody || !emptyEl) return;
|
||||||
|
emptyEl.style.display = tbody.querySelectorAll('tr').length > 0 ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Remove row handler (delegated)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.remove-row');
|
||||||
|
if (!btn) return;
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
var tbody = row.parentElement;
|
||||||
|
row.remove();
|
||||||
|
renumberRows(tbody);
|
||||||
|
// Determine which table we are in and toggle empty message
|
||||||
|
var table = tbody.closest('table');
|
||||||
|
if (table) {
|
||||||
|
if (table.id === 'driverPathsTable') toggleEmpty('driverPathsTable', 'driverPathsEmpty');
|
||||||
|
if (table.id === 'specCmdTable') toggleEmpty('specCmdTable', 'specCmdEmpty');
|
||||||
|
if (table.id === 'flCmdTable') toggleEmpty('flCmdTable', 'flCmdEmpty');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Move-up / Move-down handlers (delegated)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.move-up');
|
||||||
|
if (btn) {
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
var prev = row.previousElementSibling;
|
||||||
|
if (prev) {
|
||||||
|
row.parentElement.insertBefore(row, prev);
|
||||||
|
renumberRows(row.parentElement);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn = e.target.closest('.move-down');
|
||||||
|
if (btn) {
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
var next = row.nextElementSibling;
|
||||||
|
if (next) {
|
||||||
|
row.parentElement.insertBefore(next, row);
|
||||||
|
renumberRows(row.parentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Add Driver Path
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
var addDriverPathBtn = document.getElementById('addDriverPath');
|
||||||
|
if (addDriverPathBtn) {
|
||||||
|
addDriverPathBtn.addEventListener('click', function () {
|
||||||
|
var tbody = document.querySelector('#driverPathsTable tbody');
|
||||||
|
var idx = tbody.querySelectorAll('tr').length + 1;
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td class="order-num">' + idx + '</td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm" name="driver_path[]" value="" placeholder="e.g. C:\\Drivers\\Network"></td>' +
|
||||||
|
'<td><button type="button" class="btn btn-outline-danger btn-row-action remove-row"><i class="bi bi-trash"></i></button></td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
toggleEmpty('driverPathsTable', 'driverPathsEmpty');
|
||||||
|
tr.querySelector('input').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Add Specialize Command
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
var addSpecCmdBtn = document.getElementById('addSpecCmd');
|
||||||
|
if (addSpecCmdBtn) {
|
||||||
|
addSpecCmdBtn.addEventListener('click', function () {
|
||||||
|
var tbody = document.querySelector('#specCmdTable tbody');
|
||||||
|
var idx = tbody.querySelectorAll('tr').length + 1;
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.setAttribute('draggable', 'true');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td class="drag-handle"><i class="bi bi-grip-vertical"></i></td>' +
|
||||||
|
'<td class="order-num">' + idx + '</td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm" name="spec_cmd_path[]" value="" placeholder="Command path"></td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm" name="spec_cmd_desc[]" value="" placeholder="Description"></td>' +
|
||||||
|
'<td class="text-nowrap">' +
|
||||||
|
'<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up"><i class="bi bi-arrow-up"></i></button> ' +
|
||||||
|
'<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down"><i class="bi bi-arrow-down"></i></button> ' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-row-action remove-row"><i class="bi bi-trash"></i></button>' +
|
||||||
|
'</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
initDragForRow(tr);
|
||||||
|
toggleEmpty('specCmdTable', 'specCmdEmpty');
|
||||||
|
tr.querySelector('input[name="spec_cmd_path[]"]').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Add First Logon Command
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
var addFlCmdBtn = document.getElementById('addFlCmd');
|
||||||
|
if (addFlCmdBtn) {
|
||||||
|
addFlCmdBtn.addEventListener('click', function () {
|
||||||
|
var tbody = document.querySelector('#flCmdTable tbody');
|
||||||
|
var idx = tbody.querySelectorAll('tr').length + 1;
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.setAttribute('draggable', 'true');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td class="drag-handle"><i class="bi bi-grip-vertical"></i></td>' +
|
||||||
|
'<td class="order-num">' + idx + '</td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm" name="fl_cmd_commandline[]" value="" placeholder="Command line"></td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm" name="fl_cmd_desc[]" value="" placeholder="Description"></td>' +
|
||||||
|
'<td class="text-nowrap">' +
|
||||||
|
'<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up"><i class="bi bi-arrow-up"></i></button> ' +
|
||||||
|
'<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down"><i class="bi bi-arrow-down"></i></button> ' +
|
||||||
|
'<button type="button" class="btn btn-outline-danger btn-row-action remove-row"><i class="bi bi-trash"></i></button>' +
|
||||||
|
'</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
initDragForRow(tr);
|
||||||
|
toggleEmpty('flCmdTable', 'flCmdEmpty');
|
||||||
|
tr.querySelector('input[name="fl_cmd_commandline[]"]').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// OOBE toggle switches — keep hidden input in sync
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
document.querySelectorAll('.oobe-toggle').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
var hiddenId = this.getAttribute('data-field') + '_val';
|
||||||
|
var hidden = document.getElementById(hiddenId);
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = this.checked ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Drag-and-drop reorder for command tables
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
var dragSrcRow = null;
|
||||||
|
|
||||||
|
function initDragForRow(row) {
|
||||||
|
row.addEventListener('dragstart', function (e) {
|
||||||
|
dragSrcRow = this;
|
||||||
|
this.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', ''); // required for Firefox
|
||||||
|
});
|
||||||
|
row.addEventListener('dragover', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
row.addEventListener('dragenter', function (e) {
|
||||||
|
this.style.borderTop = '2px solid #0d6efd';
|
||||||
|
});
|
||||||
|
row.addEventListener('dragleave', function (e) {
|
||||||
|
this.style.borderTop = '';
|
||||||
|
});
|
||||||
|
row.addEventListener('drop', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.style.borderTop = '';
|
||||||
|
if (dragSrcRow !== this) {
|
||||||
|
var tbody = this.parentElement;
|
||||||
|
tbody.insertBefore(dragSrcRow, this);
|
||||||
|
renumberRows(tbody);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
row.addEventListener('dragend', function () {
|
||||||
|
this.classList.remove('dragging');
|
||||||
|
// clean up all borders
|
||||||
|
this.parentElement.querySelectorAll('tr').forEach(function (r) {
|
||||||
|
r.style.borderTop = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize drag on existing rows
|
||||||
|
document.querySelectorAll('.command-table tbody tr[draggable="true"]').forEach(initDragForRow);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Save: Form view
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
var saveFormBtn = document.getElementById('saveFormBtn');
|
||||||
|
if (saveFormBtn) {
|
||||||
|
saveFormBtn.addEventListener('click', function () {
|
||||||
|
var activeTab = document.querySelector('.editor-tabs .nav-link.active');
|
||||||
|
var form = document.getElementById('unattendForm');
|
||||||
|
var modeInput = document.getElementById('saveMode');
|
||||||
|
|
||||||
|
if (activeTab && activeTab.id === 'raw-tab') {
|
||||||
|
modeInput.value = 'raw';
|
||||||
|
} else {
|
||||||
|
modeInput.value = 'form';
|
||||||
|
}
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Save: Raw XML via AJAX
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
var saveRawBtn = document.getElementById('saveRawBtn');
|
||||||
|
if (saveRawBtn) {
|
||||||
|
saveRawBtn.addEventListener('click', function () {
|
||||||
|
var xmlContent = document.getElementById('rawXmlEditor').value;
|
||||||
|
var url = window.PXE_API_URL;
|
||||||
|
if (!url) {
|
||||||
|
alert('API URL not configured.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRawBtn.disabled = true;
|
||||||
|
saveRawBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving...';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ raw_xml: xmlContent })
|
||||||
|
})
|
||||||
|
.then(function (resp) { return resp.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.error) {
|
||||||
|
showToast('Error: ' + data.error, 'danger');
|
||||||
|
} else {
|
||||||
|
showToast('Raw XML saved successfully.', 'success');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
showToast('Network error: ' + err.message, 'danger');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
saveRawBtn.disabled = false;
|
||||||
|
saveRawBtn.innerHTML = '<i class="bi bi-floppy me-1"></i> Save Raw XML';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Toast notification helper
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function showToast(message, type) {
|
||||||
|
// Create a Bootstrap alert at the top of main content
|
||||||
|
var container = document.querySelector('.main-content');
|
||||||
|
var alert = document.createElement('div');
|
||||||
|
alert.className = 'alert alert-' + (type || 'info') + ' alert-dismissible fade show';
|
||||||
|
alert.setAttribute('role', 'alert');
|
||||||
|
alert.innerHTML = message +
|
||||||
|
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>';
|
||||||
|
container.insertBefore(alert, container.firstChild);
|
||||||
|
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
setTimeout(function () {
|
||||||
|
if (alert.parentElement) {
|
||||||
|
alert.classList.remove('show');
|
||||||
|
setTimeout(function () { alert.remove(); }, 200);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Initial empty-state check (in case page loaded with data)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
toggleEmpty('driverPathsTable', 'driverPathsEmpty');
|
||||||
|
toggleEmpty('specCmdTable', 'specCmdEmpty');
|
||||||
|
toggleEmpty('flCmdTable', 'flCmdEmpty');
|
||||||
|
|
||||||
|
});
|
||||||
185
webapp/templates/base.html
Normal file
185
webapp/templates/base.html
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS49cn91B2HOwP4cMpe1bBMnos9GBsYl7a"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||||
|
rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #1a1d21;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link {
|
||||||
|
color: #adb5bd;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link:hover,
|
||||||
|
.sidebar .nav-link.active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.sidebar .nav-link .bi {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.sidebar-heading {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #6c757d;
|
||||||
|
padding: 1rem 1.25rem 0.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sidebar .brand {
|
||||||
|
padding: 1.2rem 1.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.sidebar .brand .bi {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.status-dot.active {
|
||||||
|
background-color: #198754;
|
||||||
|
}
|
||||||
|
.status-dot.inactive {
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
.status-dot.unknown {
|
||||||
|
background-color: #ffc107;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.table th {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-row-action {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.nav-section-divider {
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="sidebar d-flex flex-column">
|
||||||
|
<div class="brand">
|
||||||
|
<i class="bi bi-hdd-network"></i>
|
||||||
|
PXE Manager
|
||||||
|
</div>
|
||||||
|
<ul class="nav flex-column mt-2">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
|
||||||
|
href="{{ url_for('dashboard') }}">
|
||||||
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}"
|
||||||
|
href="{{ url_for('images_import') }}">
|
||||||
|
<i class="bi bi-download"></i> Image Import
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="nav-section-divider"></div>
|
||||||
|
<div class="sidebar-heading">PBR Images</div>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
{% for it in ['geastandardpbr', 'geaengineerpbr', 'geashopfloorpbr'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||||
|
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||||
|
<i class="bi bi-file-earmark-code"></i> {{ all_friendly_names[it] }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="nav-section-divider"></div>
|
||||||
|
<div class="sidebar-heading">Legacy Images</div>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
{% for it in ['gestandardlegacy', 'geengineerlegacy', 'geshopfloorlegacy'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||||
|
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||||
|
<i class="bi bi-file-earmark-code"></i> {{ all_friendly_names[it] }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="main-content">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
95
webapp/templates/dashboard.html
Normal file
95
webapp/templates/dashboard.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard - PXE Server Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0">Dashboard</h2>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<i class="bi bi-gear-wide-connected me-2"></i> PXE Services
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for svc in services %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="bi bi-server me-1 text-muted"></i>
|
||||||
|
<strong>{{ svc.name }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
|
||||||
|
{{ "Running" if svc.active else "Stopped" }}
|
||||||
|
</td>
|
||||||
|
<td><code>{{ svc.state }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Images -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<i class="bi bi-disc me-2"></i> Deployment Images
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Deploy Content</th>
|
||||||
|
<th>unattend.xml</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for img in images %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ img.friendly_name }}</strong><br>
|
||||||
|
<small class="text-muted">{{ img.image_type }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if img.has_content %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Present</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary"><i class="bi bi-x-circle"></i> Empty</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if img.has_unattend %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Exists</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Missing</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><code class="small">{{ img.deploy_path }}</code></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil-square"></i> Edit Unattend
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
119
webapp/templates/import.html
Normal file
119
webapp/templates/import.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Image Import - PXE Server Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">Image Import</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-usb-drive me-2"></i> Import from USB Drive
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if usb_mounts %}
|
||||||
|
<form method="POST" id="importForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="source" class="form-label fw-semibold">Source (USB Mount Point)</label>
|
||||||
|
<select class="form-select" name="source" id="source" required>
|
||||||
|
<option value="">-- Select a mounted USB drive --</option>
|
||||||
|
{% for mount in usb_mounts %}
|
||||||
|
<option value="{{ mount }}">{{ mount }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Select the mounted USB drive containing the WinPE deployment content.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="target" class="form-label fw-semibold">Target Image Type</label>
|
||||||
|
<select class="form-select" name="target" id="target" required>
|
||||||
|
<option value="">-- Select target image --</option>
|
||||||
|
{% for it in image_types %}
|
||||||
|
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Content will be copied into the Deploy directory for this image type.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||||
|
same names will be overwritten. This operation may take several minutes for large
|
||||||
|
images.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="importBtn">
|
||||||
|
<i class="bi bi-download me-1"></i> Start Import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-usb-plug display-4 text-muted"></i>
|
||||||
|
<h5 class="mt-3 text-muted">No USB Drives Detected</h5>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
No mounted USB drives were found under <code>/mnt/</code> or <code>/media/</code>.<br>
|
||||||
|
Mount a USB drive and refresh this page.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-info-circle me-2"></i> Current Image Status
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Content</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for img in images %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ img.friendly_name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if img.has_content %}
|
||||||
|
<span class="badge bg-success">Present</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Empty</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var form = document.getElementById('importForm');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
var btn = document.getElementById('importBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
346
webapp/templates/unattend_editor.html
Normal file
346
webapp/templates/unattend_editor.html
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ friendly_name }} - Unattend Editor{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.editor-tabs .nav-link {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.command-table tbody tr {
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
.command-table tbody tr.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
.raw-xml-editor {
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 500px;
|
||||||
|
tab-size: 2;
|
||||||
|
white-space: pre;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.section-card {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.section-card .card-header {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.order-num {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">{{ friendly_name }}</h2>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-file-earmark-code me-1"></i>
|
||||||
|
<code>{{ image_type }}/Deploy/Control/unattend.xml</code>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-success" id="saveFormBtn">
|
||||||
|
<i class="bi bi-floppy me-1"></i> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="nav nav-tabs editor-tabs mb-3" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" id="form-tab" data-bs-toggle="tab"
|
||||||
|
href="#formView" role="tab">
|
||||||
|
<i class="bi bi-ui-checks-grid me-1"></i> Form Editor
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" id="raw-tab" data-bs-toggle="tab"
|
||||||
|
href="#rawView" role="tab">
|
||||||
|
<i class="bi bi-code-slash me-1"></i> Raw XML
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="POST" id="unattendForm">
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<!-- ==================== FORM VIEW ==================== -->
|
||||||
|
<div class="tab-pane fade show active" id="formView" role="tabpanel">
|
||||||
|
|
||||||
|
<!-- 1. Driver Paths -->
|
||||||
|
<div class="card section-card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-motherboard me-1"></i> Driver Paths</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="addDriverPath">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0" id="driverPathsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:40px">#</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th style="width:60px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for dp in data.driver_paths %}
|
||||||
|
<tr>
|
||||||
|
<td class="order-num">{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
name="driver_path[]" value="{{ dp }}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if not data.driver_paths %}
|
||||||
|
<div class="text-center text-muted py-3 empty-message" id="driverPathsEmpty">
|
||||||
|
No driver paths configured. Click <strong>Add</strong> to add one.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Machine Settings -->
|
||||||
|
<div class="card section-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-pc-display me-1"></i> Machine Settings
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">Computer Name</label>
|
||||||
|
<input type="text" class="form-control" name="computer_name"
|
||||||
|
value="{{ data.computer_name }}" placeholder="* (auto-generate)">
|
||||||
|
<div class="form-text">Use * to let Windows auto-generate a name.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">Time Zone</label>
|
||||||
|
<input type="text" class="form-control" name="time_zone"
|
||||||
|
value="{{ data.time_zone }}" placeholder="Eastern Standard Time">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">Registered Organization</label>
|
||||||
|
<input type="text" class="form-control" name="registered_organization"
|
||||||
|
value="{{ data.registered_organization }}" placeholder="GE Aerospace">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">Registered Owner</label>
|
||||||
|
<input type="text" class="form-control" name="registered_owner"
|
||||||
|
value="{{ data.registered_owner }}" placeholder="GE Aerospace">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Specialize Commands (RunSynchronous) -->
|
||||||
|
<div class="card section-card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-terminal me-1"></i> Specialize Commands (RunSynchronous)</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="addSpecCmd">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0 command-table" id="specCmdTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px"></th>
|
||||||
|
<th style="width:50px">Order</th>
|
||||||
|
<th>Path / Command</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="width:90px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cmd in data.specialize_commands %}
|
||||||
|
<tr draggable="true">
|
||||||
|
<td class="drag-handle"><i class="bi bi-grip-vertical"></i></td>
|
||||||
|
<td class="order-num">{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
name="spec_cmd_path[]" value="{{ cmd.path }}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
name="spec_cmd_desc[]" value="{{ cmd.description }}">
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down">
|
||||||
|
<i class="bi bi-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if not data.specialize_commands %}
|
||||||
|
<div class="text-center text-muted py-3 empty-message" id="specCmdEmpty">
|
||||||
|
No specialize commands configured. Click <strong>Add</strong> to add one.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. OOBE Settings -->
|
||||||
|
<div class="card section-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-shield-check me-1"></i> OOBE Settings
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
{% set bool_oobe_fields = [
|
||||||
|
("HideEULAPage", "Hide EULA Page"),
|
||||||
|
("HideOEMRegistrationScreen", "Hide OEM Registration Screen"),
|
||||||
|
("HideOnlineAccountScreens", "Hide Online Account Screens"),
|
||||||
|
("HideWirelessSetupInOOBE", "Hide Wireless Setup in OOBE"),
|
||||||
|
("HideLocalAccountScreen", "Hide Local Account Screen"),
|
||||||
|
("SkipUserOOBE", "Skip User OOBE"),
|
||||||
|
("SkipMachineOOBE", "Skip Machine OOBE"),
|
||||||
|
] %}
|
||||||
|
{% for key, label in bool_oobe_fields %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input oobe-toggle" type="checkbox"
|
||||||
|
id="oobe_{{ key }}"
|
||||||
|
data-field="oobe_{{ key }}"
|
||||||
|
{% if data.oobe[key]|lower == 'true' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="oobe_{{ key }}">{{ label }}</label>
|
||||||
|
<input type="hidden" name="oobe_{{ key }}" id="oobe_{{ key }}_val"
|
||||||
|
value="{{ data.oobe[key] }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Network Location</label>
|
||||||
|
<select class="form-select form-select-sm" name="oobe_NetworkLocation">
|
||||||
|
{% for opt in ["Home", "Work", "Other"] %}
|
||||||
|
<option value="{{ opt }}" {% if data.oobe.NetworkLocation == opt %}selected{% endif %}>
|
||||||
|
{{ opt }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">ProtectYourPC</label>
|
||||||
|
<select class="form-select form-select-sm" name="oobe_ProtectYourPC">
|
||||||
|
{% for opt in ["1", "2", "3"] %}
|
||||||
|
<option value="{{ opt }}" {% if data.oobe.ProtectYourPC == opt %}selected{% endif %}>
|
||||||
|
{{ opt }}{% if opt == "1" %} (Recommended){% elif opt == "3" %} (Skip){% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">1 = Recommended, 2 = Install only updates, 3 = Skip</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. First Logon Commands -->
|
||||||
|
<div class="card section-card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-play-circle me-1"></i> First Logon Commands</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="addFlCmd">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0 command-table" id="flCmdTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px"></th>
|
||||||
|
<th style="width:50px">Order</th>
|
||||||
|
<th>Command Line</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="width:90px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cmd in data.firstlogon_commands %}
|
||||||
|
<tr draggable="true">
|
||||||
|
<td class="drag-handle"><i class="bi bi-grip-vertical"></i></td>
|
||||||
|
<td class="order-num">{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
name="fl_cmd_commandline[]" value="{{ cmd.commandline }}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
name="fl_cmd_desc[]" value="{{ cmd.description }}">
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down">
|
||||||
|
<i class="bi bi-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if not data.firstlogon_commands %}
|
||||||
|
<div class="text-center text-muted py-3 empty-message" id="flCmdEmpty">
|
||||||
|
No first logon commands configured. Click <strong>Add</strong> to add one.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end formView -->
|
||||||
|
|
||||||
|
<!-- ==================== RAW XML VIEW ==================== -->
|
||||||
|
<div class="tab-pane fade" id="rawView" role="tabpanel">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-code-slash me-1"></i> Raw XML</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" id="saveRawBtn">
|
||||||
|
<i class="bi bi-floppy me-1"></i> Save Raw XML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<textarea class="form-control raw-xml-editor" name="raw_xml"
|
||||||
|
id="rawXmlEditor">{{ data.raw_xml }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end tab-content -->
|
||||||
|
|
||||||
|
<input type="hidden" name="save_mode" id="saveMode" value="form">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
// Pass the image_type and API URL to JavaScript
|
||||||
|
window.PXE_IMAGE_TYPE = "{{ image_type }}";
|
||||||
|
window.PXE_API_URL = "{{ url_for('api_save_unattend', image_type=image_type) }}";
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user