Fix PXE interface detection, add br-pxe bridge to test VM, network upload import
- Playbook: detect interface already configured with 10.9.100.1 before falling back to non-default-gateway heuristic (fixes dnsmasq binding to wrong NIC when multiple interfaces exist) - test-vm.sh: auto-attach br-pxe bridge NIC if available on host - Webapp: add network upload import via SMB share with shared driver deduplication and symlinks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,19 +61,35 @@
|
|||||||
loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}"
|
loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}"
|
||||||
ignore_errors: yes
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: "Find interface with 10.9.100.1 already configured"
|
||||||
|
set_fact:
|
||||||
|
preconfigured_iface: >-
|
||||||
|
{{ ansible_interfaces
|
||||||
|
| select('match','^e(th|n)')
|
||||||
|
| map('regex_replace','^(.*)$','ansible_\1')
|
||||||
|
| map('extract', hostvars[inventory_hostname])
|
||||||
|
| selectattr('ipv4','defined')
|
||||||
|
| selectattr('ipv4.address','equalto','10.9.100.1')
|
||||||
|
| map(attribute='device')
|
||||||
|
| list
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
- name: "Determine PXE interface"
|
- name: "Determine PXE interface"
|
||||||
set_fact:
|
set_fact:
|
||||||
pxe_iface: >-
|
pxe_iface: >-
|
||||||
{{ (ansible_interfaces
|
{{ preconfigured_iface | default('',true)
|
||||||
| select('match','^e(th|n)')
|
or (ansible_interfaces
|
||||||
| reject('equalto','lo')
|
| select('match','^e(th|n)')
|
||||||
| reject('equalto', ansible_default_ipv4.interface | default(''))
|
| reject('equalto','lo')
|
||||||
| list
|
| reject('equalto', ansible_default_ipv4.interface | default(''))
|
||||||
)
|
| list
|
||||||
| first
|
)
|
||||||
| default(ansible_default_ipv4.interface | default(
|
| first
|
||||||
ansible_interfaces | select('match','^e(th|n)') | first | default('eth0')
|
| default(ansible_default_ipv4.interface | default(
|
||||||
)) }}
|
ansible_interfaces | select('match','^e(th|n)') | first | default('eth0')
|
||||||
|
)) }}
|
||||||
|
|
||||||
- name: "Debug: final pxe_iface choice"
|
- name: "Debug: final pxe_iface choice"
|
||||||
debug:
|
debug:
|
||||||
|
|||||||
13
test-vm.sh
13
test-vm.sh
@@ -138,9 +138,15 @@ echo " Extracted vmlinuz and initrd from casper/"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "[4/4] Launching VM ($VM_NAME)..."
|
echo "[4/4] Launching VM ($VM_NAME)..."
|
||||||
|
|
||||||
# Use the default libvirt network (NAT, 192.168.122.0/24)
|
# Use the default libvirt network (NAT, 192.168.122.0/24) for install access.
|
||||||
# The VM gets a DHCP address initially for install access.
|
# If br-pxe bridge exists, add a second NIC for the isolated PXE switch.
|
||||||
# The Ansible playbook will later configure 10.9.100.1/24 for production use.
|
# The Ansible playbook will configure 10.9.100.1/24 on the PXE interface.
|
||||||
|
PXE_BRIDGE_ARGS=""
|
||||||
|
if ip link show br-pxe &>/dev/null; then
|
||||||
|
PXE_BRIDGE_ARGS="--network bridge=br-pxe,model=virtio"
|
||||||
|
echo " Found br-pxe bridge, adding isolated switch NIC"
|
||||||
|
fi
|
||||||
|
|
||||||
virt-install \
|
virt-install \
|
||||||
--name "$VM_NAME" \
|
--name "$VM_NAME" \
|
||||||
--memory "$VM_RAM" \
|
--memory "$VM_RAM" \
|
||||||
@@ -149,6 +155,7 @@ virt-install \
|
|||||||
--disk path="$UBUNTU_ISO",device=cdrom,readonly=on \
|
--disk path="$UBUNTU_ISO",device=cdrom,readonly=on \
|
||||||
--disk path="$CIDATA_ISO",device=cdrom \
|
--disk path="$CIDATA_ISO",device=cdrom \
|
||||||
--network network=default \
|
--network network=default \
|
||||||
|
$PXE_BRIDGE_ARGS \
|
||||||
--os-variant ubuntu24.04 \
|
--os-variant ubuntu24.04 \
|
||||||
--graphics none \
|
--graphics none \
|
||||||
--console pty,target_type=serial \
|
--console pty,target_type=serial \
|
||||||
|
|||||||
183
webapp/app.py
183
webapp/app.py
@@ -51,6 +51,20 @@ def audit(action, detail=""):
|
|||||||
SAMBA_SHARE = os.environ.get("SAMBA_SHARE", "/srv/samba/winpeapps")
|
SAMBA_SHARE = os.environ.get("SAMBA_SHARE", "/srv/samba/winpeapps")
|
||||||
CLONEZILLA_SHARE = os.environ.get("CLONEZILLA_SHARE", "/srv/samba/clonezilla")
|
CLONEZILLA_SHARE = os.environ.get("CLONEZILLA_SHARE", "/srv/samba/clonezilla")
|
||||||
BLANCCO_REPORTS = os.environ.get("BLANCCO_REPORTS", "/srv/samba/blancco-reports")
|
BLANCCO_REPORTS = os.environ.get("BLANCCO_REPORTS", "/srv/samba/blancco-reports")
|
||||||
|
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/home/pxe/image-upload")
|
||||||
|
SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared")
|
||||||
|
# Subdirs inside Deploy/ shared across ALL image types
|
||||||
|
SHARED_DEPLOY_GLOBAL = ["Out-of-box Drivers"]
|
||||||
|
# Subdirs inside Deploy/ shared within the same image family (by prefix)
|
||||||
|
SHARED_DEPLOY_SCOPED = {
|
||||||
|
"gea-": ["Operating Systems"],
|
||||||
|
"ge-": ["Operating Systems"],
|
||||||
|
}
|
||||||
|
# Sibling dirs at image root shared within the same image family
|
||||||
|
SHARED_ROOT_DIRS = {
|
||||||
|
"gea-": ["Sources"],
|
||||||
|
"ge-": ["Sources"],
|
||||||
|
}
|
||||||
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
||||||
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
|
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
|
||||||
|
|
||||||
@@ -118,6 +132,11 @@ def qwcm(attr):
|
|||||||
# Utility helpers
|
# Utility helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def image_root(image_type):
|
||||||
|
"""Return the root directory for an image type."""
|
||||||
|
return os.path.join(SAMBA_SHARE, image_type)
|
||||||
|
|
||||||
|
|
||||||
def deploy_path(image_type):
|
def deploy_path(image_type):
|
||||||
"""Return the Deploy directory for an image type."""
|
"""Return the Deploy directory for an image type."""
|
||||||
return os.path.join(SAMBA_SHARE, image_type, "Deploy")
|
return os.path.join(SAMBA_SHARE, image_type, "Deploy")
|
||||||
@@ -175,6 +194,103 @@ def find_usb_mounts():
|
|||||||
return sorted(set(mounts))
|
return sorted(set(mounts))
|
||||||
|
|
||||||
|
|
||||||
|
def find_upload_sources():
|
||||||
|
"""Return sub-directories inside UPLOAD_DIR that look like image content."""
|
||||||
|
sources = []
|
||||||
|
if os.path.isdir(UPLOAD_DIR):
|
||||||
|
# Include the upload dir itself if it has content
|
||||||
|
try:
|
||||||
|
entries = os.listdir(UPLOAD_DIR)
|
||||||
|
if entries:
|
||||||
|
sources.append(UPLOAD_DIR)
|
||||||
|
# Also include immediate subdirectories
|
||||||
|
for entry in entries:
|
||||||
|
full = os.path.join(UPLOAD_DIR, entry)
|
||||||
|
if os.path.isdir(full):
|
||||||
|
sources.append(full)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def _import_deploy(src_deploy, dst_deploy, target=""):
|
||||||
|
"""Copy Deploy directory contents, merging shared subdirs into _shared."""
|
||||||
|
# Build list of scoped shared dirs for this target
|
||||||
|
scoped_shared = []
|
||||||
|
prefix_key = ""
|
||||||
|
for prefix, dirs in SHARED_DEPLOY_SCOPED.items():
|
||||||
|
if target.startswith(prefix):
|
||||||
|
scoped_shared = dirs
|
||||||
|
prefix_key = prefix
|
||||||
|
break
|
||||||
|
|
||||||
|
os.makedirs(dst_deploy, exist_ok=True)
|
||||||
|
for item in os.listdir(src_deploy):
|
||||||
|
src_item = os.path.join(src_deploy, item)
|
||||||
|
dst_item = os.path.join(dst_deploy, item)
|
||||||
|
|
||||||
|
if not os.path.isdir(src_item):
|
||||||
|
shutil.copy2(src_item, dst_item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Global shared (e.g., Out-of-box Drivers) — one copy for all
|
||||||
|
if item in SHARED_DEPLOY_GLOBAL:
|
||||||
|
shared_dest = os.path.join(SHARED_DIR, item)
|
||||||
|
os.makedirs(shared_dest, exist_ok=True)
|
||||||
|
_merge_tree(src_item, shared_dest)
|
||||||
|
_replace_with_symlink(dst_item, shared_dest)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Scoped shared (e.g., Operating Systems) — per family prefix
|
||||||
|
if item in scoped_shared:
|
||||||
|
shared_dest = os.path.join(SHARED_DIR, f"{prefix_key}{item}")
|
||||||
|
os.makedirs(shared_dest, exist_ok=True)
|
||||||
|
_merge_tree(src_item, shared_dest)
|
||||||
|
_replace_with_symlink(dst_item, shared_dest)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normal copy
|
||||||
|
if os.path.exists(dst_item):
|
||||||
|
shutil.rmtree(dst_item)
|
||||||
|
shutil.copytree(src_item, dst_item)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_with_symlink(link_path, target_path):
|
||||||
|
"""Replace a file/dir/symlink at link_path with a symlink to target_path."""
|
||||||
|
if os.path.islink(link_path):
|
||||||
|
os.remove(link_path)
|
||||||
|
elif os.path.isdir(link_path):
|
||||||
|
shutil.rmtree(link_path)
|
||||||
|
os.symlink(target_path, link_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tree(src, dst):
|
||||||
|
"""Recursively merge src tree into dst, overwriting existing files."""
|
||||||
|
for item in os.listdir(src):
|
||||||
|
s = os.path.join(src, item)
|
||||||
|
d = os.path.join(dst, item)
|
||||||
|
if os.path.isdir(s):
|
||||||
|
if os.path.isdir(d):
|
||||||
|
_merge_tree(s, d)
|
||||||
|
else:
|
||||||
|
if os.path.exists(d):
|
||||||
|
os.remove(d)
|
||||||
|
shutil.copytree(s, d)
|
||||||
|
else:
|
||||||
|
os.makedirs(os.path.dirname(d), exist_ok=True)
|
||||||
|
shutil.copy2(s, d)
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_import_source(source):
|
||||||
|
"""Check if a source path is a valid import location (USB or upload dir)."""
|
||||||
|
usb = find_usb_mounts()
|
||||||
|
if any(source == m or source.startswith(m + "/") for m in usb):
|
||||||
|
return True
|
||||||
|
if source == UPLOAD_DIR or source.startswith(UPLOAD_DIR + "/"):
|
||||||
|
return os.path.isdir(source)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# XML helpers — parse / build unattend.xml
|
# XML helpers — parse / build unattend.xml
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -514,6 +630,7 @@ def dashboard():
|
|||||||
@app.route("/images/import", methods=["GET", "POST"])
|
@app.route("/images/import", methods=["GET", "POST"])
|
||||||
def images_import():
|
def images_import():
|
||||||
usb_mounts = find_usb_mounts()
|
usb_mounts = find_usb_mounts()
|
||||||
|
upload_sources = find_upload_sources()
|
||||||
images = [image_status(it) for it in IMAGE_TYPES]
|
images = [image_status(it) for it in IMAGE_TYPES]
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -528,32 +645,67 @@ 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
|
if not allowed_import_source(source):
|
||||||
allowed = find_usb_mounts()
|
flash("Source path is not a valid import location.", "danger")
|
||||||
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"))
|
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"))
|
||||||
|
|
||||||
|
root = image_root(target)
|
||||||
dest = deploy_path(target)
|
dest = deploy_path(target)
|
||||||
try:
|
try:
|
||||||
os.makedirs(dest, exist_ok=True)
|
os.makedirs(dest, exist_ok=True)
|
||||||
# Use rsync-style copy: copy contents of source into dest
|
src_items = os.listdir(source)
|
||||||
for item in os.listdir(source):
|
|
||||||
src_item = os.path.join(source, item)
|
# Detect layout: if source has Deploy/, Sources/, Tools/ at top
|
||||||
dst_item = os.path.join(dest, item)
|
# level, it's the full image root structure (USB-style).
|
||||||
if os.path.isdir(src_item):
|
# Otherwise treat it as Deploy/ contents directly.
|
||||||
if os.path.exists(dst_item):
|
top_dirs = {d for d in src_items if os.path.isdir(os.path.join(source, d))}
|
||||||
shutil.rmtree(dst_item)
|
full_layout = "Deploy" in top_dirs
|
||||||
shutil.copytree(src_item, dst_item)
|
|
||||||
else:
|
if full_layout:
|
||||||
shutil.copy2(src_item, dst_item)
|
# Determine which root-level dirs are shared for this target
|
||||||
|
shared_root = []
|
||||||
|
for prefix, dirs in SHARED_ROOT_DIRS.items():
|
||||||
|
if target.startswith(prefix):
|
||||||
|
shared_root = dirs
|
||||||
|
break
|
||||||
|
|
||||||
|
# Full image root: copy Deploy contents + sibling dirs
|
||||||
|
for item in src_items:
|
||||||
|
src_item = os.path.join(source, item)
|
||||||
|
if item == "Deploy":
|
||||||
|
_import_deploy(src_item, dest, target)
|
||||||
|
elif os.path.isdir(src_item) and item in shared_root:
|
||||||
|
# Shared sibling: merge into _shared/{prefix}{item}
|
||||||
|
# and symlink from image root
|
||||||
|
prefix_key = target.split("-")[0] + "-"
|
||||||
|
shared_dest = os.path.join(SHARED_DIR, f"{prefix_key}{item}")
|
||||||
|
os.makedirs(shared_dest, exist_ok=True)
|
||||||
|
_merge_tree(src_item, shared_dest)
|
||||||
|
dst_item = os.path.join(root, item)
|
||||||
|
if os.path.islink(dst_item):
|
||||||
|
os.remove(dst_item)
|
||||||
|
elif os.path.isdir(dst_item):
|
||||||
|
shutil.rmtree(dst_item)
|
||||||
|
os.symlink(shared_dest, dst_item)
|
||||||
|
elif os.path.isdir(src_item):
|
||||||
|
# Non-shared sibling dirs (Tools) go into image root
|
||||||
|
dst_item = os.path.join(root, item)
|
||||||
|
if os.path.exists(dst_item):
|
||||||
|
shutil.rmtree(dst_item)
|
||||||
|
shutil.copytree(src_item, dst_item)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_item, os.path.join(root, item))
|
||||||
|
else:
|
||||||
|
# Flat layout: treat source as Deploy contents
|
||||||
|
_import_deploy(source, dest, target)
|
||||||
|
|
||||||
audit("IMAGE_IMPORT", f"{source} -> {target}")
|
audit("IMAGE_IMPORT", f"{source} -> {target}")
|
||||||
flash(
|
flash(
|
||||||
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
|
f"Successfully imported content to {FRIENDLY_NAMES.get(target, target)}.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -564,6 +716,7 @@ def images_import():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"import.html",
|
"import.html",
|
||||||
usb_mounts=usb_mounts,
|
usb_mounts=usb_mounts,
|
||||||
|
upload_sources=upload_sources,
|
||||||
images=images,
|
images=images,
|
||||||
image_types=IMAGE_TYPES,
|
image_types=IMAGE_TYPES,
|
||||||
friendly_names=FRIENDLY_NAMES,
|
friendly_names=FRIENDLY_NAMES,
|
||||||
|
|||||||
@@ -6,6 +6,81 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
|
||||||
|
<!-- Network Upload Import -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-cloud-upload me-2"></i> Import from Network Upload
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if upload_sources %}
|
||||||
|
<form method="POST" id="uploadImportForm">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="uploadSource" class="form-label fw-semibold">Source</label>
|
||||||
|
<select class="form-select" name="source" id="uploadSource" required>
|
||||||
|
<option value="">-- Select upload source --</option>
|
||||||
|
{% for src in upload_sources %}
|
||||||
|
<option value="{{ src }}">{{ src }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Files uploaded via SMB to <code>\\10.9.100.1\image-upload</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="uploadTarget" class="form-label fw-semibold">Target Image Type</label>
|
||||||
|
<select class="form-select" name="target" id="uploadTarget" 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. Shared resources
|
||||||
|
(Out-of-box Drivers) are stored once and linked across all image types.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info d-flex align-items-start" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill me-2 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
|
||||||
|
into a shared directory and symlinked for each image type to save disk space.
|
||||||
|
</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="uploadImportBtn">
|
||||||
|
<i class="bi bi-download me-1"></i> Start Import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-cloud-slash display-4 text-muted"></i>
|
||||||
|
<h5 class="mt-3 text-muted">No Upload Content Found</h5>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Map <code>\\10.9.100.1\image-upload</code> on your Windows PC and copy
|
||||||
|
the Deploy directory contents there.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- USB Import -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="bi bi-usb-drive me-2"></i> Import from USB Drive
|
<i class="bi bi-usb-drive me-2"></i> Import from USB Drive
|
||||||
@@ -54,7 +129,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-4">
|
||||||
<i class="bi bi-usb-plug display-4 text-muted"></i>
|
<i class="bi bi-usb-plug display-4 text-muted"></i>
|
||||||
<h5 class="mt-3 text-muted">No USB Drives Detected</h5>
|
<h5 class="mt-3 text-muted">No USB Drives Detected</h5>
|
||||||
<p class="text-muted mb-0">
|
<p class="text-muted mb-0">
|
||||||
@@ -107,14 +182,16 @@
|
|||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var form = document.getElementById('importForm');
|
['importForm', 'uploadImportForm'].forEach(function(formId) {
|
||||||
if (form) {
|
var form = document.getElementById(formId);
|
||||||
form.addEventListener('submit', function() {
|
if (form) {
|
||||||
var btn = document.getElementById('importBtn');
|
form.addEventListener('submit', function() {
|
||||||
btn.disabled = true;
|
var btn = form.querySelector('button[type="submit"]');
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
btn.disabled = true;
|
||||||
});
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user