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:
cproudlock
2026-02-06 15:57:34 -05:00
parent 5791bd1b49
commit cee4ecd18d
11 changed files with 1928 additions and 2 deletions

631
webapp/app.py Normal file
View 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
View File

@@ -0,0 +1,2 @@
flask
lxml

298
webapp/static/app.js Normal file
View 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
View 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>

View 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 %}

View 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 %}

View 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 %}