Fix review findings: offline assets, security, audit logging
- Bundle Bootstrap CSS/JS/icons locally for air-gapped operation - Add path traversal validation on image import source - Disable Flask debug mode in production - Fix file handle leaks, remove unused import - Add python3-pip, python3-venv, p7zip-full to offline packages - Add pip wheel download/bundling for offline Flask install - Change UFW default policy from allow to deny - Fix wrong path displayed in unattend editor template - Dynamic sidebar image lists from all_image_types - Add audit logging for all write operations - Audit log viewer page with activity history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,6 +25,9 @@ offline-packages/
|
|||||||
# Boot tool binaries (built by prepare-boot-tools.sh)
|
# Boot tool binaries (built by prepare-boot-tools.sh)
|
||||||
boot-tools/
|
boot-tools/
|
||||||
|
|
||||||
|
# Python wheels for offline install (built by download-packages.sh)
|
||||||
|
pip-wheels/
|
||||||
|
|
||||||
# Deployment images (imported via webapp or USB)
|
# Deployment images (imported via webapp or USB)
|
||||||
geastandardpbr/
|
geastandardpbr/
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ if [ -d "$WEBAPP_DIR" ]; then
|
|||||||
echo " Copied webapp/"
|
echo " Copied webapp/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Copy pip wheels for offline Flask install
|
||||||
|
PIP_WHEELS_DIR="$SCRIPT_DIR/pip-wheels"
|
||||||
|
if [ -d "$PIP_WHEELS_DIR" ]; then
|
||||||
|
cp -r "$PIP_WHEELS_DIR" "$MOUNT_POINT/pip-wheels"
|
||||||
|
echo " Copied pip-wheels/"
|
||||||
|
else
|
||||||
|
echo " No pip-wheels/ found (run download-packages.sh first)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Copy boot tools (Clonezilla, Blancco, Memtest) if prepared
|
# Copy boot tools (Clonezilla, Blancco, Memtest) if prepared
|
||||||
BOOT_TOOLS_DIR="$SCRIPT_DIR/boot-tools"
|
BOOT_TOOLS_DIR="$SCRIPT_DIR/boot-tools"
|
||||||
if [ -d "$BOOT_TOOLS_DIR" ]; then
|
if [ -d "$BOOT_TOOLS_DIR" ]; then
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ PLAYBOOK_PACKAGES=(
|
|||||||
ufw
|
ufw
|
||||||
cron
|
cron
|
||||||
wimtools
|
wimtools
|
||||||
|
python3-pip
|
||||||
|
python3-venv
|
||||||
|
p7zip-full
|
||||||
)
|
)
|
||||||
|
|
||||||
# Packages installed during autoinstall late-commands (NetworkManager, WiFi, etc.)
|
# Packages installed during autoinstall late-commands (NetworkManager, WiFi, etc.)
|
||||||
@@ -64,21 +67,28 @@ DEP_COUNT=$(echo "$DEPS" | wc -l)
|
|||||||
echo " Found $DEP_COUNT packages (including dependencies)"
|
echo " Found $DEP_COUNT packages (including dependencies)"
|
||||||
|
|
||||||
# Download all packages
|
# Download all packages
|
||||||
echo "[3/3] Downloading packages to $OUT_DIR..."
|
echo "[3/4] Downloading .deb packages to $OUT_DIR..."
|
||||||
cd "$OUT_DIR"
|
cd "$OUT_DIR"
|
||||||
apt-get download $DEPS 2>&1 | tail -5
|
apt-get download $DEPS 2>&1 | tail -5
|
||||||
|
|
||||||
DEB_COUNT=$(ls -1 *.deb 2>/dev/null | wc -l)
|
DEB_COUNT=$(ls -1 *.deb 2>/dev/null | wc -l)
|
||||||
TOTAL_SIZE=$(du -sh . | cut -f1)
|
TOTAL_SIZE=$(du -sh . | cut -f1)
|
||||||
|
|
||||||
|
echo " $DEB_COUNT packages ($TOTAL_SIZE)"
|
||||||
|
|
||||||
|
# Download pip wheels for Flask webapp (offline install)
|
||||||
|
echo "[4/4] Downloading Python wheels for webapp..."
|
||||||
|
PIP_DIR="$(dirname "$OUT_DIR")/pip-wheels"
|
||||||
|
mkdir -p "$PIP_DIR"
|
||||||
|
pip3 download -d "$PIP_DIR" flask lxml 2>&1 | tail -5
|
||||||
|
|
||||||
|
WHL_COUNT=$(ls -1 "$PIP_DIR"/*.whl "$PIP_DIR"/*.tar.gz 2>/dev/null | wc -l)
|
||||||
|
echo " $WHL_COUNT Python packages downloaded to pip-wheels/"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo "Download complete!"
|
echo "Download complete!"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " Packages: $DEB_COUNT"
|
echo " .deb packages: $DEB_COUNT ($TOTAL_SIZE) in $OUT_DIR/"
|
||||||
echo " Total size: $TOTAL_SIZE"
|
echo " Python wheels: $WHL_COUNT in $PIP_DIR/"
|
||||||
echo " Location: $OUT_DIR/"
|
|
||||||
echo ""
|
|
||||||
echo "Next: copy these into your ubuntu_playbook/ directory"
|
|
||||||
echo " cp $OUT_DIR/*.deb /path/to/ubuntu_playbook/"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -336,7 +336,7 @@
|
|||||||
- name: "Enable UFW firewall"
|
- name: "Enable UFW firewall"
|
||||||
ufw:
|
ufw:
|
||||||
state: enabled
|
state: enabled
|
||||||
policy: allow
|
policy: deny
|
||||||
|
|
||||||
- name: "Schedule dnsmasq restart 15s after reboot"
|
- name: "Schedule dnsmasq restart 15s after reboot"
|
||||||
cron:
|
cron:
|
||||||
@@ -346,11 +346,6 @@
|
|||||||
job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service"
|
job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service"
|
||||||
|
|
||||||
# --- Web Management App (Flask) ---
|
# --- 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"
|
- name: "Create webapp directory"
|
||||||
file:
|
file:
|
||||||
path: /opt/pxe-webapp
|
path: /opt/pxe-webapp
|
||||||
@@ -369,10 +364,13 @@
|
|||||||
args:
|
args:
|
||||||
creates: /opt/pxe-webapp/venv/bin/python
|
creates: /opt/pxe-webapp/venv/bin/python
|
||||||
|
|
||||||
- name: "Install webapp Python dependencies"
|
- name: "Install webapp Python dependencies (offline wheels)"
|
||||||
pip:
|
shell: >
|
||||||
requirements: /opt/pxe-webapp/requirements.txt
|
/opt/pxe-webapp/venv/bin/pip install --no-index
|
||||||
virtualenv: /opt/pxe-webapp/venv
|
--find-links="{{ usb_mount }}/../pip-wheels/"
|
||||||
|
--find-links="{{ usb_mount }}/pip-wheels/"
|
||||||
|
-r /opt/pxe-webapp/requirements.txt 2>/dev/null ||
|
||||||
|
/opt/pxe-webapp/venv/bin/pip install -r /opt/pxe-webapp/requirements.txt
|
||||||
|
|
||||||
- name: "Create systemd service for PXE webapp"
|
- name: "Create systemd service for PXE webapp"
|
||||||
copy:
|
copy:
|
||||||
@@ -390,6 +388,7 @@
|
|||||||
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
|
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
|
||||||
Environment=WEB_ROOT={{ web_root }}
|
Environment=WEB_ROOT={{ web_root }}
|
||||||
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
|
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
|
||||||
|
Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
|
||||||
ExecStart=/opt/pxe-webapp/venv/bin/python app.py
|
ExecStart=/opt/pxe-webapp/venv/bin/python app.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Flask web application for managing a GE Aerospace PXE server."""
|
"""Flask web application for managing a GE Aerospace PXE server."""
|
||||||
|
|
||||||
import copy
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
@@ -24,6 +26,22 @@ app = Flask(__name__)
|
|||||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")
|
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 * 1024 # 16 GB max upload
|
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 * 1024 # 16 GB max upload
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audit logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
|
||||||
|
audit_logger = logging.getLogger("pxe_audit")
|
||||||
|
audit_logger.setLevel(logging.INFO)
|
||||||
|
_audit_handler = logging.FileHandler(AUDIT_LOG, mode="a")
|
||||||
|
_audit_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
||||||
|
audit_logger.addHandler(_audit_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def audit(action, detail=""):
|
||||||
|
"""Write an entry to the audit log."""
|
||||||
|
ip = request.remote_addr if request else "system"
|
||||||
|
audit_logger.info(f"[{ip}] {action}: {detail}")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration
|
# Configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -190,7 +208,8 @@ def parse_unattend(xml_path):
|
|||||||
data["raw_xml"] = UNATTEND_TEMPLATE
|
data["raw_xml"] = UNATTEND_TEMPLATE
|
||||||
return data
|
return data
|
||||||
|
|
||||||
raw = open(xml_path, "r", encoding="utf-8").read()
|
with open(xml_path, "r", encoding="utf-8") as fh:
|
||||||
|
raw = fh.read()
|
||||||
data["raw_xml"] = raw
|
data["raw_xml"] = raw
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -480,6 +499,12 @@ def images_import():
|
|||||||
flash("Invalid target image type.", "danger")
|
flash("Invalid target image type.", "danger")
|
||||||
return redirect(url_for("images_import"))
|
return redirect(url_for("images_import"))
|
||||||
|
|
||||||
|
# Validate source is under an allowed USB mount path
|
||||||
|
allowed = find_usb_mounts()
|
||||||
|
if not any(source == m or source.startswith(m + "/") for m in allowed):
|
||||||
|
flash("Source path is not on a mounted USB device.", "danger")
|
||||||
|
return redirect(url_for("images_import"))
|
||||||
|
|
||||||
if not os.path.isdir(source):
|
if not os.path.isdir(source):
|
||||||
flash(f"Source path does not exist: {source}", "danger")
|
flash(f"Source path does not exist: {source}", "danger")
|
||||||
return redirect(url_for("images_import"))
|
return redirect(url_for("images_import"))
|
||||||
@@ -497,6 +522,7 @@ def images_import():
|
|||||||
shutil.copytree(src_item, dst_item)
|
shutil.copytree(src_item, dst_item)
|
||||||
else:
|
else:
|
||||||
shutil.copy2(src_item, dst_item)
|
shutil.copy2(src_item, dst_item)
|
||||||
|
audit("IMAGE_IMPORT", f"{source} -> {target}")
|
||||||
flash(
|
flash(
|
||||||
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
|
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
|
||||||
"success",
|
"success",
|
||||||
@@ -553,6 +579,7 @@ def unattend_editor(image_type):
|
|||||||
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
||||||
with open(xml_file, "w", encoding="utf-8") as fh:
|
with open(xml_file, "w", encoding="utf-8") as fh:
|
||||||
fh.write(xml_content)
|
fh.write(xml_content)
|
||||||
|
audit("UNATTEND_SAVE", f"{image_type} ({save_mode})")
|
||||||
flash("unattend.xml saved successfully.", "success")
|
flash("unattend.xml saved successfully.", "success")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
flash(f"Failed to save: {exc}", "danger")
|
flash(f"Failed to save: {exc}", "danger")
|
||||||
@@ -615,6 +642,7 @@ def clonezilla_upload():
|
|||||||
os.makedirs(CLONEZILLA_SHARE, exist_ok=True)
|
os.makedirs(CLONEZILLA_SHARE, exist_ok=True)
|
||||||
dest = os.path.join(CLONEZILLA_SHARE, filename)
|
dest = os.path.join(CLONEZILLA_SHARE, filename)
|
||||||
f.save(dest)
|
f.save(dest)
|
||||||
|
audit("BACKUP_UPLOAD", filename)
|
||||||
flash(f"Uploaded {filename} successfully.", "success")
|
flash(f"Uploaded {filename} successfully.", "success")
|
||||||
return redirect(url_for("clonezilla_backups"))
|
return redirect(url_for("clonezilla_backups"))
|
||||||
|
|
||||||
@@ -635,6 +663,7 @@ def clonezilla_delete(filename):
|
|||||||
fpath = os.path.join(CLONEZILLA_SHARE, filename)
|
fpath = os.path.join(CLONEZILLA_SHARE, filename)
|
||||||
if os.path.isfile(fpath):
|
if os.path.isfile(fpath):
|
||||||
os.remove(fpath)
|
os.remove(fpath)
|
||||||
|
audit("BACKUP_DELETE", filename)
|
||||||
flash(f"Deleted {filename}.", "success")
|
flash(f"Deleted {filename}.", "success")
|
||||||
else:
|
else:
|
||||||
flash(f"Backup not found: {filename}", "danger")
|
flash(f"Backup not found: {filename}", "danger")
|
||||||
@@ -684,6 +713,7 @@ def blancco_delete_report(filename):
|
|||||||
fpath = os.path.join(BLANCCO_REPORTS, filename)
|
fpath = os.path.join(BLANCCO_REPORTS, filename)
|
||||||
if os.path.isfile(fpath):
|
if os.path.isfile(fpath):
|
||||||
os.remove(fpath)
|
os.remove(fpath)
|
||||||
|
audit("REPORT_DELETE", filename)
|
||||||
flash(f"Deleted {filename}.", "success")
|
flash(f"Deleted {filename}.", "success")
|
||||||
else:
|
else:
|
||||||
flash(f"Report not found: {filename}", "danger")
|
flash(f"Report not found: {filename}", "danger")
|
||||||
@@ -696,7 +726,6 @@ def blancco_delete_report(filename):
|
|||||||
|
|
||||||
def _wim_extract_startnet(wim_path):
|
def _wim_extract_startnet(wim_path):
|
||||||
"""Extract startnet.cmd from a WIM file using wimextract."""
|
"""Extract startnet.cmd from a WIM file using wimextract."""
|
||||||
import tempfile
|
|
||||||
tmpdir = tempfile.mkdtemp()
|
tmpdir = tempfile.mkdtemp()
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -707,8 +736,8 @@ def _wim_extract_startnet(wim_path):
|
|||||||
)
|
)
|
||||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||||
if result.returncode == 0 and os.path.isfile(startnet_path):
|
if result.returncode == 0 and os.path.isfile(startnet_path):
|
||||||
content = open(startnet_path, "r", encoding="utf-8", errors="replace").read()
|
with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
return content
|
return fh.read()
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@@ -718,7 +747,6 @@ def _wim_extract_startnet(wim_path):
|
|||||||
|
|
||||||
def _wim_update_startnet(wim_path, content):
|
def _wim_update_startnet(wim_path, content):
|
||||||
"""Update startnet.cmd inside a WIM file using wimupdate."""
|
"""Update startnet.cmd inside a WIM file using wimupdate."""
|
||||||
import tempfile
|
|
||||||
tmpdir = tempfile.mkdtemp()
|
tmpdir = tempfile.mkdtemp()
|
||||||
try:
|
try:
|
||||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||||
@@ -796,12 +824,33 @@ def startnet_save():
|
|||||||
content = request.form.get("content", "")
|
content = request.form.get("content", "")
|
||||||
ok, err = _wim_update_startnet(BOOT_WIM, content)
|
ok, err = _wim_update_startnet(BOOT_WIM, content)
|
||||||
if ok:
|
if ok:
|
||||||
|
audit("STARTNET_SAVE", "boot.wim updated")
|
||||||
flash("startnet.cmd updated successfully in boot.wim.", "success")
|
flash("startnet.cmd updated successfully in boot.wim.", "success")
|
||||||
else:
|
else:
|
||||||
flash(f"Failed to update boot.wim: {err}", "danger")
|
flash(f"Failed to update boot.wim: {err}", "danger")
|
||||||
return redirect(url_for("startnet_editor"))
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — Audit Log
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route("/audit")
|
||||||
|
def audit_log():
|
||||||
|
entries = []
|
||||||
|
if os.path.isfile(AUDIT_LOG):
|
||||||
|
with open(AUDIT_LOG, "r") as fh:
|
||||||
|
for line in fh:
|
||||||
|
entries.append(line.strip())
|
||||||
|
entries.reverse() # newest first
|
||||||
|
return render_template(
|
||||||
|
"audit.html",
|
||||||
|
entries=entries,
|
||||||
|
image_types=IMAGE_TYPES,
|
||||||
|
friendly_names=FRIENDLY_NAMES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes — API
|
# Routes — API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -849,6 +898,7 @@ def api_save_unattend(image_type):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"error": f"Failed to write file: {exc}"}), 500
|
return jsonify({"error": f"Failed to write file: {exc}"}), 500
|
||||||
|
|
||||||
|
audit("UNATTEND_SAVE_API", image_type)
|
||||||
return jsonify({"status": "ok", "path": xml_file})
|
return jsonify({"status": "ok", "path": xml_file})
|
||||||
|
|
||||||
|
|
||||||
@@ -859,7 +909,6 @@ def api_save_unattend(image_type):
|
|||||||
@app.template_filter("timestamp_fmt")
|
@app.template_filter("timestamp_fmt")
|
||||||
def timestamp_fmt(ts):
|
def timestamp_fmt(ts):
|
||||||
"""Format a Unix timestamp to a human-readable date string."""
|
"""Format a Unix timestamp to a human-readable date string."""
|
||||||
from datetime import datetime
|
|
||||||
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
@@ -880,4 +929,4 @@ def inject_globals():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
|
|||||||
5
webapp/static/bootstrap-icons.min.css
vendored
Normal file
5
webapp/static/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
webapp/static/bootstrap.bundle.min.js
vendored
Normal file
7
webapp/static/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
webapp/static/bootstrap.min.css
vendored
Normal file
6
webapp/static/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
webapp/static/fonts/bootstrap-icons.woff
Normal file
BIN
webapp/static/fonts/bootstrap-icons.woff
Normal file
Binary file not shown.
BIN
webapp/static/fonts/bootstrap-icons.woff2
Normal file
BIN
webapp/static/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
62
webapp/templates/audit.html
Normal file
62
webapp/templates/audit.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Audit Log - PXE Server Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h2>
|
||||||
|
<span class="badge bg-secondary fs-6">{{ entries|length }} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<i class="bi bi-clock-history me-2"></i> Activity History
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if entries %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 180px;">Timestamp</th>
|
||||||
|
<th style="width: 130px;">Source</th>
|
||||||
|
<th style="width: 180px;">Action</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in entries %}
|
||||||
|
<tr>
|
||||||
|
{% set parts = entry.split(' ', 1) %}
|
||||||
|
{% if parts|length == 2 %}
|
||||||
|
{% set meta = parts[1].split('] ', 1) %}
|
||||||
|
<td><small class="text-muted">{{ parts[0] }}</small></td>
|
||||||
|
{% if meta|length == 2 %}
|
||||||
|
<td><code>{{ meta[0].lstrip('[') }}</code></td>
|
||||||
|
{% set action_detail = meta[1].split(': ', 1) %}
|
||||||
|
{% if action_detail|length == 2 %}
|
||||||
|
<td><span class="badge bg-primary">{{ action_detail[0] }}</span></td>
|
||||||
|
<td>{{ action_detail[1] }}</td>
|
||||||
|
{% else %}
|
||||||
|
<td colspan="2">{{ meta[1] }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<td colspan="3">{{ parts[1] }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<td colspan="4">{{ entry }}</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-journal-text" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-2">No audit log entries yet.</p>
|
||||||
|
<p class="small">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,12 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
||||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||||
rel="stylesheet"
|
<link href="{{ url_for('static', filename='bootstrap-icons.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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--sidebar-width: 280px;
|
--sidebar-width: 280px;
|
||||||
@@ -155,12 +151,18 @@
|
|||||||
<i class="bi bi-shield-check"></i> Blancco Reports
|
<i class="bi bi-shield-check"></i> Blancco Reports
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
|
||||||
|
href="{{ url_for('audit_log') }}">
|
||||||
|
<i class="bi bi-journal-text"></i> Audit Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="nav-section-divider"></div>
|
<div class="nav-section-divider"></div>
|
||||||
<div class="sidebar-heading">GE Aerospace Images</div>
|
<div class="sidebar-heading">GE Aerospace Images</div>
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
{% for it in ['gea-standard', 'gea-engineer', 'gea-shopfloor'] %}
|
{% for it in all_image_types if it.startswith('gea-') %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
<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) }}">
|
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||||
@@ -173,7 +175,7 @@
|
|||||||
<div class="nav-section-divider"></div>
|
<div class="nav-section-divider"></div>
|
||||||
<div class="sidebar-heading">GE Legacy Images</div>
|
<div class="sidebar-heading">GE Legacy Images</div>
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
{% for it in ['ge-standard', 'ge-engineer', 'ge-shopfloor-lockdown', 'ge-shopfloor-mce'] %}
|
{% for it in all_image_types if it.startswith('ge-') and not it.startswith('gea-') %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
<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) }}">
|
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||||
@@ -200,9 +202,7 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
|
||||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<h2 class="mb-1">{{ friendly_name }}</h2>
|
<h2 class="mb-1">{{ friendly_name }}</h2>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
<i class="bi bi-file-earmark-code me-1"></i>
|
<i class="bi bi-file-earmark-code me-1"></i>
|
||||||
<code>{{ image_type }}/Deploy/Control/unattend.xml</code>
|
<code>{{ image_type }}/Deploy/FlatUnattendW10.xml</code>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user