Webapp: Updates pages, image page tabs with review-before-save, BPRT sync

Updates group:
- Dell drivers and BIOS: check Dell's catalogs against all three driver maps
  and models.txt, download with a shared speed cap, repack, preview, apply
  with backups and rollback.
- iPXE boot files: check GitHub for the signed Secure Boot set, verify the
  sha256 and each file's signer, install atomically, roll back. Includes the
  by-hand procedure.
- Blancco: upload a release, stage it, test-boot it from the menu, activate,
  roll back.
- BPRT sync: validate a batch uploaded from the MediaCreator Lite cache
  (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site
  overlay files kept.

Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and
Packages tabs. Every save shows a diff first, keeps the file's format, makes a
backup and can be undone. Driver lint runs inline.

Enrollment: upload standard/engineer MCL packages, one per tenant/purpose,
with archive and restore. Hardware coverage counts engineer as Win11.
This commit is contained in:
cproudlock
2026-10-13 16:58:55 -04:00
parent ddf0a2fdf2
commit 397063f987
39 changed files with 15109 additions and 1106 deletions

View File

@@ -0,0 +1,445 @@
<#
.SYNOPSIS
Upload the new and changed files of the vendor BPRT folder to the PXE server.
.DESCRIPTION
Run on the GE work PC where MediaCreator Lite keeps the vendor's BPRT
folder (the MCL provisioning packages, the Office zips and the bulk-enroll
scripts). The PXE server is reachable from there only over the GE network:
its webapp and SSH, no SMB.
1. GET <WebUrl>/updates/bprt/manifest.json: path, size and sha256 of every
vendor file in the server's live BPRT. The first request after a big
change makes the server hash 7.7 GB; it answers 503 until then and
this script waits.
2. Hash the local folder (Get-FileHash SHA256) and parse-check every
PowerShell file (a corrupt MediaCreator cache once held scripts with
stale tails), and check every .ppkg has a WIM header.
3. Write sync.json (the complete intended vendor file list with sizes and
hashes) and upload the new and changed files plus sync.json with ONE
sftp session into BPRT-incoming/<batch>.part on the server, renamed to
<batch> when complete. You type the password once.
4. Print the webapp page where the batch is validated and activated.
Nothing on the server changes until the batch is activated on that page.
The site's own files in BPRT (our Select-PCConfig.ps1 shim, the
Register-BulkEnrollTask*.ps1 scripts) are not in the manifest and are
never uploaded; the vendor's Select-PCConfig.ps1 is compared with the copy
the server keeps as Select-PCConfig-vendor.ps1.
Windows PowerShell 5.1, built-in modules and the built-in OpenSSH client
(sftp.exe) only.
.PARAMETER Source
The vendor BPRT folder. Default: the MediaCreator Lite cache.
.PARAMETER WebUrl
The PXE webapp on the GE network.
.PARAMETER SshHost
The PXE server's GE-network address for sftp.
.PARAMETER SshUser
SSH login (password prompt).
.PARAMETER DryRun
Compare and write sync.json and the sftp batch file, print what would be
uploaded, and stop before connecting with sftp. The manifest is still
read from the webapp when it answers (read-only); use -ManifestFile to
work fully offline.
.PARAMETER ZipOut
Instead of sftp, write the changed files plus sync.json into this zip, to
upload on the webapp page (the fallback when SSH is not available).
.PARAMETER ManifestFile
Use a saved manifest.json instead of fetching it.
.PARAMETER Force
Upload even when a script fails to parse or a package has no WIM header.
The webapp still refuses to activate such a batch.
.PARAMETER MaxWaitMinutes
How long to wait for the server to finish hashing its BPRT.
.PARAMETER UseSystemProxy
Reach the webapp through the system proxy (default: direct, as for any
10.x address).
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1 -DryRun
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1 -ZipOut C:\Temp\bprt.zip
#>
[CmdletBinding()]
param(
[string]$Source = 'C:\ProgramData\GEAerospace\MediaCenter\Cache\Deploy\Applications\BPRT',
[string]$WebUrl = 'http://10.134.48.170:9009',
[string]$SshHost = '10.134.48.170',
[string]$SshUser = 'pxe',
[switch]$DryRun,
[string]$ZipOut,
[string]$ManifestFile,
[switch]$Force,
[int]$MaxWaitMinutes = 30,
[switch]$UseSystemProxy
)
$ErrorActionPreference = 'Stop'
# The cache folder has also been seen under MediaCreator rather than
# MediaCenter; try that one when the default is missing.
$AltSource = 'C:\ProgramData\GEAerospace\MediaCreator\Cache\Deploy\Applications\BPRT'
# Files never sent: Windows litter. Names with [ or ] would be read as
# wildcards by sftp's put.
$SkipNames = @('Thumbs.db', 'desktop.ini', '.DS_Store')
$ScriptExts = @('.ps1', '.psm1', '.psd1')
$WimMagic = [byte[]](0x4D, 0x53, 0x57, 0x49, 0x4D, 0x00, 0x00, 0x00) # "MSWIM\0\0\0"
function Write-Step([string]$Text) { Write-Host ''; Write-Host "== $Text" -ForegroundColor Cyan }
function Format-Size([double]$Bytes) {
if ($Bytes -ge 1GB) { return ('{0:N2} GB' -f ($Bytes / 1GB)) }
if ($Bytes -ge 1MB) { return ('{0:N0} MB' -f ($Bytes / 1MB)) }
if ($Bytes -ge 1KB) { return ('{0:N0} KB' -f ($Bytes / 1KB)) }
return ('{0} B' -f $Bytes)
}
function Get-Prefix([string]$Name) {
# Tenant_Environment_Purpose, the part the enroll scripts match on.
return (($Name -split '_')[0..2] -join '_')
}
function Get-PpkgVersion([string]$Name) {
if ($Name -match '_(v\d+\.\d+)\.ppkg$') { return $Matches[1] }
return '?'
}
function Find-WebException($Err) {
$ex = $Err.Exception
while ($ex -and -not ($ex -is [System.Net.WebException])) { $ex = $ex.InnerException }
return $ex
}
function Get-Manifest {
param([string]$Url)
$deadline = (Get-Date).AddMinutes($MaxWaitMinutes)
while ($true) {
$req = [System.Net.HttpWebRequest]::Create($Url)
$req.Timeout = 60000
$req.ReadWriteTimeout = 60000
if (-not $UseSystemProxy) { $req.Proxy = $null }
$resp = $null
try {
$resp = $req.GetResponse()
} catch {
$wex = Find-WebException $_
if ($wex -and $wex.Response) { $resp = $wex.Response }
else { throw "Cannot reach $Url : $($_.Exception.Message)" }
}
$status = [int]$resp.StatusCode
$retry = 30
$ra = $resp.Headers['Retry-After']
if ($ra -and ($ra -as [int])) { $retry = [int]$ra }
try {
$reader = New-Object System.IO.StreamReader($resp.GetResponseStream(), [System.Text.Encoding]::UTF8)
$body = $reader.ReadToEnd()
$reader.Close()
} finally {
$resp.Close()
}
if ($status -eq 200) { return ($body | ConvertFrom-Json) }
if ($status -eq 503) {
$msg = 'The server is busy.'
try {
$j = $body | ConvertFrom-Json
if ($j.message) { $msg = $j.message }
if ($null -ne $j.percent) { $msg = "$msg ($($j.percent)%)" }
} catch { }
if ((Get-Date) -gt $deadline) {
throw "Gave up after $MaxWaitMinutes minutes: $msg Run this again later."
}
Write-Host (" {0} Asking again in {1} s." -f $msg, $retry)
Start-Sleep -Seconds $retry
continue
}
throw "HTTP $status from $Url : $body"
}
}
function ConvertTo-SftpPath([string]$Path) {
# sftp batch arguments: forward slashes (the Windows client takes them),
# in double quotes so spaces survive ("M365 Office x86.zip").
return '"' + ($Path -replace '\\', '/') + '"'
}
function Write-Utf8NoBom([string]$Path, [string]$Text) {
[System.IO.File]::WriteAllText($Path, $Text, (New-Object System.Text.UTF8Encoding($false)))
}
try {
# --- source folder ----------------------------------------------------------
if (-not (Test-Path -LiteralPath $Source -PathType Container)) {
if (-not $PSBoundParameters.ContainsKey('Source') -and (Test-Path -LiteralPath $AltSource -PathType Container)) {
$Source = $AltSource
} else {
throw "Source folder not found: $Source (use -Source)"
}
}
$Source = (Resolve-Path -LiteralPath $Source).ProviderPath.TrimEnd('\', '/')
$WebUrl = $WebUrl.TrimEnd('/')
Write-Host "Source: $Source"
Write-Host "Server: $WebUrl (sftp $SshUser@$SshHost)"
# --- server manifest --------------------------------------------------------
Write-Step 'Reading the server manifest'
$manifest = $null
if ($ManifestFile) {
$manifest = Get-Content -LiteralPath $ManifestFile -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Host " from $ManifestFile"
} else {
try {
$manifest = Get-Manifest -Url "$WebUrl/updates/bprt/manifest.json"
} catch {
if (-not $DryRun) { throw }
Write-Warning "$($_.Exception.Message)"
Write-Warning 'Dry run without a manifest: every file counts as new.'
}
}
$server = @{} # vendor path -> manifest row (PowerShell hashtables ignore case, like Windows)
$neverSend = @{} # site files on the server, never uploaded
$incoming = '/srv/samba/winpeapps/_shared/Applications/BPRT-incoming'
$finishUrl = "$WebUrl/updates/bprt/"
if ($manifest) {
foreach ($f in @($manifest.files)) { if ($f) { $server[$f.path] = $f } }
foreach ($n in @($manifest.overlay)) { if ($n) { $neverSend[$n] = $true } }
if ($manifest.incoming) { $incoming = $manifest.incoming }
if ($manifest.finish_url) { $finishUrl = $manifest.finish_url }
Write-Host (" {0} vendor files on the server, {1}, manifest of {2}" -f $server.Count, (Format-Size $manifest.bytes), $manifest.generated)
}
# --- local files ------------------------------------------------------------
Write-Step 'Hashing the local folder'
$all = @(Get-ChildItem -LiteralPath $Source -Recurse -File -Force | Sort-Object FullName)
$local = New-Object System.Collections.Generic.List[object]
$skipped = New-Object System.Collections.Generic.List[string]
$badNames = New-Object System.Collections.Generic.List[string]
$totalBytes = [double]0
foreach ($fi in $all) {
$rel = $fi.FullName.Substring($Source.Length).TrimStart('\', '/') -replace '\\', '/'
if ($SkipNames -contains $fi.Name) { $skipped.Add($rel); continue }
if ($neverSend.ContainsKey($rel)) { $skipped.Add("$rel (site file on the server)"); continue }
if ($rel -match '[\[\]]') { $badNames.Add($rel) }
$local.Add([pscustomobject]@{ Path = $rel; Full = $fi.FullName; Size = [int64]$fi.Length; Sha = '' })
$totalBytes += $fi.Length
}
if ($local.Count -eq 0) { throw "No files in $Source" }
$done = [double]0
$sw = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($f in $local) {
$pct = 0
if ($totalBytes -gt 0) { $pct = [int](100 * $done / $totalBytes) }
Write-Progress -Activity 'Hashing BPRT' -Status ("{0} ({1})" -f $f.Path, (Format-Size $f.Size)) -PercentComplete $pct
$f.Sha = (Get-FileHash -LiteralPath $f.Full -Algorithm SHA256).Hash.ToUpperInvariant()
$done += $f.Size
}
Write-Progress -Activity 'Hashing BPRT' -Completed
Write-Host (" {0} files, {1}, hashed in {2:N0} s" -f $local.Count, (Format-Size $totalBytes), $sw.Elapsed.TotalSeconds)
foreach ($s in $skipped) { Write-Host " skipped $s" }
# --- sanity checks ----------------------------------------------------------
Write-Step 'Checking scripts and packages'
$problems = New-Object System.Collections.Generic.List[object]
foreach ($f in $local) {
$ext = [System.IO.Path]::GetExtension($f.Path).ToLowerInvariant()
if ($ScriptExts -contains $ext) {
$tokens = $null
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($f.Full, [ref]$tokens, [ref]$errs)
if ($errs -and $errs.Count -gt 0) {
$first = $errs[0]
$problems.Add([ordered]@{ path = $f.Path; error = ("line {0}: {1} ({2} error(s))" -f $first.Extent.StartLineNumber, $first.Message, $errs.Count) })
}
} elseif ($ext -eq '.ppkg') {
$head = New-Object byte[] 8
$fs = [System.IO.File]::OpenRead($f.Full)
try { $n = $fs.Read($head, 0, 8) } finally { $fs.Close() }
$ok = ($n -eq 8)
for ($i = 0; $ok -and $i -lt 8; $i++) { if ($head[$i] -ne $WimMagic[$i]) { $ok = $false } }
if (-not $ok) { $problems.Add([ordered]@{ path = $f.Path; error = 'no WIM header: not a provisioning package' }) }
}
}
foreach ($p in $problems) { Write-Host (" PROBLEM {0}: {1}" -f $p.path, $p.error) -ForegroundColor Red }
foreach ($b in $badNames) { Write-Host " PROBLEM $b : sftp would read [ ] in the name as a wildcard" -ForegroundColor Red }
if ($problems.Count -eq 0 -and $badNames.Count -eq 0) { Write-Host ' all scripts parse, all packages have a WIM header' }
if ($badNames.Count -gt 0) { throw 'Rename the files listed above, or upload a zip on the webapp page.' }
if ($problems.Count -gt 0 -and -not $Force) {
throw ('{0} file(s) look corrupt. If the MediaCreator cache is damaged, delete it and let MediaCreator Lite download again. -Force uploads anyway, but the server refuses such a batch; it is only for looking at the files there.' -f $problems.Count)
}
# --- compare ----------------------------------------------------------------
$changed = New-Object System.Collections.Generic.List[object]
$localPaths = @{}
foreach ($f in $local) {
$localPaths[$f.Path] = $true
$s = $server[$f.Path]
$same = $false
if ($s) {
$same = ($f.Sha -eq ([string]$s.sha256).ToUpperInvariant())
if (-not $same -and $s.sha256_nobom) { $same = ($f.Sha -eq ([string]$s.sha256_nobom).ToUpperInvariant()) }
}
if (-not $same) { $changed.Add($f) }
}
$removed = @($server.Keys | Where-Object { -not $localPaths.ContainsKey($_) } | Sort-Object)
$uploadBytes = [double]0
foreach ($f in $changed) { $uploadBytes += $f.Size }
Write-Step 'Differences'
foreach ($f in $changed) {
$tag = 'changed'
if (-not $server.ContainsKey($f.Path)) { $tag = 'new' }
Write-Host (" {0,-8} {1} ({2})" -f $tag, $f.Path, (Format-Size $f.Size))
}
foreach ($r in $removed) { Write-Host (" {0,-8} {1} (not in the local folder; activation removes it from BPRT)" -f 'removed', $r) -ForegroundColor Yellow }
# Package versions per tenant/environment/purpose.
$oldPk = @{}
$newPk = @{}
foreach ($k in $server.Keys) { if ($k -like '*.ppkg') { $oldPk[(Get-Prefix $k)] = @($oldPk[(Get-Prefix $k)]) + $k } }
foreach ($f in $local) { if ($f.Path -like '*.ppkg') { $newPk[(Get-Prefix $f.Path)] = @($newPk[(Get-Prefix $f.Path)]) + $f.Path } }
$prefixes = @(@($oldPk.Keys) + @($newPk.Keys) | Sort-Object -Unique)
foreach ($p in $prefixes) {
$o = (@($oldPk[$p] | Where-Object { $_ }) | ForEach-Object { Get-PpkgVersion $_ }) -join ', '
$n = (@($newPk[$p] | Where-Object { $_ }) | ForEach-Object { Get-PpkgVersion $_ }) -join ', '
if ($o -ne $n) {
if (-not $o) { $o = 'none' }
if (-not $n) { $n = 'none' }
Write-Host (" package {0}: {1} to {2}" -f $p, $o, $n) -ForegroundColor Green
}
}
Write-Host (" {0} new or changed ({1}), {2} removed, {3} unchanged" -f $changed.Count, (Format-Size $uploadBytes), $removed.Count, ($local.Count - $changed.Count))
if ($manifest -and $changed.Count -eq 0 -and $removed.Count -eq 0) {
Write-Host ''
Write-Host 'The server already has exactly this BPRT. Nothing to upload.' -ForegroundColor Green
exit 0
}
# --- sync.json and the sftp batch ------------------------------------------
$computer = ($env:COMPUTERNAME -replace '[^A-Za-z0-9-]', '')
if (-not $computer) { $computer = 'pc' }
$batch = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmss'), $computer
$tempRoot = $env:TEMP
if (-not $tempRoot) { $tempRoot = [System.IO.Path]::GetTempPath() }
$work = Join-Path (Join-Path $tempRoot 'Sync-BPRT') $batch
New-Item -ItemType Directory -Path $work -Force | Out-Null
$fileRows = @($local | ForEach-Object { [ordered]@{ path = $_.Path; size = $_.Size; sha256 = $_.Sha } })
$sync = [ordered]@{
format = 1
tool = 'Sync-BPRT.ps1'
source = $Source
computer = $env:COMPUTERNAME
user = $env:USERNAME
created = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
manifest_generated = $(if ($manifest) { $manifest.generated } else { '' })
files = $fileRows
uploaded = @($changed | ForEach-Object { $_.Path })
removed = $removed
parse_errors = $problems.ToArray() # @() of an empty List fails: "Argument types do not match"
}
$syncPath = Join-Path $work 'sync.json'
Write-Utf8NoBom $syncPath ($sync | ConvertTo-Json -Depth 6)
if ($ZipOut) {
Write-Step "Writing $ZipOut"
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zipFull = [System.IO.Path]::GetFullPath($ZipOut)
if (Test-Path -LiteralPath $zipFull) { Remove-Item -LiteralPath $zipFull -Force }
$zip = [System.IO.Compression.ZipFile]::Open($zipFull, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($f in $changed) {
Write-Host " + $($f.Path)"
# Packages and zips do not compress; storing them saves minutes.
$level = [System.IO.Compression.CompressionLevel]::Optimal
if ($f.Path -match '\.(ppkg|zip|exe|png)$') { $level = [System.IO.Compression.CompressionLevel]::NoCompression }
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $f.Full, $f.Path, $level)
}
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $syncPath, 'sync.json', [System.IO.Compression.CompressionLevel]::Optimal)
} finally {
$zip.Dispose()
}
Write-Host ''
Write-Host ("Wrote {0} ({1})." -f $zipFull, (Format-Size (Get-Item -LiteralPath $zipFull).Length)) -ForegroundColor Green
Write-Host "Upload it under 'Upload a zip instead' on $finishUrl"
exit 0
}
$remote = "$($incoming.TrimEnd('/'))/$batch"
$part = "$remote.part"
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('progress') # batch mode starts with the meter off
$lines.Add('-mkdir ' + (ConvertTo-SftpPath $incoming)) # leading '-': an existing folder is fine
$lines.Add('mkdir ' + (ConvertTo-SftpPath $part))
$dirs = @($changed | ForEach-Object { $d = [System.IO.Path]::GetDirectoryName($_.Path); if ($d) { $d -replace '\\', '/' } } | Sort-Object -Unique)
$made = @{}
foreach ($d in $dirs) {
$acc = ''
foreach ($piece in ($d -split '/')) {
if ($acc) { $acc = "$acc/$piece" } else { $acc = $piece }
if (-not $made.ContainsKey($acc)) { $made[$acc] = $true; $lines.Add('-mkdir ' + (ConvertTo-SftpPath "$part/$acc")) }
}
}
foreach ($f in $changed) {
$lines.Add('put ' + (ConvertTo-SftpPath $f.Full) + ' ' + (ConvertTo-SftpPath "$part/$($f.Path)"))
}
# sync.json last, then the rename: the server only treats a folder without
# .part as a finished upload.
$lines.Add('put ' + (ConvertTo-SftpPath $syncPath) + ' ' + (ConvertTo-SftpPath "$part/sync.json"))
$lines.Add('rename ' + (ConvertTo-SftpPath $part) + ' ' + (ConvertTo-SftpPath $remote))
$batchFile = Join-Path $work 'sftp-batch.txt'
Write-Utf8NoBom $batchFile (($lines -join "`r`n") + "`r`n")
# BatchMode=no before -b: sftp -b turns BatchMode on (no password prompt),
# and the first value given wins.
$sftpArgs = @('-o', 'BatchMode=no', '-b', $batchFile, "$SshUser@$SshHost")
Write-Step 'Upload'
Write-Host " sync.json: $syncPath"
Write-Host " batch file: $batchFile"
Write-Host " command: sftp $($sftpArgs -join ' ')"
Write-Host (" uploads {0} file(s), {1}, into {2}" -f $changed.Count, (Format-Size $uploadBytes), $remote)
if ($DryRun) {
Write-Host ''
Write-Host 'Dry run: nothing uploaded. Run again without -DryRun to upload.' -ForegroundColor Yellow
exit 0
}
$sftp = Get-Command sftp.exe -ErrorAction SilentlyContinue
if (-not $sftp) { $sftp = Get-Command sftp -ErrorAction SilentlyContinue }
if (-not $sftp) {
throw 'sftp.exe not found (Settings > Apps > Optional features > OpenSSH Client). Or use -ZipOut and upload the zip on the webapp page.'
}
Write-Host ''
Write-Host "Enter the password for $SshUser@$SshHost when asked." -ForegroundColor Cyan
& $sftp.Path @sftpArgs
if ($LASTEXITCODE -ne 0) {
throw "sftp failed (exit $LASTEXITCODE). The partial upload is in $part on the server; delete it on the webapp page, then run this again."
}
Write-Host ''
Write-Host "Uploaded batch $batch." -ForegroundColor Green
Write-Host 'Validate and activate it here:'
Write-Host (" {0}batch/{1}" -f ($finishUrl.TrimEnd('/') + '/'), $batch)
exit 0
} catch {
Write-Host ''
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
exit 1
}

View File

@@ -10,6 +10,10 @@ This file is the route surface; most logic lives in ``services/``:
services.deploy - import_deploy + merge_tree + symlink dance services.deploy - import_deploy + merge_tree + symlink dance
services.unattend - parse + build + form-extract for unattend.xml services.unattend - parse + build + form-extract for unattend.xml
services.wim - boot.wim startnet.cmd extract/update via wimtools services.wim - boot.wim startnet.cmd extract/update via wimtools
services.image_pages - per-image page tabs, header + overview data
services.control_files - preview/backup/atomic write/undo of control files
The Updates pages are blueprints in ``views/`` (registered below).
""" """
import hashlib import hashlib
@@ -35,7 +39,6 @@ from flask import (
stream_with_context, stream_with_context,
url_for, url_for,
) )
from lxml import etree
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import config import config
@@ -43,17 +46,29 @@ from services import (
blancco_evidence, blancco_evidence,
hardware_coverage, hardware_coverage,
blancco_report, blancco_report,
dell_updates,
deploy, deploy,
driver_map,
fs, fs,
control_files,
image_pages,
image_registry, image_registry,
images, images,
imaging_status, imaging_status,
mcl_ppkg,
system, system,
unattend, unattend,
wim, wim,
) )
from services.audit import audit from services.audit import audit
from services.csrf import init_csrf from services.csrf import init_csrf
from views import blancco_update as blancco_update_views
from views import bprt_sync as bprt_sync_views
from views import dell_updates as dell_updates_views
from views import images as images_views
from views import images_drivers as images_drivers_views
from views import images_unattend as images_unattend_views
from views import ipxe as ipxe_views
app = Flask(__name__) app = Flask(__name__)
app.secret_key = config.FLASK_SECRET_KEY app.secret_key = config.FLASK_SECRET_KEY
@@ -65,6 +80,21 @@ init_csrf(app)
# baked-in config.py defaults on first run). Mutates config.* in place. # baked-in config.py defaults on first run). Mutates config.* in place.
image_registry.reload() image_registry.reload()
# Updates pages (Dell drivers/BIOS, iPXE, Blancco, BPRT sync): one blueprint
# each in views/, logic in services/; all routes live under /updates/.
app.register_blueprint(dell_updates_views.bp)
app.register_blueprint(ipxe_views.bp)
app.register_blueprint(blancco_update_views.bp)
app.register_blueprint(bprt_sync_views.bp)
# GE Aerospace image pages: one page per image with tabs (Overview, Drivers,
# Unattend, OS and Packages) under templates/_image_header.html. The tab ->
# endpoint table is services/image_pages.IMAGE_TABS; unattend_editor below
# is the Unattend tab, the others are blueprints.
app.register_blueprint(images_views.bp)
app.register_blueprint(images_drivers_views.bp)
app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes - pages # Routes - pages
@@ -186,38 +216,15 @@ def unattend_editor(image_type):
xml_file = fs.unattend_path(image_type) xml_file = fs.unattend_path(image_type)
if request.method == "POST": if request.method == "POST":
# Plain form post (the page itself saves through Review changes,
# views/images_unattend.py). Same path: the form is applied to the
# current text, backup, undo point, lint gate.
save_mode = request.form.get("save_mode", "form") save_mode = request.form.get("save_mode", "form")
if save_mode == "raw": if save_mode == "raw":
raw_xml = request.form.get("raw_xml", "") body = {"mode": "raw", "raw_xml": request.form.get("raw_xml", "")}
try:
etree.fromstring(raw_xml.encode("utf-8"))
except etree.XMLSyntaxError as exc:
flash(f"Invalid XML: {exc}", "danger")
data = unattend.parse_unattend(xml_file)
data["raw_xml"] = raw_xml
return render_template(
"unattend_editor.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
data=data,
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
)
xml_content = raw_xml
else: else:
form_data = unattend.extract_form_data(request.form) body = {"mode": "form", "form": unattend.extract_form_data(request.form)}
xml_content = unattend.build_unattend_xml(form_data) images_unattend_views.save_and_flash(image_type, body, f"Unattend save ({save_mode}, form post)")
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)
audit("UNATTEND_SAVE", f"{image_type} ({save_mode})")
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)) return redirect(url_for("unattend_editor", image_type=image_type))
data = unattend.parse_unattend(xml_file) data = unattend.parse_unattend(xml_file)
@@ -225,77 +232,72 @@ def unattend_editor(image_type):
"unattend_editor.html", "unattend_editor.html",
image_type=image_type, image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type), friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="unattend",
data=data, data=data,
image_types=config.IMAGE_TYPES, image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES, friendly_names=config.FRIENDLY_NAMES,
**images_unattend_views.page_context(image_type),
) )
@app.route("/images/<image_type>/config") @app.route("/images/<image_type>/config")
def image_config(image_type): def image_config(image_type):
"""Old single Configuration page, now split into tabs. Bookmarks land on
the Overview, or on one tab with ?tab=drivers|os|unattend."""
if image_type not in config.IMAGE_TYPES: if image_type not in config.IMAGE_TYPES:
flash("Unknown image type.", "danger") flash("Unknown image type.", "danger")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
tab = request.args.get("tab", "overview")
if tab not in image_pages.TAB_IDS:
tab = "overview"
return redirect(image_pages.tab_url(image_type, tab))
cfg = images.load_image_config(image_type)
return render_template( # Legacy /config/save sections and the tab each one now lives on.
"image_config.html", _CONFIG_SECTIONS = {
image_type=image_type, "hardware_models": "os",
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type), "drivers": "drivers",
config=cfg, "operating_systems": "os",
) "packages": "os",
}
@app.route("/images/<image_type>/config/save", methods=["POST"]) @app.route("/images/<image_type>/config/save", methods=["POST"])
def image_config_save(image_type): def image_config_save(image_type):
"""Old one-section form save. No page posts here any more, and it must
not write: it would skip the diff preview, the stale-file check and the
Drivers tab's lint gate. It sends the user to the tab that saves now."""
if image_type not in config.IMAGE_TYPES: if image_type not in config.IMAGE_TYPES:
flash("Unknown image type.", "danger") flash("Unknown image type.", "danger")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
tab = _CONFIG_SECTIONS.get(request.form.get("section", ""), "overview")
flash("This form no longer saves. Make the change on this tab and use Review and save; "
"nothing was written.", "warning")
return redirect(image_pages.tab_url(image_type, tab))
section = request.form.get("section", "")
payload = request.form.get("payload", "[]")
try:
data = json.loads(payload)
except json.JSONDecodeError:
flash("Invalid JSON payload.", "danger")
return redirect(url_for("image_config", image_type=image_type))
ctrl = fs.control_path(image_type) def _register_package(image_type, fname, dest):
tools = fs.tools_path(image_type) """Append {fileName, destinationDir} to packages.json through the same
path as the OS and Packages tab (format kept, .bak-<ts>-webui backup,
try: atomic, the tab's undo point). False when it is already listed."""
if section == "hardware_models": path = image_pages.control_file(image_type, "packages.json")
us_file = os.path.join(tools, "user_selections.json") text = control_files.read_text(path)
us_raw = fs.load_json(us_file) entries = []
us_data = us_raw[0] if us_raw and isinstance(us_raw, list) else {} if text.strip():
us_data["HardwareModelSelection"] = data try:
fs.save_json(us_file, [us_data]) entries = json.loads(text.lstrip("\ufeff"))
audit("CONFIG_SAVE", f"{image_type}/hardware_models") except ValueError:
entries = None
elif section == "drivers": if not isinstance(entries, list):
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data] raise ValueError("packages.json is not a JSON list, so the file was not registered")
fs.save_json(os.path.join(ctrl, "HardwareDriver.json"), clean) if any(str(e.get("fileName") or e.get("FileName") or "").lower() == fname.lower()
audit("CONFIG_SAVE", f"{image_type}/drivers") for e in entries if isinstance(e, dict)):
return False
elif section == "operating_systems": entries.append({"fileName": fname, "destinationDir": dest})
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data] plan = control_files.plan_json(path, entries, "packages.json")
fs.save_json(os.path.join(ctrl, "OperatingSystem.json"), clean) control_files.commit(image_type, images_views.UNDO_SCOPE, [plan],
audit("CONFIG_SAVE", f"{image_type}/operating_systems") action=f"Package upload: {fname} registered")
return True
elif section == "packages":
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
fs.save_json(os.path.join(ctrl, "packages.json"), clean)
audit("CONFIG_SAVE", f"{image_type}/packages")
else:
flash(f"Unknown section: {section}", "danger")
return redirect(url_for("image_config", image_type=image_type))
flash(f"Saved {section.replace('_', ' ')} successfully.", "success")
except Exception as exc:
flash(f"Failed to save {section}: {exc}", "danger")
return redirect(url_for("image_config", image_type=image_type))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -304,71 +306,65 @@ def image_config_save(image_type):
@app.route("/images/<image_type>/drivers/upload", methods=["POST"]) @app.route("/images/<image_type>/drivers/upload", methods=["POST"])
def images_drivers_upload(image_type): def images_drivers_upload(image_type):
"""Store a driver .zip under Out-of-box Drivers (services/driver_map.
save_upload: temp file + rename, in-use zips never overwritten). It is
NOT added to HardwareDriver.json here: the Drivers tab's Add to map does
that through the review step, with a backup and Undo. family and
destination_dir from old forms are ignored for that reason."""
if image_type not in config.IMAGE_TYPES: if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger") flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
back = redirect(image_pages.tab_url(image_type, "drivers"))
f = request.files.get("driver_file") f = request.files.get("driver_file")
if not f or not f.filename: if not f or not f.filename:
flash("No file selected.", "danger") flash("No file selected.", "danger")
return redirect(url_for("image_config", image_type=image_type)) return back
family = (request.form.get("family") or "").strip()
dest = (request.form.get("destination_dir") or "").strip()
overwrite = request.form.get("overwrite") == "1" overwrite = request.form.get("overwrite") == "1"
try: try:
rec = images.upload_driver(image_type, f, family=family, rec = driver_map.save_upload(image_type, f, overwrite=overwrite)
destination_dir=dest, overwrite=overwrite) audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} replaced={rec['replaced']}")
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}") flash(f"Uploaded {rec['filename']}. It is listed under Unregistered zips; "
msg = f"Uploaded {rec['filename']}." "use Add to map there to give it an entry.", "success")
msg += " Registered to family in HardwareDriver.json." if rec["registered"] else " Now in orphans list until adopted." except (ValueError, FileExistsError) as ex:
flash(msg, "success")
except (ValueError, FileExistsError, FileNotFoundError) as ex:
flash(str(ex), "danger") flash(str(ex), "danger")
except Exception as ex: except Exception as ex:
flash(f"Upload failed: {ex}", "danger") flash(f"Upload failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type)) return back
@app.route("/images/<image_type>/drivers/adopt", methods=["POST"]) @app.route("/images/<image_type>/drivers/adopt", methods=["POST"])
def images_drivers_adopt(image_type): def images_drivers_adopt(image_type):
"""Old one-click adopt. It rewrote HardwareDriver.json with indent 2, no
backup and an entry with no modelswminame (PESetup throws on that), so
it now writes nothing and points at Add to map on the Drivers tab."""
if image_type not in config.IMAGE_TYPES: if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger") flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
filename = (request.form.get("filename") or "").strip() flash("Use Add to map under Unregistered zips: the new entry needs model tokens and is "
family = (request.form.get("family") or "").strip() "saved through Review and save. Nothing was written.", "info")
dest = (request.form.get("destination_dir") or "").strip() return redirect(image_pages.tab_url(image_type, "drivers"))
try:
rec = images.adopt_orphan(image_type, filename, family, dest)
audit("DRIVER_ADOPT", f"{image_type}/{rec['filename']} family={family}")
if rec.get("already_registered"):
flash(f"{rec['filename']} was already registered.", "info")
else:
flash(f"Adopted {rec['filename']} into HardwareDriver.json.", "success")
except (ValueError, FileNotFoundError) as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Adopt failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type))
@app.route("/images/<image_type>/drivers/orphans/delete", methods=["POST"]) @app.route("/images/<image_type>/drivers/orphans/delete", methods=["POST"])
def images_drivers_orphans_delete(image_type): def images_drivers_orphans_delete(image_type):
"""Old orphan delete (form field "filename"), now the same safe delete
as the Drivers tab: zips any image, undo or rollback uses are kept."""
if image_type not in config.IMAGE_TYPES: if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger") flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
# Filenames come as repeated form fields (one per checkbox). back = redirect(image_pages.tab_url(image_type, "drivers"))
filenames = request.form.getlist("filename") filenames = request.form.getlist("filename")
if not filenames: if not filenames:
flash("No files selected for removal.", "warning") flash("No files selected for removal.", "warning")
return redirect(url_for("image_config", image_type=image_type)) return back
rec = images.remove_orphans(image_type, filenames) rec = driver_map.delete_orphans(image_type, filenames)
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} missing={len(rec['missing'])}") audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} "
parts = [] f"refused={len(rec['refused'])} {', '.join(rec['removed'])}")
if rec["removed"]: if rec["removed"]:
parts.append(f"Removed {len(rec['removed'])} orphan(s).") flash(f"Deleted {len(rec['removed'])} unregistered zip(s).", "success")
if rec["missing"]: for rel, why in rec["refused"]:
parts.append(f"{len(rec['missing'])} not found / errored.") flash(f"Not deleted: {rel} ({why}).", "warning")
flash(" ".join(parts) or "No-op.", "success" if rec["removed"] else "warning") return back
return redirect(url_for("image_config", image_type=image_type))
@app.route("/images/<image_type>/packages/upload", methods=["POST"]) @app.route("/images/<image_type>/packages/upload", methods=["POST"])
@@ -379,20 +375,31 @@ def images_packages_upload(image_type):
f = request.files.get("package_file") f = request.files.get("package_file")
if not f or not f.filename: if not f or not f.filename:
flash("No file selected.", "danger") flash("No file selected.", "danger")
return redirect(url_for("image_config", image_type=image_type)) return redirect(image_pages.tab_url(image_type, "os"))
dest = (request.form.get("destination_dir") or "").strip() dest = (request.form.get("destination_dir") or "").strip()
overwrite = request.form.get("overwrite") == "1" overwrite = request.form.get("overwrite") == "1"
try: try:
rec = images.upload_package(image_type, f, destination_dir=dest, overwrite=overwrite) # The file only; packages.json is edited here through control_files
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}") # (services.images.upload_package would rewrite it without a backup).
rec = images.upload_package(image_type, f, destination_dir="", overwrite=overwrite)
msg = f"Uploaded {rec['filename']}." msg = f"Uploaded {rec['filename']}."
msg += " Registered in packages.json." if rec["registered"] else " Not registered (no destination_dir provided)." registered = False
if dest:
try:
registered = _register_package(image_type, rec["filename"], dest)
msg += (" Registered in packages.json (backup made; Undo last save puts it back)."
if registered else " Already listed in packages.json.")
except (ValueError, OSError) as ex:
flash(f"Not registered: {ex}", "warning")
else:
msg += " Not registered (no destination_dir provided)."
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={registered}")
flash(msg, "success") flash(msg, "success")
except (ValueError, FileExistsError) as ex: except (ValueError, FileExistsError) as ex:
flash(str(ex), "danger") flash(str(ex), "danger")
except Exception as ex: except Exception as ex:
flash(f"Upload failed: {ex}", "danger") flash(f"Upload failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type)) return redirect(image_pages.tab_url(image_type, "os"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -761,8 +768,13 @@ def hardware_seen():
@app.route("/hardware") @app.route("/hardware")
def hardware_page(): def hardware_page():
"""Every model WinPE has seen, with driver-pack and BIOS coverage.""" """Every model WinPE has seen, with driver-pack and BIOS coverage."""
try:
# Files with a newer Dell pack/BIOS in the last Updates > Dell scan.
hints = dell_updates.update_hints()
except Exception:
hints = {"drivers": {}, "bios": {}}
return render_template("hardware.html", rows=hardware_coverage.coverage(), return render_template("hardware.html", rows=hardware_coverage.coverage(),
images=hardware_coverage.IMAGES) images=hardware_coverage.IMAGES, hints=hints)
@app.route("/hardware/dismiss", methods=["POST"]) @app.route("/hardware/dismiss", methods=["POST"])
@@ -1048,6 +1060,9 @@ def enrollment():
return render_template( return render_template(
"enrollment.html", "enrollment.html",
packages=packages, packages=packages,
mcl_packages=mcl_ppkg.packages(),
mcl_archive=mcl_ppkg.archived(),
mcl_links=mcl_ppkg.image_links(),
active_ppkg=read_ppkg_conf(), active_ppkg=read_ppkg_conf(),
image_types=config.IMAGE_TYPES, image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES, friendly_names=config.FRIENDLY_NAMES,
@@ -1069,6 +1084,13 @@ def enrollment_upload():
if not filename.lower().endswith(".ppkg"): if not filename.lower().endswith(".ppkg"):
flash("Only .ppkg files are accepted.", "danger") flash("Only .ppkg files are accepted.", "danger")
return redirect(url_for("enrollment")) return redirect(url_for("enrollment"))
# An MCL name would also match the ppkg.conf regex below and become the
# shopfloor package; those go to BPRT through the MCL section instead.
# Check the sanitised name too: secure_filename turns spaces into
# underscores, which can make an MCL name out of one that was not.
if mcl_ppkg.is_mcl_name(f.filename) or mcl_ppkg.is_mcl_name(filename):
flash(f"{filename} is a standard/engineer MCL package; upload it in the MCL section.", "danger")
return redirect(url_for("enrollment") + "#mcl")
os.makedirs(config.ENROLLMENT_PPKG_DIR, exist_ok=True) os.makedirs(config.ENROLLMENT_PPKG_DIR, exist_ok=True)
dest = os.path.join(config.ENROLLMENT_PPKG_DIR, filename) dest = os.path.join(config.ENROLLMENT_PPKG_DIR, filename)
@@ -1093,6 +1115,56 @@ def enrollment_upload():
return redirect(url_for("enrollment")) return redirect(url_for("enrollment"))
@app.route("/enrollment/mcl/upload", methods=["POST"])
def mcl_ppkg_upload():
"""Standard/engineer MCL package: replaces the one with the same
tenant/environment/purpose, archiving the old (services.mcl_ppkg)."""
f = request.files.get("ppkg_file")
if not f or not f.filename:
flash("No file selected.", "danger")
return redirect(url_for("enrollment") + "#mcl")
try:
info, moved = mcl_ppkg.install_upload(f, f.filename)
except (ValueError, OSError) as exc:
flash(f"Upload rejected: {exc}", "danger")
return redirect(url_for("enrollment") + "#mcl")
name = info["filename"]
audit("MCL_PPKG_UPLOAD", f"{name} replaces {', '.join(moved) or 'nothing'}")
flash(f"Installed {name} for {info['prefix']}"
+ (f"; archived {', '.join(moved)}." if moved else "."), "success")
for warning in info["warnings"]:
flash(warning + ".", "warning")
return redirect(url_for("enrollment") + "#mcl")
@app.route("/enrollment/mcl/archive", methods=["POST"])
def mcl_ppkg_archive():
"""Move one package out of BPRT (clears a duplicate made by hand)."""
try:
name = mcl_ppkg.archive(request.form.get("filename", ""))
except (ValueError, OSError) as exc:
flash(f"Archive failed: {exc}", "danger")
return redirect(url_for("enrollment") + "#mcl")
audit("MCL_PPKG_ARCHIVE", name)
flash(f"Archived {name}.", "success")
return redirect(url_for("enrollment") + "#mcl")
@app.route("/enrollment/mcl/restore", methods=["POST"])
def mcl_ppkg_restore():
batch = request.form.get("batch", "")
name = request.form.get("filename", "")
try:
info, moved = mcl_ppkg.restore(batch, name)
except (ValueError, OSError) as exc:
flash(f"Restore failed: {exc}", "danger")
return redirect(url_for("enrollment") + "#mcl")
name = info["filename"]
audit("MCL_PPKG_RESTORE", f"{batch}/{name} replaces {', '.join(moved) or 'nothing'}")
flash(f"Restored {name}" + (f"; archived {', '.join(moved)}." if moved else "."), "success")
return redirect(url_for("enrollment") + "#mcl")
@app.route("/enrollment/download/<filename>") @app.route("/enrollment/download/<filename>")
def enrollment_download(filename): def enrollment_download(filename):
filename = secure_filename(filename) filename = secure_filename(filename)
@@ -1492,31 +1564,24 @@ def api_save_unattend(image_type):
xml_file = fs.unattend_path(image_type) xml_file = fs.unattend_path(image_type)
payload = request.get_json(silent=True) payload = request.get_json(silent=True)
if not payload: if not payload or not isinstance(payload, dict):
return jsonify({"error": "No JSON body provided"}), 400 return jsonify({"error": "No JSON body provided"}), 400
# {"raw_xml": ...} writes the text as is; any other body is form data
# (keys left out stay as they are in the file). Backup, undo point and
# lint gate as on the Unattend tab.
if "raw_xml" in payload: if "raw_xml" in payload:
raw_xml = payload["raw_xml"] body = {"mode": "raw", "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: else:
try: body = {"mode": "form", "form": payload}
xml_content = unattend.build_unattend_xml(payload)
except Exception as exc:
return jsonify({"error": f"Failed to build XML: {exc}"}), 400
try: try:
os.makedirs(os.path.dirname(xml_file), exist_ok=True) plan = images_unattend_views.save(image_type, body, action="Unattend save (API)")
with open(xml_file, "w", encoding="utf-8") as fh: except images_unattend_views.Refused as exc:
fh.write(xml_content) return jsonify({"error": str(exc)}), exc.status
except Exception as exc:
return jsonify({"error": f"Failed to write file: {exc}"}), 500
audit("UNATTEND_SAVE_API", image_type) if plan["changed"]:
return jsonify({"status": "ok", "path": xml_file}) audit("UNATTEND_SAVE_API", image_type)
return jsonify({"status": "ok", "path": xml_file, "changed": plan["changed"]})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1559,11 +1624,24 @@ def timestamp_fmt(ts):
return local_dt(ts) return local_dt(ts)
@app.template_filter("tojson_ordered")
def tojson_ordered(value):
"""JSON in the data's own key order (Flask's tojson sorts keys, which
turns a save of an unchanged row into a reordered-keys diff). Output is
a plain string, so it is HTML-escaped like any other value."""
return json.dumps(value, ensure_ascii=False)
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
return { return {
"all_image_types": config.IMAGE_TYPES, "all_image_types": config.IMAGE_TYPES,
"all_friendly_names": config.FRIENDLY_NAMES, "all_friendly_names": config.FRIENDLY_NAMES,
# image pages: tab table, header data, tab URLs (see _image_header.html)
"image_tabs": image_pages.IMAGE_TABS,
"image_header_info": image_pages.header_info,
"image_tab_url": image_pages.tab_url,
"image_tab_for_endpoint": image_pages.tab_for_endpoint,
"blancco_todo": dict(blancco_evidence.todo_counts(), "blancco_todo": dict(blancco_evidence.todo_counts(),
hardware=hardware_coverage.gap_count(), hardware=hardware_coverage.gap_count(),
total=blancco_evidence.todo_counts()["total"] + hardware_coverage.gap_count()), total=blancco_evidence.todo_counts()["total"] + hardware_coverage.gap_count()),

View File

@@ -32,6 +32,13 @@ DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
HARDWARE_STATE = os.environ.get("HARDWARE_STATE", "/var/lib/pxe-webapp/hardware-seen.json") HARDWARE_STATE = os.environ.get("HARDWARE_STATE", "/var/lib/pxe-webapp/hardware-seen.json")
BIOS_DIR = os.environ.get("BIOS_DIR", "/srv/samba/winpeapps/_shared/BIOS") BIOS_DIR = os.environ.get("BIOS_DIR", "/srv/samba/winpeapps/_shared/BIOS")
BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json") BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json")
# PXE server address the Blancco preferences must point the report share at
# (blancco-init.sh greps for it too); checked when a release is staged.
BLANCCO_SERVER = os.environ.get("BLANCCO_SERVER", "172.16.9.1")
# Updates pages (Dell drivers/BIOS, iPXE): settings, staged downloads, backups.
UPDATES_DIR = os.environ.get("UPDATES_DIR", "/var/lib/pxe-webapp/updates")
TFTP_ROOT = os.environ.get("TFTP_ROOT", "/srv/tftp")
DNSMASQ_CONF = os.environ.get("DNSMASQ_CONF", "/etc/dnsmasq.conf")
# Log sources used by services/imaging_log_tail.py to infer client progress # Log sources used by services/imaging_log_tail.py to infer client progress
# when no /imaging/status push has arrived yet. Override per-host if any of # when no /imaging/status push has arrived yet. Override per-host if any of

File diff suppressed because it is too large Load Diff

1467
webapp/services/bprt_sync.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,478 @@
"""Safe edits of an image's control files: preview, backup, atomic write, undo.
Every save on the image pages (Drivers, Unattend, OS and Packages) follows
the same three steps, and this module is the shared implementation:
1. plan_json(path, data) / plan_text(path, text) render what WOULD be
written, in the file's own on-disk format, and diff it against the
current file. Nothing is written. The page shows plan["diff"].
2. commit(image_type, scope, plans, action) backs every changed file up
next to itself as <name>.bak-<YYYYmmdd-HHMMSS>-webui, writes the new
text atomically (temp file in the same dir + os.replace, original
owner and mode kept) and remembers the save as the undo point for
(image_type, scope). Pass expect={path: sha256} (from the preview) to
refuse the save when a file changed after the preview was shown.
3. undo(image_type, scope) puts the backups of the last commit back, but
only while every file still holds exactly what that commit wrote.
The on-disk format is never changed by a save: JSON keeps its indent, BOM,
line endings, trailing newline and ASCII escaping (detect_json_format).
A file that does not round-trip byte for byte (a hand-edited vendor file
with mixed tabs and spaces, say) is not re-rendered whole: for a top-level
JSON list the entries that did not change keep their exact original text
and only new or edited entries are rendered (_splice_list), so the diff
shows the real change. Only when that is impossible does a plan say
"reformat": True, so the page can warn that whitespace will move.
commit() and undo() hold one lock (UPDATES_DIR/webui-undo/.lock) from the
stale check to the last write, so two saves cannot interleave, and a
commit that fails half way puts the files it already wrote back.
Undo records live in UPDATES_DIR/webui-undo/<image>--<scope>.json; only
the last save per scope can be undone (the .bak files stay on the share
for anything older).
"""
import contextlib
import difflib
import fcntl
import hashlib
import json
import os
import re
import shutil
import tempfile
from datetime import datetime
from services import updates_common as uc
BACKUP_TAG = "webui"
_BOM = "\ufeff"
class Conflict(Exception):
"""A file changed on disk since the preview or since the last save."""
# --- format ------------------------------------------------------------------
def encode(text):
"""Bytes as written: UTF-8, a leading \\ufeff becomes a real BOM."""
if text.startswith(_BOM):
return b"\xef\xbb\xbf" + text[1:].encode("utf-8")
return text.encode("utf-8")
def sha256_text(text):
return hashlib.sha256(encode(text)).hexdigest()
def file_sha256(path):
"""sha256 of the file's bytes ("" hashed when missing)."""
try:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
except OSError:
return hashlib.sha256(b"").hexdigest()
def read_text(path):
"""Current file content as text ("" when missing). A BOM is kept as the
leading \\ufeff character so it survives a round trip."""
try:
with open(path, "rb") as fh:
raw = fh.read()
except OSError:
return ""
if raw.startswith(b"\xef\xbb\xbf"):
return _BOM + raw[3:].decode("utf-8", errors="replace")
return raw.decode("utf-8", errors="replace")
def detect_json_format(text):
"""How an existing JSON file is laid out. Defaults (for a new file) are
indent 4, no BOM, LF, trailing newline, non-ASCII kept as is."""
fmt = {"indent": 4, "bom": False, "newline": "\n", "trailing_newline": True,
"ensure_ascii": False}
if not text:
return fmt
fmt["bom"] = text.startswith(_BOM)
body = text[1:] if fmt["bom"] else text
fmt["newline"] = "\r\n" if "\r\n" in body else "\n"
fmt["trailing_newline"] = body.endswith("\n")
lines = body.splitlines()
if len(lines) < 2:
# One line: compact JSON, unless it is an empty "[]" / "{}", which
# says nothing about layout (keep the indent 4 default).
if not re.fullmatch(r"\s*(\[\s*\]|\{\s*\})\s*", body):
fmt["indent"] = None
else:
m = re.match(r"[ \t]+", lines[1])
if m:
ws = m.group(0)
fmt["indent"] = "\t" if ws.startswith("\t") else len(ws)
# \uXXXX escapes and no raw non-ASCII: the writer escaped everything.
fmt["ensure_ascii"] = bool(re.search(r"\\u[0-9a-fA-F]{4}", body)) and body.isascii()
return fmt
def render_json(data, fmt):
text = json.dumps(data, indent=fmt["indent"], ensure_ascii=fmt["ensure_ascii"])
if fmt["trailing_newline"]:
text += "\n"
if fmt["newline"] != "\n":
text = text.replace("\n", fmt["newline"])
if fmt["bom"]:
text = _BOM + text
return text
# --- plan ----------------------------------------------------------------------
def _diff(old, new, name):
return "".join(difflib.unified_diff(
old.splitlines(keepends=True), new.splitlines(keepends=True),
fromfile=f"a/{name}", tofile=f"b/{name}", n=3))
def plan_text(path, new_text, label=None):
"""What saving new_text to path would change. Keys: path, name, old,
new, changed, diff, sha_before, existed, reformat (always False here)."""
old = read_text(path)
name = label or os.path.basename(path)
return {
"path": path,
"name": name,
"old": old,
"new": new_text,
"changed": old != new_text,
"diff": _diff(old, new_text, name) if old != new_text else "",
"sha_before": file_sha256(path),
"existed": os.path.isfile(path),
"reformat": False,
}
_WS = re.compile(r"[ \t\r\n]*")
def _key(obj):
"""Order-sensitive identity of a JSON value (key order matters on disk)."""
return json.dumps(obj, ensure_ascii=False)
def _splice_object(s, span, old_obj, new_obj, fmt):
"""Text of new_obj made from the old object's text at span, when both
are objects with the same keys in the same order: every member keeps
its text and only changed values are replaced (rendered on one line
when scalar, else with fmt at that member's indentation). None when
the shapes differ."""
if not (isinstance(old_obj, dict) and isinstance(new_obj, dict)
and list(old_obj) == list(new_obj)):
return None
dec = json.JSONDecoder()
start, end = span
i = _WS.match(s, start + 1).end()
out, pos = [], start
try:
while s[i:i + 1] == '"':
key, k_end = dec.raw_decode(s, i)
j = _WS.match(s, k_end).end()
if s[j:j + 1] != ":":
return None
v_start = _WS.match(s, j + 1).end()
_val, v_end = dec.raw_decode(s, v_start)
if _key(old_obj.get(key)) != _key(new_obj.get(key)):
line_start = s.rfind("\n", 0, i) + 1
indent = re.match(r"[ \t]*", s[line_start:i]).group(0)
text = json.dumps(new_obj[key], indent=fmt["indent"],
ensure_ascii=fmt["ensure_ascii"])
out.append(s[pos:v_start])
out.append(text.replace("\n", fmt["newline"] + indent))
pos = v_end
j = _WS.match(s, v_end).end()
if s[j:j + 1] == ",":
i = _WS.match(s, j + 1).end()
else:
break
except ValueError:
return None
out.append(s[pos:end])
return "".join(out)
def _splice_list(old, data, fmt):
"""New text for a top-level JSON list that keeps the exact original
text of every entry that did not change (and the separators between
neighbours that stay neighbours); new or edited entries are rendered
with fmt at the first entry's indentation. None when old is not a
plain JSON list or the result would not parse back to data."""
if not isinstance(data, list) or not data:
return None
s = old
dec = json.JSONDecoder()
i = _WS.match(s, 1 if s.startswith(_BOM) else 0).end()
if s[i:i + 1] != "[":
return None
head_end = i + 1
i = _WS.match(s, head_end).end()
if s[i:i + 1] == "]":
return None
spans, items = [], []
try:
while True:
obj, end = dec.raw_decode(s, i)
spans.append((i, end))
items.append(obj)
j = _WS.match(s, end).end()
if s[j:j + 1] == ",":
i = _WS.match(s, j + 1).end()
continue
if s[j:j + 1] == "]":
close = j
break
return None
except ValueError:
return None
lead = s[head_end:spans[0][0]]
nl = fmt["newline"]
if "\n" in lead:
item_indent = lead.rsplit("\n", 1)[-1]
std_sep = "," + nl + item_indent
else:
item_indent, std_sep = "", ", "
sm = difflib.SequenceMatcher(None, [_key(o) for o in items], [_key(o) for o in data],
autojunk=False)
# src: old entry identical to new entry n; origin: the old entry new
# entry n stands in for (identical, or edited in a one-for-one replace).
src = [None] * len(data)
origin = [None] * len(data)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal" or (tag == "replace" and i2 - i1 == j2 - j1):
for k in range(j2 - j1):
origin[j1 + k] = i1 + k
if tag == "equal":
src[j1 + k] = i1 + k
def fresh(obj):
text = json.dumps(obj, indent=fmt["indent"], ensure_ascii=fmt["ensure_ascii"])
return text.replace("\n", nl + item_indent)
def edited(n, obj):
# An entry that is one of the old ones with values changed (same
# keys, same order) keeps its own layout: only the values move.
k = origin[n]
if src[n] is not None:
return s[spans[k][0]:spans[k][1]]
if k is not None:
text = _splice_object(s, spans[k], items[k], obj, fmt)
if text is not None:
return text
return fresh(obj)
out = [s[:head_end], lead]
for n, obj in enumerate(data):
if n:
a, b = origin[n - 1], origin[n]
out.append(s[spans[a][1]:spans[b][0]] if a is not None and b == a + 1 else std_sep)
out.append(edited(n, obj))
out.append(s[spans[-1][1]:close])
out.append(s[close:])
text = "".join(out)
try:
if _key(json.loads(text.lstrip(_BOM))) != _key(data):
return None
except ValueError:
return None
return text
def plan_json(path, data, label=None):
"""plan_text for a JSON document, rendered in the file's own format.
A file that does not round-trip keeps its unchanged entries verbatim
(_splice_list); "reformat" is set only when that is not possible."""
old = read_text(path)
fmt = detect_json_format(old)
if old:
try:
same = render_json(json.loads(old.lstrip(_BOM)), fmt) == old
except ValueError:
same = False
if not same:
spliced = _splice_list(old, data, fmt)
if spliced is not None:
return plan_text(path, spliced, label)
plan = plan_text(path, render_json(data, fmt), label)
plan["reformat"] = True
return plan
return plan_text(path, render_json(data, fmt), label)
def public(plan):
"""The part of a plan a page needs (no full file texts)."""
return {k: plan[k] for k in ("name", "changed", "diff", "sha_before", "existed", "reformat")}
# --- write ---------------------------------------------------------------------
def _stamp():
return datetime.now().strftime("%Y%m%d-%H%M%S")
def _match_owner(path, ref_stat):
if ref_stat is None:
return
try:
os.chown(path, ref_stat.st_uid, ref_stat.st_gid)
except (PermissionError, OSError):
pass # not root (local tests): the file stays ours
def _ref_stat(path):
"""Stat to copy owner/mode from: the file, else its directory."""
for p in (path, os.path.dirname(path)):
try:
return os.stat(p)
except OSError:
continue
return None
def atomic_write(path, text):
"""Write text (see encode) or bytes via a temp file in the same
directory, keeping the old file's owner and mode. A symlinked control
file is written through to its target (os.replace on the link itself
would turn it into a separate copy)."""
path = os.path.realpath(path)
folder = os.path.dirname(path)
os.makedirs(folder, exist_ok=True)
existed = os.path.isfile(path)
ref = _ref_stat(path)
data = text if isinstance(text, bytes) else encode(text)
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".webui-")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
if existed:
shutil.copymode(path, tmp)
else:
os.chmod(tmp, 0o664)
_match_owner(tmp, ref)
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.remove(tmp)
raise
def backup(path, tag=BACKUP_TAG, stamp=None):
"""Copy path to <path>.bak-<stamp>-<tag> (owner kept). None if missing."""
if not os.path.isfile(path):
return None
dst = f"{path}.bak-{stamp or _stamp()}-{tag}"
n = 1
while os.path.exists(dst): # two saves in one second
n += 1
dst = f"{path}.bak-{stamp or _stamp()}-{tag}{n}"
shutil.copy2(path, dst)
_match_owner(dst, _ref_stat(path))
return dst
# --- undo records ------------------------------------------------------------------
def _undo_path(image_type, scope):
safe = re.sub(r"[^A-Za-z0-9._-]", "_", f"{image_type}--{scope}")
return os.path.join(uc.state_dir("webui-undo"), safe + ".json")
@contextlib.contextmanager
def _locked():
"""One lock for every commit/undo (saves are rare; threads and worker
processes both honour it, flock is per open file)."""
lock = os.path.join(uc.state_dir("webui-undo"), ".lock")
with open(lock, "a") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(fh, fcntl.LOCK_UN)
def _restore(path, bak):
"""Put a file back as its backup holds it, or remove it (bak None)."""
if bak:
with open(bak, "rb") as fh:
atomic_write(path, fh.read())
elif os.path.lexists(path):
os.remove(path)
def last_save(image_type, scope):
"""The undo point for (image_type, scope): {"ts", "action", "files":
[{"path", "name", "backup", "existed", "sha_after"}]} or None."""
return uc.read_json(_undo_path(image_type, scope), None)
def commit(image_type, scope, plans, action="", expect=None):
"""Back up and write every changed plan; record the undo point.
Returns the list of changed plans (empty when nothing changed, and then
the previous undo point is kept). Raises Conflict when expect is given
and a file no longer has the previewed hash. When a write fails part
way, the files already written are put back before the error is raised."""
expect = expect or {}
with _locked():
changed = [p for p in plans if p["changed"]]
stale = [p["name"] for p in plans
if p["name"] in expect and expect[p["name"]] != file_sha256(p["path"])]
if stale:
raise Conflict("changed on disk since the preview: " + ", ".join(stale)
+ ". Reload the page and review again.")
if not changed:
return []
stamp = _stamp()
files = []
try:
for p in changed:
bak = backup(p["path"], stamp=stamp)
files.append({"path": p["path"], "name": p["name"], "backup": bak,
"existed": bak is not None, "sha_after": sha256_text(p["new"])})
atomic_write(p["path"], p["new"])
uc.write_json(_undo_path(image_type, scope),
{"ts": uc.now(), "action": action, "files": files})
except BaseException:
for f in reversed(files):
try:
_restore(f["path"], f["backup"])
except OSError:
pass # the .bak file is still next to it
raise
return changed
def undo(image_type, scope):
"""Restore the backups of the last commit for (image_type, scope).
The current files are themselves backed up (tag webui-undo) first, so
an undo can be undone by hand. Returns the record that was undone."""
with _locked():
rec = last_save(image_type, scope)
if not rec:
raise Conflict("nothing to undo")
moved = [f["name"] for f in rec["files"]
if file_sha256(f["path"]) != f["sha_after"]]
if moved:
raise Conflict("changed since that save, not undone: " + ", ".join(moved)
+ ". The backups are still next to the files.")
missing = [f["name"] for f in rec["files"]
if f["existed"] and not os.path.isfile(f["backup"] or "")]
if missing:
raise Conflict("backup missing for: " + ", ".join(missing))
stamp = _stamp()
for f in rec["files"]:
backup(f["path"], tag=BACKUP_TAG + "-undo", stamp=stamp)
_restore(f["path"], f["backup"] if f["existed"] else None)
os.remove(_undo_path(image_type, scope))
return rec

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
"""Lint an image's HardwareDriver.json against PESetup's real driver matcher.
A port of scripts/lint-driver-catalogue.py for the webapp (the script is
left as it is: it still gates deploys from a workstation). Same matcher,
same checks, with three differences that come from running on the server:
* zips are looked up on the share directly, and a zip found only with
different letter case is a WARN, not an ERROR, when WinPE still finds
it: WinPE reads the share over SMB and Samba matches each folder name
case-insensitively for Windows clients (default "case sensitive =
auto"). It stays an ERROR when a folder with the exact spelling exists
and the zip sits in its other-case twin;
* a missing (null) modelswminame or family is CRITICAL, not just "no
tokens": the C# calls .Split / .ToLower on them, which throws for every
model the family filter lets through;
* "win10-first": a Win11 entry's model is answered by a Win10 entry
earlier in the list. Every GE image deploys Windows 11 and PESetup
has no OS filter, so those PCs get Windows 10 drivers.
Positions in messages are 1-based (#5 = fifth entry), as on the Drivers tab.
lint_entries(entries, image_type) -> findings, each a dict:
{"level", "check", "message", "index" (0-based or None),
"indexes" (every 0-based entry the finding is about)}
lint_image(image_type) reads the map from disk (used by the Overview tab).
"""
import json
import os
from pathlib import PurePosixPath
from services import fs, image_pages
CRITICAL, ERROR, WARN, INFO = "CRITICAL", "ERROR", "WARN", "INFO"
LEVELS = (CRITICAL, ERROR, WARN, INFO)
LEVEL_ORDER = {lvl: i for i, lvl in enumerate(LEVELS)}
# Short names for the checks, shown as badges on the Drivers tab.
CHECK_LABELS = {
"virtual-platform": "virtual platform",
"null-tokens": "no modelswminame",
"null-family": "no family",
"empty-token": "empty token",
"token-whitespace": "token whitespace",
"no-tokens": "no tokens",
"family-mismatch": "family mismatch",
"no-filename": "no file name",
"missing-zip": "zip missing",
"case-mismatch": "zip case differs",
"win10-first": "Win10 entry wins",
"shadowed": "shadowed",
"duplicate-token": "duplicate token",
"bad-entry": "not an object",
}
# --- PESetup's matcher (docs/PESETUP-INTERNALS.md, GetDriverByModel) ---------
def model_family(model):
"""Three Contains() tests, the last hit wins, "" when none hit."""
upper = model.upper()
family = ""
if "LATITUDE" in upper:
family = "Latitude"
if "OPTIPLEX" in upper:
family = "Optiplex"
if "PRECISION" in upper:
family = "Precision"
return family
def entry_tokens(entry):
"""modelswminame split on comma. NOT trimmed: the C# does not trim.
A missing (null) modelswminame gives no tokens here; PESetup throws on
it instead, which the null-tokens check reports."""
if entry.get("modelswminame") is None:
return []
return str(entry.get("modelswminame")).split(",")
def entry_matches(entry, model):
family = model_family(model)
if family and family.lower() not in str(entry.get("family") or "").lower():
return False
# model.ToLower().Contains(token.ToLower()); "" is in every string.
return any(t.lower() in model.lower() for t in entry_tokens(entry))
def select_driver(entries, model):
"""First match wins, like GetDriverByModel: (index, entry) or (None, None)."""
for i, entry in enumerate(entries):
if entry_matches(entry, model):
return i, entry
return None, None
# --- helpers -------------------------------------------------------------------
def label(entry, index):
name = entry.get("modelsfriendlyname") or entry.get("modelswminame") or "?"
return "#%d %s" % (index + 1, name)
def _smb_lookup(root, rel):
"""Resolve rel the way Samba does for a Windows client: per folder, the
exact name when it exists, else the one name that differs only in case.
No backtracking: once a folder is picked, a file under its other-case
twin is not found (Dell_11 has held both Optiplex and OptiPlex)."""
cur = root
for part in PurePosixPath(rel).parts:
nxt = os.path.join(cur, part)
if not os.path.lexists(nxt):
try:
names = [n for n in os.listdir(cur) if n.lower() == part.lower()]
except OSError:
return None
if len(names) != 1:
return None
nxt = os.path.join(cur, names[0])
cur = nxt
return cur if os.path.isfile(cur) else None
def zip_state(image_type, entry):
"""Where an entry's zip is: {"rel", "status", "path"}. rel is relative
to the image root (Deploy/Out-of-box Drivers/...). status:
ok the exact path exists
case only with other letter case, and WinPE (SMB) still finds it
trap on disk under another-case folder that SMB does not pick
missing nowhere
none the entry names no file"""
rel = image_pages.hardware_coverage._zip_relpath(entry)
if not rel:
return {"rel": "", "status": "none", "path": None}
if ".." in PurePosixPath(rel).parts:
# Never look outside the image tree: /drivers/check takes any
# posted entry, and no real entry climbs out with "..".
return {"rel": rel, "status": "missing", "path": None}
root = fs.image_root(image_type)
exact = os.path.join(root, rel)
if os.path.isfile(exact):
return {"rel": rel, "status": "ok", "path": exact}
smb = _smb_lookup(root, rel)
if smb:
return {"rel": rel, "status": "case", "path": smb, "actual": os.path.relpath(smb, root)}
found = image_pages.zip_path(image_type, entry)
if found:
return {"rel": rel, "status": "trap", "path": found, "actual": os.path.relpath(found, root)}
return {"rel": rel, "status": "missing", "path": None}
def _add(out, level, check, message, index=None, indexes=None):
idx = list(indexes) if indexes is not None else ([] if index is None else [index])
out.append({"level": level, "check": check, "message": message,
"index": index, "indexes": idx})
# --- checks --------------------------------------------------------------------
def lint_entries(entries, image_type=None, zips=None):
"""Every finding for a list of entries. image_type enables the zip
checks; zips (a list of zip_state results, one per entry) saves
looking them up again."""
out = []
rows = [e if isinstance(e, dict) else {} for e in entries]
for i, e in enumerate(entries):
if not isinstance(e, dict):
_add(out, CRITICAL, "bad-entry",
"#%d is not a JSON object; PESetup cannot read the map." % (i + 1), i)
for i, entry in enumerate(rows):
tag = label(entry, i)
# Virtual platform hijack: one such entry anywhere flips PESetup into
# virtual-only mode and every physical machine fails.
manufacturer = str(entry.get("manufacturer") or "")
if "virtual platform" in manufacturer.lower():
_add(out, CRITICAL, "virtual-platform",
"%s manufacturer is %r. PESetup then takes the first entry as the driver and "
"REQUIRES the machine to look virtual, so every physical PC fails." % (tag, manufacturer), i)
# Null fields the C# dereferences without a check.
if entry.get("modelswminame") is None:
_add(out, CRITICAL, "null-tokens",
"%s has no modelswminame. PESetup calls Split() on it and throws for every "
"model the family filter lets through, so driver selection fails." % tag, i)
if entry.get("family") is None:
_add(out, CRITICAL, "null-family",
"%s has no family. PESetup calls ToLower() on it for every Latitude, OptiPlex "
"and Precision model and throws." % tag, i)
# Empty token: Contains("") is true for every model. An empty
# modelswminame is one empty token, so it matches everything too.
tokens = entry_tokens(entry)
if tokens == [""]:
_add(out, CRITICAL, "empty-token",
"%s modelswminame is empty. Split gives one empty token, which matches every "
"model, so this entry takes over the map." % tag, i)
elif any(t == "" for t in tokens):
_add(out, CRITICAL, "empty-token",
"%s modelswminame %r has an EMPTY token (trailing or doubled comma). An empty "
"token matches every model, so this entry takes over the map."
% (tag, entry.get("modelswminame")), i)
# Whitespace around a token is kept by Split(','), so it must be in the model.
for token in tokens:
if token and token != token.strip():
_add(out, ERROR, "token-whitespace",
"%s token %r carries whitespace. PESetup does not trim, so the model "
"string must contain the space too; this token will usually miss." % (tag, token), i)
if tokens and tokens != [""] and not str(entry.get("modelswminame")).strip():
_add(out, ERROR, "no-tokens",
"%s has an empty modelswminame, so no model can ever select it." % tag, i)
# The family filter runs on the MODEL first, then wants entry.family
# to contain it: a token implying another family can never match.
family_field = str(entry.get("family") or "")
for token in tokens:
token = token.strip()
implied = model_family(token) if token else ""
if implied and implied.lower() not in family_field.lower():
_add(out, ERROR, "family-mismatch",
"%s token %r implies family %s, but family is %r. The filter runs before "
"the token test, so this token can never match." % (tag, token, implied, family_field), i)
# The referenced zip.
if not str(entry.get("fileName") or entry.get("FileName") or ""):
_add(out, ERROR, "no-filename",
"%s has no fileName, so PESetup has nothing to unzip." % tag, i)
elif image_type:
z = zips[i] if zips is not None else zip_state(image_type, entry)
if z["status"] == "missing":
_add(out, ERROR, "missing-zip",
"%s references %s, which is not on the share. A driver miss is only a "
"warning in PESetup, so imaging finishes with no drivers." % (tag, z["rel"]), i)
elif z["status"] == "case":
_add(out, WARN, "case-mismatch",
"%s references %s but the share holds %s. WinPE finds it (Samba matches "
"case-insensitively), Linux tools and the lint script do not."
% (tag, z["rel"], z.get("actual", "")), i)
elif z["status"] == "trap":
_add(out, ERROR, "case-mismatch",
"%s references %s but the zip is at %s. A folder with the exact spelling "
"exists and does not hold it, so WinPE will not find it."
% (tag, z["rel"], z.get("actual", "")), i)
# Shadowing: replay each entry's own tokens through the matcher. When a
# Win10 entry answers a Win11 entry's model, that is worse than dead
# weight: Win11 PCs get Windows 10 drivers.
os_of = [image_pages.driver_os(e) for e in rows]
for i, entry in enumerate(rows):
for token in entry_tokens(entry):
token = token.strip()
if not token:
continue
win, _ = select_driver(rows, token)
if win is None or win == i:
continue
if os_of[i] == "Win11" and os_of[win] == "Win10":
_add(out, ERROR, "win10-first",
"%s token %r is answered by %s, a Windows 10 pack earlier in the list. "
"PESetup has no OS filter, so Windows 11 PCs get Windows 10 drivers. "
"Move the Win11 entry above it." % (label(entry, i), token, label(rows[win], win)),
i, indexes=[i, win])
else:
_add(out, WARN, "shadowed",
"%s token %r is answered by %s instead (first match wins)."
% (label(entry, i), token, label(rows[win], win)), i)
# One token claimed by several entries: the first one wins whatever the OS.
by_token = {}
for i, entry in enumerate(rows):
for token in entry_tokens(entry):
token = token.strip().lower()
if token:
by_token.setdefault(token, [])
if i not in by_token[token]:
by_token[token].append(i)
for token, idxs in sorted(by_token.items()):
if len(idxs) > 1:
detail = ", ".join("%s (%s, osId %s)" % (label(rows[i], i), os_of[i], rows[i].get("osId"))
for i in idxs)
_add(out, WARN, "duplicate-token",
"token %r is claimed by %d entries: %s. osId is not part of the match, so %s "
"wins whatever OS is applied." % (token, len(idxs), detail, label(rows[idxs[0]], idxs[0])),
None, indexes=idxs)
out.sort(key=lambda f: (LEVEL_ORDER[f["level"]], f["indexes"][0] if f["indexes"] else -1))
return out
def counts(findings):
"""{"total", "CRITICAL": n, "ERROR": n, "WARN": n, "INFO": n}."""
c = {lvl: 0 for lvl in LEVELS}
for f in findings:
c[f["level"]] += 1
c["total"] = sum(c[lvl] for lvl in LEVELS)
return c
def map_path(image_type):
return image_pages.control_file(image_type, "HardwareDriver.json")
def lint_image(image_type):
"""Findings for the map on disk. [] when the image has no map; one
CRITICAL finding when it does not parse or is not a list."""
path = map_path(image_type)
if not os.path.isfile(path):
return []
try:
with open(path, encoding="utf-8-sig") as fh:
entries = json.load(fh)
except (OSError, ValueError) as exc:
return [{"level": CRITICAL, "check": "parse", "index": None, "indexes": [],
"message": "HardwareDriver.json does not parse: %s" % exc}]
if not isinstance(entries, list):
return [{"level": CRITICAL, "check": "parse", "index": None, "indexes": [],
"message": "HardwareDriver.json is a %s, expected a list" % type(entries).__name__}]
return lint_entries(entries, image_type)

View File

@@ -0,0 +1,416 @@
"""The Drivers tab's view of an image's HardwareDriver.json.
PESetup reads Deploy/Control/HardwareDriver.json and takes the FIRST entry
whose modelswminame token is in the PC's WMI model (no OS filter), so the
list order matters and is kept exactly as the user arranges it.
The page edits the whole map in the browser and posts it back as a list of
entries; every entry that came from the file carries "_orig" (its 0-based
position when the page was loaded), so the server can tell an edit from a
move, an addition or a removal. Keys starting with "_" are never written.
rows(image_type, entries) per-entry facts for the table (zip on disk,
OS, lint findings, Dell update hint)
orphans(image_type, entries) zips under the image's driver folder that
this map does not reference
staged(body) validate a posted map: (entries, origs)
changes(old, new, origs) per-entry summary + JSON diff for the review
delete_orphans(image_type, rels) zips no map, undo or rollback uses
save_upload(image_type, file) an uploaded zip, temp file + rename
Writes go through services/control_files (format kept, backup, undo).
"""
import difflib
import json
import os
import tempfile
import config
from services import control_files, driver_lint, fs, image_pages
FILE_NAME = "HardwareDriver.json"
DRIVERS_SUBDIR = "Out-of-box Drivers"
DEST_PREFIX = "*destinationdir*\\Deploy\\Out-of-box Drivers\\"
# Keys the C# reads as strings; a posted entry must not turn them into
# numbers or objects.
STRING_KEYS = ("manufacturer", "product", "family", "modelswminame", "modelsfriendlyname",
"fileName", "destinationDir", "FileName", "DestinationDir", "osId", "models")
def map_path(image_type):
return image_pages.control_file(image_type, FILE_NAME)
def load(image_type):
"""(entries, error). entries is [] and error a message when the file is
missing, does not parse or is not a list."""
path = map_path(image_type)
if not os.path.isfile(path):
return [], None
try:
with open(path, encoding="utf-8-sig") as fh:
data = json.load(fh)
except (OSError, ValueError) as exc:
return [], f"{FILE_NAME} does not parse: {exc}"
if not isinstance(data, list):
return [], f"{FILE_NAME} is a {type(data).__name__}, expected a list"
# The page edits entries as objects; a string or null entry would be
# turned into an object (or crash the page), so such a map is read-only.
bad = [i + 1 for i, e in enumerate(data) if not isinstance(e, dict)]
if bad:
return [], (f"{FILE_NAME} entry #{bad[0]} is not a JSON object "
f"({len(bad)} such entr{'y' if len(bad) == 1 else 'ies'})")
return data, None
def file_sha(image_type):
return control_files.file_sha256(map_path(image_type))
def _stat(path):
try:
st = os.stat(path)
return st.st_size, st.st_mtime
except (OSError, TypeError):
return None, None
def _dell_hints():
try:
from services import dell_updates
return dell_updates.update_hints()["drivers"]
except Exception: # no scan yet or the module failed: no hints
return {}
def rows(image_type, entries):
"""(rows, findings, counts) for a list of entries (from the file or
staged in the page). One row per entry, in order:
{"os", "zip": {"status", "rel", "actual", "size", "mtime"},
"findings": [finding, ...], "dell": newer Dell version or ""}"""
dicts = [e if isinstance(e, dict) else {} for e in entries]
zips = [driver_lint.zip_state(image_type, e) for e in dicts]
findings = driver_lint.lint_entries(entries, image_type, zips=zips)
per = [[] for _ in entries]
for f in findings:
for i in f["indexes"]:
if 0 <= i < len(per):
per[i].append({"level": f["level"], "check": f["check"], "message": f["message"],
"label": driver_lint.CHECK_LABELS.get(f["check"], f["check"])})
hints = _dell_hints()
out = []
for i, e in enumerate(dicts):
z = zips[i]
size, mtime = _stat(z["path"])
name = str(e.get("fileName") or e.get("FileName") or "")
out.append({
"os": image_pages.driver_os(e),
"zip": {"status": z["status"], "rel": z["rel"], "actual": z.get("actual", ""),
"size": size, "mtime": mtime},
"findings": per[i],
"dell": hints.get(name.lower(), ""),
})
return out, findings, driver_lint.counts(findings)
# --- orphans ------------------------------------------------------------------------
def _drivers_root(image_type):
"""Real path of the image's Out-of-box Drivers folder (a symlink into
_shared on the server), or None."""
path = os.path.join(fs.deploy_path(image_type), DRIVERS_SUBDIR)
real = os.path.realpath(path)
return real if os.path.isdir(real) else None
def _share_images():
"""Every image whose map can point into the shared driver tree: the
registered ones plus any other folder on the share with a map (an image
dropped from the registry can still be booted)."""
images = list(config.IMAGE_TYPES)
try:
names = sorted(os.listdir(config.SAMBA_SHARE))
except OSError:
names = []
for name in names:
if (not name.startswith("_") and name not in images
and os.path.isfile(map_path(name))):
images.append(name)
return images
def _restore_points():
"""[(image, label, map file)] for maps the app can still put back: the
backup behind each image's Undo last save, and the map backups of every
Dell update that can be rolled back. A zip those maps use must not be
deleted, or the undo / rollback restores an entry with no zip."""
out = []
for image in _share_images():
rec = control_files.last_save(image, "drivers") or {}
for f in rec.get("files", []):
if f.get("backup") and os.path.basename(f.get("path", "")) == FILE_NAME:
out.append((image, f"{image} (undo of last save)", f["backup"]))
try:
from services import dell_updates
history = dell_updates.history()
except Exception: # no Dell history yet or the module failed
history = []
for rec in history:
if rec.get("rolled_back"):
continue
for path, bak in (rec.get("backups") or {}).items():
if os.path.basename(path) != FILE_NAME or not bak:
continue
image = os.path.relpath(path, config.SAMBA_SHARE).split(os.sep)[0]
out.append((image, f"{image} (Dell update rollback)", bak))
return out
def _entries_of(path):
try:
with open(path, encoding="utf-8-sig") as fh:
data = json.load(fh)
except (OSError, ValueError):
return []
return [e for e in data if isinstance(e, dict)] if isinstance(data, list) else []
def _referenced():
"""{real zip path: [who uses it]}: every image's map, plus the maps an
undo or a Dell rollback would restore (see _restore_points)."""
refs = {}
sources = [(image, image, map_path(image)) for image in _share_images()]
for image, label, path in sources + _restore_points():
for e in _entries_of(path):
p = image_pages.zip_path(image, e)
if p:
refs.setdefault(os.path.realpath(p), []).append(label)
return refs
def orphans(image_type, refs=None):
"""Zips under this image's driver folder that its map does not use.
used_by lists the other images that do use it (deleting those would
break them, so the page only allows Add to map)."""
root = _drivers_root(image_type)
if not root:
return []
refs = _referenced() if refs is None else refs
out = []
# followlinks=False: Dell_11/OptiPlex can be a symlink to Optiplex, and
# following it would list every zip there twice.
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for fn in sorted(filenames):
if not fn.lower().endswith(".zip"):
continue
full = os.path.join(dirpath, fn)
users = sorted(set(refs.get(os.path.realpath(full), [])))
if image_type in users:
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
folder = os.path.dirname(rel)
size, mtime = _stat(full)
out.append({
"name": fn,
"rel": rel,
"folder": folder,
"dest": DEST_PREFIX.rstrip("\\") + ("\\" + folder.replace("/", "\\") if folder else ""),
"size": size,
"mtime": mtime,
"used_by": users,
})
return out
def delete_orphans(image_type, rels):
"""Delete zips (paths relative to the driver folder) that NO image's
map references. Returns {"removed": [...], "refused": [(rel, why)]}."""
root = _drivers_root(image_type)
removed, refused = [], []
if not root:
return {"removed": removed, "refused": [(r, "no driver folder") for r in rels]}
refs = _referenced()
for rel in rels:
rel = str(rel or "")
full = os.path.realpath(os.path.join(root, rel))
if not full.startswith(root + os.sep) or not full.lower().endswith(".zip"):
refused.append((rel, "not a zip in the driver folder"))
elif os.path.islink(os.path.join(root, rel)):
# Unlinking the resolved path would delete the zip it points at.
refused.append((rel, "a symlink; remove it by hand"))
elif not os.path.isfile(full):
refused.append((rel, "not found"))
elif refs.get(full):
refused.append((rel, "used by " + ", ".join(sorted(set(refs[full])))))
else:
try:
os.unlink(full)
removed.append(rel)
except OSError as exc:
refused.append((rel, str(exc)))
return {"removed": removed, "refused": refused}
def save_upload(image_type, upload, overwrite=False):
"""Store an uploaded driver .zip at the top of the image's driver folder.
Written to a temp file next to it and renamed, so a PC copying the old
file never reads a half-written one; owner and group follow the folder
(the app runs as root, the share is pxe:pxe). Overwriting a zip any map,
undo or rollback uses is refused: its entry's hash and size would no
longer match. Nothing is added to the map (Add to map does that, through
the review step). Returns {"filename", "path", "replaced"}."""
from services import images # the existing upload name sanitizer
name = images._safe_filename(getattr(upload, "filename", ""))
if not name.lower().endswith(".zip"):
raise ValueError("Driver upload must be a .zip file")
root = _drivers_root(image_type)
if not root:
root = os.path.join(fs.deploy_path(image_type), DRIVERS_SUBDIR)
os.makedirs(root, exist_ok=True)
dst = os.path.join(root, name)
replaced = os.path.lexists(dst)
if replaced:
if not overwrite:
raise FileExistsError(f"{name} already exists in {DRIVERS_SUBDIR}/")
if os.path.islink(dst) or not os.path.isfile(dst):
raise ValueError(f"{name} in {DRIVERS_SUBDIR}/ is not a plain file; replace it by hand")
users = sorted(set(_referenced().get(os.path.realpath(dst), [])))
if users:
raise ValueError(f"{name} is in use ({', '.join(users)}); upload it under a new name "
"and point the entry at it instead")
fd, tmp = tempfile.mkstemp(dir=root, prefix=".webui-upload-", suffix=".part")
try:
with os.fdopen(fd, "wb") as fh:
upload.save(fh)
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp, 0o664)
try:
st = os.stat(root)
os.chown(tmp, st.st_uid, st.st_gid)
except OSError:
pass # not root (local tests)
os.replace(tmp, dst)
except BaseException:
if os.path.exists(tmp):
os.remove(tmp)
raise
return {"filename": name, "path": dst, "replaced": replaced}
# --- staged map -----------------------------------------------------------------------
def staged(body, old_len):
"""Validate a posted {"entries": [...]}. Returns (entries, origs, moved):
entries cleaned of "_" keys, origs[i] = position in the file the entry
came from (or None for a new one), moved[i] = the user moved it with
the arrows ("_moved"; only used to word the review). Raises ValueError."""
raw = body.get("entries") if isinstance(body, dict) else None
if not isinstance(raw, list) or not all(isinstance(e, dict) for e in raw):
raise ValueError("expected entries: a list of objects")
entries, origs, moved, seen = [], [], [], set()
for n, e in enumerate(raw, 1):
o = e.get("_orig")
if isinstance(o, bool) or not isinstance(o, int) or not 0 <= o < old_len or o in seen:
o = None
else:
seen.add(o)
clean = {k: v for k, v in e.items() if not str(k).startswith("_")}
for k in STRING_KEYS:
if k in clean and clean[k] is not None and not isinstance(clean[k], str):
raise ValueError(f"entry #{n}: {k} must be text")
if "aOsIds" in clean and not (isinstance(clean["aOsIds"], list)
and all(isinstance(x, str) for x in clean["aOsIds"])):
raise ValueError(f"entry #{n}: aOsIds must be a list of text")
entries.append(clean)
origs.append(o)
moved.append(bool(e.get("_moved")))
return entries, origs, moved
def _dump(entry):
return json.dumps(entry, indent=4, ensure_ascii=False)
def _kept_in_order(seq, weights):
"""Indexes (into seq) of the heaviest increasing run of values: the
entries that kept their relative order; the rest were moved. When the
user swaps two neighbours either one could be "the moved one", so the
entries the user did not touch weigh more and stay in the run."""
n = len(seq)
if not n:
return set()
best = list(weights)
prev = [-1] * n
for i in range(n):
for j in range(i):
if seq[j] < seq[i] and best[j] + weights[i] > best[i]:
best[i], prev[i] = best[j] + weights[i], j
i = max(range(n), key=lambda k: best[k])
keep = set()
while i != -1:
keep.add(i)
i = prev[i]
return keep
def changes(old, new, origs, user_moved=None):
"""What a save would do, entry by entry, for the review step:
[{"kind": "added"|"removed"|"edited"|"moved"|"edited+moved",
"label", "old_pos", "new_pos" (1-based or None), "file" (moves only),
"fields", "diff"}]
and a summary {"added", "removed", "edited", "moved"}."""
out = []
kept = [(i, o) for i, o in enumerate(origs) if o is not None]
user_moved = user_moved or [False] * len(new)
in_order = _kept_in_order([o for _, o in kept],
[1 if user_moved[i] else 1000 for i, _ in kept])
moved = {kept[k][0] for k in range(len(kept)) if k not in in_order}
summary = {"added": 0, "removed": 0, "edited": 0, "moved": 0}
for i, (entry, o) in enumerate(zip(new, origs)):
lbl = driver_lint.label(entry, i)
if o is None:
summary["added"] += 1
out.append({"kind": "added", "label": lbl, "old_pos": None, "new_pos": i + 1,
"fields": list(entry.keys()),
"diff": _udiff("", _dump(entry) + "\n", "(new)", f"#{i + 1}")})
continue
before = old[o] if isinstance(old[o], dict) else {}
edited = _dump(before) != _dump(entry)
was_moved = i in moved
if not edited and not was_moved:
continue
kind = "edited+moved" if edited and was_moved else ("edited" if edited else "moved")
if edited:
summary["edited"] += 1
if was_moved:
summary["moved"] += 1
fields = sorted({k for k in set(before) | set(entry) if before.get(k) != entry.get(k)}
| ({"(key order)"} if edited and list(before) != list(entry)
and set(before) == set(entry) else set()))
out.append({"kind": kind, "label": lbl, "old_pos": o + 1, "new_pos": i + 1,
"file": str(entry.get("fileName") or entry.get("FileName") or ""),
"fields": fields,
"diff": _udiff(_dump(before) + "\n", _dump(entry) + "\n",
f"#{o + 1}", f"#{i + 1}") if edited else ""})
gone = sorted(set(range(len(old))) - {o for o in origs if o is not None})
for o in gone:
before = old[o] if isinstance(old[o], dict) else {}
summary["removed"] += 1
out.append({"kind": "removed", "label": driver_lint.label(before, o), "old_pos": o + 1,
"new_pos": None, "fields": [],
"diff": _udiff(_dump(before) + "\n", "", f"#{o + 1}", "(removed)")})
out.sort(key=lambda c: (c["new_pos"] or c["old_pos"] or 0, c["kind"] != "removed"))
return out, summary
def _udiff(a, b, from_label, to_label):
# Whole entry as context: an entry is about 20 lines, and a reviewer
# wants to see which model a changed line belongs to.
return "".join(difflib.unified_diff(a.splitlines(keepends=True), b.splitlines(keepends=True),
fromfile=from_label, tofile=to_label, n=60))

View File

@@ -33,10 +33,12 @@ from pathlib import PurePosixPath
import config import config
# Images whose HardwareDriver.json is checked, with the Windows they deploy. # Images whose HardwareDriver.json is checked, with the Windows they deploy.
# All three deploy Windows 11 now (engineer moved off Win10), so every one
# counts toward a model's gaps.
IMAGES = ( IMAGES = (
("gea-standard", "Win11"), ("gea-standard", "Win11"),
("gea-shopfloor", "Win11 LTSC"), ("gea-shopfloor", "Win11 LTSC"),
("gea-engineer", "Win10"), ("gea-engineer", "Win11"),
) )
KEEP = 10 # serials / versions remembered per model KEEP = 10 # serials / versions remembered per model
@@ -239,7 +241,7 @@ def coverage(include_ok=True):
drivers = driver_status(model) drivers = driver_status(model)
bios = bios_status(model, rec.get("last_bios_version", "")) bios = bios_status(model, rec.get("last_bios_version", ""))
missing = [img for img, d in drivers.items() missing = [img for img, d in drivers.items()
if d["status"] in ("no-entry", "zip-missing") and img != "gea-engineer"] if d["status"] in ("no-entry", "zip-missing")]
gap = bool(missing) or bios["status"] in ("none", "file-missing") gap = bool(missing) or bios["status"] in ("none", "file-missing")
row = dict(rec, model=model, drivers=drivers, bios=bios, missing=missing, row = dict(rec, model=model, drivers=drivers, bios=bios, missing=missing,
gap=gap, dismissed=model in state["dismissed"]) gap=gap, dismissed=model in state["dismissed"])

View File

@@ -0,0 +1,348 @@
"""Shared state for the per-image pages (GE Aerospace Images in the sidebar).
Each image has one page with tabs, all drawn under templates/_image_header.html:
tab id label endpoint URL
overview Overview images.overview /images/<t>
drivers Drivers images_drivers.page /images/<t>/drivers
unattend Unattend unattend_editor /images/<t>/unattend
os OS and Packages images.os_packages /images/<t>/os
IMAGE_TABS is the single place that maps a tab to its endpoint: the header
partial, the sidebar and every redirect use it (tab_url). A page that moves
to another endpoint changes its row here and nothing else.
header_info() is what the header partial shows; overview() feeds the
Overview tab. Both only read files, nothing here writes (saves go through
services/control_files.py).
"""
import glob
import os
from pathlib import PurePosixPath
from flask import url_for
import config
from services import control_files, fs, hardware_coverage
IMAGE_TABS = (
{"id": "overview", "label": "Overview", "icon": "bi-clipboard-data", "endpoint": "images.overview"},
{"id": "drivers", "label": "Drivers", "icon": "bi-hdd-network", "endpoint": "images_drivers.page"},
{"id": "unattend", "label": "Unattend", "icon": "bi-file-earmark-code", "endpoint": "unattend_editor"},
{"id": "os", "label": "OS and Packages", "icon": "bi-windows", "endpoint": "images.os_packages"},
)
TAB_IDS = tuple(t["id"] for t in IMAGE_TABS)
# PESetup loads the first of these that exists (docs/PESETUP-INTERNALS.md,
# "Paths it hardcodes"); the Unattend tab edits the fallback.
UNATTEND_PREFERRED = ("Deploy", "Tools", "GE", "XML", "FlatUnattendW10.xml")
# osId values that mean Windows 10 in the GE catalogue: 18 is the only id
# the Dell_10 packs carry; 19, 20 and 21 appear only on Win11 packs (20 =
# Win11 LTSC 24H2, 21 = Win11 Pro 24H2).
WIN10_OS_IDS = {"18"}
def tab_url(image_type, tab="overview", **kw):
"""URL of one tab of an image page."""
endpoint = next(t["endpoint"] for t in IMAGE_TABS if t["id"] == tab)
return url_for(endpoint, image_type=image_type, **kw)
def tab_for_endpoint(endpoint):
"""Tab id for a request endpoint, else None. Exact match first; then a
tab whose blueprint is its own (images_drivers.*) claims every endpoint
of that blueprint. The shared "images" blueprint only matches exactly."""
for t in IMAGE_TABS:
if endpoint == t["endpoint"]:
return t["id"]
bp = (endpoint or "").partition(".")[0]
for t in IMAGE_TABS:
own = t["endpoint"].partition(".")[0]
if "." in t["endpoint"] and own == bp and own != "images":
return t["id"]
return None
# --- file locations ----------------------------------------------------------
def control_file(image_type, name):
return os.path.join(fs.control_path(image_type), name)
def user_selections_path(image_type):
return os.path.join(fs.tools_path(image_type), "user_selections.json")
def preferred_unattend_path(image_type):
return os.path.join(fs.image_root(image_type), *UNATTEND_PREFERRED)
# --- small readers -------------------------------------------------------------
def _list(data):
return data if isinstance(data, list) else []
def _load(path):
"""fs.load_json that also survives a file that is not UTF-8 (a UTF-16
file saved by PowerShell 5.1 Out-File raises UnicodeDecodeError, which
fs.load_json lets through and which would fail every image tab)."""
try:
return fs.load_json(path)
except ValueError:
return []
def drivers(image_type):
"""HardwareDriver.json entries (the only driver file PESetup reads)."""
return _list(_load(control_file(image_type, "HardwareDriver.json")))
def user_selections(image_type):
"""(first object of user_selections.json, whole list) - the file is a
one-element JSON array written by Media Creator Lite."""
raw = _load(user_selections_path(image_type))
if isinstance(raw, list) and raw and isinstance(raw[0], dict):
return raw[0], raw
return {}, []
def driver_os(entry):
"""'Win11', 'Win10' or 'Other' for one driver entry. The pack file name
(win10_/win11_) decides first, then the Dell_10/Dell_11 folder, then
the osId list."""
name = str(entry.get("fileName") or entry.get("FileName") or "").lower()
dest = str(entry.get("destinationDir") or entry.get("DestinationDir") or "").lower()
if name.startswith("win10"):
return "Win10"
if name.startswith("win11"):
return "Win11"
if "\\dell_10\\" in dest or "/dell_10/" in dest:
return "Win10"
if "\\dell_11\\" in dest or "/dell_11/" in dest:
return "Win11"
ids = [i.strip() for i in str(entry.get("osId") or "").split(",") if i.strip()]
if not ids:
return "Other"
return "Win10" if all(i in WIN10_OS_IDS for i in ids) else "Win11"
def zip_path(image_type, entry):
"""Real path of an entry's zip (directories matched case-insensitively,
as on the live share), or None when it is not on disk."""
rel = hardware_coverage._zip_relpath(entry)
if not rel:
return None
return _find_ci(fs.image_root(image_type), PurePosixPath(rel).parts)
def _find_ci(cur, parts):
"""Depth-first case-insensitive lookup. Every case variant of a folder
is tried (Dell_11 can hold both Optiplex and OptiPlex as separate
folders), the exact spelling first."""
if not parts:
return cur if os.path.isfile(cur) else None
try:
names = os.listdir(cur)
except OSError:
return None
hits = sorted((n for n in names if n.lower() == parts[0].lower()), key=lambda n: n != parts[0])
for hit in hits:
found = _find_ci(os.path.join(cur, hit), parts[1:])
if found:
return found
return None
def _stat(path):
try:
st = os.stat(path)
return {"exists": True, "size": st.st_size, "mtime": st.st_mtime}
except OSError:
return {"exists": False, "size": None, "mtime": None}
def operating_systems(image_type):
"""OperatingSystem.json entries with the wim resolved on disk:
[{"entry", "osv", "wim_dir", "wim_path", "wim": _stat}]."""
out = []
for entry in _list(_load(control_file(image_type, "OperatingSystem.json"))):
osv = entry.get("operatingSystemVersion") or {}
wim = osv.get("wim") or {}
dest = wim.get("DestinationDir") or wim.get("destinationDir") or ""
wim_dir = fs.resolve_destination(dest, image_type)
wim_path = os.path.join(wim_dir, "install.wim") if wim_dir else ""
out.append({"entry": entry, "osv": osv, "dest": dest, "wim_path": wim_path,
"wim": _stat(wim_path) if wim_path else _stat("")})
return out
def packages(image_type):
"""packages.json entries with the file resolved on disk:
[{"entry", "path", "file": _stat}]."""
out = []
for entry in _list(_load(control_file(image_type, "packages.json"))):
name = entry.get("fileName") or entry.get("FileName") or ""
dest = entry.get("destinationDir") or entry.get("DestinationDir") or ""
folder = fs.resolve_destination(dest, image_type)
path = os.path.join(folder, name) if folder and name else ""
out.append({"entry": entry, "path": path, "file": _stat(path) if path else _stat("")})
return out
def _current_os(rows, selection):
"""The OS row imaging uses: the one user_selections.json selects, else
the first active one, else the first."""
want = str(selection if selection is not None else "")
return (next((r for r in rows if str(r["osv"].get("id")) == want), None)
or next((r for r in rows if r["osv"].get("isActive")), None)
or (rows[0] if rows else None))
def os_label(image_type):
"""'Windows 11 Professional 24H2 (osId 21)' for the selected OS, else
the first active entry, else ''."""
sel, _ = user_selections(image_type)
row = _current_os(operating_systems(image_type), sel.get("OperatingSystemSelection"))
if not row:
return ""
pick = row["osv"]
name = pick.get("productName") or pick.get("marketingName") or "Windows"
ver = pick.get("versionNumber") or ""
return f"{name} {ver} (osId {pick.get('id')})".replace(" ", " ")
# --- header + overview ---------------------------------------------------------------
def header_info(image_type):
"""What _image_header.html shows: name, OS, counts for the tab pills."""
entries = drivers(image_type)
return {
"image_type": image_type,
"friendly_name": config.FRIENDLY_NAMES.get(image_type, image_type),
"os_label": os_label(image_type),
"driver_count": len(entries),
"package_count": len(packages(image_type)),
"has_unattend": os.path.isfile(fs.unattend_path(image_type)),
}
def _lint_counts(image_type):
"""Findings of services/driver_lint (built by the Drivers tab) as
{"available", "total", "by_level"}; unavailable until that module
exists or when it fails."""
try:
from services import driver_lint # noqa: WPS433 (optional module)
findings = driver_lint.lint_image(image_type)
except Exception: # missing module or a bad map: show "not available"
return {"available": False, "total": 0, "by_level": {}, "map_errors": 0}
by_level = {}
map_errors = 0
for f in findings or []:
f = f if isinstance(f, dict) else vars(f)
level = str(f.get("level") or "INFO").upper()
by_level[level] = by_level.get(level, 0) + 1
# A missing zip is an ERROR finding too, but the Overview counts
# those on their own tile; map_errors is what is wrong in the map.
if level in ("CRITICAL", "ERROR") and f.get("check") != "missing-zip":
map_errors += 1
return {"available": True, "total": sum(by_level.values()), "by_level": by_level,
"map_errors": map_errors}
def _control_files(image_type):
"""The files the image pages edit, with size/mtime and backups."""
items = [
("HardwareDriver.json", control_file(image_type, "HardwareDriver.json"), "drivers",
"Driver map PESetup matches the PC model against"),
("OperatingSystem.json", control_file(image_type, "OperatingSystem.json"), "os",
"Windows build, install.wim location and index"),
("packages.json", control_file(image_type, "packages.json"), "os",
"Servicing packages applied offline (updates, .NET)"),
("user_selections.json", user_selections_path(image_type), "os",
"Media Creator Lite selections (OS and driver families)"),
("FlatUnattendW10.xml", fs.unattend_path(image_type), "unattend",
"Windows answer file (fallback location, the one this app edits)"),
]
out = []
for name, path, tab, what in items:
st = _stat(path)
backups = sorted(glob.glob(glob.escape(path) + ".bak-*"))
out.append(dict(st, name=name, path=path,
rel=os.path.relpath(path, fs.image_root(image_type)),
tab=tab, what=what, backups=len(backups)))
return out
def overview(image_type):
"""Everything the Overview tab shows, from data already on the server."""
entries = drivers(image_type)
by_os = {"Win11": 0, "Win10": 0, "Other": 0}
missing = []
for i, e in enumerate(entries, 1):
by_os[driver_os(e)] += 1
if not zip_path(image_type, e):
missing.append({"pos": i, "file": e.get("fileName") or e.get("FileName") or "",
"models": e.get("modelswminame") or e.get("models") or "",
"os": driver_os(e)})
sel, _ = user_selections(image_type)
oses = operating_systems(image_type)
pkgs = packages(image_type)
# Hardware seen by WinPE with no working driver entry in THIS image.
gaps = []
try:
for row in hardware_coverage.coverage(include_ok=False):
d = (row.get("drivers") or {}).get(image_type)
if d and d["status"] in ("no-entry", "zip-missing") and not row.get("dismissed"):
gaps.append({"model": row["model"], "status": d["status"],
"file": d.get("file", ""), "last_seen": row.get("last_seen"),
"skus": row.get("skus") or []})
except Exception:
pass
covered = image_type in dict(hardware_coverage.IMAGES)
# Newer Dell packs for files this map uses (last Updates > Dell scan).
dell = {"checked": None, "rows": []}
try:
from services import dell_updates
hints = dell_updates.update_hints()["drivers"]
dell["checked"] = (dell_updates.last_scan() or {}).get("checked")
seen = set()
for i, e in enumerate(entries, 1):
name = str(e.get("fileName") or e.get("FileName") or "")
if name.lower() in hints and name.lower() not in seen:
seen.add(name.lower())
dell["rows"].append({"pos": i, "file": name, "ver": hints[name.lower()],
"models": e.get("modelswminame") or ""})
except Exception:
pass
preferred = preferred_unattend_path(image_type)
hw_extra = control_file(image_type, "hw_drivers.json")
families = {str(e.get("family") or "") for e in entries}
models = sel.get("HardwareModelSelection") or []
return {
"drivers": {"total": len(entries), "by_os": by_os, "missing": missing},
"lint": _lint_counts(image_type),
"os": {"selection": sel.get("OperatingSystemSelection"), "rows": oses,
"current": _current_os(oses, sel.get("OperatingSystemSelection"))},
"packages": {
"total": len(pkgs),
"enabled": sum(1 for p in pkgs if p["entry"].get("enabled")),
"missing": [p for p in pkgs if p["entry"].get("enabled") and not p["file"]["exists"]],
},
"models": {"total": len(models),
"no_entry": [m for m in models if str(m.get("Id") or "") not in families]},
"files": _control_files(image_type),
"gaps": gaps,
"gaps_checked": covered,
"dell": dell,
"unattend_override": preferred if os.path.isfile(preferred) else None,
"hw_drivers_extra": hw_extra if os.path.isfile(hw_extra) else None,
"last_saves": {scope: control_files.last_save(image_type, scope)
for scope in ("drivers", "unattend", "os")},
}

View File

@@ -0,0 +1,724 @@
"""Keep the Secure Boot iPXE chain on the TFTP root current.
What dnsmasq serves (see /etc/dnsmasq.conf):
* ipxe-shim.efi - Microsoft-signed shim (upstream x86_64-sb/shimx64.efi),
for the MACs tagged ipxe2; it always loads ipxe.efi from
the TFTP ROOT, whatever folder it came from;
* ipxe.efi - iPXE signed by the iPXE Secure Boot CA, loaded by the shim;
* ipxe-bcm.efi - the old 2021 Broadcom-signed build, everyone else.
Never touched here;
* ipxe.pxe - legacy BIOS iPXE (unsigned; BIOS has no Secure Boot).
Upstream publishes the signed set as one tarball on every release:
https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz, with
the signed x86 files in ipxeboot/x86_64-sb/. GitHub lists a sha256 digest per
release asset, which is checked before anything is unpacked, and every EFI
file's Authenticode chain is read with sbverify before it can be installed.
The chain check is by certificate name (sbverify --list); the cryptographic
trust comes from the GitHub digest. Do not swap in `sbverify --cert`: it
prints "Signature verification OK" even for a missing or unrelated cert.
Why it matters: the shim and the Broadcom build are signed by Microsoft's
UEFI CA 2011, whose signing period ended in 2026. Machines that only trust
the 2023 CA, or a revocation (DBX/SBAT) update, will refuse them; the fix is
a newer upstream release, installed here.
Staging runs as a background job (python3 -m services.ipxe_update stage);
install and rollback are a few MB of copying and run inline.
"""
from __future__ import annotations
import fcntl
import glob
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import tarfile
import tempfile
from datetime import datetime
import config
from services import updates_common as uc
GITHUB_API = "https://api.github.com/repos/ipxe/ipxe/releases/latest"
RELEASES_PAGE = "https://github.com/ipxe/ipxe/releases"
LATEST_TARBALL_URL = "https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz"
TARBALL = "ipxeboot.tar.gz"
JOB_UNIT = "pxe-webapp-ipxe-stage"
# TFTP name -> path inside the tarball, and whether it must carry a
# Secure Boot signature (and from whom).
MANAGED = {
"ipxe-shim.efi": ("ipxeboot/x86_64-sb/shimx64.efi", "microsoft"),
"ipxe.efi": ("ipxeboot/x86_64-sb/ipxe.efi", "ipxe"),
"ipxe.pxe": ("ipxeboot/x86_64/ipxe.pxe", None),
}
# Installed by default; ipxe.pxe (legacy BIOS) only when asked.
DEFAULT_INSTALL = ("ipxe-shim.efi", "ipxe.efi")
# Shown on the page for reference, never written.
READ_ONLY = ("ipxe-bcm.efi",)
NEED_LABEL = {"microsoft": "a Microsoft UEFI CA", "ipxe": "the iPXE Secure Boot CA"}
# Issuer CNs, as sbverify prints them.
MS_CA_2011 = "Microsoft Corporation UEFI CA 2011"
MS_CA_2023 = "Microsoft UEFI CA 2023"
IPXE_CA = "iPXE Secure Boot CA"
def _dir():
return uc.state_dir("ipxe")
def _job_file():
return os.path.join(_dir(), "job.json")
# --- reading binaries -------------------------------------------------------
def signers(path):
"""[(subject CN, issuer CN), ...] from the Authenticode chain, [] if none.
Raises FileNotFoundError when sbverify is not installed."""
r = subprocess.run(["sbverify", "--list", path], capture_output=True, text=True, timeout=60)
pairs, subject = [], None
for line in r.stdout.splitlines():
line = line.strip()
m = re.match(r"- subject: .*?CN=([^/]+)", line)
if m:
subject = m.group(1).strip()
continue
m = re.match(r"issuer: .*?CN=([^/]+)", line)
if m and subject is not None:
pairs.append((subject, m.group(1).strip()))
subject = None
return pairs
def signature_kind(pairs):
"""microsoft-2023 | microsoft-2011 | microsoft | ipxe | other | unsigned.
A file signed under both Microsoft CAs counts as 2023."""
issuers = {i for _, i in pairs}
names = " ".join(s + " " + i for s, i in pairs)
if MS_CA_2023 in issuers:
return "microsoft-2023"
if MS_CA_2011 in issuers:
return "microsoft-2011"
if "Microsoft" in names:
return "microsoft"
if IPXE_CA in issuers:
return "ipxe"
return "unsigned" if not pairs else "other"
def _sbat(data):
"""The SBAT CSV a shim or iPXE build carries, as {component: [fields]}."""
i = data.find(b"sbat,1,SBAT Version,")
if i < 0:
return {}
text = data[i:i + 4096].split(b"\0", 1)[0].decode("ascii", errors="replace")
out = {}
for line in text.splitlines():
fields = line.split(",")
if len(fields) >= 2 and fields[0] != "sbat":
out[fields[0]] = fields
return out
def version_of(path):
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return ""
# A shim says so in its build ident; the shim's SBAT also names iPXE
# (shim.ipxe), so test this first.
m = re.search(rb"\$Version: ([^ $]{1,30})", data)
if m:
return "shim " + m.group(1).decode(errors="replace")
sbat = _sbat(data)
if "ipxe" in sbat and len(sbat["ipxe"]) >= 5:
return "iPXE " + sbat["ipxe"][4]
m = re.search(rb"([0-9]+\.[0-9]+\.[0-9]+\+? ?\(g[0-9a-f]+\))", data)
if m:
return "iPXE " + m.group(1).decode(errors="replace")
return ""
def sbat_generations(path):
"""'shim 4, shim.ipxe 1': the generation numbers a revocation compares."""
try:
with open(path, "rb") as f:
sbat = _sbat(f.read())
except OSError:
return ""
return ", ".join(f"{k} {v[1]}" for k, v in sbat.items())
def vendor_cert_cn(path):
"""Subject CN of the certificate a shim trusts for its second stage (the
PE .vendor_cert section), or None. iPXE's shim embeds the iPXE Secure
Boot CA, so ipxe.efi must chain to that name."""
try:
with open(path, "rb") as f:
d = f.read()
pe = struct.unpack_from("<I", d, 0x3C)[0]
if d[pe:pe + 4] != b"PE\0\0":
return None
nsec = struct.unpack_from("<H", d, pe + 6)[0]
symptr, nsym = struct.unpack_from("<II", d, pe + 12)
optsz = struct.unpack_from("<H", d, pe + 20)[0]
strtab = symptr + nsym * 18
off = pe + 24 + optsz
for i in range(nsec):
raw, _vsize, _vaddr, _rsize, rptr = struct.unpack_from("<8sIIII", d, off + 40 * i)
name = raw.rstrip(b"\0")
if name.startswith(b"/") and symptr: # long name, in the COFF string table
o = strtab + int(name[1:])
name = d[o:d.index(b"\0", o)]
if name != b".vendor_cert":
continue
size, _dsize, cert_off, _doff = struct.unpack_from("<IIII", d, rptr)
cert = d[rptr + cert_off:rptr + cert_off + size]
# DER: every CN is OID 2.5.4.3 then a short string. Issuer comes
# before subject, so the last one is the subject.
cns = [cert[m.end() + 2:m.end() + 2 + cert[m.end() + 1]].decode(errors="replace")
for m in re.finditer(rb"\x06\x03\x55\x04\x03(?=[\x0c\x13\x16])", cert)]
return cns[-1] if cns else None
except (OSError, struct.error, ValueError, IndexError):
return None
return None
def describe(path):
if not os.path.isfile(path):
return None
st = os.stat(path)
efi = path.endswith(".efi")
pairs, error = [], ""
if efi:
try:
pairs = signers(path)
except (OSError, subprocess.SubprocessError) as ex:
error = f"sbverify failed: {ex}"
return {
"size": st.st_size, "modified": st.st_mtime,
"sha256": uc.file_hash(path), "version": version_of(path),
"sbat": sbat_generations(path) if efi else "",
"signers": pairs, "kind": (signature_kind(pairs) if not error else "error") if efi else "bios",
"trusts": vendor_cert_cn(path) if efi else None,
"sig_error": error,
}
def _installed_log():
"""{name: {tag, sha256, at}}: which release each installed file came from."""
log = uc.read_json(os.path.join(_dir(), "installed.json"), {})
return log if isinstance(log, dict) else {}
def installed():
log = _installed_log()
rows = []
for name in list(MANAGED) + list(READ_ONLY):
info = describe(os.path.join(config.TFTP_ROOT, name))
origin = log.get(name) or {}
if info and origin.get("sha256") == info["sha256"]:
info["release"] = origin.get("tag")
rows.append({"name": name, "managed": name in MANAGED, "info": info})
# The shim only starts an ipxe.efi signed under the CA it embeds.
by = {r["name"]: r["info"] for r in rows}
shim, ipxe = by.get("ipxe-shim.efi"), by.get("ipxe.efi")
if shim and ipxe and shim.get("trusts"):
ipxe["chain_ok"] = shim["trusts"] in {i for _, i in ipxe["signers"]}
ipxe["trusts_needed"] = shim["trusts"]
return rows
# --- dnsmasq ----------------------------------------------------------------
MAC_RE = re.compile(r"^(?:[0-9a-fA-F*]{1,2}[:-]){5}[0-9a-fA-F*]{1,2}$")
# Debian/Ubuntu start dnsmasq with -7 /etc/dnsmasq.d,.dpkg-dist,.dpkg-old,
# .dpkg-new (CONFIG_DIR in /etc/default/dnsmasq), so that folder is read even
# when dnsmasq.conf never names it.
DEBIAN_CONF_DIR_EXCLUDE = (".dpkg-dist", ".dpkg-old", ".dpkg-new")
def _conf_dir_lines(folder, wanted, depth, seen):
"""dnsmasq conf-dir rules: '*.conf' entries are the only names read
(if any are given), other entries are suffixes to skip, and dot files
and '~' backups are always skipped."""
include = [w[1:] for w in wanted if w.startswith("*")]
exclude = [w for w in wanted if w and not w.startswith("*")]
out = []
for p in sorted(glob.glob(os.path.join(folder, "*"))):
base = os.path.basename(p)
if base.startswith(".") or base.endswith("~") or not os.path.isfile(p):
continue
if include and not any(base.endswith(w) for w in include):
continue
if any(base.endswith(w) for w in exclude):
continue
out += _conf_lines(p, depth + 1, seen)
return out
def _conf_lines(path, depth=0, seen=None):
"""Lines of dnsmasq.conf plus any conf-file/conf-dir it pulls in. Each
file is read once, so an include named twice does not list MACs twice."""
seen = set() if seen is None else seen
real = os.path.realpath(path)
if real in seen:
return []
seen.add(real)
try:
with open(path, encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
except OSError:
return []
out = []
for line in lines:
s = line.split("#", 1)[0].strip()
if not s:
continue
out.append((path, s))
if depth > 2:
continue
if s.startswith("conf-file="):
out += _conf_lines(s.split("=", 1)[1].strip(), depth + 1, seen)
elif s.startswith("conf-dir="):
parts = s.split("=", 1)[1].split(",")
out += _conf_dir_lines(parts[0].strip(), [p.strip() for p in parts[1:]], depth, seen)
if depth == 0:
implicit = os.path.join(os.path.dirname(path), "dnsmasq.d")
if os.path.isdir(implicit):
out += _conf_dir_lines(implicit, list(DEBIAN_CONF_DIR_EXCLUDE), depth, seen)
return out
def dnsmasq_routing():
"""Which boot file each class of client gets, read from dnsmasq.conf.
Read-only: this module never edits dnsmasq."""
tag_macs, boots, raw = {}, [], []
for src, s in _conf_lines(config.DNSMASQ_CONF):
key, _, val = s.partition("=")
key = key.strip()
if key == "dhcp-host":
fields = [f.strip() for f in val.split(",")]
macs = [f.lower() for f in fields if MAC_RE.match(f)]
for f in fields:
if f.startswith("set:"):
tag_macs.setdefault(f[4:], []).extend(macs)
elif key == "dhcp-boot":
fields = [f.strip() for f in val.split(",")]
need = [f[4:] for f in fields if f.startswith("tag:") and not f.startswith("tag:!")]
exclude = [f[5:] for f in fields if f.startswith("tag:!")]
rest = [f for f in fields if not f.startswith("tag:")]
boots.append({"file": rest[0] if rest else "", "need": need, "exclude": exclude,
"line": s, "source": src})
if key in ("dhcp-boot", "dhcp-host", "dhcp-match", "dhcp-userclass", "dhcp-vendorclass",
"tag-if", "enable-tftp", "tftp-root"):
if key != "dhcp-host" or "set:" in val:
raw.append(s)
for b in boots:
b["macs"] = sorted({m for t in b["need"] for m in tag_macs.get(t, [])})
# A path like ipxe2/ipxe-shim.efi is another copy, not the file in
# the TFTP root that this page installs.
rel = b["file"].lstrip("/")
b["managed"] = rel in MANAGED
b["read_only"] = rel in READ_ONLY
b["other_copy"] = rel not in MANAGED and os.path.basename(rel) in MANAGED
return {"tags": tag_macs, "boots": boots, "lines": raw,
"shim_macs": sorted({m for b in boots if os.path.basename(b["file"]) == "ipxe-shim.efi"
for m in b["macs"]})}
# --- upstream ---------------------------------------------------------------
def _version_tuple(text):
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text or "")
return tuple(int(x or 0) for x in m.groups()) if m else None
def compare(tag, installed_version):
"""'update' | 'current' | 'newer' (installed is ahead) | 'unknown'."""
a, b = _version_tuple(tag), _version_tuple(installed_version)
if not a or not b:
return "unknown"
return "update" if a > b else "current" if a == b else "newer"
def check_latest(timeout=20):
"""Latest GitHub release: tag, date, tarball URL and sha256. Cached in
latest.json so the page and the stage job agree on what to fetch."""
try:
rel = json.loads(uc.fetch_text(GITHUB_API, timeout=timeout))
except ValueError:
raise RuntimeError("GitHub returned something that is not JSON (proxy login page?)")
asset = next((a for a in rel.get("assets", []) if a.get("name") == TARBALL), None)
if not rel.get("tag_name"):
raise RuntimeError("GitHub answer has no release tag")
if not asset:
raise RuntimeError(f"release {rel.get('tag_name')} has no {TARBALL}")
digest = str(asset.get("digest") or "")
page = str(rel.get("html_url") or "") # shown as a link on the page
info = {
"tag": rel.get("tag_name"), "name": rel.get("name") or "",
"published": rel.get("published_at"),
"page": page if page.startswith("https://") else RELEASES_PAGE,
"url": asset.get("browser_download_url"),
"size": asset.get("size"),
"sha256": digest.split(":", 1)[1].lower() if digest.startswith("sha256:") else "",
"checked": uc.now(),
}
uc.write_json(os.path.join(_dir(), "latest.json"), info)
return info
def last_check():
return uc.read_json(os.path.join(_dir(), "latest.json"))
def _safe_tag(tag):
tag = re.sub(r"[^\w.-]", "_", str(tag or ""))
if not tag or tag in (".", ".."):
raise ValueError("bad release tag")
return tag
def _resolve(members, name):
"""Follow symlinks/hardlinks inside the archive to a regular member."""
for _ in range(8):
m = members.get(name)
if m is None or m.isfile():
return m
if m.issym():
name = os.path.normpath(os.path.join(os.path.dirname(name), m.linkname))
elif m.islnk():
name = os.path.normpath(m.linkname)
else:
return None
return None
def _progress(**fields):
data = uc.read_json(_job_file(), {}) or {}
data.update(fields, updated=uc.now())
uc.write_json(_job_file(), data)
def stage(info=None):
"""Download the tarball, verify it, unpack the managed files to
staged/<tag>/ and check each one's signature. Returns the staged record."""
info = info or last_check() or check_latest()
if not info.get("sha256"):
raise RuntimeError("GitHub did not publish a sha256 for this release; stage it by hand")
folder = os.path.join(_dir(), "staged", _safe_tag(info["tag"]))
os.makedirs(folder, exist_ok=True)
tar_path = os.path.join(folder, TARBALL)
_progress(step="download", tag=info["tag"], size=info.get("size"), tarball=tar_path)
if os.path.isfile(tar_path) and uc.file_hash(tar_path).lower() != info["sha256"].lower():
# A partial is resumed. A wrong file at full size or more cannot be
# (curl fails with 416/36 on every retry), so start it over.
size = info.get("size") or 0
if not size or os.path.getsize(tar_path) >= size:
os.remove(tar_path)
if not (os.path.isfile(tar_path) and uc.file_hash(tar_path).lower() == info["sha256"].lower()):
# Room for a slow cap: twice the time the cap allows, at least 10 min.
cap = float(uc.settings().get("max_mbps") or 0)
size = float(info.get("size") or 0)
timeout = max(600, int(2 * size / (cap * 1024 * 1024))) if cap and size else 600
uc.download(info["url"], tar_path, expect=info["sha256"], timeout=timeout)
_progress(step="verify")
files = {}
with tarfile.open(tar_path) as tar:
members = {os.path.normpath(m.name[2:] if m.name.startswith("./") else m.name): m
for m in tar.getmembers()}
for name, (inner, need) in MANAGED.items():
m = _resolve(members, inner)
if m is None:
files[name] = {"ok": False, "error": f"{inner} not in the tarball"}
continue
dest = os.path.join(folder, name)
tmp = dest + ".part"
with tar.extractfile(m) as src, open(tmp, "wb") as out:
shutil.copyfileobj(src, out)
os.replace(tmp, dest)
d = describe(dest)
if d["sig_error"]:
ok, error = False, d["sig_error"]
elif need and not d["kind"].startswith(need):
ok, error = False, f"expected a signature under {NEED_LABEL[need]}, found {d['kind']}"
elif not d["size"]:
ok, error = False, "empty file"
else:
ok, error = True, ""
files[name] = dict(d, ok=ok, error=error, source=inner)
# The shim from this release must trust the CA its ipxe.efi is signed by.
shim, ipxe = files.get("ipxe-shim.efi") or {}, files.get("ipxe.efi") or {}
if shim.get("ok") and ipxe.get("ok") and shim.get("trusts"):
if shim["trusts"] not in {i for _, i in ipxe["signers"]}:
ipxe.update(ok=False, error=f"not signed under {shim['trusts']}, which this shim trusts")
record = {"tag": info["tag"], "published": info.get("published"), "folder": folder,
"tarball_sha256": info["sha256"], "files": files, "staged": uc.now()}
uc.write_json(os.path.join(folder, "staged.json"), record)
return record
def staged(tag=None):
root = os.path.join(_dir(), "staged")
if not os.path.isdir(root):
return []
out = []
for t in sorted(os.listdir(root), reverse=True):
rec = uc.read_json(os.path.join(root, t, "staged.json"))
if not (isinstance(rec, dict) and rec.get("tag") and isinstance(rec.get("files"), dict)
and rec["files"]):
continue # a hand-edited or half-written record has nothing to install
if tag is None or rec.get("tag") == tag:
# Where it is now, not the path recorded at staging time.
rec["folder"] = os.path.join(root, t)
out.append(rec)
out.sort(key=lambda r: r.get("staged") or "", reverse=True)
return out
# --- background stage job ---------------------------------------------------
def job_state():
"""Last stage job's progress, with 'running' from systemd and, while
downloading, a percent from the partial file size."""
data = uc.read_json(_job_file(), {}) or {}
try:
running = uc.job_running(JOB_UNIT)
except OSError:
running = False
data["running"] = running
if data.get("state") in ("queued", "running") and not running:
# Give systemd-run a moment to bring the unit up before calling it dead.
try:
age = datetime.now().timestamp() - os.path.getmtime(_job_file())
except OSError:
age = 0
if age > 20:
data["state"] = "interrupted"
if data.get("step") == "download" and data.get("size") and data.get("tarball"):
try:
done = os.path.getsize(data["tarball"])
data["percent"] = min(100, int(100 * done / data["size"]))
except OSError:
data["percent"] = 0
return data
def start_stage():
"""Queue the stage job (systemd-run). The page polls job_state()."""
if uc.job_running(JOB_UNIT):
raise RuntimeError("a download is already running")
info = last_check()
if not info:
raise RuntimeError("check for updates first")
uc.write_json(_job_file(), {"state": "queued", "tag": info["tag"], "started": uc.now(),
"updated": uc.now()})
try:
uc.start_job(JOB_UNIT, ["services.ipxe_update", "stage"])
except Exception as ex:
uc.write_json(_job_file(), {"state": "error", "tag": info["tag"], "error": str(ex),
"updated": uc.now()})
raise
def run_stage_job():
"""Entry point of the background job. Never raises; the outcome goes to job.json."""
_progress(state="running", step="start", error="", started=uc.now())
try:
rec = stage(last_check())
bad = [n for n, f in rec["files"].items() if not f.get("ok")]
_progress(state="done", step="done", tag=rec["tag"], finished=uc.now(),
failed=bad)
return 0
except Exception as ex: # job boundary: record any failure for the page
_progress(state="error", error=str(ex)[:500], finished=uc.now())
return 1
# --- install / rollback -----------------------------------------------------
def _new_folder(root):
"""A fresh timestamped folder; never reuses one (a rollback in the same
second as its backup must not overwrite the files it restores)."""
base = os.path.join(root, datetime.now().strftime("%Y%m%d-%H%M%S"))
folder, n = base, 1
while True:
try:
os.makedirs(folder)
return folder
except FileExistsError:
folder, n = f"{base}-{n}", n + 1
def _backup(names, reason):
folder = _new_folder(os.path.join(_dir(), "backups"))
saved, absent = [], []
for name in names:
src = os.path.join(config.TFTP_ROOT, name)
if os.path.isfile(src):
shutil.copy2(src, os.path.join(folder, name))
saved.append(name)
else:
absent.append(name)
uc.write_json(os.path.join(folder, "backup.json"),
{"reason": reason, "files": saved, "absent": absent, "at": uc.now()})
return folder
class _Lock:
"""One install or rollback at a time: two at once would back up each
other's half-placed files."""
def __enter__(self):
self.f = open(os.path.join(_dir(), ".install.lock"), "w")
try:
fcntl.flock(self.f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self.f.close()
raise RuntimeError("another install or rollback is running; try again") from None
return self
def __exit__(self, *exc):
self.f.close()
def _prepare(src, name, sha256=None):
"""Copy src next to its TFTP name under a temporary name, fsynced, mode
0644 and with the current file's owner. With sha256, the copy is checked.
Returns the temporary path; nothing is live yet."""
dest = os.path.join(config.TFTP_ROOT, name)
fd, tmp = tempfile.mkstemp(dir=config.TFTP_ROOT, prefix=f".{name}.", suffix=".new")
try:
with open(src, "rb") as s, os.fdopen(fd, "wb") as out:
shutil.copyfileobj(s, out)
out.flush()
os.fsync(out.fileno())
if sha256 and uc.file_hash(tmp).lower() != sha256.lower():
raise RuntimeError(f"{name}: copy does not match its sha256, nothing replaced")
os.chmod(tmp, 0o644)
try:
st = os.stat(dest)
os.chown(tmp, st.st_uid, st.st_gid)
except OSError:
pass # no current file, or not root (local tests)
return tmp
except BaseException:
os.remove(tmp)
raise
def _place_all(items):
"""[(src, name, sha256)] into the TFTP root. Every copy is made and
checked first, then each is renamed into place (atomic per file, so a
booting client never reads half a file). A failed copy leaves all the
live files as they were."""
ready = []
try:
for src, name, sha256 in items:
ready.append((_prepare(src, name, sha256), name))
except BaseException:
for tmp, _ in ready:
os.remove(tmp)
raise
for tmp, name in ready:
os.replace(tmp, os.path.join(config.TFTP_ROOT, name))
def install(tag, names=DEFAULT_INSTALL):
"""Copy staged files for `tag` into the TFTP root after backing up the
current ones. Refuses a set with a failed signature check, and refuses to
install ipxe-shim.efi without its matching ipxe.efi (the shim only trusts
the iPXE CA the same release signs with). Returns the backup folder."""
rec = next(iter(staged(tag)), None)
if not rec:
raise ValueError(f"{tag} is not staged")
names = [n for n in MANAGED if n in names]
if not names:
raise ValueError("nothing selected")
if "ipxe-shim.efi" in names and "ipxe.efi" not in names:
raise ValueError("install ipxe-shim.efi together with ipxe.efi")
if uc.job_running(JOB_UNIT):
raise RuntimeError("a download is running; wait for it to finish")
with _Lock():
return _install(tag, rec, names)
def _install(tag, rec, names):
for n in names:
f = rec["files"].get(n) or {}
if not f.get("ok"):
raise ValueError(f"{n}: {f.get('error') or 'not staged'}")
path = os.path.join(rec["folder"], n)
if not os.path.isfile(path) or uc.file_hash(path) != f["sha256"]:
raise ValueError(f"{n}: staged copy changed since it was verified; download again")
if "ipxe.efi" in names and "ipxe-shim.efi" not in names:
# ipxe.efi alone must still be one the installed shim will start.
trusts = vendor_cert_cn(os.path.join(config.TFTP_ROOT, "ipxe-shim.efi"))
issuers = {i for _, i in rec["files"]["ipxe.efi"].get("signers", [])}
if trusts and trusts not in issuers:
raise ValueError(f"the installed shim trusts {trusts}; this ipxe.efi is not signed "
"under it, install the shim from the same release too")
backup = _backup(names, f"before installing {tag}")
_place_all([(os.path.join(rec["folder"], n), n, rec["files"][n]["sha256"]) for n in names])
log = _installed_log()
for n in names:
log[n] = {"tag": tag, "sha256": rec["files"][n]["sha256"], "at": uc.now()}
uc.write_json(os.path.join(_dir(), "installed.json"), log)
return backup
def backups():
root = os.path.join(_dir(), "backups")
if not os.path.isdir(root):
return []
out = []
for b in sorted(os.listdir(root), reverse=True):
folder = os.path.join(root, b)
if not os.path.isdir(folder):
continue
meta = uc.read_json(os.path.join(folder, "backup.json"), {})
meta = meta if isinstance(meta, dict) else {}
files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))]
out.append({"id": b, "reason": meta.get("reason", ""), "at": meta.get("at", ""),
"files": files, "absent": meta.get("absent", []),
"versions": {f: version_of(os.path.join(folder, f)) for f in files}})
return out
def rollback(backup_id):
"""Put the files of one backup back, after backing up what is there now."""
bid = os.path.basename(str(backup_id or ""))
folder = os.path.join(_dir(), "backups", bid)
if not bid or bid.startswith(".") or not os.path.isdir(folder):
raise ValueError("no such backup")
files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))]
if not files:
raise ValueError("that backup holds no boot files")
with _Lock():
_backup(files, f"before rolling back to {bid}")
_place_all([(os.path.join(folder, f), f, uc.file_hash(os.path.join(folder, f)))
for f in files])
log = _installed_log()
for f in files:
log.pop(f, None)
uc.write_json(os.path.join(_dir(), "installed.json"), log)
return files
if __name__ == "__main__":
if sys.argv[1:2] == ["stage"]:
sys.exit(run_stage_job())
print("usage: python3 -m services.ipxe_update stage", file=sys.stderr)
sys.exit(2)

371
webapp/services/mcl_ppkg.py Normal file
View File

@@ -0,0 +1,371 @@
"""MCL provisioning packages for gea-standard and gea-engineer.
The vendor's bulk-enroll scripts in _shared/Applications/BPRT (every image's
Deploy/Applications/BPRT is a symlink to it) pick the package by filename:
* the name must match ^((?:\\w|-)+?_){5,}v\\d+\\.\\d+.ppkg$ (PowerShell
-Match, so case-insensitive), e.g. GCCH_Prod_MCL-PERS_Exp_XXXXXXXX_v2.19.ppkg;
* Insert-BPRTToPPKG.ps1 then keeps the names that are -like
"<Tenant>_<Environment>_<Purpose>*" (a case-insensitive startswith, with
no trailing underscore) and FAILS enrollment when more than one is left.
So an upload replaces the package with the same first three fields, and the
old one moves to BPRT-archive/<timestamp>/ - outside BPRT, because PESetup
copies the whole folder to every PC. Restore swaps an archived package back
the same way. Either is refused when the result would leave two packages that
one prefix claims (e.g. MCL-SH and MCL-SHX).
A package is a WIM (Insert-BPRTToPPKG.ps1 renames it to .wim and mounts it),
so an upload must start with the WIM magic. The upload is written under the
archive folder first and renamed into BPRT only when complete, so a bay
copying BPRT mid-upload never sees a partial file.
"""
from __future__ import annotations
import errno
import os
import re
import shutil
import threading
import time
from datetime import datetime
import config
# The vendor's own pattern (Insert-BPRTToPPKG.ps1 / Start-BulkEnrollOrchestrator.ps1).
VENDOR_PATTERN = re.compile(r"^((?:\w|-)+?_){5,}v\d+\.\d+.ppkg$", re.IGNORECASE)
_VERSION = re.compile(r"_(v\d+\.\d+)\.ppkg$", re.IGNORECASE)
# Insert-BPRTToPPKG.ps1 swaps this placeholder for the token's expiry date on
# the PC's copy; a name without it is a package already used on a PC.
_PLACEHOLDER = "_exp_xxxxxxxx_"
_BATCH = re.compile(r"^\d{8}-\d{6}(-\d+)?$")
WIM_MAGIC = b"MSWIM\0\0\0"
# Serialises the archive-and-place step (uploads and restores).
_lock = threading.Lock()
def bprt_dir():
return os.path.join(config.SHARED_DIR, "Applications", "BPRT")
def archive_dir():
return os.path.join(config.SHARED_DIR, "Applications", "BPRT-archive")
def clean_name(name):
"""Bare file name; some browsers send a full Windows path."""
return os.path.basename(str(name or "").replace("\\", "/")).strip()
def is_mcl_name(name):
"""True for a name the bulk-enroll scripts would treat as an MCL package
(the shopfloor SFLD names, e.g. GCCH_Prod_SFLD_v4.18.ppkg, are not)."""
return bool(VENDOR_PATTERN.match(clean_name(name)))
def parse(name, strict=True):
"""{"prefix", "version"} for a usable package name, else ValueError.
The vendor regex ends in `.ppkg` with an unescaped dot, so it also takes
e.g. ..._v2.19-ppkg; strict (uploads, restores) wants the real extension,
while the listing (strict=False) shows what the enroll scripts would see."""
if not VENDOR_PATTERN.match(name) or (strict and not name.lower().endswith(".ppkg")):
raise ValueError(
f"{name} does not match the vendor naming the enroll scripts need, "
"e.g. GCCH_Prod_MCL-PERS_Exp_XXXXXXXX_v2.19.ppkg")
m = _VERSION.search(name)
return {"prefix": "_".join(name.split("_")[:3]),
"version": m.group(1) if m else ""}
def _claims(prefix, name):
"""What the vendor's `-like "$packagePrefix*"` does."""
return name.lower().startswith(prefix.lower())
def _clashes(names):
"""{name: [other names its prefix also claims]} for a set of vendor-valid
names; empty when every prefix finds exactly one package."""
out = {}
for n in names:
others = [m for m in names if m != n and _claims(parse(n, strict=False)["prefix"], m)]
if others:
out[n] = others
return out
def _check_unique(names, name):
"""Refuse when `name` would be part of a clash: its prefix claims another
package, or another's prefix claims it. A clash among other packages
(made by hand) is flagged on the page but does not block this one."""
clashes = {n: o for n, o in _clashes(names).items() if n == name or name in o}
if clashes:
n, others = next(iter(clashes.items()))
raise ValueError(
f"{parse(n, strict=False)['prefix']} would match {n} and {', '.join(others)}; "
"the enroll scripts fail when one prefix finds two packages")
def _pkg(folder, name):
st = os.stat(os.path.join(folder, name))
info = {"filename": name, "size": st.st_size, "modified": st.st_mtime,
"placeholder": _PLACEHOLDER in name.lower()}
try:
info.update(parse(name, strict=False))
except ValueError:
info.update(prefix="", version="")
return info
def _listed(folder, name):
return ((name.lower().endswith(".ppkg") or VENDOR_PATTERN.match(name))
and os.path.isfile(os.path.join(folder, name)))
def packages():
"""Current packages in BPRT, sorted by prefix."""
folder = bprt_dir()
out = []
if os.path.isdir(folder):
for name in os.listdir(folder):
if _listed(folder, name):
out.append(_pkg(folder, name))
out.sort(key=lambda p: (p["prefix"].lower(), p["filename"].lower()))
# A prefix that finds two packages breaks enrollment for that tenant/purpose.
clashes = _clashes([p["filename"] for p in out if p["prefix"]])
for p in out:
p["conflict_with"] = clashes.get(p["filename"], [])
p["conflict"] = bool(p["conflict_with"])
return out
def archived():
"""Archived packages, newest batch first: [{"batch", "filename", ...}]."""
root = archive_dir()
out = []
if os.path.isdir(root):
for batch in sorted(os.listdir(root), reverse=True):
folder = os.path.join(root, batch)
if not _BATCH.match(batch) or not os.path.isdir(folder):
continue # .incoming (uploads in progress) and strays
for name in sorted(os.listdir(folder)):
if _listed(folder, name):
out.append(dict(_pkg(folder, name), batch=batch))
return out
# --- moving files -----------------------------------------------------------
def _move(src, dst):
"""Rename src onto dst. BPRT and its archive sit on one filesystem, so this
is a rename; the copy fallback only covers a share split across mounts."""
try:
os.replace(src, dst)
except OSError as exc:
if exc.errno != errno.EXDEV:
raise
tmp = os.path.join(os.path.dirname(dst), f".{os.path.basename(dst)}.part")
try:
shutil.copy2(src, tmp)
os.replace(tmp, dst)
finally:
if os.path.exists(tmp):
os.remove(tmp)
os.remove(src)
def _new_batch():
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
path = os.path.join(archive_dir(), stamp)
n = 1
while os.path.exists(path): # two changes in one second must not share a batch
n += 1
path = os.path.join(archive_dir(), f"{stamp}-{n}")
os.makedirs(path)
_match_perms(path, directory=True)
return path
def _archive(names):
"""Move these BPRT packages into a new archive batch. Returns the batch
path (None when there was nothing to move)."""
if not names:
return None
batch = _new_batch()
done = []
try:
for name in names:
_move(os.path.join(bprt_dir(), name), os.path.join(batch, name))
done.append(name)
except OSError:
_unarchive(batch, done)
raise
return batch
def _unarchive(batch, names):
"""Undo _archive after a failed placement: best effort, every file back."""
for name in names:
try:
_move(os.path.join(batch, name), os.path.join(bprt_dir(), name))
except OSError:
pass
try:
os.rmdir(batch)
except OSError:
pass
def _same_prefix(prefix):
"""Valid BPRT package names whose own prefix equals this one."""
return [p["filename"] for p in packages()
if p["prefix"] and p["prefix"].lower() == prefix.lower()]
def _plan(name, info):
"""(names to archive) for placing `name`, or ValueError if the result
would leave a prefix finding two packages."""
current = [p["filename"] for p in packages() if p["prefix"]]
replaced = _same_prefix(info["prefix"])
_check_unique([n for n in current if n not in replaced] + [name], name)
return replaced
def _place(src, name, info):
"""Archive what `name` replaces, then rename src into BPRT. Returns the
archived names."""
with _lock:
replaced = _plan(name, info)
batch = _archive(replaced)
dest = os.path.join(bprt_dir(), name)
try:
_move(src, dest)
except OSError:
if batch:
_unarchive(batch, replaced)
raise
return replaced
# --- upload and restore -----------------------------------------------------
def check_upload(name):
"""Validate a name before the body is saved: (clean name, info)."""
name = clean_name(name)
info = parse(name)
_plan(name, info)
return name, info
def install_upload(fileobj, name):
"""Save an uploaded package into BPRT, archiving the one it replaces.
Returns (info, archived_names); info["filename"] is the name used and
info["warnings"] lists things worth telling the user."""
name, info = check_upload(name)
os.makedirs(bprt_dir(), exist_ok=True)
incoming = os.path.join(archive_dir(), ".incoming")
os.makedirs(incoming, exist_ok=True)
_sweep(incoming)
_match_perms(archive_dir(), directory=True)
_match_perms(incoming, directory=True)
tmp = os.path.join(incoming, f"{os.getpid()}-{threading.get_ident()}-{name}.part")
try:
fileobj.save(tmp)
if os.path.getsize(tmp) == 0:
raise ValueError("uploaded file is empty")
with open(tmp, "rb") as f:
if f.read(len(WIM_MAGIC)) != WIM_MAGIC:
raise ValueError(f"{name} is not a provisioning package (no WIM header)")
moved = _place(tmp, name, info)
_match_perms(os.path.join(bprt_dir(), name))
finally:
if os.path.exists(tmp):
os.remove(tmp)
info["filename"] = name
info["warnings"] = []
if _PLACEHOLDER not in name.lower():
info["warnings"].append(
f"{name} has no Exp_XXXXXXXX placeholder; a package already used on a PC "
"carries its BPRT and the enroll script cannot inject a new one")
return info, moved
def _sweep(incoming, age=86400):
"""Drop temp files a crashed upload left behind (300 MB each, invisible
on the page). A live upload's file is seconds old."""
cutoff = time.time() - age
for n in os.listdir(incoming):
path = os.path.join(incoming, n)
try:
if n.endswith(".part") and os.path.isfile(path) and os.path.getmtime(path) < cutoff:
os.remove(path)
except OSError:
pass
def archive(name):
"""Move one package out of BPRT into a new archive batch (clears a
duplicate made by hand). Returns the bare name."""
name = clean_name(name)
with _lock:
if name not in [p["filename"] for p in packages()]:
raise ValueError(f"{name or '(blank)'} is not a package in BPRT")
_archive([name])
return name
def restore(batch, name):
"""Put an archived package back, archiving whatever holds its prefix now."""
batch = str(batch or "")
if not _BATCH.match(batch):
raise ValueError(f"{batch or '(blank)'} is not an archive batch")
name = clean_name(name)
folder = os.path.join(archive_dir(), batch)
src = os.path.join(folder, name)
if not name or not os.path.isfile(src):
raise ValueError(f"{batch}/{name} is not in the archive")
info = parse(name)
moved = _place(src, name, info)
try:
os.rmdir(folder)
except OSError:
pass # batch still holds other packages
info["filename"] = name
return info, moved
def _match_perms(path, directory=False):
"""Uploads run as root; keep the share's pxe ownership so the Samba side
can still manage the file."""
try:
st = os.stat(bprt_dir())
os.chown(path, st.st_uid, st.st_gid)
os.chmod(path, 0o755 if directory else 0o744)
except OSError:
pass
def image_links():
"""[{"image", "state", "target"}] for every image with a
Deploy/Applications folder: state "ok" when its BPRT is the link to the
shared folder, "copy" when it is a real folder (an image import rsync
replaces the link with one, and then that image's PCs never see a
package changed here), "elsewhere" when it links somewhere else or
dangles, "none" when the image has no BPRT."""
shared = os.path.realpath(bprt_dir())
out = []
for image in config.IMAGE_TYPES:
apps = os.path.join(config.SAMBA_SHARE, image, "Deploy", "Applications")
if not os.path.isdir(apps):
continue
path = os.path.join(apps, "BPRT")
target = ""
if os.path.islink(path):
target = os.readlink(path)
state = "ok" if os.path.realpath(path) == shared and os.path.isdir(path) else "elsewhere"
elif os.path.isdir(path):
state = "copy"
else:
state = "none"
out.append({"image": image, "state": state, "target": target, "path": path})
return out

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
"""Shared plumbing for the Updates pages (Dell drivers/BIOS and iPXE).
* settings: the download speed cap, one small JSON file;
* download(): curl with that cap, resume, and a hash check;
* background jobs: long downloads run as a transient systemd unit
(systemd-run), so a webapp restart or a closed browser does not kill a
40-minute driver pull. Each job writes its progress to a JSON state file
the page polls.
The server reaches the internet through its corporate NIC (enp0s31f6); GE's
Zscaler re-signs GitHub, so the Zscaler root CA must be in the system trust
store (the playbook installs it). downloads.dell.com is not intercepted.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
import config
DEFAULT_SETTINGS = {
# MB/s; 0 = no cap. 10 MB/s is about 80 Mbps, a fifth of the site link.
"max_mbps": 10,
}
def state_dir(*parts):
path = os.path.join(config.UPDATES_DIR, *parts)
os.makedirs(path, exist_ok=True)
return path
def now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def read_json(path, default=None):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return default
def write_json(path, data):
folder = os.path.dirname(path)
os.makedirs(folder, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".tmp-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=1)
os.chmod(tmp, 0o644) # mkstemp makes 0600
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.remove(tmp)
raise
# --- settings ---------------------------------------------------------------
def settings():
data = dict(DEFAULT_SETTINGS)
saved = read_json(os.path.join(state_dir(), "settings.json"), {})
if isinstance(saved, dict):
data.update(saved)
try:
cap = float(data.get("max_mbps"))
except (TypeError, ValueError):
cap = -1
if not math.isfinite(cap) or cap < 0:
data["max_mbps"] = DEFAULT_SETTINGS["max_mbps"] # hand-edited junk
return data
def save_settings(max_mbps):
try:
value = float(str(max_mbps).strip())
except ValueError:
raise ValueError("speed cap must be a number of MB/s") from None
# float() takes "nan", which passes both comparisons below.
if not math.isfinite(value) or value < 0 or value > 1000:
raise ValueError("speed cap must be between 0 and 1000 MB/s")
data = settings()
# A tiny cap must not round to 0, which means unlimited.
data["max_mbps"] = max(round(value, 1), 0.1) if value > 0 else 0
write_json(os.path.join(state_dir(), "settings.json"), data)
return data
# --- downloads --------------------------------------------------------------
def file_hash(path, algorithm="sha256"):
h = hashlib.new(algorithm)
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 22), b""):
h.update(block)
return h.hexdigest()
def download(url, dest, expect=None, algorithm="sha256", timeout=None):
"""Fetch url to dest at the configured speed cap, resuming a partial file.
expect: hex digest to verify (case-insensitive). Raises on failure.
Without expect nothing catches a stale file resumed into, so callers
that pass no hash should remove dest first."""
os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True)
cmd = ["curl", "-sS", "-L", "--fail", "--retry", "5", "--retry-delay", "5",
"-C", "-", "-o", dest]
cap = settings().get("max_mbps") or 0
if cap:
cmd += ["--limit-rate", f"{max(int(float(cap) * 1024 * 1024), 1024)}"]
if timeout:
cmd += ["-m", str(int(timeout))]
for attempt in range(2):
r = subprocess.run(cmd + [url], capture_output=True, text=True)
if r.returncode == 33:
# The server will not resume: the partial on disk is not the
# file, so throw it away and fetch from the start.
if os.path.exists(dest):
os.remove(dest)
continue
if r.returncode:
raise RuntimeError(f"download failed ({r.returncode}): {r.stderr.strip()[:300]}")
if not expect or file_hash(dest, algorithm).lower() == expect.strip().lower():
return dest
# A corrupt partial resumes into a corrupt whole: start over once.
os.remove(dest)
if expect and os.path.exists(dest):
os.remove(dest)
raise RuntimeError(f"could not download a verified copy of {os.path.basename(dest)}"
+ (f" ({algorithm} mismatch)" if expect else ""))
def fetch_text(url, timeout=30):
r = subprocess.run(["curl", "-sS", "-L", "--fail", "-m", str(timeout), url],
capture_output=True)
if r.returncode:
raise RuntimeError(f"cannot fetch {url}: {r.stderr.decode(errors='replace').strip()[:300]}")
return r.stdout
# --- background jobs --------------------------------------------------------
def job_running(unit):
r = subprocess.run(["systemctl", "is-active", "--quiet", unit])
return r.returncode == 0
def _job_env():
"""--setenv flags for the job: a transient unit starts with an empty
environment, but pxe-webapp.service sets the share paths (SAMBA_SHARE,
WEB_ROOT, AUDIT_LOG, ...) and TZ, and the job must see the same config."""
keep = {"TZ", "LANG", "PYTHONPATH"}
# Never secrets: systemd-run's argv is readable by every local user.
secret = ("KEY", "SECRET", "PASS", "TOKEN")
return [f"--setenv={k}={v}" for k, v in sorted(os.environ.items())
if (k in keep or (k.isupper() and hasattr(config, k)))
and not any(w in k for w in secret) and "\n" not in v]
def start_job(unit, args):
"""Run `python3 -m <args>` from the webapp directory as a transient
systemd unit, with the webapp's own interpreter and config environment.
Refuses when the unit is already running."""
if job_running(unit):
raise RuntimeError("a job of this kind is already running")
subprocess.run(["systemctl", "reset-failed", unit], capture_output=True)
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
r = subprocess.run(
["systemd-run", f"--unit={unit}", "--collect", f"--working-directory={here}"]
+ _job_env() + [sys.executable or "/usr/bin/python3", "-m"] + list(args),
capture_output=True, text=True)
if r.returncode:
raise RuntimeError(f"systemd-run failed: {r.stderr.strip()[:300]}")
def stop_job(unit):
subprocess.run(["systemctl", "stop", unit], capture_output=True)

View File

@@ -0,0 +1,114 @@
/**
* image-pages.js - helpers shared by the GE Aerospace image tabs.
* Loaded by templates/_image_header.html. Exposes window.PxeImage:
*
* PxeImage.postJSON(url, body) POST JSON with the CSRF header;
* resolves {ok, status, data}.
* PxeImage.renderDiff(el, files) draw [{name, changed, diff, reformat}]
* (services/control_files.public) into el.
* PxeImage.reviewAndSave(opts) preview -> diff modal -> save -> reload.
* opts: previewUrl, saveUrl, body (object), title (optional),
* onSaved(data) (optional; default reloads the page).
*/
(function () {
'use strict';
function csrfToken() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.getAttribute('content') : '';
}
function postJSON(url, body) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken() },
body: JSON.stringify(body || {}),
credentials: 'same-origin'
}).then(function (resp) {
return resp.json().catch(function () { return { error: 'HTTP ' + resp.status }; })
.then(function (data) { return { ok: resp.ok, status: resp.status, data: data }; });
});
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function renderDiff(container, files) {
container.innerHTML = '';
var changed = (files || []).filter(function (f) { return f.changed; });
if (!changed.length) {
container.appendChild(el('div', 'alert alert-info mb-0', 'No changes: the files already hold exactly this.'));
return 0;
}
changed.forEach(function (f) {
var box = el('div', 'diff-file');
var head = el('div', 'diff-file-head');
head.appendChild(el('i', 'bi bi-file-earmark-diff'));
head.appendChild(el('span', 'mono', f.name));
if (!f.existed) head.appendChild(el('span', 'badge badge-info', 'new file'));
if (f.reformat) {
var w = el('span', 'badge badge-warning', 'whitespace also changes');
w.title = 'The current file is not laid out the way it will be written; ' +
'unchanged lines may show as changed.';
head.appendChild(w);
}
box.appendChild(head);
var pre = el('pre', 'diff-view');
String(f.diff || '').split('\n').forEach(function (line, i, all) {
if (i === all.length - 1 && line === '') return;
var c = '';
if (line.indexOf('+++') === 0 || line.indexOf('---') === 0) c = 'd-meta';
else if (line.indexOf('@@') === 0) c = 'd-hunk';
else if (line.charAt(0) === '+') c = 'd-add';
else if (line.charAt(0) === '-') c = 'd-del';
pre.appendChild(el('span', c, line || ' '));
});
box.appendChild(pre);
container.appendChild(box);
});
return changed.length;
}
function reviewAndSave(opts) {
var modalEl = document.getElementById('imageReviewModal');
var body = document.getElementById('imageReviewBody');
var err = document.getElementById('imageReviewError');
var confirmBtn = document.getElementById('imageReviewConfirm');
var title = document.getElementById('imageReviewTitle');
if (!modalEl || !window.bootstrap) return;
title.textContent = opts.title || 'Review changes';
body.innerHTML = '<div class="text-muted py-3">Building the preview...</div>';
err.classList.add('d-none');
confirmBtn.disabled = true;
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
function fail(msg) {
err.textContent = msg;
err.classList.remove('d-none');
}
postJSON(opts.previewUrl, opts.body).then(function (r) {
if (!r.ok) { body.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; }
var n = renderDiff(body, r.data.files);
var expect = {};
(r.data.files || []).forEach(function (f) { expect[f.name] = f.sha_before; });
confirmBtn.disabled = n === 0;
confirmBtn.onclick = function () {
confirmBtn.disabled = true;
var payload = Object.assign({}, opts.body, { expect: expect });
postJSON(opts.saveUrl, payload).then(function (s) {
if (!s.ok) { fail(s.data.error || 'Save failed.'); confirmBtn.disabled = false; return; }
if (opts.onSaved) opts.onSaved(s.data);
else window.location.reload();
}).catch(function (e) { fail('Save failed: ' + e); confirmBtn.disabled = false; });
};
}).catch(function (e) { body.innerHTML = ''; fail('Preview failed: ' + e); });
}
window.PxeImage = { postJSON: postJSON, renderDiff: renderDiff, reviewAndSave: reviewAndSave };
})();

View File

@@ -0,0 +1,512 @@
/**
* unattend-editor.js - the Unattend tab of a GE Aerospace image page
* (templates/unattend_editor.html). Needs static/image-pages.js (PxeImage,
* loaded by _image_header.html) and window.PXE_UNATTEND {previewUrl,
* saveUrl, baseSha}.
*
* - command rows: auto-growing textareas, length counters against the
* schema limits, move up/down, drag to reorder, add, remove;
* - a filter over both command lists (reordering is off while it is set);
* - section index that follows the scroll, and marks sections with edits;
* - Review changes (Ctrl+S): posts the form (or the Raw XML text) to
* previewUrl, shows the diff, the lint findings and the full XML in the
* shared review modal, then saves with the previewed file hash.
*
* Rows keep data-orig (their index in the file as parsed) so the server
* can tell a moved or edited item from a new one.
*/
(function () {
'use strict';
var CFG = window.PXE_UNATTEND || {};
function $(sel, root) { return (root || document).querySelector(sel); }
function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
var LIST_IDS = { spec: 'unaSpec', fl: 'unaFl', driverpaths: 'unaDriverPaths', accounts: 'unaAccounts' };
var CMD_FIELD = { spec: 'path', fl: 'commandline' };
function list(name) { return document.getElementById(LIST_IDS[name]); }
function rows(name) {
var l = list(name);
return l ? $$(':scope > .una-row, :scope > .una-simple', l) : [];
}
function origOf(row) {
return row.hasAttribute('data-orig') ? parseInt(row.getAttribute('data-orig'), 10) : null;
}
// ---------------------------------------------------------------------
// Reading the form into the dict services/unattend.apply_form takes
// ---------------------------------------------------------------------
function setKey(obj, key, value) {
var parts = key.split('.');
if (parts.length === 2) {
obj[parts[0]] = obj[parts[0]] || {};
obj[parts[0]][parts[1]] = value;
} else {
obj[key] = value;
}
}
function cmdRow(row, field) {
var o = { description: $('.una-desc', row).value, orig: origOf(row) };
o[field] = $('.una-cmd', row).value;
return o;
}
function collect() {
var f = {};
$$('.una-field').forEach(function (el) { setKey(f, el.getAttribute('data-key'), el.value); });
// A password typed here is plain text: an encoded one (PlainText false
// in the file) that is replaced must not keep PlainText false.
var alPass = document.getElementById('unaAlPass');
if (alPass && f.autologon && alPass.value !== alPass.defaultValue) f.autologon.plain_text = 'true';
f.driver_paths = rows('driverpaths').map(function (r) {
return { path: $('.una-dp', r).value, orig: origOf(r) };
});
f.user_accounts = rows('accounts').map(function (r) {
var a = { plain_text: r.getAttribute('data-plain') || 'true', orig: origOf(r) };
$$('[data-f]', r).forEach(function (i) { a[i.getAttribute('data-f')] = i.value; });
var pw = $('[data-f="password"]', r);
if (pw && pw.value !== pw.defaultValue) a.plain_text = 'true';
return a;
});
f.specialize_commands = rows('spec').map(function (r) { return cmdRow(r, 'path'); });
f.firstlogon_commands = rows('fl').map(function (r) { return cmdRow(r, 'commandline'); });
return f;
}
// What each index entry / card covers, to mark sections that have edits.
var SECTIONS = {
machine: ['computer_name', 'time_zone', 'registered_organization', 'registered_owner'],
intl: ['intl'], driverpaths: ['driver_paths'], accounts: ['user_accounts'],
autologon: ['autologon'], oobe: ['oobe'], oobetz: ['oobe_timezone'],
spec: ['specialize_commands'], fl: ['firstlogon_commands']
};
function sectionState(f) {
var out = {};
Object.keys(SECTIONS).forEach(function (s) {
out[s] = JSON.stringify(SECTIONS[s].map(function (k) { return f[k]; }));
});
return out;
}
// ---------------------------------------------------------------------
// Row bookkeeping: positions, counts, empty notes, edited marks
// ---------------------------------------------------------------------
function rowSnapshot(row) {
return JSON.stringify($$('input, textarea', row).map(function (i) { return i.value; }));
}
function renumber(name) {
var rs = rows(name);
rs.forEach(function (r, i) { var p = $('.una-pos', r); if (p) p.textContent = i + 1; });
$$('[data-count="' + name + '"]').forEach(function (c) { c.textContent = rs.length; });
var empty = $('[data-empty="' + name + '"]');
if (empty) empty.hidden = rs.length > 0;
}
// Length as lint-unattend.py counts it: the escaped text in the file.
function xmlLength(s) {
var n = s.length;
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i);
if (c === '&') n += 4; else if (c === '<' || c === '>') n += 3;
}
return n;
}
function counter(el, input, limit, always) {
if (!el || !limit) return;
var n = xmlLength(input.value);
var near = n > limit * 0.9;
el.classList.toggle('near', near && n <= limit);
el.classList.toggle('over', n > limit);
el.title = 'Characters as written in the XML (& counts 5, < and > count 4); limit ' + limit + '.';
if (!always && n < limit * 0.8) { el.textContent = ''; return; }
if (el.getAttribute('data-for') === 'desc') {
el.textContent = 'Description ' + n + ' / ' + limit + ' chars' +
(n > limit ? ': over the limit, Windows rejects the whole pass' : '');
} else {
el.textContent = n + ' / ' + limit + (n > limit ? ' over!' : '');
}
}
function refreshCounters(row) {
var cmd = $('.una-cmd', row);
if (!cmd) return;
var lim = parseInt(cmd.getAttribute('data-limit') || row.parentNode.getAttribute('data-limit'), 10);
counter($('[data-for="cmd"]', row), cmd, lim, true);
var desc = $('.una-desc', row);
counter($('[data-for="desc"]', row), desc, parseInt(desc.getAttribute('data-limit'), 10), false);
var foot = $('.una-foot', row);
if (foot) foot.hidden = !foot.textContent.trim();
}
function grow(ta) {
if (!ta.offsetParent) return; // hidden: sized when shown
ta.style.height = 'auto';
ta.style.height = (ta.scrollHeight + 2) + 'px';
}
function growAll() { $$('textarea.una-cmd').forEach(grow); }
// ---------------------------------------------------------------------
// Dirty state
// ---------------------------------------------------------------------
var raw = document.getElementById('unaRawXml');
var reviewBtn = document.getElementById('unaReview');
var initial = '', initialSections = {}, rawInitial = raw ? raw.value : '';
var saving = false;
function formDirty() { return JSON.stringify(collect()) !== initial; }
function rawDirty() { return raw && raw.value !== rawInitial; }
function update() {
var f = collect();
var fd = JSON.stringify(f) !== initial;
var rd = rawDirty();
var head = document.querySelector('.image-head');
if (head) head.classList.toggle('dirty', fd || rd);
if (reviewBtn) reviewBtn.disabled = !(fd || rd);
var now = sectionState(f);
var groups = {};
Object.keys(now).forEach(function (s) {
var changed = now[s] !== initialSections[s];
var a = $('.una-index a[href="#sec-' + s + '"]');
if (!a) return;
a.classList.toggle('has-edits', changed);
if (changed) groups[a.getAttribute('data-group')] = true;
});
$$('.una-index a.ix-group').forEach(function (a) {
a.classList.toggle('has-edits', !!groups[a.getAttribute('data-group')]);
});
$$('.una-field').forEach(function (el) {
if (el.type !== 'hidden') el.classList.toggle('una-field-changed', el.value !== el.getAttribute('data-initial'));
});
$$('.una-row[data-orig], .una-simple[data-orig]').forEach(function (r) {
r.classList.toggle('is-edited', rowSnapshot(r) !== r.getAttribute('data-initial'));
});
}
// ---------------------------------------------------------------------
// Editing: input, add, remove, move, drag
// ---------------------------------------------------------------------
document.addEventListener('input', function (e) {
var t = e.target;
if (t.classList && t.classList.contains('una-cmd')) {
if (/[\r\n]/.test(t.value)) { // one line in the file; a pasted break is a space
var pos = t.selectionStart;
t.value = t.value.replace(/[\r\n]+/g, ' ');
t.setSelectionRange(pos, pos);
}
grow(t);
}
var row = t.closest && t.closest('.una-row');
if (row) refreshCounters(row);
if (t.id === 'unaFilter') applyFilter();
else scheduleUpdate();
});
document.addEventListener('change', function (e) {
if (e.target.closest && e.target.closest('#unaFormView, #unaRawView')) scheduleUpdate();
});
document.addEventListener('keydown', function (e) {
if (e.target.classList && e.target.classList.contains('una-cmd') && e.key === 'Enter') {
e.preventDefault(); // commands are single lines
}
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
if (reviewBtn && !reviewBtn.disabled) review();
}
if (e.target.id === 'unaFilter' && e.key === 'Escape') {
e.target.value = '';
applyFilter();
}
});
var pending = null;
function scheduleUpdate() {
if (pending) return;
pending = window.requestAnimationFrame(function () { pending = null; update(); });
}
function listName(row) {
var l = row.parentNode;
return l ? l.getAttribute('data-list') : null;
}
function afterChange(name) { renumber(name); scheduleUpdate(); }
document.addEventListener('click', function (e) {
var btn = e.target.closest('.una-up, .una-down, .una-remove, .una-add');
if (!btn) return;
if (btn.classList.contains('una-add')) { addRow(btn.getAttribute('data-list')); return; }
var row = btn.closest('.una-row, .una-simple');
var name = listName(row);
if (btn.classList.contains('una-remove')) {
row.remove();
} else if (btn.classList.contains('una-up') && row.previousElementSibling) {
row.parentNode.insertBefore(row, row.previousElementSibling);
btn.focus();
} else if (btn.classList.contains('una-down') && row.nextElementSibling) {
row.parentNode.insertBefore(row.nextElementSibling, row);
btn.focus();
}
afterChange(name);
});
var TPL = { spec: 'unaTplCmd', fl: 'unaTplCmd', driverpaths: 'unaTplDp', accounts: 'unaTplAcct' };
function addRow(name) {
var filter = document.getElementById('unaFilter');
if (filter && filter.value) { filter.value = ''; applyFilter(); }
var tpl = document.getElementById(TPL[name]);
var row = tpl.content.firstElementChild.cloneNode(true);
list(name).appendChild(row);
var cmd = $('.una-cmd', row);
if (cmd) {
cmd.setAttribute('data-limit', list(name).getAttribute('data-limit'));
grow(cmd);
refreshCounters(row);
}
afterChange(name);
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
var first = $('textarea, input', row);
if (first) first.focus({ preventScroll: true });
}
// Drag only from the grip, so text in the row stays selectable.
var dragRow = null;
document.addEventListener('mousedown', function (e) {
var grip = e.target.closest('.una-grip');
if (grip) grip.closest('.una-row').setAttribute('draggable', 'true');
});
document.addEventListener('mouseup', function () {
if (!dragRow) $$('.una-row[draggable]').forEach(function (r) { r.removeAttribute('draggable'); });
});
document.addEventListener('dragstart', function (e) {
var row = e.target.closest && e.target.closest('.una-row');
if (!row || !row.getAttribute('draggable')) return;
dragRow = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
});
function clearDrop() { $$('.drop-before, .drop-after').forEach(function (r) { r.classList.remove('drop-before', 'drop-after'); }); }
function dropTarget(e) {
var row = e.target.closest && e.target.closest('.una-row');
if (!dragRow || !row || row === dragRow || row.parentNode !== dragRow.parentNode) return null;
var box = row.getBoundingClientRect();
return { row: row, after: e.clientY > box.top + box.height / 2 };
}
document.addEventListener('dragover', function (e) {
var t = dropTarget(e);
if (!t) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
clearDrop();
t.row.classList.add(t.after ? 'drop-after' : 'drop-before');
});
document.addEventListener('drop', function (e) {
var t = dropTarget(e);
if (!t) return;
e.preventDefault();
t.row.parentNode.insertBefore(dragRow, t.after ? t.row.nextSibling : t.row);
afterChange(listName(dragRow));
});
document.addEventListener('dragend', function () {
if (dragRow) { dragRow.classList.remove('dragging'); dragRow.removeAttribute('draggable'); }
dragRow = null;
clearDrop();
});
// ---------------------------------------------------------------------
// Filter over both command lists
// ---------------------------------------------------------------------
function applyFilter() {
var input = document.getElementById('unaFilter');
if (!input) return;
var q = input.value.trim().toLowerCase();
var shown = 0, total = 0;
['spec', 'fl'].forEach(function (name) {
var l = list(name);
l.classList.toggle('filtering', !!q);
var hits = 0, rs = rows(name);
rs.forEach(function (r, i) {
total++;
var hay = [$('.una-desc', r).value, $('.una-cmd', r).value,
($('.una-note', r) || {}).textContent || ''].join(' ').toLowerCase();
var hit = !q || hay.indexOf(q) >= 0 || q === String(i + 1);
r.hidden = !hit;
if (hit) hits++;
});
shown += hits;
var none = $('[data-nomatch="' + name + '"]');
if (none) none.hidden = !(q && rs.length && !hits);
});
var count = document.getElementById('unaFilterCount');
if (count) {
count.innerHTML = q
? '<strong>' + shown + '</strong> of ' + total + ' commands match. Reordering is off while filtering.'
: total + ' commands';
}
if (q) growAll();
}
// ---------------------------------------------------------------------
// Section index follows the scroll
// ---------------------------------------------------------------------
// The current section is the last one whose top has passed a line a
// third of the way down the window; its link (or, in the narrow chip
// bar, its group's chip) is marked.
var sections = $$('.una-section');
var spyQueued = false;
function spy() {
spyQueued = false;
var line = window.innerHeight * 0.33;
var current = sections[0];
sections.forEach(function (s) { if (s.getBoundingClientRect().top <= line) current = s; });
if (!current) return;
var id = current.id, group = current.getAttribute('data-group');
$$('.una-index a').forEach(function (a) {
var on = a.classList.contains('ix-group')
? a.getAttribute('data-group') === group
: a.getAttribute('href').slice(1) === id;
a.classList.toggle('active', on);
});
// Narrow bar: when the current entry is not shown (a group heading, or
// a section folded into its group chip), light the first shown one.
var shown = $$('.una-index a').filter(function (a) { return a.offsetParent; });
if (!shown.some(function (a) { return a.classList.contains('active'); })) {
var alt = shown.filter(function (a) { return a.getAttribute('data-group') === group; })[0];
if (alt) alt.classList.add('active');
}
}
window.addEventListener('scroll', function () {
if (!spyQueued) { spyQueued = true; window.requestAnimationFrame(spy); }
}, { passive: true });
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () { growAll(); spy(); }, 150);
});
// ---------------------------------------------------------------------
// Review and save
// ---------------------------------------------------------------------
function activeView() {
var rawPane = document.getElementById('unaRawView');
return rawPane && rawPane.classList.contains('active') ? 'raw' : 'form';
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function lintBlock(lint) {
var box = el('div', 'una-review-lint');
if (!lint || !lint.findings || !lint.findings.length) {
var okLine = el('div', 'una-lint-ok');
okLine.appendChild(el('i', 'bi bi-check-circle-fill'));
okLine.appendChild(document.createTextNode('Lint: no findings in the result (the checks of scripts/lint-unattend.py).'));
box.appendChild(okLine);
return box;
}
var bad = lint.new_errors > 0;
var a = el('div', 'alert mb-0 ' + (bad ? 'alert-danger' : 'alert-warning'));
a.appendChild(el('strong', '', bad
? 'This change adds ' + lint.new_errors + ' lint error' + (lint.new_errors === 1 ? '' : 's') +
' and will not be saved. One error invalidates the whole answer file for its pass.'
: 'Lint findings in the result (none added by this change):'));
var ul = el('ul', 'una-lint-list');
lint.findings.forEach(function (f) {
var li = el('li');
li.appendChild(el('span', 'badge ' + (f.level === 'ERROR' ? 'badge-danger' : 'badge-warning'), f.level));
if (f['new']) li.appendChild(el('span', 'badge badge-info ms-1', 'new'));
li.appendChild(document.createTextNode(' ' + f.message));
ul.appendChild(li);
});
a.appendChild(ul);
box.appendChild(a);
return box;
}
function review() {
// The view on screen, unless only the other one has edits.
var view = activeView();
var fd = formDirty(), rd = rawDirty();
var mode = view;
if (view === 'raw' && !rd && fd) mode = 'form';
if (view === 'form' && !fd && rd) mode = 'raw';
var body = { mode: mode, base_sha: CFG.baseSha };
if (mode === 'form') body.form = collect(); else body.raw_xml = raw.value;
var modalEl = document.getElementById('imageReviewModal');
var out = document.getElementById('imageReviewBody');
var err = document.getElementById('imageReviewError');
var confirmBtn = document.getElementById('imageReviewConfirm');
if (!modalEl || !window.bootstrap || !window.PxeImage) return;
document.getElementById('imageReviewTitle').textContent =
'Review changes: FlatUnattendW10.xml (' + (mode === 'form' ? 'Form' : 'Raw XML') + ')';
out.innerHTML = '<div class="text-muted py-3">Building the XML the ' +
(mode === 'form' ? 'form' : 'Raw XML tab') + ' would save...</div>';
err.classList.add('d-none');
confirmBtn.disabled = true;
bootstrap.Modal.getOrCreateInstance(modalEl).show();
function fail(msg) { err.textContent = msg; err.classList.remove('d-none'); }
PxeImage.postJSON(CFG.previewUrl, body).then(function (r) {
if (!r.ok) { out.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; }
var n = PxeImage.renderDiff(out, r.data.files);
if ((mode === 'form' && rd) || (mode === 'raw' && fd)) {
out.insertBefore(el('div', 'alert alert-info',
(mode === 'form' ? 'Your Raw XML edits are' : 'Your Form edits are') +
' not part of this save and are lost when the page reloads after it.'), out.firstChild);
}
out.appendChild(lintBlock(r.data.lint));
if (n) {
var det = el('details', 'una-review-xml');
det.appendChild(el('summary', '', 'Show the whole file as it will be written'));
det.appendChild(el('pre', 'mono', r.data.xml));
out.appendChild(det);
}
var expect = {};
(r.data.files || []).forEach(function (f) { expect[f.name] = f.sha_before; });
confirmBtn.disabled = n === 0 || (r.data.lint && r.data.lint.new_errors > 0);
confirmBtn.onclick = function () {
confirmBtn.disabled = true;
PxeImage.postJSON(CFG.saveUrl, Object.assign({}, body, { expect: expect })).then(function (s) {
if (!s.ok) { fail(s.data.error || 'Save failed.'); confirmBtn.disabled = s.status === 409; return; }
saving = true;
window.location.reload();
}).catch(function (e) { fail('Save failed: ' + e); confirmBtn.disabled = false; });
};
}).catch(function (e) { out.innerHTML = ''; fail('Preview failed: ' + e); });
}
if (reviewBtn) reviewBtn.addEventListener('click', review);
window.addEventListener('beforeunload', function (e) {
if (!saving && (formDirty() || rawDirty())) { e.preventDefault(); e.returnValue = ''; }
});
// ---------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------
$$('.una-field').forEach(function (f) { f.setAttribute('data-initial', f.value); });
$$('.una-row[data-orig], .una-simple[data-orig]').forEach(function (r) {
r.setAttribute('data-initial', rowSnapshot(r));
});
$$('.una-row').forEach(refreshCounters);
initial = JSON.stringify(collect());
initialSections = sectionState(collect());
growAll();
applyFilter();
spy();
var formTab = document.getElementById('unaFormTab');
if (formTab) formTab.addEventListener('shown.bs.tab', function () { growAll(); spy(); });
if (document.fonts && document.fonts.ready) document.fonts.ready.then(growAll);
})();

View File

@@ -0,0 +1,150 @@
{#
_image_header.html - shared header + tabs for every GE Aerospace image page.
CONTRACT (image-shell; used by the Overview, Drivers, Unattend and OS and
Packages tabs):
Use it as the first thing inside {% block content %}:
{% set header_actions %}
<button type="button" class="btn btn-success btn-sm" id="...">Save</button>
{% endset %}
{% set header_subtitle = 'Deploy/FlatUnattendW10.xml' %}
{% include "_image_header.html" %}
Variables it reads from the template context:
image_type required. Image key, e.g. "gea-standard".
active_tab optional. One of "overview", "drivers", "unattend", "os".
When missing it is worked out from request.endpoint
(image_tab_for_endpoint), so a route need not pass it.
header_actions optional. HTML for the right side of the title row
(Save / Review / Undo buttons). Build it with a
{% set header_actions %}...{% endset %} block.
header_subtitle optional. Plain text appended to the meta line
(file path, last save time, ...).
Globals it uses (context processor in app.py):
image_tabs services/image_pages.IMAGE_TABS: id, label, icon,
endpoint for each tab, in display order.
image_tab_url(t, tab) URL of a tab; use it for links and redirects
(in Python: services.image_pages.tab_url).
image_header_info(t) friendly_name, os_label, driver_count,
package_count, has_unattend.
Tab endpoints (change a row of IMAGE_TABS if a page moves):
overview images.overview GET /images/<t> views/images.py
drivers images_drivers.page GET /images/<t>/drivers views/images_drivers.py
unattend unattend_editor GET /images/<t>/unattend app.py
os images.os_packages GET /images/<t>/os views/images.py
Old URLs: /images/<t>/config redirects to the Overview (or ?tab=<id>).
The sidebar marks an image active whenever active_tab is set (or the
endpoint maps to a tab) and image_type matches, so any page that includes
this partial highlights the right sidebar entry.
It also loads static/image-pages.js and the shared review modal
(#imageReviewModal). For a "Review changes" step call
PxeImage.reviewAndSave({previewUrl, saveUrl, body, title})
where previewUrl answers {"files": [control_files.public(plan), ...]}
and saveUrl takes the same body plus "expect": {name: sha_before} and
answers {"ok": true} (it flashes; the page reloads) or {"error": "..."}.
PxeImage.renderDiff(el, files) draws the same diff anywhere.
#}
{% set _tab = active_tab if active_tab is defined and active_tab else image_tab_for_endpoint(request.endpoint) %}
{% set _hdr = image_header_info(image_type) %}
<style>
.image-head { margin-bottom: 1.25rem; }
.image-head-top {
display: flex; flex-wrap: wrap; gap: 0.75rem 1rem;
justify-content: space-between; align-items: flex-end;
margin-bottom: 0.75rem;
}
.image-head-title { flex: 1 1 20rem; min-width: 0; }
.image-head h1 { font-size: 20px; font-weight: 500; line-height: 1.3; margin: 0.15rem 0 0.2rem; }
.image-head-meta { font-size: 13px; color: var(--text-light); display: flex; flex-wrap: wrap; gap: 0.25rem 0.6rem; align-items: center; }
.image-head-meta .sep { opacity: 0.5; }
.image-head .header-actions { flex-wrap: wrap; align-items: center; }
.image-tabs.nav-tabs { flex-wrap: wrap; }
.image-tabs .nav-link { font-size: 12px; padding: 0.65rem 1rem; display: flex; align-items: center; gap: 0.4rem; }
.image-tabs .nav-link .bi { font-size: 14px; }
.image-tabs .tab-count {
font-size: 10px; font-weight: 600; letter-spacing: 0; line-height: 1;
padding: 2px 7px; border-radius: 999px;
background: var(--bg); border: 1px solid var(--border); color: var(--text-light);
}
.image-tabs .nav-link.active .tab-count { color: var(--primary); border-color: var(--primary); }
/* Unified diff (review step of every save) */
.diff-file { border: 1px solid var(--border); border-radius: 0.25rem; margin-bottom: 1rem; overflow: hidden; }
.diff-file-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; }
.diff-view {
margin: 0; max-height: 55vh; overflow: auto; font-size: 12px; line-height: 1.45;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; background: var(--bg-card-solid);
}
.diff-view span { display: block; padding: 0 0.75rem; white-space: pre-wrap; word-break: break-all; }
.diff-view .d-add { background: rgba(10, 214, 79, 0.16); }
.diff-view .d-del { background: rgba(220, 53, 69, 0.14); }
.diff-view .d-hunk { color: var(--primary); background: rgba(65, 129, 255, 0.08); }
.diff-view .d-meta { color: var(--text-light); }
[data-theme="dark"] .diff-view .d-add { background: rgba(10, 214, 79, 0.18); }
[data-theme="dark"] .diff-view .d-del { background: rgba(245, 54, 92, 0.2); }
</style>
<div class="image-head">
<div class="image-head-top">
<div class="image-head-title">
<div class="eyebrow">GE Aerospace image</div>
<h1>{{ _hdr.friendly_name }}</h1>
<div class="image-head-meta">
<span class="mono">{{ image_type }}</span>
{% if _hdr.os_label %}<span class="sep">|</span><span><i class="bi bi-windows"></i> {{ _hdr.os_label }}</span>{% endif %}
{% if header_subtitle is defined and header_subtitle %}<span class="sep">|</span><span class="mono">{{ header_subtitle }}</span>{% endif %}
</div>
</div>
{% if header_actions is defined and header_actions %}
<div class="header-actions">{{ header_actions }}</div>
{% endif %}
</div>
<ul class="nav nav-tabs image-tabs" role="tablist">
{% for t in image_tabs %}
<li class="nav-item">
<a class="nav-link {% if t.id == _tab %}active{% endif %}"
{% if t.id == _tab %}aria-current="page"{% endif %}
href="{{ image_tab_url(image_type, t.id) }}">
<i class="bi {{ t.icon }}"></i> {{ t.label }}
{% if t.id == 'drivers' %}<span class="tab-count" title="Entries in HardwareDriver.json">{{ _hdr.driver_count }}</span>
{% elif t.id == 'os' and _hdr.package_count %}<span class="tab-count" title="Entries in packages.json">{{ _hdr.package_count }}</span>
{% elif t.id == 'unattend' and not _hdr.has_unattend %}<span class="tab-count" title="FlatUnattendW10.xml is missing">missing</span>{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
{# Shared review step: filled by PxeImage.reviewAndSave (static/image-pages.js) #}
<div class="modal fade" id="imageReviewModal" tabindex="-1" aria-labelledby="imageReviewTitle">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="imageReviewTitle">Review changes</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3" id="imageReviewIntro">
This is exactly what will be written. Each changed file is backed up next to itself
(<span class="mono">.bak-&lt;date&gt;-webui</span>) first, and the save can be undone from this page.
</p>
<div id="imageReviewBody"></div>
<div class="alert alert-danger d-none mb-0" id="imageReviewError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="imageReviewConfirm" disabled>
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='image-pages.js') }}"></script>

View File

@@ -102,19 +102,45 @@
</li> </li>
</ul> </ul>
<div class="nav-section">GE Aerospace Images</div> <div class="nav-section">Updates</div>
<ul class="sidebar-nav nav flex-column"> <ul class="sidebar-nav nav flex-column">
{% 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 and request.endpoint.startswith('dell_updates.') %}active{% endif %}"
href="{{ url_for('unattend_editor', image_type=it) }}"> href="{{ url_for('dell_updates.page') }}">
<i class="bi bi-windows"></i> {{ all_friendly_names[it] }} <i class="bi bi-motherboard"></i> Dell Drivers and BIOS
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link nav-sublink {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}" <a class="nav-link {% if request.endpoint and request.endpoint.startswith('ipxe.') %}active{% endif %}"
href="{{ url_for('image_config', image_type=it) }}"> href="{{ url_for('ipxe.page') }}">
<i class="bi bi-gear"></i> Configuration <i class="bi bi-shield-lock"></i> iPXE Boot Files
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('blancco_update.') %}active{% endif %}"
href="{{ url_for('blancco_update.page') }}">
<i class="bi bi-eraser"></i> Blancco Updates
</a>
</li>
<li class="nav-item">
{# Plain href: the page still renders if views.bprt_sync is not registered yet. #}
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('bprt_sync.') %}active{% endif %}"
href="/updates/bprt/">
<i class="bi bi-arrow-left-right"></i> BPRT Sync
</a>
</li>
</ul>
<div class="nav-section">GE Aerospace Images</div>
<ul class="sidebar-nav nav flex-column">
{% for it in all_image_types if it.startswith('gea-') %}
{# One entry per image; active on any of its tabs (see _image_header.html). #}
{% set _on_image = image_type is defined and image_type == it
and ((active_tab is defined and active_tab) or image_tab_for_endpoint(request.endpoint)) %}
<li class="nav-item">
<a class="nav-link {% if _on_image %}active{% endif %}"
href="{{ image_tab_url(it, 'overview') }}">
<i class="bi bi-windows"></i> {{ all_friendly_names[it] }}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}

View File

@@ -119,9 +119,9 @@
</td> </td>
<td><span class="mono">{{ img.deploy_path }}</span></td> <td><span class="mono">{{ img.deploy_path }}</span></td>
<td class="actions text-end"> <td class="actions text-end">
<a href="{{ url_for('image_config', image_type=img.image_type) }}" <a href="{{ image_tab_url(img.image_type, 'overview') }}"
class="btn btn-sm btn-outline-secondary btn-row-action"> class="btn btn-sm btn-outline-secondary btn-row-action">
<i class="bi bi-gear"></i> Config <i class="bi bi-clipboard-data"></i> Open
</a> </a>
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}" <a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
class="btn btn-sm btn-outline-primary btn-row-action"> class="btn btn-sm btn-outline-primary btn-row-action">

View File

@@ -22,7 +22,7 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span><i class="bi bi-box-seam"></i> GCCH Provisioning Packages</span> <span><i class="bi bi-box-seam"></i> Shop Floor (SFLD) Provisioning Packages</span>
<span class="badge badge-secondary">{{ packages|length }}</span> <span class="badge badge-secondary">{{ packages|length }}</span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@@ -82,6 +82,107 @@
</p> </p>
</div> </div>
<div class="card" id="mcl">
<div class="card-header">
<span><i class="bi bi-box-seam"></i> Standard / Engineer (MCL) Provisioning Packages</span>
<span class="badge badge-secondary">{{ mcl_packages|length }}</span>
</div>
<div class="card-body">
{% set mcl_users = mcl_links | selectattr('state', 'equalto', 'ok') | map(attribute='image') | list %}
<p class="text-muted">
Packages live in <span class="mono">_shared/Applications/BPRT</span>, which {{ mcl_users | join(', ') if mcl_users else 'no image' }} link{{ 's' if mcl_users|length == 1 }} to.
The bulk-enroll scripts pick the package by its first three name fields (tenant, environment, purpose) and fail
enrollment if two match, so an upload <strong>replaces</strong> the package with the same fields; the old one moves
to the archive below and can be restored. Every PC built from those images copies this folder while it images, so
upload, archive or restore only when no build is in progress.
</p>
<p class="text-muted">
<i class="bi bi-arrow-left-right"></i> A whole new vendor drop (packages, Office zips and scripts from MediaCreator Lite)
goes through <a href="/updates/bprt/">BPRT Sync</a> instead: it uploads only the changed files from the work PC, checks
them and swaps the folder in one step, keeping the site's own scripts.
</p>
{% for l in mcl_links if l.state in ('copy', 'elsewhere') %}
<div class="alert alert-warning py-2 mb-2">
<strong>{{ l.image }}</strong>
{% if l.state == 'copy' %}has its own copy of BPRT (<span class="mono">{{ l.path }}</span> is a real folder, not a link to
<span class="mono">_shared/Applications/BPRT</span>; an image import does this),
{% else %}links BPRT to <span class="mono">{{ l.target or '?' }}</span> instead of <span class="mono">_shared/Applications/BPRT</span>,
{% endif %}so packages changed here do not reach its PCs. Move that folder aside and link it back to the shared one.
</div>
{% endfor %}
<form action="{{ url_for('mcl_ppkg_upload') }}" method="post" enctype="multipart/form-data" class="d-flex flex-wrap gap-2 align-items-end mb-3">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="mclFile" class="form-label">Package (.ppkg)</label>
<input type="file" class="form-control" id="mclFile" name="ppkg_file" accept=".ppkg" required>
<div class="form-text">Vendor name, unchanged, e.g. <span class="mono">GCCH_Prod_MCL-PERS_Exp_XXXXXXXX_v2.19.ppkg</span></div>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload and replace</button>
</form>
{% if mcl_packages %}
<div class="table-container">
<table class="table table-hover mb-0">
<thead>
<tr><th>Tenant / purpose</th><th>Filename</th><th>Version</th><th>Size</th><th>Last Modified</th><th class="text-end"></th></tr>
</thead>
<tbody>
{% for p in mcl_packages %}
<tr>
<td class="mono">{{ p.prefix or '-' }}
{% if p.conflict %}<span class="badge badge-danger" title="This prefix also matches {{ p.conflict_with | join(', ') }}; enrollment for it will fail">Duplicate</span>{% endif %}
{% if not p.prefix %}<span class="badge badge-warning" title="Name does not match the vendor pattern; the enroll scripts ignore it">Ignored</span>
{% elif not p.placeholder %}<span class="badge badge-warning" title="No Exp_XXXXXXXX placeholder in the name: this looks like a package already used on a PC, and the enroll script cannot inject a new token into it">Used</span>{% endif %}
</td>
<td class="mono">{{ p.filename }}</td>
<td><span class="badge badge-info">{{ p.version or '?' }}</span></td>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
<td>{{ p.modified | timestamp_fmt }}</td>
<td class="text-end">
<form action="{{ url_for('mcl_ppkg_archive') }}" method="post" style="display:inline;"
onsubmit="return confirm('Move this package to the archive? Enrollment for its tenant and purpose fails until another package is uploaded or restored.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ p.filename }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Move to the archive"><i class="bi bi-archive"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No MCL packages found in BPRT.</p>
{% endif %}
{% if mcl_archive %}
<h6 class="mt-4">Archive</h6>
<div class="table-container">
<table class="table table-sm mb-0">
<thead><tr><th>Archived</th><th>Filename</th><th>Version</th><th>Size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for p in mcl_archive %}
<tr>
<td class="mono">{{ p.batch }}</td>
<td class="mono">{{ p.filename }}</td>
<td>{{ p.version or '?' }}</td>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
<td class="text-end">
<form action="{{ url_for('mcl_ppkg_restore') }}" method="post" style="display:inline;"
onsubmit="return confirm('Restore this package? The current package for the same tenant and purpose moves to the archive.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="batch" value="{{ p.batch }}">
<input type="hidden" name="filename" value="{{ p.filename }}">
<button type="submit" class="btn btn-sm btn-outline-primary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Restore</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
<!-- Upload Modal --> <!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1"> <div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">

View File

@@ -15,6 +15,13 @@
{% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %} {% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %}
{%- endmacro %} {%- endmacro %}
{# A newer Dell pack/BIOS for the file this model uses, from the last Updates > Dell scan. #}
{% macro update_hint(found, file) -%}
{% if file and file | lower in found %}
<div class="mt-1"><a href="{{ url_for('dell_updates.page') }}" class="badge badge-info text-decoration-none" title="{{ file }}: Dell has {{ found[file | lower] or 'a newer version' }}. Open Updates to download it.">Update {{ found[file | lower] }}</a></div>
{% endif %}
{%- endmacro %}
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<h1><i class="bi bi-cpu"></i> Hardware Coverage</h1> <h1><i class="bi bi-cpu"></i> Hardware Coverage</h1>
@@ -53,8 +60,8 @@
</td> </td>
<td class="mono">{{ r.skus | join(', ') or '-' }}</td> <td class="mono">{{ r.skus | join(', ') or '-' }}</td>
<td class="mono">{{ r.last_bios_version or '-' }}</td> <td class="mono">{{ r.last_bios_version or '-' }}</td>
{% for image, os_label in images %}<td>{{ driver_badge(r.drivers[image]) }}</td>{% endfor %} {% for image, os_label in images %}<td>{{ driver_badge(r.drivers[image]) }}{{ update_hint(hints.drivers, r.drivers[image].file) }}</td>{% endfor %}
<td>{{ bios_badge(r.bios) }}</td> <td>{{ bios_badge(r.bios) }}{{ update_hint(hints.bios, r.bios.file) }}</td>
<td class="mono text-nowrap">{{ r.last_seen | local_dt }}<div class="small text-muted">{{ r.count }} boot{{ 's' if r.count != 1 }}</div></td> <td class="mono text-nowrap">{{ r.last_seen | local_dt }}<div class="small text-muted">{{ r.count }} boot{{ 's' if r.count != 1 }}</div></td>
<td class="text-end text-nowrap"> <td class="text-end text-nowrap">
{% if r.gap %} {% if r.gap %}

View File

@@ -1,492 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - Configuration{% endblock %}
{% block extra_head %}
<style>
/* image_config-specific tweaks layered on top of pxe-theme.css tokens */
.config-table td, .config-table th { vertical-align: middle; }
.config-table .form-control-sm { min-width: 120px; }
.badge-disk { font-size: 11px; }
.text-truncate-cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Unregistered-drivers strip: warning-tinted footer that reads in both themes */
.orphan-section {
background: rgba(255, 149, 0, 0.08);
border-top: 1px solid var(--border);
}
[data-theme="dark"] .orphan-section { background: rgba(255, 149, 0, 0.10); }
.orphan-toggle { color: var(--text); text-decoration: none; }
.orphan-toggle:hover { color: var(--primary); }
.section-count { background: var(--secondary); }
.card-header .header-actions { flex: 0 0 auto; }
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ friendly_name }}</h1>
<div class="eyebrow" style="margin-top:0.35rem;">
Image Configuration &mdash; OS Selection:
<strong>{{ config.os_selection or 'Not set' }}</strong>
</div>
</div>
<div class="header-actions">
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil-square"></i> Edit Unattend
</a>
</div>
</div>
{# ==================== SECTION 1: Hardware Models ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-cpu"></i> Hardware Models
<span class="badge section-count">{{ config.hardware_models|length }}</span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
<i class="bi bi-plus-lg"></i> Add
</button>
<button type="button" class="btn btn-sm btn-success" id="saveHwModels">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="hwModelsForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="hardware_models">
<input type="hidden" name="payload" id="hwModelsData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="hwModelsTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Model</th>
<th>Driver Family ID</th>
<th style="width:90px">On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for hm in config.hardware_models %}
<tr>
<td class="order-num">{{ loop.index }}</td>
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ hm.Model }}"></td>
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td>
<td>
{% if hm._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.hardware_models %}
<div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty">
No hardware models configured.
</div>
{% endif %}
</div>
</div>
{# ==================== SECTION 2: Driver Packs ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-hdd-network"></i> Driver Packs
<span class="badge section-count">{{ config.drivers|length }}</span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
<i class="bi bi-upload"></i> Upload .zip
</button>
<button type="button" class="btn btn-sm btn-success" id="saveDrivers">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="driversForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="drivers">
<input type="hidden" name="payload" id="driversData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="driversTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Family</th>
<th>Models</th>
<th>File Name</th>
<th style="width:70px">OS IDs</th>
<th style="width:90px">On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for drv in config.drivers %}
<tr data-json='{{ drv | tojson }}'>
<td class="order-num">{{ loop.index }}</td>
<td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td>
<td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td>
<td class="text-truncate-cell mono" title="{{ drv.FileName or drv.get('fileName','') }}">
{{ drv.FileName or drv.get('fileName','') }}
</td>
<td class="mono">{{ drv.osId }}</td>
<td>
{% if drv._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.drivers %}
<div class="text-center text-muted py-3 empty-message" id="driversEmpty">
No driver packs configured.
</div>
{% endif %}
</div>
{# Orphan drivers sub-section: zips on disk that aren't referenced by #}
{# HardwareDriver.json. Each row has an inline Adopt form (family + dest #}
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
{# the selected .zip files from Out-of-box Drivers/. #}
{% if config.orphan_drivers %}
<div class="orphan-section">
<div class="px-3 py-2 d-flex justify-content-between align-items-center">
<a class="orphan-toggle d-flex align-items-center gap-2" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
<i class="bi bi-exclamation-triangle"></i>
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
<span class="text-muted" style="font-size:0.82rem;">.zip files on disk, no JSON entry</span>
</a>
</div>
<div class="collapse show" id="orphanDrivers">
<form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="table-container">
<table class="table mb-0 align-middle">
<thead>
<tr>
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
<th>File Name</th>
<th>Relative Path</th>
<th style="width:300px">Adopt into HardwareDriver.json</th>
</tr>
</thead>
<tbody>
{% for orph in config.orphan_drivers %}
<tr>
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
<td class="mono">{{ orph.fileName }}</td>
<td class="mono text-muted">{{ orph.relPath }}</td>
<td>
{# adopt inputs target standalone adoptForm below - form-in-form is dropped by parser and hijacked the delete form #}
<div class="d-flex gap-1">
<input type="text" class="form-control form-control-sm" name="family"
form="adoptForm{{ loop.index }}"
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
<input type="text" class="form-control form-control-sm" name="destination_dir"
form="adoptForm{{ loop.index }}"
placeholder="destinationDir" required style="width:11rem;">
<button type="submit" class="btn btn-sm btn-success" form="adoptForm{{ loop.index }}">Adopt</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="px-3 py-2 text-end">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> Delete selected
</button>
</div>
</form>
{# one adopt form per orphan row, kept OUTSIDE the delete form #}
{% for orph in config.orphan_drivers %}
<form method="POST" id="adoptForm{{ loop.index }}"
action="{{ url_for('images_drivers_adopt', image_type=image_type) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ orph.fileName }}">
</form>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Driver upload modal -->
<div class="modal fade" id="driverUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Upload driver .zip</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label">Driver .zip file</label>
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
</div>
<div class="form-field">
<label class="form-label">Family ID (optional)</label>
<input type="text" class="form-control mono" name="family"
placeholder="Optiplex_7060">
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
</div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Drivers\Optiplex">
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</div>
</form>
</div>
</div>
{# ==================== SECTION 3: Operating Systems ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-windows"></i> Operating Systems
<span class="badge section-count">{{ config.operating_systems|length }}</span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-success" id="saveOs">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="osForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="operating_systems">
<input type="hidden" name="payload" id="osData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="osTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Product Name</th>
<th>Version</th>
<th>Build</th>
<th style="width:60px">ID</th>
<th style="width:70px">Active</th>
<th style="width:90px">WIM On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for entry in config.operating_systems %}
{% set osv = entry.operatingSystemVersion %}
<tr data-json='{{ entry | tojson }}'>
<td class="order-num">{{ loop.index }}</td>
<td>{{ osv.productName }}</td>
<td>{{ osv.versionNumber }}</td>
<td>{{ osv.buildNumber }}</td>
<td class="mono">{{ osv.id }}</td>
<td>
{% if osv.isActive %}
<span class="badge badge-success badge-disk">Active</span>
{% else %}
<span class="badge badge-secondary badge-disk">Inactive</span>
{% endif %}
</td>
<td>
{% if entry._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.operating_systems %}
<div class="text-center text-muted py-3 empty-message" id="osEmpty">
No operating systems configured.
</div>
{% endif %}
</div>
</div>
{# ==================== SECTION 4: Packages ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-box-seam"></i> Packages
<span class="badge section-count">{{ config.packages|length }}</span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
<i class="bi bi-upload"></i> Upload file
</button>
<button type="button" class="btn btn-sm btn-success" id="savePackages">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="packagesForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="packages">
<input type="hidden" name="payload" id="packagesData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="packagesTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Name</th>
<th>Comment</th>
<th>File</th>
<th style="width:70px">OS IDs</th>
<th style="width:80px">Enabled</th>
<th style="width:90px">On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for pkg in config.packages %}
<tr data-json='{{ pkg | tojson }}'>
<td class="order-num">{{ loop.index }}</td>
<td class="text-truncate-cell" title="{{ pkg.name }}">{{ pkg.name }}</td>
<td class="text-truncate-cell" title="{{ pkg.comment }}">{{ pkg.comment }}</td>
<td class="text-truncate-cell mono" title="{{ pkg.fileName or pkg.get('FileName','') }}">
{{ pkg.fileName or pkg.get('FileName','') }}
</td>
<td class="mono">{{ pkg.osId }}</td>
<td>
{% if pkg.enabled %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-secondary badge-disk">No</span>
{% endif %}
</td>
<td>
{% if pkg._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.packages %}
<div class="text-center text-muted py-3 empty-message" id="packagesEmpty">
No packages configured.
</div>
{% endif %}
</div>
</div>
<!-- Package upload modal -->
<div class="modal fade" id="packageUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Upload package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label">Package file</label>
<input type="file" class="form-control" name="package_file" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
</div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Packages">
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Orphan drivers: select-all checkbox toggles every row's selector.
document.addEventListener('click', function(e) {
if (e.target && e.target.id === 'orphanSelectAll') {
document.querySelectorAll('.orphan-select').forEach(function(cb) {
cb.checked = e.target.checked;
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,893 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - Drivers{% endblock %}
{# Drivers tab: the whole HardwareDriver.json staged in the browser, saved in
one go through a review step (views/images_drivers.py). The table is drawn
by the script at the bottom from #drvData; the server re-lints the staged
map after every edit (/drivers/check). #}
{% block extra_head %}
<style>
.drv-explain { font-size: 13.5px; }
.drv-explain p:last-child { margin-bottom: 0; }
.dirty-flag { font-size: 12px; color: var(--warning-dark); display: none; align-items: center; gap: 0.35rem; }
[data-theme="dark"] .dirty-flag { color: var(--warning); }
.dirty .dirty-flag { display: inline-flex; }
.drv-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); }
.drv-toolbar .drv-search { flex: 1 1 16rem; max-width: 26rem; position: relative; }
.drv-toolbar .drv-search .bi { position: absolute; left: 0.65rem; top: 50%; transform: translateY(-50%); color: var(--text-light); font-size: 13px; }
.drv-toolbar .drv-search input { padding-left: 2rem; }
.drv-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.drv-seg button { background: transparent; border: 0; color: var(--text); font-size: 12px; padding: 0.35rem 0.75rem; }
.drv-seg button + button { border-left: 1px solid var(--border); }
.drv-seg button.active { background: var(--primary); color: #fff; }
.drv-chip-toggle { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 12.5px; border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.7rem; cursor: pointer; user-select: none; margin: 0; }
.drv-chip-toggle input { width: 0.95rem; height: 0.95rem; margin: 0; }
.drv-chip-toggle.on { border-color: var(--primary); color: var(--primary); }
.drv-showing { font-size: 12.5px; color: var(--text-light); margin-left: auto; }
.drv-table { table-layout: auto; }
.drv-table td { white-space: normal; vertical-align: top; padding-top: 0.6rem; padding-bottom: 0.6rem; }
.drv-table td.pos { width: 1%; white-space: nowrap; text-align: center; color: var(--text-light); font-weight: 600; }
.drv-table td.pos .num { display: block; font-size: 13px; margin-bottom: 0.3rem; color: var(--text); }
.drv-table td.pos .btn { padding: 2px 6px; font-size: 11px; display: block; margin: 0 auto 2px; }
.drv-table td.os { width: 1%; white-space: nowrap; }
.drv-table td.acts { width: 1%; white-space: nowrap; text-align: right; }
.drv-table td.acts .btn + .btn { margin-left: 0.25rem; }
.drv-table .zipcell { min-width: 15rem; width: 40%; }
.drv-table .models { min-width: 12rem; width: 46%; }
.drv-table .sub { font-size: 12px; color: var(--text-light); margin-top: 0.2rem; overflow-wrap: anywhere; }
.drv-table .fname { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 12.5px; overflow-wrap: anywhere; }
.drv-table .copy-btn { border: 0; background: transparent; color: var(--text-light); padding: 0 0.25rem; font-size: 12px; vertical-align: baseline; }
.drv-table .copy-btn:hover { color: var(--primary); }
.tok { display: inline-block; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 11.5px; border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; margin: 0 3px 4px 0; background: var(--bg); overflow-wrap: anywhere; white-space: pre-wrap; }
.tok.bad { border-color: var(--danger); color: var(--danger); }
.drv-table tr.st-new > td, .drv-table tr.st-edited > td { background: rgba(65, 129, 255, 0.06); }
.drv-table tr.st-new > td:first-child, .drv-table tr.st-edited > td:first-child, .drv-table tr.st-moved > td:first-child { box-shadow: inset 3px 0 0 var(--primary); }
.drv-table tr.drv-find > td { border-top: 0; padding-top: 0; }
.drv-find ul { list-style: none; margin: 0; padding: 0; }
.drv-find li { display: flex; gap: 0.5rem; align-items: baseline; font-size: 12.5px; padding: 0.15rem 0; overflow-wrap: anywhere; }
.drv-find li .badge { flex: 0 0 auto; }
.lvl-CRITICAL, .lvl-ERROR { background: var(--danger); color: #fff; }
.lvl-WARN { background: var(--warning); color: #00003d; }
.lvl-INFO { background: var(--secondary); color: #fff; }
.drv-empty td { text-align: center; color: var(--text-light); padding: 1.5rem; }
.drv-lint-badges { display: inline-flex; gap: 0.35rem; }
.drv-lint-badges .badge { cursor: pointer; }
.orphan-table td { white-space: normal; vertical-align: top; }
.orphan-table .sub { font-size: 12px; color: var(--text-light); overflow-wrap: anywhere; }
#drvEditModal .tok-preview { min-height: 1.6rem; margin-top: 0.4rem; }
#drvEditModal .zip-check { font-size: 12.5px; margin-top: 0.35rem; }
#drvEditModal textarea { min-height: 5.5rem; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 13px; }
.rv-summary { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
.rv-moves { list-style: none; padding: 0; margin: 0 0 1rem; font-size: 13px; }
.rv-moves li { padding: 0.2rem 0; }
.rv-lint { font-size: 13px; margin-bottom: 1rem; }
.rv-lint ul { margin: 0.35rem 0 0; padding-left: 1.1rem; }
details.rv-full summary { cursor: pointer; font-size: 13px; font-weight: 600; margin-bottom: 0.5rem; }
</style>
{% endblock %}
{% block content %}
{% set header_actions %}
<span class="dirty-flag"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved changes</span>
{% if last_save %}
<form method="POST" action="{{ url_for('images_drivers.undo', image_type=image_type) }}" class="d-inline"
onsubmit="return confirm('Put HardwareDriver.json back as it was before the save of {{ last_save.ts | local_dt }}?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Last save: {{ last_save.ts | local_dt }}">
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
</button>
</form>
{% endif %}
<button type="button" class="btn btn-success btn-sm" id="drvReview" disabled>
<i class="bi bi-file-earmark-diff"></i> Review and save
</button>
{% endset %}
{% set header_subtitle = 'Deploy/Control/HardwareDriver.json' %}
{% include "_image_header.html" %}
<div class="text-muted drv-explain mb-3">
<p class="mb-2">
At imaging PESetup reads this map and takes the <strong>first</strong> entry, from the top,
that has a model token contained in the PC's WMI model (case-insensitive). Latitude, OptiPlex and
Precision PCs only look at entries whose family contains that word. <strong>There is no OS filter</strong>,
so a Windows 11 entry must sit above any Windows 10 entry for the same model.
A PC that matches nothing still images, with no drivers.
</p>
<p>
Edits, moves and removals are staged until <strong>Review and save</strong>, which shows every changed
entry first. The
<a href="{{ image_tab_url(image_type, 'os') }}">Hardware Models</a> list (user_selections.json) is the
Media Creator Lite build selection; PESetup does not read it.
</p>
</div>
{% if load_error %}
<div class="alert alert-danger"><i class="bi bi-x-octagon"></i> {{ load_error }}. The table is empty and saving is off until the file is fixed.</div>
{% endif %}
{% if hw_drivers_extra %}
<div class="alert alert-info"><i class="bi bi-info-circle"></i>
This image also has <span class="mono">Deploy/Control/hw_drivers.json</span>. PESetup never reads it; only the map below counts.
</div>
{% endif %}
{# ==================== Driver map ==================== #}
<div class="card" id="drvCard">
<div class="card-header">
<span class="d-flex align-items-center gap-2 flex-wrap">
<i class="bi bi-hdd-network"></i> Driver map
<span class="badge badge-secondary" id="drvCount">{{ entry_count }}</span>
<span class="drv-lint-badges" id="drvLintBadges"></span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary" id="drvAdd" {% if load_error %}disabled{% endif %}>
<i class="bi bi-plus-lg"></i> Add entry
</button>
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
<i class="bi bi-upload"></i> Upload .zip
</button>
</div>
</div>
<div class="drv-toolbar">
<div class="drv-search">
<i class="bi bi-search"></i>
<input type="search" class="form-control form-control-sm" id="drvSearch"
placeholder="Search models, family, file, folder" aria-label="Search the driver map">
</div>
<div class="drv-seg" role="group" aria-label="Windows version" id="drvOsSeg">
<button type="button" data-os="" class="active">All</button>
<button type="button" data-os="Win11">Win11</button>
<button type="button" data-os="Win10">Win10</button>
</div>
<label class="drv-chip-toggle" id="drvMissingLbl"><input type="checkbox" id="drvMissing"> Zip missing <span id="drvMissingN"></span></label>
<label class="drv-chip-toggle" id="drvFindLbl"><input type="checkbox" id="drvFind"> Has findings <span id="drvFindN"></span></label>
<span class="drv-showing" id="drvShowing"></span>
</div>
<div class="table-container">
<table class="table mb-0 drv-table" id="drvTable">
<thead>
<tr>
<th title="Position: the first matching entry wins">#</th>
<th>Models and family</th>
<th>OS</th>
<th>Driver zip</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
{# ==================== Unregistered zips ==================== #}
{# Zips no image uses come first and can be deleted. Zips another image's
map uses (the driver tree is shared) are folded away: they can be added
to this map but never deleted from here. #}
{% set free = orphans | selectattr('used_by', 'equalto', []) | list %}
{% set shared = orphans | rejectattr('used_by', 'equalto', []) | list %}
{% macro orphan_row(o, hidden=False) %}
<tr class="{{ 'orphan-shared' if o.used_by }}" {% if hidden %}style="display:none"{% endif %}>
<td>
{% if o.used_by %}
<input type="checkbox" disabled title="Used by {{ o.used_by | join(', ') }}: cannot be deleted from here" aria-label="In use">
{% else %}
<input type="checkbox" class="orphan-select" name="rel" value="{{ o.rel }}" aria-label="Select {{ o.name }}">
{% endif %}
</td>
<td>
<div class="mono" style="overflow-wrap:anywhere;">{{ o.name }}</div>
<div class="sub mono">{{ o.folder or '(top folder)' }}</div>
</td>
<td class="text-nowrap">
{{ (o.size or 0) | filesizeformat }}
<div class="sub">{{ o.mtime | local_dt if o.mtime else '' }}</div>
</td>
<td>
{% if o.used_by %}{% for u in o.used_by %}<span class="badge badge-info">{{ u }}</span> {% endfor %}
{% else %}<span class="text-muted small">No image</span>{% endif %}
</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-sm btn-outline-primary orphan-add" {% if load_error %}disabled{% endif %}
data-name="{{ o.name }}" data-dest="{{ o.dest }}" data-size="{{ o.size or 0 }}">
<i class="bi bi-plus-lg"></i> Add to map
</button>
</td>
</tr>
{% endmacro %}
<div class="card" id="orphanCard">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-zip"></i> Unregistered zips
<span class="badge {{ 'badge-warning' if free else 'badge-secondary' }}" title="Zips no image uses">{{ free | length }}</span>
</span>
<span class="small text-muted fw-normal">Zips under Out-of-box Drivers that this map does not use</span>
</div>
{% if orphans %}
<form method="POST" action="{{ url_for('images_drivers.orphans_remove', image_type=image_type) }}"
onsubmit="return confirm('Delete the selected zip files from the share? This cannot be undone.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="table-container">
<table class="table mb-0 orphan-table">
<thead>
<tr>
<th style="width:30px"><input type="checkbox" id="orphanSelectAll" aria-label="Select all" {% if not free %}disabled{% endif %}></th>
<th>File</th>
<th>Size and date</th>
<th>Used by</th>
<th></th>
</tr>
</thead>
<tbody>
{% for o in free %}{{ orphan_row(o) }}{% endfor %}
{% if not free %}
<tr><td colspan="5" class="text-muted small">No zip here is unused: every one is in this map or in another image's map.</td></tr>
{% endif %}
{% if shared %}
<tr class="orphan-toggle-row">
<td colspan="5">
<button type="button" class="btn btn-link p-0" id="orphanSharedToggle" aria-expanded="false" style="font-size:13px;">
<i class="bi bi-chevron-right"></i> Show {{ shared | length }} zip{{ 's' if shared | length != 1 }} that other images (or an undo or rollback) use
</button>
</td>
</tr>
{% for o in shared %}{{ orphan_row(o, hidden=True) }}{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="px-3 py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
<span class="small text-muted">Only zips no image uses can be deleted. Add to map stages a new entry; it is written with the next save.</span>
<button type="submit" class="btn btn-sm btn-outline-danger" {% if not free %}disabled title="No zip here is unused"{% endif %}>
<i class="bi bi-trash"></i> Delete selected
</button>
</div>
</form>
{% else %}
<div class="card-body text-muted small">None: every zip under Out-of-box Drivers is in this map.</div>
{% endif %}
</div>
{# ==================== Edit entry modal ==================== #}
<div class="modal fade" id="drvEditModal" tabindex="-1" aria-labelledby="drvEditTitle">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="drvEditTitle">Edit entry</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label" for="drvEdTokens">Model tokens (modelswminame), one per line</label>
<textarea class="form-control" id="drvEdTokens" rows="3" spellcheck="false"></textarea>
<div class="tok-preview" id="drvEdTokPreview"></div>
<div class="form-text">The entry matches when any token appears, as written, inside the PC's WMI model (case is ignored):
<span class="mono">OptiPlex 7020</span> matches the model <span class="mono">OptiPlex 7020</span> but not
<span class="mono">OptiPlex Micro 7020</span>. Saved comma-separated with no spaces, because PESetup does not trim.</div>
</div>
<div class="row g-3">
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdFamily">Family</label>
<input type="text" class="form-control" id="drvEdFamily" list="drvFamilies">
<div class="form-text">For Latitude, OptiPlex and Precision PCs this must contain that word, or the entry is skipped.</div>
</div>
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdFriendly">Friendly name (modelsfriendlyname)</label>
<input type="text" class="form-control" id="drvEdFriendly">
<div class="form-text">Shown in logs and on this page only.</div>
</div>
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdOsId">osId</label>
<input type="text" class="form-control mono" id="drvEdOsId" placeholder="20,21">
<div class="form-text">18 = Windows 10; 20 = Win11 LTSC 24H2; 21 = Win11 Pro 24H2. Informational: not part of the match.</div>
</div>
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdPos">Position</label>
<input type="number" class="form-control" id="drvEdPos" min="1">
<div class="form-text" id="drvEdPosHelp">1 is the top. The first match wins.</div>
</div>
</div>
<div class="form-field mt-3">
<label class="form-label" for="drvEdFile">Zip file name (fileName)</label>
<input type="text" class="form-control mono" id="drvEdFile" spellcheck="false">
</div>
<div class="form-field">
<label class="form-label" for="drvEdDir">Folder (destinationDir)</label>
<input type="text" class="form-control mono" id="drvEdDir" list="drvFolders" spellcheck="false">
<div class="zip-check" id="drvEdZip"></div>
</div>
<p class="small text-muted mb-0" id="drvEdNote">
url, hash and size describe the pack download (Updates, Dell drivers sets them) and are not changed here.
FileName, DestinationDir, models and aOsIds are kept in step with the fields above.
</p>
<div class="alert alert-danger d-none mt-3 mb-0" id="drvEdError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="drvEdApply"><i class="bi bi-check-lg"></i> Apply</button>
</div>
</div>
</div>
</div>
<datalist id="drvFamilies"></datalist>
<datalist id="drvFolders"></datalist>
<!-- Driver upload modal -->
<div class="modal fade" id="driverUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Upload driver .zip</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label">Driver .zip file</label>
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
<div class="form-text">Lands in the top of <span class="mono">Out-of-box Drivers/</span> and shows under
Unregistered zips. Use Add to map there to give it an entry (reviewed with the next save).</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</div>
</form>
</div>
</div>
<script type="application/json" id="drvData">{{ page_json }}</script>
{% endblock %}
{% block extra_scripts %}
<script>
// Drivers tab. state.items holds the staged map in order; each item is
// {orig: position in the file or null, moved, data: the entry, row: server facts}.
(function () {
'use strict';
var DATA = JSON.parse(document.getElementById('drvData').textContent);
var URLS = DATA.urls;
// Key order of a new entry: the shape most entries in the GE maps have.
var NEW_KEYS = ['manufacturer', 'product', 'family', 'modelswminame', 'modelsfriendlyname', 'fileName',
'destinationDir', 'url', 'hash', 'size', 'modifiedDate', 'osId', 'imagedisk', 'models', 'FileName',
'DestinationDir', 'aOsIds'];
var state = {
items: DATA.entries.map(function (e, i) {
return { orig: i, moved: false, data: e, row: DATA.rows[i], status: null };
}),
counts: DATA.counts,
dirty: false
};
var filt = { q: '', os: '', missing: false, find: false };
var tbody = document.querySelector('#drvTable tbody');
var reviewBtn = document.getElementById('drvReview');
var head = document.querySelector('.image-head');
// ---- small helpers ----
function h(tag, attrs, kids) {
var e = document.createElement(tag);
Object.keys(attrs || {}).forEach(function (k) {
if (k === 'text') e.textContent = attrs[k];
else if (k === 'cls') e.className = attrs[k];
else e.setAttribute(k, attrs[k]);
});
(kids || []).forEach(function (c) {
if (c === null || c === undefined) return;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
}
function fileOf(d) { return String(d.fileName || d.FileName || ''); }
function dirOf(d) { return String(d.destinationDir || d.DestinationDir || ''); }
function tokensOf(d) { return d.modelswminame === undefined || d.modelswminame === null ? [] : String(d.modelswminame).split(','); }
function shortDir(dir) { return dir.replace(/^\*destinationdir\*\\?/i, '').replace(/^Deploy\\Out-of-box Drivers\\?/i, ''); }
function fmtSize(n) {
if (n === null || n === undefined) return '';
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB';
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
return Math.round(n / 1024) + ' KB';
}
function fmtDate(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
function p(n) { return (n < 10 ? '0' : '') + n; }
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
}
// Same rule as services/image_pages.driver_os.
function osOf(d) {
var name = fileOf(d).toLowerCase(), dir = dirOf(d).toLowerCase();
if (name.indexOf('win10') === 0) return 'Win10';
if (name.indexOf('win11') === 0) return 'Win11';
if (dir.indexOf('\\dell_10\\') >= 0 || dir.indexOf('/dell_10/') >= 0) return 'Win10';
if (dir.indexOf('\\dell_11\\') >= 0 || dir.indexOf('/dell_11/') >= 0) return 'Win11';
var ids = String(d.osId || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (!ids.length) return 'Other';
return ids.every(function (i) { return i === '18'; }) ? 'Win10' : 'Win11';
}
function zipBad(row) { return row && ['missing', 'trap', 'none'].indexOf(row.zip.status) >= 0; }
function copyText(text, btn) {
function done() { var i = btn.querySelector('i'); i.className = 'bi bi-check2'; setTimeout(function () { i.className = 'bi bi-clipboard'; }, 1200); }
// navigator.clipboard needs a secure context; the server is plain http.
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done); return; }
var ta = h('textarea', { style: 'position:fixed;top:-100px;opacity:0' });
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) { window.prompt('Copy:', text); }
document.body.removeChild(ta);
}
// ---- filters ----
function filtering() { return !!(filt.q || filt.os || filt.missing || filt.find); }
function visible(it) {
var d = it.data, r = it.row;
if (filt.os && osOf(d) !== filt.os) return false;
if (filt.missing && !zipBad(r)) return false;
if (filt.find && !(r && r.findings.length)) return false;
if (filt.q) {
var hay = [d.modelswminame, d.modelsfriendlyname, d.family, fileOf(d), dirOf(d), d.osId, d.product]
.join(' ').toLowerCase();
var words = filt.q.toLowerCase().split(/\s+/).filter(Boolean);
for (var i = 0; i < words.length; i++) if (hay.indexOf(words[i]) < 0) return false;
}
return true;
}
// ---- table ----
function render() {
tbody.innerHTML = '';
var n = state.items.length, shown = 0, f = filtering();
state.items.forEach(function (it, idx) {
if (!visible(it)) return;
shown++;
var d = it.data, r = it.row;
var wasMoved = it.moved && it.orig !== null && idx !== it.orig;
var cls = it.orig === null ? 'st-new' : (it.status === 'edited' ? 'st-edited' : (wasMoved ? 'st-moved' : ''));
var tr = h('tr', { cls: cls, 'data-idx': idx });
// position + move
var up = h('button', { type: 'button', cls: 'btn btn-outline-secondary act-up', title: f ? 'Clear the search and filters to reorder' : 'Move up', 'aria-label': 'Move up' }, [h('i', { cls: 'bi bi-arrow-up' })]);
var dn = h('button', { type: 'button', cls: 'btn btn-outline-secondary act-down', title: f ? 'Clear the search and filters to reorder' : 'Move down', 'aria-label': 'Move down' }, [h('i', { cls: 'bi bi-arrow-down' })]);
up.disabled = f || idx === 0; dn.disabled = f || idx === n - 1;
tr.appendChild(h('td', { cls: 'pos' }, [h('span', { cls: 'num', text: String(idx + 1) }), up, dn]));
// models + family
var toks = tokensOf(d).map(function (t) {
var bad = t === '' || t !== t.trim();
return h('span', { cls: 'tok' + (bad ? ' bad' : ''), title: bad ? 'Empty or untrimmed token' : 'Matches a WMI model containing this', text: t === '' ? '(empty)' : t });
});
var models = h('td', { cls: 'models' }, [h('div', {}, toks.length ? toks : [h('span', { cls: 'badge badge-danger', text: 'no modelswminame' })])]);
var famLine = [d.modelsfriendlyname ? h('strong', { text: d.modelsfriendlyname }) : null,
h('span', { text: (d.modelsfriendlyname ? ' | ' : '') + 'family: ' }), h('span', { text: d.family === undefined || d.family === null ? '(none)' : String(d.family) })];
models.appendChild(h('div', { cls: 'sub' }, famLine));
if (it.orig === null) models.appendChild(h('span', { cls: 'badge badge-info', text: 'New' }));
else if (it.status === 'edited') models.appendChild(h('span', { cls: 'badge badge-info', text: 'Edited' }));
if (wasMoved) models.appendChild(h('span', { cls: 'badge badge-secondary ms-1', text: 'Moved from #' + (it.orig + 1) }));
tr.appendChild(models);
// OS
var os = osOf(d);
tr.appendChild(h('td', { cls: 'os' }, [
h('span', { cls: 'badge ' + (os === 'Win11' ? 'badge-primary' : (os === 'Win10' ? 'badge-secondary' : 'badge-warning')), text: os }),
h('div', { cls: 'sub mono', title: 'osId', text: d.osId === undefined ? '' : String(d.osId) })
]));
// zip
var name = fileOf(d);
var copy = h('button', { type: 'button', cls: 'copy-btn act-copy', title: 'Copy file name', 'aria-label': 'Copy file name' }, [h('i', { cls: 'bi bi-clipboard' })]);
var zipTd = h('td', { cls: 'zipcell' }, [
h('div', {}, [h('span', { cls: 'fname', title: name, text: name || '(no fileName)' }), name ? copy : null]),
h('div', { cls: 'sub mono', title: dirOf(d), text: shortDir(dirOf(d)) || '(no destinationDir)' })
]);
var zline = h('div', { cls: 'sub' });
if (!r) zline.appendChild(h('span', { cls: 'text-muted', text: 'checking...' }));
else if (r.zip.status === 'ok' || r.zip.status === 'case') {
zline.appendChild(h('span', { text: fmtSize(r.zip.size) + (r.zip.mtime ? ', ' + fmtDate(r.zip.mtime) : '') }));
if (r.zip.status === 'case') zline.appendChild(h('span', { cls: 'badge badge-warning ms-1', title: 'On the share as ' + r.zip.actual, text: 'case differs' }));
} else if (r.zip.status === 'trap') {
zline.appendChild(h('span', { cls: 'badge badge-danger', title: 'On the share as ' + r.zip.actual, text: 'not found by WinPE' }));
} else {
zline.appendChild(h('span', { cls: 'badge badge-danger', text: r.zip.status === 'none' ? 'no file name' : 'zip missing' }));
}
if (r && r.dell) zline.appendChild(h('span', { cls: 'badge badge-info' + (zline.childNodes.length ? ' ms-1' : ''), title: 'Newer Dell pack in the last Updates scan', text: 'Dell ' + r.dell + ' available' }));
zipTd.appendChild(zline);
tr.appendChild(zipTd);
// actions
tr.appendChild(h('td', { cls: 'acts' }, [
h('button', { type: 'button', cls: 'btn btn-outline-primary btn-row-action act-edit', title: 'Edit this entry' }, [h('i', { cls: 'bi bi-pencil' }), ' Edit']),
h('button', { type: 'button', cls: 'btn btn-outline-danger btn-row-action act-remove', title: 'Remove this entry', 'aria-label': 'Remove' }, [h('i', { cls: 'bi bi-trash' })])
]));
tbody.appendChild(tr);
if (r && r.findings.length) {
var ul = h('ul');
r.findings.forEach(function (x) {
ul.appendChild(h('li', {}, [h('span', { cls: 'badge lvl-' + x.level, text: x.level + ': ' + x.label }), h('span', { text: x.message })]));
});
tbody.appendChild(h('tr', { cls: 'drv-find ' + cls, 'data-idx': idx }, [h('td'), h('td', { colspan: '4' }, [ul])]));
}
});
if (!shown) {
tbody.appendChild(h('tr', { cls: 'drv-empty' }, [h('td', { colspan: '5', text: n ? 'No entry matches the search and filters.' : 'The map is empty: no PC gets drivers.' })]));
}
document.getElementById('drvShowing').textContent = f ? ('Showing ' + shown + ' of ' + n) : (n + ' entries');
document.getElementById('drvCount').textContent = n;
var miss = state.items.filter(function (it) { return zipBad(it.row); }).length;
var withF = state.items.filter(function (it) { return it.row && it.row.findings.length; }).length;
document.getElementById('drvMissingN').textContent = '(' + miss + ')';
document.getElementById('drvFindN').textContent = '(' + withF + ')';
renderLint();
}
function renderLint() {
var box = document.getElementById('drvLintBadges');
box.innerHTML = '';
var c = state.counts || {};
var bad = (c.CRITICAL || 0) + (c.ERROR || 0);
if (c.CRITICAL) box.appendChild(h('span', { cls: 'badge lvl-CRITICAL', title: 'Show entries with findings', text: c.CRITICAL + ' critical' }));
if (c.ERROR) box.appendChild(h('span', { cls: 'badge lvl-ERROR', title: 'Show entries with findings', text: c.ERROR + ' error' + (c.ERROR === 1 ? '' : 's') }));
if (c.WARN) box.appendChild(h('span', { cls: 'badge lvl-WARN', title: 'Show entries with findings', text: c.WARN + ' warning' + (c.WARN === 1 ? '' : 's') }));
if (!bad && !c.WARN) box.appendChild(h('span', { cls: 'badge badge-success', text: 'lint clean' }));
}
// ---- staging ----
var checkTimer = null, checkSeq = 0;
function payload() {
return state.items.map(function (it) {
var o = {};
Object.keys(it.data).forEach(function (k) { o[k] = it.data[k]; });
if (it.orig !== null) o._orig = it.orig;
if (it.moved) o._moved = true;
return o;
});
}
function changed() {
// Rows from a check still in flight describe the old order: drop them.
checkSeq++;
state.dirty = true;
reviewBtn.disabled = false;
if (head) head.classList.add('dirty');
render();
clearTimeout(checkTimer);
checkTimer = setTimeout(recheck, 250);
}
function recheck() {
var seq = ++checkSeq;
PxeImage.postJSON(URLS.check, { entries: payload() }).then(function (r) {
if (seq !== checkSeq || !r.ok) return;
r.data.rows.forEach(function (row, i) { if (state.items[i]) state.items[i].row = row; });
state.counts = r.data.counts;
render();
});
}
function move(idx, to) {
if (to < 0 || to >= state.items.length || to === idx) return;
var it = state.items.splice(idx, 1)[0];
it.moved = true;
state.items.splice(to, 0, it);
changed();
}
// ---- table events ----
tbody.addEventListener('click', function (e) {
var btn = e.target.closest('button');
if (!btn) return;
var tr = btn.closest('tr');
var idx = parseInt(tr.getAttribute('data-idx'), 10);
var it = state.items[idx];
if (btn.classList.contains('act-up')) move(idx, idx - 1);
else if (btn.classList.contains('act-down')) move(idx, idx + 1);
else if (btn.classList.contains('act-copy')) copyText(fileOf(it.data), btn);
else if (btn.classList.contains('act-edit')) openEditor(idx);
else if (btn.classList.contains('act-remove')) {
var lbl = '#' + (idx + 1) + ' ' + (it.data.modelsfriendlyname || it.data.modelswminame || fileOf(it.data));
if (confirm('Remove ' + lbl + ' from the map? The zip stays on disk. Nothing is written until Review and save.')) {
state.items.splice(idx, 1);
changed();
}
}
});
// ---- filter events ----
var search = document.getElementById('drvSearch');
search.addEventListener('input', function () { filt.q = search.value.trim(); render(); });
document.querySelectorAll('#drvOsSeg button').forEach(function (b) {
b.addEventListener('click', function () {
document.querySelectorAll('#drvOsSeg button').forEach(function (x) { x.classList.toggle('active', x === b); });
filt.os = b.getAttribute('data-os');
render();
});
});
function bindToggle(id, key) {
var cb = document.getElementById(id);
cb.addEventListener('change', function () {
filt[key] = cb.checked;
cb.parentNode.classList.toggle('on', cb.checked);
render();
});
return cb;
}
var missCb = bindToggle('drvMissing', 'missing');
var findCb = bindToggle('drvFind', 'find');
document.getElementById('drvLintBadges').addEventListener('click', function (e) {
if (e.target.classList.contains('badge') && !findCb.checked) { findCb.checked = true; findCb.dispatchEvent(new Event('change')); }
});
// Deep links from the Overview: ?q=, ?os=Win10, ?missing=1, ?findings=1
(function () {
var p = new URLSearchParams(window.location.search);
if (p.get('q')) { search.value = p.get('q'); filt.q = search.value.trim(); }
// Only the two known values: anything else in a selector would throw and stop the page.
var osq = p.get('os');
if (osq === 'Win10' || osq === 'Win11') document.querySelector('#drvOsSeg button[data-os="' + osq + '"]').click();
if (p.get('missing') === '1') { missCb.checked = true; missCb.dispatchEvent(new Event('change')); }
if (p.get('findings') === '1') { findCb.checked = true; findCb.dispatchEvent(new Event('change')); }
})();
// ---- edit modal ----
var ed = {
modal: document.getElementById('drvEditModal'),
title: document.getElementById('drvEditTitle'),
tokens: document.getElementById('drvEdTokens'),
preview: document.getElementById('drvEdTokPreview'),
family: document.getElementById('drvEdFamily'),
friendly: document.getElementById('drvEdFriendly'),
osId: document.getElementById('drvEdOsId'),
pos: document.getElementById('drvEdPos'),
posHelp: document.getElementById('drvEdPosHelp'),
file: document.getElementById('drvEdFile'),
dir: document.getElementById('drvEdDir'),
zip: document.getElementById('drvEdZip'),
err: document.getElementById('drvEdError'),
apply: document.getElementById('drvEdApply'),
idx: null, fresh: null
};
DATA.families.forEach(function (f) { document.getElementById('drvFamilies').appendChild(h('option', { value: f })); });
DATA.folders.forEach(function (f) { document.getElementById('drvFolders').appendChild(h('option', { value: f })); });
function edTokens() {
return ed.tokens.value.split(/\r?\n|,/).map(function (t) { return t.trim(); }).filter(Boolean);
}
function edPreview() {
var t = edTokens();
ed.preview.innerHTML = '';
if (!t.length) ed.preview.appendChild(h('span', { cls: 'text-muted small', text: 'No tokens: an empty modelswminame matches EVERY model.' }));
t.forEach(function (x) { ed.preview.appendChild(h('span', { cls: 'tok', text: x })); });
}
var zipTimer = null;
function edZip() {
clearTimeout(zipTimer);
zipTimer = setTimeout(function () {
var probe = { fileName: ed.file.value.trim(), destinationDir: ed.dir.value.trim(), modelswminame: 'x', family: 'x' };
ed.zip.textContent = 'Looking for the zip...';
ed.zip.className = 'zip-check text-muted';
PxeImage.postJSON(URLS.check, { entries: [probe] }).then(function (r) {
if (!r.ok) return;
var z = r.data.rows[0].zip;
if (z.status === 'ok') { ed.zip.className = 'zip-check text-success'; ed.zip.textContent = 'On the share: ' + fmtSize(z.size) + ', ' + fmtDate(z.mtime); }
else if (z.status === 'case') { ed.zip.className = 'zip-check text-warning'; ed.zip.textContent = 'On the share with other letter case (' + z.actual + '); WinPE finds it.'; }
else if (z.status === 'trap') { ed.zip.className = 'zip-check text-danger'; ed.zip.textContent = 'Only at ' + z.actual + ': WinPE will not find it at this path.'; }
else if (z.status === 'none') { ed.zip.className = 'zip-check text-danger'; ed.zip.textContent = 'No file name.'; }
else { ed.zip.className = 'zip-check text-danger'; ed.zip.textContent = 'Not on the share: ' + z.rel; }
});
}, 250);
}
ed.tokens.addEventListener('input', edPreview);
ed.file.addEventListener('input', edZip);
ed.dir.addEventListener('input', edZip);
// Where a new entry goes by default: a Win11 entry above the first Win10
// one (first match wins), anything else at the end.
function defaultPos(d) {
if (osOf(d) === 'Win11') {
for (var i = 0; i < state.items.length; i++) if (osOf(state.items[i].data) === 'Win10') return i + 1;
}
return state.items.length + 1;
}
function openEditor(idx, fresh) {
ed.idx = idx; ed.fresh = fresh || null;
var d = fresh || state.items[idx].data;
ed.title.textContent = fresh ? 'New entry' : ('Edit entry #' + (idx + 1));
ed.tokens.value = tokensOf(d).join('\n');
ed.family.value = d.family || '';
ed.friendly.value = d.modelsfriendlyname || '';
ed.osId.value = d.osId === undefined ? '' : String(d.osId);
ed.file.value = fileOf(d);
ed.dir.value = dirOf(d);
var max = state.items.length + (fresh ? 1 : 0);
ed.pos.max = max;
ed.pos.value = fresh ? defaultPos(d) : idx + 1;
ed.posHelp.textContent = '1 is the top, ' + max + ' the bottom. The first match wins' + (fresh ? '; a Windows 11 entry defaults to just above the first Windows 10 entry.' : '.');
ed.err.classList.add('d-none');
edPreview(); edZip();
bootstrap.Modal.getOrCreateInstance(ed.modal).show();
}
function setKey(d, key, value) { d[key] = value; }
ed.apply.addEventListener('click', function () {
var toks = edTokens();
var fail = function (m) { ed.err.textContent = m; ed.err.classList.remove('d-none'); };
if (!toks.length) return fail('Give at least one model token. An empty modelswminame matches every PC.');
if (!ed.family.value.trim()) return fail('Give a family.');
if (!ed.file.value.trim()) return fail('Give the zip file name.');
if (!ed.dir.value.trim()) return fail('Give the folder (destinationDir).');
var max = parseInt(ed.pos.max, 10);
var pos = parseInt(ed.pos.value, 10);
if (!(pos >= 1 && pos <= max)) return fail('Position must be between 1 and ' + max + '.');
var src = ed.fresh || state.items[ed.idx].data;
var d = {};
Object.keys(src).forEach(function (k) { d[k] = src[k]; });
var osIds = ed.osId.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
setKey(d, 'family', ed.family.value.trim());
setKey(d, 'modelswminame', toks.join(','));
if ('modelsfriendlyname' in d || ed.friendly.value.trim()) setKey(d, 'modelsfriendlyname', ed.friendly.value.trim());
if ('osId' in d || osIds.length) setKey(d, 'osId', osIds.join(','));
// Mirrored keys: both spellings are in the GE maps; keep them equal.
if ('fileName' in d || !('FileName' in d)) setKey(d, 'fileName', ed.file.value.trim());
if ('FileName' in d) setKey(d, 'FileName', ed.file.value.trim());
if ('destinationDir' in d || !('DestinationDir' in d)) setKey(d, 'destinationDir', ed.dir.value.trim());
if ('DestinationDir' in d) setKey(d, 'DestinationDir', ed.dir.value.trim());
if ('models' in d) setKey(d, 'models', d.modelswminame);
if ('aOsIds' in d) setKey(d, 'aOsIds', osIds);
if (ed.fresh) {
// product holds the Dell line in the GE maps (Latitude, OptiPlex, Precision).
var line = /precision/i.test(toks[0]) ? 'Precision' : /optiplex/i.test(toks[0]) ? 'OptiPlex'
: /latitude/i.test(toks[0]) ? 'Latitude' : '';
if (line && d.product === 'Dell') setKey(d, 'product', line);
state.items.splice(pos - 1, 0, { orig: null, moved: false, data: d, row: null, status: 'new' });
} else {
var it = state.items[ed.idx];
if (JSON.stringify(d) !== JSON.stringify(it.data)) {
it.data = d;
it.status = (it.orig !== null && JSON.stringify(d) === JSON.stringify(DATA.entries[it.orig])) ? null : 'edited';
}
if (pos - 1 !== ed.idx) {
state.items.splice(ed.idx, 1);
it.moved = true;
state.items.splice(pos - 1, 0, it);
}
}
bootstrap.Modal.getOrCreateInstance(ed.modal).hide();
changed();
});
function newEntry(fileName, dest, size) {
var d = {};
var os = /\\dell_10\\/i.test(dest + '\\') ? '18' : '20,21';
var defaults = { manufacturer: 'Dell', product: 'Dell', family: '', modelswminame: '', modelsfriendlyname: '',
fileName: fileName || '', destinationDir: dest || '', url: '', hash: '', size: size || 0,
modifiedDate: '0001-01-01T00:00:00', osId: os, imagedisk: 0, models: '', FileName: fileName || '',
DestinationDir: dest || '', aOsIds: os.split(',') };
NEW_KEYS.forEach(function (k) { d[k] = defaults[k]; });
return d;
}
document.getElementById('drvAdd').addEventListener('click', function () {
openEditor(null, newEntry('', '', 0));
});
document.querySelectorAll('.orphan-add').forEach(function (b) {
b.addEventListener('click', function () {
openEditor(null, newEntry(b.getAttribute('data-name'), b.getAttribute('data-dest'), parseInt(b.getAttribute('data-size'), 10) || 0));
});
});
var shTog = document.getElementById('orphanSharedToggle');
if (shTog) shTog.addEventListener('click', function () {
var open = shTog.getAttribute('aria-expanded') !== 'true';
shTog.setAttribute('aria-expanded', open ? 'true' : 'false');
shTog.querySelector('i').className = 'bi ' + (open ? 'bi-chevron-down' : 'bi-chevron-right');
document.querySelectorAll('.orphan-shared').forEach(function (tr) { tr.style.display = open ? '' : 'none'; });
});
var selAll = document.getElementById('orphanSelectAll');
if (selAll) selAll.addEventListener('change', function () {
document.querySelectorAll('.orphan-select').forEach(function (cb) { cb.checked = selAll.checked; });
});
// ---- review and save (shared modal from _image_header.html) ----
function review() {
var modalEl = document.getElementById('imageReviewModal');
var body = document.getElementById('imageReviewBody');
var err = document.getElementById('imageReviewError');
var ok = document.getElementById('imageReviewConfirm');
document.getElementById('imageReviewTitle').textContent = 'Review changes: HardwareDriver.json';
body.innerHTML = '<div class="text-muted py-3">Building the preview...</div>';
err.classList.add('d-none');
ok.disabled = true;
bootstrap.Modal.getOrCreateInstance(modalEl).show();
function fail(m) { err.textContent = m; err.classList.remove('d-none'); }
var req = { entries: payload(), base: DATA.base };
PxeImage.postJSON(URLS.preview, req).then(function (r) {
if (!r.ok) { body.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; }
var p = r.data;
body.innerHTML = '';
var s = p.summary;
var sum = h('div', { cls: 'rv-summary' });
[['edited', 'badge-info'], ['moved', 'badge-secondary'], ['added', 'badge-success'], ['removed', 'badge-danger']].forEach(function (k) {
if (s[k[0]]) sum.appendChild(h('span', { cls: 'badge badge-lg ' + k[1], text: s[k[0]] + ' ' + k[0] }));
});
var fileChanged = p.files[0] && p.files[0].changed;
if (!fileChanged) {
body.appendChild(h('div', { cls: 'alert alert-info mb-0', text: 'No changes: the file already holds exactly this.' }));
return;
}
body.appendChild(sum);
// lint before and after
var lb = p.lint.before, la = p.lint.after;
function lc(c) { return c.CRITICAL + ' critical, ' + c.ERROR + ' errors, ' + c.WARN + ' warnings'; }
var lint = h('div', { cls: 'rv-lint' }, [h('strong', { text: 'Lint: ' }), h('span', { text: 'now ' + lc(lb) + '; after this save ' + lc(la) + '.' })]);
if (la.CRITICAL + la.ERROR > lb.CRITICAL + lb.ERROR) {
var ul = h('ul');
p.lint.after_errors.slice(0, 8).forEach(function (m) { ul.appendChild(h('li', { text: m })); });
lint.appendChild(ul);
}
body.appendChild(lint);
if (p.blocked.length) {
body.appendChild(h('div', { cls: 'alert alert-danger' }, [h('strong', { text: 'Cannot save: ' }),
h('span', { text: 'this adds a CRITICAL finding, which breaks imaging for many or all PCs. ' + p.blocked[0] })]));
}
// moved-only entries as one list, the rest as JSON diffs
var moves = p.changes.filter(function (c) { return c.kind === 'moved'; });
if (moves.length) {
body.appendChild(h('div', { cls: 'fw-semibold small mb-1', text: 'Moved (content unchanged)' }));
var ml = h('ul', { cls: 'rv-moves' });
moves.forEach(function (c) {
ml.appendChild(h('li', {}, [h('span', { cls: 'mono', text: '#' + c.old_pos + ' to #' + c.new_pos }),
' ' + c.label.replace(/^#\d+ /, ''), h('span', { cls: 'text-muted mono', text: c.file ? ' ' + c.file : '' })]));
});
body.appendChild(ml);
}
var diffs = p.changes.filter(function (c) { return c.kind !== 'moved'; }).map(function (c) {
var what = c.kind === 'added' ? 'new at #' + c.new_pos
: c.kind === 'removed' ? 'removed from #' + c.old_pos
: c.kind === 'edited+moved' ? 'edited, moved #' + c.old_pos + ' to #' + c.new_pos
: 'edited (' + c.fields.join(', ') + ')';
return { name: c.label.replace(/^#\d+ /, '') + ': ' + what, changed: true, existed: true, diff: c.diff, reformat: false };
});
if (diffs.length) {
var dbox = h('div');
body.appendChild(dbox);
PxeImage.renderDiff(dbox, diffs);
}
var full = h('details', { cls: 'rv-full' }, [h('summary', { text: 'Whole-file diff of HardwareDriver.json' })]);
var fbox = h('div');
full.appendChild(fbox);
body.appendChild(full);
PxeImage.renderDiff(fbox, p.files);
var expect = {};
p.files.forEach(function (f) { expect[f.name] = f.sha_before; });
ok.disabled = p.blocked.length > 0;
ok.onclick = function () {
ok.disabled = true;
PxeImage.postJSON(URLS.save, { entries: req.entries, base: req.base, expect: expect }).then(function (sres) {
if (!sres.ok) { fail(sres.data.error || 'Save failed.'); ok.disabled = !!sres.data.stale; return; }
state.dirty = false;
window.location.reload();
}).catch(function (e) { fail('Save failed: ' + e); ok.disabled = false; });
};
}).catch(function (e) { body.innerHTML = ''; fail('Preview failed: ' + e); });
}
reviewBtn.addEventListener('click', review);
window.addEventListener('beforeunload', function (e) {
if (state.dirty) { e.preventDefault(); e.returnValue = ''; }
});
render();
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,399 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - OS and Packages{% endblock %}
{% block extra_head %}
<style>
.osp-table td { vertical-align: top; white-space: normal; }
.osp-table td.num { width: 40px; color: var(--text-light); font-weight: 600; text-align: center; }
.osp-table .wrap { overflow-wrap: anywhere; }
.osp-table .sub { font-size: 12px; color: var(--text-light); }
.osp-table .form-control-sm { min-width: 10rem; }
.osp-table tr.row-new td { background: rgba(65, 129, 255, 0.06); }
#ospModelTable td { vertical-align: middle; }
.osp-note { font-size: 13px; color: var(--text-light); padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); }
.osp-explain { font-size: 13.5px; }
.osp-explain p:last-child { margin-bottom: 0; }
.dirty-flag { font-size: 12px; color: var(--warning-dark); display: none; align-items: center; gap: 0.35rem; }
[data-theme="dark"] .dirty-flag { color: var(--warning); }
.dirty .dirty-flag { display: inline-flex; }
.empty-row td { text-align: center; color: var(--text-light); padding: 1.25rem; }
</style>
{% endblock %}
{% block content %}
{% set header_actions %}
<span class="dirty-flag" id="ospDirty"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved changes</span>
{% if last_save %}
<form method="POST" action="{{ url_for('images.os_undo', image_type=image_type) }}" class="d-inline"
onsubmit="return confirm('Put back {{ last_save.files | map(attribute='name') | join(', ') }} as they were before the save of {{ last_save.ts | local_dt }}?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Last save: {{ last_save.ts | local_dt }}">
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
</button>
</form>
{% endif %}
<button type="button" class="btn btn-success btn-sm" id="ospReview" disabled>
<i class="bi bi-file-earmark-diff"></i> Review and save
</button>
{% endset %}
{% include "_image_header.html" %}
<p class="text-muted osp-explain">
What PESetup puts on the disk besides drivers: the Windows image from
<span class="mono">OperatingSystem.json</span>, then the offline servicing packages from
<span class="mono">packages.json</span> (both in <span class="mono">Deploy/Control</span>).
Edits here are staged until <strong>Review and save</strong>, which shows the exact diff of every file first.
</p>
{# ==================== Operating system ==================== #}
<div class="card" data-section="operating_systems">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-windows"></i> Operating system
<span class="badge badge-secondary">{{ oses | length }}</span>
</span>
<span class="small text-muted fw-normal mono">OperatingSystem.json</span>
</div>
<div class="table-container">
<table class="table mb-0 osp-table" id="ospOsTable">
<thead>
<tr>
<th>#</th>
<th>Windows</th>
<th>osId</th>
<th>Status</th>
<th>install.wim</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in oses %}
{% set osv = r.osv %}
<tr data-json='{{ r.entry | tojson_ordered }}'>
<td class="num order-num">{{ loop.index }}</td>
<td>
<strong>{{ osv.productName or osv.marketingName or 'Windows' }}</strong>
<div class="sub">{{ osv.marketingName }}{% if osv.versionNumber %} | version {{ osv.versionNumber }}{% endif %}{% if osv.buildNumber %} | build {{ osv.buildNumber }}{% endif %}</div>
</td>
<td class="mono">{{ osv.id }}</td>
<td class="text-nowrap">
{% if osv.isActive %}<span class="badge badge-success">Active</span>{% else %}<span class="badge badge-secondary">Inactive</span>{% endif %}
{% if os_selection is not none and osv.id | string == os_selection | string %}<span class="badge badge-primary" title="user_selections.json OperatingSystemSelection">Selected</span>{% endif %}
</td>
<td>
<div class="mono wrap">{{ r.dest or '(no DestinationDir)' }}</div>
<div class="sub">
{% if r.wim.exists %}<span class="badge badge-success">On disk</span> {{ r.wim.size | filesizeformat }}, modified {{ r.wim.mtime | local_dt }}
{% else %}<span class="badge badge-danger">install.wim not on disk</span>{% endif %}
| index {{ osv.wimindex if osv.wimindex is defined else (r.entry.wimindex if r.entry.wimindex is defined else '-') }}
{% if osv.wim and osv.wim.size and r.wim.exists and osv.wim.size != r.wim.size %}
<span class="badge badge-warning" title="size in OperatingSystem.json: {{ osv.wim.size }}">size differs from the JSON</span>
{% endif %}
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this entry">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
<tr class="empty-row" {% if oses %}style="display:none"{% endif %}><td colspan="6">No operating system entries. PESetup has nothing to apply.</td></tr>
</tbody>
</table>
</div>
<div class="osp-note">
PESetup applies the image index of <span class="mono">install.wim</span> from the entry's folder
(<span class="mono">*destinationdir*</span> is this image's share).
{% if os_selection is not none %}The entry marked Selected is the one the media was built for
(<span class="mono">OperatingSystemSelection = {{ os_selection }}</span>).{% endif %}
</div>
</div>
{# ==================== Packages ==================== #}
<div class="card" data-section="packages">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-box-seam"></i> Packages
<span class="badge badge-secondary">{{ packages | length }}</span>
</span>
<div class="header-actions">
<span class="small text-muted fw-normal mono align-self-center">packages.json</span>
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
<i class="bi bi-upload"></i> Upload file
</button>
</div>
</div>
<div class="table-container">
<table class="table mb-0 osp-table" id="ospPkgTable">
<thead>
<tr>
<th>#</th>
<th>Package</th>
<th>File</th>
<th>osId</th>
<th>Enabled</th>
<th>On disk</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in packages %}
{% set p = r.entry %}
<tr data-json='{{ p | tojson_ordered }}'>
<td class="num order-num">{{ loop.index }}</td>
<td>
<strong>{{ p.name or '(no name)' }}</strong>
{% if p.comment %}<div class="sub">{{ p.comment }}</div>{% endif %}
</td>
<td>
<div class="mono wrap">{{ p.fileName or p.FileName or '' }}</div>
<div class="sub mono wrap">{{ p.destinationDir or p.DestinationDir or '' }}</div>
</td>
<td class="mono">{{ p.osId }}</td>
<td>
<div class="form-check form-switch m-0">
<input class="form-check-input pkg-enabled" type="checkbox" role="switch"
aria-label="Enabled" {% if p.enabled %}checked{% endif %}>
</div>
</td>
<td class="text-nowrap">
{% if r.file.exists %}<span class="badge badge-success">Yes</span><div class="sub">{{ r.file.size | filesizeformat }}</div>
{% else %}<span class="badge badge-danger">No</span>{% endif %}
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this entry">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
<tr class="empty-row" {% if packages %}style="display:none"{% endif %}><td colspan="7">No packages configured.</td></tr>
</tbody>
</table>
</div>
<div class="osp-note">
A package is applied when it is enabled and its osId list contains the OS being installed.
Removing an entry here leaves the file on disk.
</div>
</div>
{# ==================== Hardware models (user_selections.json) ==================== #}
<div class="card" data-section="hardware_models">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-cpu"></i> Hardware Models
<span class="badge badge-secondary">{{ models | length }}</span>
</span>
<div class="header-actions">
<span class="small text-muted fw-normal mono align-self-center">Tools/user_selections.json</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="ospAddModel" {% if not has_selections %}disabled{% endif %}>
<i class="bi bi-plus-lg"></i> Add
</button>
</div>
</div>
<div class="card-body osp-explain border-bottom">
<p class="mb-2">
<strong>Media build selection, not used while imaging.</strong>
Media Creator Lite saved this list when the media was built: the driver families (Family ID) whose packs it
downloaded, and the OS it built for. <span class="mono">scripts/Download-Drivers.ps1</span> and
<span class="mono">Upload-Image.ps1</span> read it to choose which driver packs and OS to fetch and upload.
</p>
<p class="text-muted">
PESetup never reads this file: at imaging it matches the PC's WMI model against
<a href="{{ image_tab_url(image_type, 'drivers') }}">HardwareDriver.json</a> alone, so adding a model here does not
give a PC drivers. The Driver entries column counts map entries with the same <span class="mono">family</span>.
</p>
</div>
<div class="table-container">
<table class="table mb-0 osp-table" id="ospModelTable">
<thead>
<tr>
<th>#</th>
<th>Model</th>
<th>Family ID</th>
<th>Driver entries</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in models %}
<tr data-json='{{ r.m | tojson_ordered }}'>
<td class="num order-num">{{ loop.index }}</td>
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ r.m.Model }}" aria-label="Model"></td>
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ r.m.Id }}" aria-label="Family ID"></td>
<td class="text-nowrap">
{% if r.fam.entries %}
<span class="badge {{ 'badge-success' if r.fam.on_disk == r.fam.entries else 'badge-warning' }}">{{ r.fam.entries }} entr{{ 'ies' if r.fam.entries != 1 else 'y' }}</span>
<div class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</div>
{% else %}
<span class="badge badge-secondary">None</span>
{% endif %}
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this model">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
<tr class="empty-row" {% if models %}style="display:none"{% endif %}><td colspan="5">{% if has_selections %}No hardware models selected.{% else %}No user_selections.json for this image.{% endif %}</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Package upload modal (unchanged from the old Configuration page) -->
<div class="modal fade" id="packageUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Upload package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label">Package file</label>
<input type="file" class="form-control" name="package_file" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
</div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Packages">
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// OS and Packages: stage edits in the tables, then Review and save posts
// only the sections that changed (views/images.py os_preview / os_save).
(function () {
var dirty = {};
var reviewBtn = document.getElementById('ospReview');
var head = document.querySelector('.image-head');
function markDirty(section) {
dirty[section] = true;
reviewBtn.disabled = false;
if (head) head.classList.add('dirty');
}
function rowsOf(tableId) {
return Array.prototype.slice.call(
document.querySelectorAll('#' + tableId + ' tbody tr:not(.empty-row)'));
}
function baseObject(row) {
try { return JSON.parse(row.getAttribute('data-json') || '{}'); } catch (e) { return {}; }
}
function refreshEmpty(table) {
var empty = table.querySelector('.empty-row');
if (!empty) return;
empty.style.display = table.querySelectorAll('tbody tr:not(.empty-row)').length ? 'none' : '';
}
// Each card carries data-section; any edit inside it marks that section.
document.querySelectorAll('[data-section]').forEach(function (card) {
var section = card.getAttribute('data-section');
card.addEventListener('input', function () { markDirty(section); });
card.addEventListener('change', function () { markDirty(section); });
card.addEventListener('click', function (e) {
// app.js removes the row and renumbers (delegated on document, so it
// runs after this); refresh the empty row once it is gone.
if (e.target.closest('.remove-row')) {
markDirty(section);
var table = e.target.closest('table');
setTimeout(function () { refreshEmpty(table); }, 0);
}
});
});
var addBtn = document.getElementById('ospAddModel');
if (addBtn) {
addBtn.addEventListener('click', function () {
var tbody = document.querySelector('#ospModelTable tbody');
var tr = document.createElement('tr');
tr.className = 'row-new';
tr.innerHTML =
'<td class="num order-num"></td>' +
'<td><input type="text" class="form-control form-control-sm" data-field="Model" placeholder="Model name" aria-label="Model"></td>' +
'<td><input type="text" class="form-control form-control-sm" data-field="Id" placeholder="Driver family ID" aria-label="Family ID"></td>' +
'<td><span class="badge badge-info">New</span></td>' +
'<td class="text-end"><button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this model"><i class="bi bi-trash"></i></button></td>';
tbody.insertBefore(tr, tbody.querySelector('.empty-row'));
rowsOf('ospModelTable').forEach(function (r, i) { r.querySelector('.order-num').textContent = i + 1; });
refreshEmpty(tbody.closest('table'));
markDirty('hardware_models');
tr.querySelector('input').focus();
});
}
function collect() {
var body = {};
if (dirty.operating_systems) {
body.operating_systems = rowsOf('ospOsTable').map(baseObject);
}
if (dirty.packages) {
// "enabled" keeps the file's own type: the MCL packages.json uses 1/0
// (PESetup reads it as a number), ours true/false. A row whose switch
// was not touched is sent exactly as it came.
var rows = rowsOf('ospPkgTable');
var numeric = rows.some(function (row) { return typeof baseObject(row).enabled === 'number'; });
body.packages = rows.map(function (row) {
var obj = baseObject(row);
var sw = row.querySelector('.pkg-enabled');
if (sw && sw.checked !== sw.defaultChecked) {
var asNumber = typeof obj.enabled === 'number' || (obj.enabled === undefined && numeric);
obj.enabled = asNumber ? (sw.checked ? 1 : 0) : sw.checked;
}
return obj;
});
}
if (dirty.hardware_models) {
// Keep each row's other keys and key order; only Model/Id change.
body.hardware_models = rowsOf('ospModelTable').map(function (row) {
var obj = row.getAttribute('data-json') ? baseObject(row) : { Id: '', Model: '' };
row.querySelectorAll('[data-field]').forEach(function (inp) {
obj[inp.getAttribute('data-field')] = inp.value.trim();
});
return obj;
});
}
return body;
}
reviewBtn.addEventListener('click', function () {
PxeImage.reviewAndSave({
title: 'Review changes: OS and Packages',
previewUrl: '{{ url_for("images.os_preview", image_type=image_type) }}',
saveUrl: '{{ url_for("images.os_save", image_type=image_type) }}',
body: collect(),
onSaved: function () { dirty = {}; window.location.reload(); }
});
});
window.addEventListener('beforeunload', function (e) {
if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ''; }
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,261 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - Overview{% endblock %}
{% block extra_head %}
<style>
.ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.ov-tile {
display: block; text-decoration: none; color: var(--text);
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.25rem;
border-left: 4px solid var(--border);
padding: 0.9rem 1rem; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
transition: border-color 0.15s;
}
a.ov-tile:hover { color: var(--text); border-color: var(--primary); }
.ov-tile.ok { border-left-color: var(--success); }
.ov-tile.warn { border-left-color: var(--warning); }
.ov-tile.bad { border-left-color: var(--danger); }
.ov-tile .label { font-size: 11px; color: var(--text-light); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
.ov-tile .value { font-size: 1.6rem; font-weight: 600; line-height: 1.2; margin: 0.25rem 0 0.15rem; }
.ov-tile .value.small-value { font-size: 0.95rem; line-height: 1.35; }
.ov-tile .detail { font-size: 12px; color: var(--text-light); }
.ov-tile.bad .value { color: var(--danger); }
.ov-tile.warn .value { color: var(--warning-dark); }
[data-theme="dark"] .ov-tile.warn .value { color: var(--warning); }
.ov-attn { list-style: none; padding: 0; margin: 0; }
.ov-attn li { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.55rem 1.25rem; border-bottom: 1px solid var(--border); font-size: 14px; }
.ov-attn li:last-child { border-bottom: none; }
.ov-attn .bi { margin-top: 0.15rem; }
.ov-attn .bi-x-octagon { color: var(--danger); }
.ov-attn .bi-exclamation-triangle { color: var(--warning); }
.ov-attn .bi-info-circle { color: var(--primary); }
.ov-attn a { margin-left: auto; white-space: nowrap; font-size: 13px; }
.ov-table td { vertical-align: top; white-space: normal; }
.ov-table .file-cell { overflow-wrap: anywhere; }
.ov-table .small { font-size: 12px; }
.ov-list-more { font-size: 12.5px; color: var(--text-light); padding: 0.5rem 1.25rem; }
</style>
{% endblock %}
{% block content %}
{% set header_actions %}
<a href="{{ url_for('hardware_page') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-cpu"></i> Hardware Coverage</a>
<a href="{{ url_for('dell_updates.page') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-motherboard"></i> Dell Updates</a>
{% endset %}
{% include "_image_header.html" %}
{% set d = ov.drivers %}
{% set lint = ov.lint %}
{# missing-zip findings have their own tile; bad_lint is what is wrong in the map itself #}
{% set bad_lint = lint.map_errors if lint.available else 0 %}
{% set os_row = ov.os.current %}
{# -------- tiles -------- #}
<div class="ov-grid">
<a class="ov-tile {{ 'ok' if d.total else 'bad' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
<div class="label">Driver entries</div>
<div class="value">{{ d.total }}</div>
<div class="detail">{{ d.by_os.Win11 }} Win11 | {{ d.by_os.Win10 }} Win10{% if d.by_os.Other %} | {{ d.by_os.Other }} other{% endif %}</div>
</a>
<a class="ov-tile {{ 'bad' if d.missing else 'ok' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
<div class="label">Driver zips missing</div>
<div class="value">{{ d.missing | length }}</div>
<div class="detail">{% if d.missing %}{{ d.missing | selectattr('os', 'equalto', 'Win11') | list | length }} of them Win11{% else %}every entry's zip is on the share{% endif %}</div>
</a>
<a class="ov-tile {{ ('bad' if bad_lint else ('warn' if lint.total else 'ok')) if lint.available else '' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
<div class="label">Driver map lint</div>
{% if lint.available %}
<div class="value">{{ lint.total }}</div>
<div class="detail">{% for lv, n in lint.by_level | dictsort %}{{ n }} {{ lv | lower }}{% if not loop.last %} | {% endif %}{% else %}no findings{% endfor %}</div>
{% else %}
<div class="value small-value text-muted">Not available</div>
<div class="detail">Shown once the Drivers tab lint is installed</div>
{% endif %}
</a>
<a class="ov-tile {{ ('ok' if os_row.wim.exists else 'bad') if os_row else 'bad' }}" href="{{ image_tab_url(image_type, 'os') }}">
<div class="label">Operating system</div>
{% if os_row %}
<div class="value small-value">{{ os_row.osv.productName or os_row.osv.marketingName }} {{ os_row.osv.versionNumber }}</div>
<div class="detail">osId {{ os_row.osv.id }} |
{% if os_row.wim.exists %}install.wim on disk ({{ (os_row.wim.size / 1073741824) | round(1) }} GB){% else %}<strong>install.wim not on disk</strong>{% endif %}</div>
{% else %}
<div class="value small-value">None</div>
<div class="detail">OperatingSystem.json is empty or missing</div>
{% endif %}
</a>
<a class="ov-tile {{ 'bad' if ov.packages.missing else 'ok' }}" href="{{ image_tab_url(image_type, 'os') }}">
<div class="label">Packages</div>
<div class="value">{{ ov.packages.enabled }}<span class="detail"> of {{ ov.packages.total }} enabled</span></div>
<div class="detail">{% if ov.packages.missing %}{{ ov.packages.missing | length }} enabled file{{ 's' if ov.packages.missing | length != 1 }} missing{% else %}all enabled files on disk{% endif %}</div>
</a>
<a class="ov-tile {{ ('bad' if ov.gaps else 'ok') if ov.gaps_checked else '' }}" href="{{ url_for('hardware_page') }}">
<div class="label">Hardware gaps</div>
{% if ov.gaps_checked %}
<div class="value">{{ ov.gaps | length }}</div>
<div class="detail">models seen by WinPE with no working driver entry here</div>
{% else %}
<div class="value small-value text-muted">Not tracked</div>
<div class="detail">Hardware Coverage checks the three GE images only</div>
{% endif %}
</a>
<a class="ov-tile {{ 'warn' if ov.dell.rows else ('ok' if ov.dell.checked else '') }}" href="{{ url_for('dell_updates.page') }}">
<div class="label">Dell updates</div>
{% if ov.dell.checked %}
<div class="value">{{ ov.dell.rows | length }}</div>
<div class="detail">newer driver pack{{ 's' if ov.dell.rows | length != 1 }} | checked {{ ov.dell.checked | local_dt }}</div>
{% else %}
<div class="value small-value text-muted">Not checked</div>
<div class="detail">Run a check on Updates, Dell Drivers and BIOS</div>
{% endif %}
</a>
</div>
{# -------- needs attention -------- #}
{% set attn = [] %}
{% if d.missing %}{% set _ = attn.append(('x-octagon', d.missing | length ~ ' driver entr' ~ ('ies point' if d.missing | length != 1 else 'y points') ~ ' at a zip that is not on the share. PESetup only warns, and the PC comes up without NIC or WiFi drivers.', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %}
{% if bad_lint %}{% set _ = attn.append(('x-octagon', bad_lint ~ ' driver map lint error' ~ ('s' if bad_lint != 1) ~ ' in the entries themselves (a token or order PESetup will mis-match; see the findings on the Drivers tab).', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %}
{% if not os_row %}{% set _ = attn.append(('x-octagon', 'OperatingSystem.json has no entry: PESetup has no Windows image to apply.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}
{% elif not os_row.wim.exists %}{% set _ = attn.append(('x-octagon', 'install.wim is not on disk in ' ~ (os_row.dest or 'the configured folder (DestinationDir is empty)') ~ '.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %}
{% if ov.packages.missing %}{% set _ = attn.append(('exclamation-triangle', ov.packages.missing | length ~ ' enabled package file' ~ ('s are' if ov.packages.missing | length != 1 else ' is') ~ ' not on disk.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %}
{% if ov.gaps %}{% set _ = attn.append(('exclamation-triangle', ov.gaps | length ~ ' model' ~ ('s' if ov.gaps | length != 1) ~ ' booted WinPE here with no working driver entry in this image.', url_for('hardware_page'), 'Hardware Coverage')) %}{% endif %}
{% if ov.unattend_override %}{% set _ = attn.append(('exclamation-triangle', 'Deploy/Tools/GE/XML/FlatUnattendW10.xml exists. PESetup loads that file first, so the Unattend tab (which edits Deploy/FlatUnattendW10.xml) is not what imaging uses.', image_tab_url(image_type, 'unattend'), 'Unattend')) %}{% endif %}
{% if ov.hw_drivers_extra %}{% set _ = attn.append(('info-circle', 'Deploy/Control/hw_drivers.json exists. PESetup never reads it; only HardwareDriver.json decides drivers.', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %}
{% if ov.dell.rows %}{% set _ = attn.append(('info-circle', ov.dell.rows | length ~ ' driver pack' ~ ('s have' if ov.dell.rows | length != 1 else ' has') ~ ' a newer Dell release (last check ' ~ (ov.dell.checked | local_dt) ~ ').', url_for('dell_updates.page'), 'Dell Updates')) %}{% endif %}
{% if ov.models.no_entry %}{% set _ = attn.append(('info-circle', ov.models.no_entry | length ~ ' hardware model selection' ~ ('s name' if ov.models.no_entry | length != 1 else ' names') ~ ' a driver family with no entry in HardwareDriver.json (media build selection only; imaging does not use it).', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-clipboard-pulse"></i> Needs attention</span>
<span class="badge {{ 'badge-danger' if attn | selectattr(0, 'equalto', 'x-octagon') | list else ('badge-warning' if attn else 'badge-success') }}">{{ attn | length or 'All clear' }}</span>
</div>
{% if attn %}
<ul class="ov-attn">
{% for icon, text, link, label in attn %}
<li><i class="bi bi-{{ icon }}"></i><span>{{ text }}</span><a href="{{ link }}">{{ label }} <i class="bi bi-chevron-right"></i></a></li>
{% endfor %}
</ul>
{% else %}
<div class="card-body text-muted">Driver zips, install.wim and packages are all on disk and no seen model is missing drivers.</div>
{% endif %}
</div>
{# -------- control files -------- #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-folder2-open"></i> Control files</span>
</div>
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th>File</th><th>Last modified</th><th class="text-end">Size</th><th class="text-end">Backups</th></tr></thead>
<tbody>
{% for f in ov.files %}
<tr>
<td>
<a href="{{ image_tab_url(image_type, f.tab) }}" class="mono">{{ f.name }}</a>
<div class="small text-muted">{{ f.what }}</div>
<div class="small text-muted mono file-cell">{{ f.rel }}</div>
</td>
<td class="text-nowrap">
{% if f.exists %}{{ f.mtime | local_dt }}{% else %}<span class="badge badge-danger">Missing</span>{% endif %}
</td>
<td class="text-end text-nowrap mono">{% if f.exists %}{{ f.size | filesizeformat }}{% else %}-{% endif %}</td>
<td class="text-end mono">{{ f.backups or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% set saves = ov.last_saves | dictsort | selectattr(1) | list %}
{% if saves %}
<div class="card-footer small text-muted">
Last saves from this app:
{% for scope, rec in saves %}{{ {'drivers': 'Drivers', 'unattend': 'Unattend', 'os': 'OS and Packages'}[scope] }} {{ rec.ts | local_dt }} ({{ rec.files | map(attribute='name') | join(', ') }}){% if not loop.last %}; {% endif %}{% endfor %}
</div>
{% endif %}
</div>
<div class="row gx-4 gy-0">
<div class="col-12 col-xl-6">
{# -------- coverage gaps -------- #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-cpu"></i> Hardware coverage gaps</span>
<a href="{{ url_for('hardware_page') }}" class="small fw-normal">Hardware Coverage</a>
</div>
{% if not ov.gaps_checked %}
<div class="card-body text-muted small">This image is not one of the images Hardware Coverage checks.</div>
{% elif ov.gaps %}
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th>Model (WMI)</th><th>Problem</th><th>Last seen</th></tr></thead>
<tbody>
{% for g in ov.gaps[:8] %}
<tr>
<td class="mono">{{ g.model }}{% if g.skus %}<div class="small text-muted">SKU {{ g.skus | join(', ') }}</div>{% endif %}</td>
<td>{% if g.status == 'no-entry' %}<span class="badge badge-danger">No entry</span>{% else %}<span class="badge badge-danger" title="{{ g.file }}">Zip missing</span>{% endif %}</td>
<td class="text-nowrap small">{{ g.last_seen | local_dt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if ov.gaps | length > 8 %}<div class="ov-list-more">and {{ ov.gaps | length - 8 }} more on Hardware Coverage</div>{% endif %}
{% else %}
<div class="card-body text-muted small">Every model that has booted WinPE here matches a driver entry with its zip on disk.</div>
{% endif %}
</div>
{# -------- dell hints -------- #}
{% if ov.dell.rows %}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-motherboard"></i> Newer Dell driver packs</span>
<a href="{{ url_for('dell_updates.page') }}" class="small fw-normal">Dell Updates</a>
</div>
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th class="text-end">#</th><th>Current file</th><th>Dell has</th></tr></thead>
<tbody>
{% for r in ov.dell.rows[:8] %}
<tr>
<td class="text-end mono text-muted">{{ r.pos }}</td>
<td class="mono file-cell">{{ r.file }}<div class="small text-muted file-cell">{{ r.models }}</div></td>
<td><span class="badge badge-info">{{ r.ver or 'newer' }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if ov.dell.rows | length > 8 %}<div class="ov-list-more">and {{ ov.dell.rows | length - 8 }} more on Dell Updates</div>{% endif %}
</div>
{% endif %}
</div>
<div class="col-12 col-xl-6">
{# -------- missing zips -------- #}
{% if d.missing %}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-file-earmark-x"></i> Zips not on the share</span>
<a href="{{ image_tab_url(image_type, 'drivers') }}" class="small fw-normal">Drivers</a>
</div>
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th class="text-end">#</th><th>File</th><th>OS</th></tr></thead>
<tbody>
{% for m in d.missing[:8] %}
<tr>
<td class="text-end mono text-muted">{{ m.pos }}</td>
<td class="mono file-cell">{{ m.file or '(no file name)' }}<div class="small text-muted file-cell">{{ m.models }}</div></td>
<td>{{ m.os }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if d.missing | length > 8 %}<div class="ov-list-more">and {{ d.missing | length - 8 }} more on the Drivers tab</div>{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,398 @@
{% extends "base.html" %}
{% block title %}Blancco Updates - PXE Server Manager{% endblock %}
{% macro mb(n) -%}{{ "%.1f"|format((n or 0) / 1048576) }} MB{%- endmacro %}
{% macro release_cells(r) -%}
<td class="mono">{{ r.kernel or '-' }}</td>
<td class="mono">{{ r.prefs_version or '-' }}{% if r.vendor_prefs_version and r.vendor_prefs_version != r.prefs_version %}<div class="small text-muted">vendor {{ r.vendor_prefs_version }}</div>{% endif %}</td>
<td class="text-nowrap">{{ mb(r.size) }}</td>
{%- endmacro %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-eraser"></i> Blancco Updates</h1>
<div class="header-actions">
<span class="badge badge-info badge-lg">Live: {{ current.version }}{% if current.inferred %} (installed by hand){% endif %}</span>
{% if test %}<span class="badge badge-warning badge-lg">Test entry: {{ test.version or '?' }}</span>{% endif %}
</div>
</div>
<p class="text-muted">
Installs a new Blancco Drive Eraser release from the vendor ISO (or the vendor's USB image, <span class="mono">.img</span>
or <span class="mono">.img.gz</span>). Staging extracts the release next to the live one, applies the same changes as
<span class="mono">scripts/prepare-boot-tools.sh</span> (our preferences written into <span class="mono">config.img</span>,
the initramfs network patch), and a test entry in the PXE boot menu boots the staged copy. Only <strong>Activate</strong>
changes what the normal Blancco entry boots, and the files it replaces are kept as a backup you can roll back to.
The Ubuntu kernel, its initrd and the kmod tarball the UEFI path uses are not part of a release and are never touched.
</p>
{# ---- Live release -------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd-network"></i> Live release</div>
<p class="small text-muted mb-2">Served from <span class="mono">{{ current.path }}</span>.</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Rootfs date</th><th>Installed</th></tr></thead>
<tbody>
<tr>
<td class="mono">{{ current.version }}
{% if current.inferred %}<div class="small text-muted">from the preferences in config.img</div>{% endif %}
{% if current.changed_by_hand %}<div class="small text-warning">files changed outside this page since the last activate or roll back</div>{% endif %}</td>
{{ release_cells(current) }}
<td class="mono text-nowrap">{{ current.rootfs_mtime | timestamp_fmt if current.rootfs_mtime else '-' }}</td>
<td class="small">
{% if current.activated %}{{ current.activated | local_dt }}
{% if current.restored_from %}<div class="text-muted">rolled back to {{ current.restored_from }}</div>
{% elif current.source %}<div class="text-muted">from {{ current.source }}</div>{% endif %}
{% else %}<span class="text-muted">by hand (before this page)</span>{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
{% if current.missing %}
<div class="alert alert-warning mt-2 mb-0">Missing from the live folder: <span class="mono">{{ current.missing | join(', ') }}</span></div>
{% endif %}
</div>
{# ---- Job ----------------------------------------------------------------- #}
{% if job.state %}
<div class="section-card" id="blanccoJob" data-state="{{ job.state }}">
<div class="section-title"><i class="bi bi-gear"></i> Staging {{ job.source or '' }}</div>
{% if job_running %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm" role="status"></span>
<span id="blanccoJobStep">{{ job.step or 'Starting' }}</span>
<span id="blanccoJobMsg" class="small text-muted">{{ job.message or '' }}</span>
</div>
<div class="progress mb-2" style="height: 0.5rem;">
<div id="blanccoJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
</div>
<form method="post" action="{{ url_for('blancco_update.cancel') }}" class="mb-2"
onsubmit="return confirm('Stop staging and remove the partial files?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm"><i class="bi bi-x-circle"></i> Cancel</button>
</form>
{% elif job.state == 'done' %}
<div class="alert alert-success mb-2">{{ job.message }} <span class="small text-muted">({{ job.finished | local_dt }})</span></div>
{% elif job.state == 'blocked' %}
<div class="alert alert-warning mb-2"><strong>Staging stopped: preferences need merging.</strong> {{ job.message }}</div>
{% elif job.state == 'cancelled' %}
<div class="alert alert-warning mb-2">Staging was cancelled.</div>
{% else %}
<div class="alert alert-danger mb-2">{{ job.message or 'Staging failed.' }}</div>
{% endif %}
{% if job.log %}
<details {% if job_running or job.state in ('failed', 'blocked') %}open{% endif %}>
<summary class="small text-muted" style="cursor:pointer;">Job log</summary>
<pre id="blanccoJobLog" class="mono small mb-0" style="white-space: pre-wrap; max-height: 16rem; overflow:auto;">{{ job.log | join('\n') }}</pre>
</details>
{% endif %}
</div>
{% endif %}
{# ---- Get a release ------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-upload"></i> Release files</div>
<form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end mb-2">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="blanccoFile" class="form-label">Vendor ISO or USB image</label>
<input type="file" class="form-control" id="blanccoFile" name="release_file" accept=".iso,.img,.gz" required>
<div class="form-text">About 1 GB; streamed to <span class="mono">{{ upload_dir }}</span>.</div>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload</button>
</form>
<div id="blanccoUploadProgress" class="mb-3" style="display:none;">
<div class="small mb-1" id="blanccoUploadText">Uploading...</div>
<div class="progress" style="height: 0.5rem;"><div id="blanccoUploadBar" class="progress-bar" style="width: 0%;"></div></div>
</div>
<p class="small text-muted">
Files copied in by hand are listed too, from
{% for key, folder in source_dirs %}<span class="mono">{{ folder }}</span>{{ ', ' if not loop.last }}{% endfor %}
(<span class="mono">.iso</span>, <span class="mono">.img</span>, <span class="mono">.img.gz</span>, <span class="mono">.iso.gz</span>).
</p>
{% if sources %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>File</th><th>Folder</th><th>Size</th><th>Modified</th><th class="text-end"></th></tr></thead>
<tbody>
{% for s in sources %}
<tr>
<td class="mono">{{ s.name }}</td>
<td class="small text-muted">{{ s.key }}</td>
<td class="text-nowrap">{{ mb(s.size) }}</td>
<td class="mono text-nowrap">{{ s.modified | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('blancco_update.stage') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="source" value="{{ s.key }}:{{ s.name }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action" {% if job_running %}disabled{% endif %}>
<i class="bi bi-box-seam"></i> Stage</button>
</form>
{% if s.deletable %}
<form method="post" action="{{ url_for('blancco_update.delete_upload', name=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No release files yet. Upload the vendor ISO or USB image above.</p>
{% endif %}
</div>
{# ---- Staged -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> Staged releases</div>
{% if staged %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Status</th><th>Staged</th><th class="text-end"></th></tr></thead>
<tbody>
{% for s in staged %}
<tr>
<td class="mono">{{ s.name }}{% if s.in_menu %} <span class="badge badge-warning">in boot menu</span>{% endif %}</td>
{{ release_cells(s) }}
<td>
{% if s.status == 'ready' %}<span class="badge badge-success">Ready</span>
{% if s.merged_prefs %}<div class="small text-muted">merged preferences</div>{% endif %}
{% elif s.status == 'blocked' %}<span class="badge badge-danger">Needs preferences merge</span>
{% elif s.status == 'failed' %}<span class="badge badge-danger">Failed</span>
{% if s.error %}<div class="small text-danger wrap">{{ s.error }}</div>{% endif %}
{% else %}<span class="badge badge-secondary">{{ s.status }}</span>{% endif %}
</td>
<td class="small text-nowrap">{{ s.staged | local_dt }}<div class="text-muted">{{ s.source or '' }}</div></td>
<td class="text-end text-nowrap">
{% if s.status == 'ready' %}
{% if not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.test_add', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Add a test entry for ' ~ s.name ~ ' to the PXE boot menu' ~ (' (replacing the one for ' ~ (test.version or '?') ~ ')' if test else '') ~ '? The menu is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-list-check"></i> Add test entry</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Make Blancco ' ~ s.name ~ ' the live release? Every Blancco boot from now on gets it. The current files (' ~ current.version ~ ') are backed up first and can be rolled back.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action"><i class="bi bi-check2-circle"></i> Activate</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_staged', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete staged ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% if s.status == 'blocked' %}
<tr>
<td colspan="7" class="wrap">
<div class="alert alert-warning mb-2">{{ s.blocked }}</div>
<ol class="small mb-2">
<li><a href="{{ url_for('blancco_update.vendor_prefs', version=s.name) }}">Download the vendor preferences.xml of {{ s.name }}</a>.</li>
<li>Carry our settings across from <span class="mono">playbook/blancco-preferences.xml</span> (report share
<span class="mono">{{ server }}</span>/<span class="mono">blancco-reports</span>, <span class="mono">auto_backup</span>, WiFi,
screensaver, business location and the rest), keeping the new file's <span class="mono">&lt;version&gt;</span> block and any new settings.</li>
<li>Upload the merged file here; staging then finishes. Commit it to the repo as <span class="mono">playbook/blancco-preferences.xml</span>.</li>
</ol>
<form method="post" action="{{ url_for('blancco_update.merged_prefs', version=s.name) }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(20rem, 100%);">
<label class="form-label" for="prefs-{{ loop.index }}">Merged preferences.xml</label>
<input type="file" class="form-control" id="prefs-{{ loop.index }}" name="prefs_file" accept=".xml" required>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload and finish staging</button>
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Nothing staged.</p>
{% endif %}
</div>
{# ---- Test entry ---------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-check"></i> Test entry in the PXE boot menu</div>
{% if test %}
<p class="mb-2">
The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged
<strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>.
</p>
{% if test.efi is false %}
<div class="alert alert-warning">The entry's UEFI branch is off: when it was added, the UEFI initrd did not read
<span class="mono">blancco.base=</span>, so a UEFI test boot would have run the LIVE release. A UEFI boot of the entry
says so and returns to the menu. After rebuilding the initrd, add the test entry again.</div>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.test_remove') }}"
onsubmit="return confirm('Remove the test entry from the PXE boot menu? The menu is backed up first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-x-circle"></i> Remove test entry</button>
</form>
{% else %}
<p class="text-muted">No test entry. <strong>Add test entry</strong> on a staged release puts one under the normal Blancco entry.</p>
{% endif %}
<ul class="small mt-3 mb-0">
<li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder
(<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li>
<li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel,
Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which
the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand:
{% if initrd.state == 'yes' %}<span class="badge badge-success">supported</span>
{% elif initrd.state == 'no' %}<span class="badge badge-danger">not supported</span>
<div class="text-danger">This initrd predates <span class="mono">blancco.base=</span>: booted, it would run the LIVE release, so a test
entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current
<span class="mono">playbook/blancco-init.sh</span>, then add the entry again (the boot screen shows "release under test"
when it works).</div>
{% else %}<span class="badge badge-secondary">unknown</span> <span class="text-muted">{{ initrd.detail }}</span>{% endif %}</li>
<li>Add, replace and remove edit only the test entry's lines; each edit saves
<span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the
version under test removes its entry.</li>
</ul>
</div>
{# ---- Backups ------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
<p class="small text-muted mb-2">Folders <span class="mono">{{ current.path }}/backup-*</span>. A rollback first saves what is live, so it can be undone the same way;
the restored backup's folder is then released, because its files are the live files.</p>
{% if backups %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Backup</th><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Saved</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in backups %}
<tr>
<td class="mono">{{ b.name }}{% if b.reason %}<div class="small text-muted">{{ b.reason }}</div>{% endif %}</td>
<td class="mono">{{ b.version }}</td>
{{ release_cells(b) }}
<td class="mono text-nowrap">{{ b.created | local_dt if b.created is string else b.created | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
{% if b.complete %}
<form method="post" action="{{ url_for('blancco_update.rollback', name=b.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Put ' ~ b.name ~ ' back live? What is live now (' ~ current.version ~ ') is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Roll back</button>
</form>
{% else %}
<span class="badge badge-secondary" title="Missing {{ b.missing | join(', ') }}">Incomplete</span>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_backup', name=b.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete ' ~ b.name ~ '? It cannot be rolled back to afterwards.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-text">Each backup holds one release, about 1 GB. Taking one is instant (hard links of the live files before they are replaced).</div>
{% else %}
<p class="text-muted mb-0">No backups yet. Activating a release saves the one it replaces here.</p>
{% endif %}
</div>
{# ---- How it works -------------------------------------------------------- #}
<div class="pxe-card">
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> What a release changes</h3>
<p class="small mb-2">
Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>,
<span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch),
<span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span>
(vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>,
<span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the
ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>,
<span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>,
<span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>.
</p>
<p class="small mb-0">
Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys
<span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file,
so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded.
After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back.
</p>
<p class="small mb-0 mt-2">
A playbook run also copies <span class="mono">boot-tools/blancco/*</span> from the USB over the live files. After activating
a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run
puts the old release back.
</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// Upload: stream the raw file (no multipart parse, no copy in /tmp) with a progress bar.
var form = document.getElementById('blanccoUpload');
form.addEventListener('submit', function (ev) {
var input = document.getElementById('blanccoFile');
if (!input.files.length || !window.XMLHttpRequest) { return; }
ev.preventDefault();
var file = input.files[0];
var box = document.getElementById('blanccoUploadProgress');
var bar = document.getElementById('blanccoUploadBar');
var text = document.getElementById('blanccoUploadText');
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action + '?name=' + encodeURIComponent(file.name));
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (e) {
if (!e.lengthComputable) { return; }
var pct = 100 * e.loaded / e.total;
bar.style.width = pct.toFixed(1) + '%';
text.textContent = 'Uploading ' + file.name + ': ' + Math.round(e.loaded / 1048576) + ' of ' +
Math.round(e.total / 1048576) + ' MB';
};
xhr.onload = function () {
var r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status === 200 && r.ok) { location.reload(); return; }
text.textContent = 'Upload failed: ' + (r.error || ('HTTP ' + xhr.status));
bar.classList.add('bg-danger');
};
xhr.onerror = function () { text.textContent = 'Upload failed: connection lost.'; bar.classList.add('bg-danger'); };
box.style.display = '';
form.querySelector('button[type=submit]').disabled = true;
xhr.send(file);
});
// Follow a running staging job; reload when it ends.
var job = document.getElementById('blanccoJob');
if (!job || job.dataset.state !== 'running') { return; }
var URL = '{{ url_for("blancco_update.status") }}';
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.state !== 'running') { location.reload(); return; }
document.getElementById('blanccoJobStep').textContent = d.step || '';
document.getElementById('blanccoJobMsg').textContent = d.message || '';
document.getElementById('blanccoJobBar').style.width = (d.percent || 0) + '%';
var log = document.getElementById('blanccoJobLog');
if (log && d.log) { log.textContent = d.log.join('\n'); }
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,556 @@
{% extends "base.html" %}
{% block title %}BPRT Sync - PXE Server Manager{% endblock %}
{% macro size(n) -%}
{%- if n is none -%}-{%- elif n >= 1000000000 -%}{{ '%.2f' | format(n / 1000000000) }} GB{%- elif n >= 1000000 -%}{{ '%.0f' | format(n / 1000000) }} MB{%- elif n >= 1000 -%}{{ '%.0f' | format(n / 1000) }} KB{%- else -%}{{ n }} B{%- endif -%}
{%- endmacro %}
{# Long package names: allow a line break after each underscore, nowhere else. #}
{% macro fname(n) -%}
{%- for part in n.split('_') %}{{ part }}{% if not loop.last %}_<wbr>{% endif %}{% endfor -%}
{%- endmacro %}
{% macro val_badge(v, lg=false) -%}
{% set cls = 'badge badge-lg' if lg else 'badge' %}
{% if not v %}<span class="{{ cls }} badge-secondary">Not validated</span>
{% elif v.stale %}<span class="{{ cls }} badge-warning" title="{{ v.stale }}">Changed since validation</span>
{% elif v.state == 'valid' %}<span class="{{ cls }} badge-success">Valid</span>
{% else %}<span class="{{ cls }} badge-danger">{{ v.errors | length }} error{{ 's' if v.errors | length != 1 }}</span>{% endif %}
{%- endmacro %}
{% macro job_panel(job) -%}
{% if job.running %}
<div class="section-card" id="bprtJob" data-running="1">
<div class="section-title"><span class="spinner-border spinner-border-sm"></span>
{{ {'manifest': 'Hashing the live BPRT', 'validate': 'Validating', 'activate': 'Activating', 'unzip': 'Unpacking the zip'}.get(job.kind, job.kind) }}
{% if job.arg %}<span class="mono small">{{ job.arg }}</span>{% endif %}</div>
<div class="small mb-1"><span id="bprtJobStep">{{ job.step or 'Starting' }}</span> <span id="bprtJobCurrent" class="mono text-muted">{{ job.current or '' }}</span></div>
<div class="progress" style="height: 0.5rem;"><div id="bprtJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div></div>
<div class="small text-muted mt-1" id="bprtJobPct">{% if job.percent is not none %}{{ job.percent }}%{% endif %}</div>
</div>
{% elif job.state in ('error', 'interrupted') %}
<div class="alert alert-danger">
<strong>The last BPRT {{ job.kind }} job {{ 'failed' if job.state == 'error' else 'stopped before it finished' }}</strong>
{% if job.arg %}(<span class="mono">{{ job.arg }}</span>){% endif %}{% if job.finished %}, {{ job.finished | local_dt }}{% endif %}{% if job.error %}: {{ job.error }}{% endif %}
{% if job.kind == 'activate' %}<div class="small mt-1">The live BPRT is only replaced by the last step (two renames), so it is either the old folder or the new one, never a half-built one.</div>{% endif %}
</div>
{% elif job.state == 'done' and job.result %}
<div class="small text-muted mb-3"><i class="bi bi-check2-circle"></i> Last job: {{ job.kind }}{% if job.arg %} <span class="mono">{{ job.arg }}</span>{% endif %}, {{ job.result }}{% if job.finished %} ({{ job.finished | local_dt }}){% endif %}.</div>
{% endif %}
{%- endmacro %}
{% macro imaging_alert(imaging) -%}
{% if imaging %}
<div class="alert alert-warning">
<strong><i class="bi bi-exclamation-triangle"></i> {{ imaging | length }} PC{{ 's are' if imaging | length != 1 else ' is' }} imaging now.</strong>
PESetup copies BPRT to each PC while it images; a PC copying during the swap can end up with a mix of old and new files.
Activate or roll back when these have finished:
{% for s in imaging[:8] %}<span class="mono">{{ s.hostname or s.id }}</span>{% if s.stage %} ({{ s.stage }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if imaging | length > 8 %} and {{ imaging | length - 8 }} more{% endif %}.
<a href="{{ url_for('imaging_dashboard') }}">Imaging progress</a>
</div>
{% endif %}
{%- endmacro %}
{% block extra_head %}
<style>
.bprt td.wrap { white-space: normal; word-break: break-word; min-width: 8rem; }
.bprt .file-list { columns: 2 22rem; column-gap: 2rem; margin: 0.25rem 0 0; padding-left: 1.1rem; }
.bprt .file-list li { break-inside: avoid; }
.bprt details > summary { cursor: pointer; }
.bprt .fname { word-break: normal; overflow-wrap: normal; }
.bprt .msg-list { margin: 0; padding-left: 1.1rem; }
.bprt .msg-list li { margin-bottom: 0.25rem; }
.bprt pre.cmd { white-space: pre-wrap; word-break: break-all; margin: 0.25rem 0 0.75rem; }
</style>
{% endblock %}
{% block content %}
<div class="bprt">
{% if detail %}
{# ======================= one incoming batch ============================== #}
{% set b = detail %}
<div class="page-header">
<h1><i class="bi bi-inboxes"></i> Incoming batch <span class="mono">{{ b.name }}</span></h1>
<div class="header-actions">
{{ val_badge(res, lg=true) }}
<a href="{{ url_for('bprt_sync.page') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> BPRT Sync</a>
</div>
</div>
<p class="text-muted">
{% if b.sync %}From <strong>{{ b.sync.computer or 'unknown PC' }}</strong>{% if b.sync.user %} ({{ b.sync.user }}){% endif %}{% if b.sync.created %}, {{ b.sync.created | local_dt }}{% endif %}.
{% if b.sync.source %}Source <span class="mono">{{ b.sync.source }}</span>.{% endif %}
{{ b.sync.listed }} vendor files listed, {{ b.files }} uploaded ({{ size(b.bytes) }}).{% endif %}
Folder <span class="mono">{{ incoming }}/{{ b.name }}</span>.
</p>
{% if b.sync_error %}<div class="alert alert-danger">{{ b.sync_error }}</div>{% endif %}
{{ job_panel(job) }}
{% if not res %}
<div class="section-card">
<div class="section-title"><i class="bi bi-clipboard-check"></i> Validate</div>
<p>Validation hashes every uploaded file against <span class="mono">sync.json</span>, checks that every other listed file is
unchanged in the live BPRT, applies the vendor's package rules and works out what activating the batch would change.
Nothing on the share changes.</p>
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate</button>
</form>
</div>
{% else %}
{% if res.errors %}
<div class="section-card">
<div class="section-title text-danger"><i class="bi bi-x-octagon"></i> Errors <span class="badge badge-danger">{{ res.errors | length }}</span></div>
<p class="small text-muted">This batch cannot be activated. Fix the cause on the work PC and run Sync-BPRT.ps1 again (it makes a new batch), then delete this one.</p>
<ul class="msg-list">{% for e in res.errors %}<li>{{ e }}</li>{% endfor %}</ul>
</div>
{% endif %}
{% if res.stale %}<div class="alert alert-warning">{{ res.stale }}: validate again before activating.</div>{% endif %}
{% if res.warnings %}
<div class="section-card">
<div class="section-title text-warning"><i class="bi bi-exclamation-triangle"></i> Warnings <span class="badge badge-warning">{{ res.warnings | length }}</span></div>
<ul class="msg-list">{% for w in res.warnings %}<li>{{ w }}</li>{% endfor %}</ul>
</div>
{% endif %}
{% if res.counts %}
<div class="dashboard-grid mb-4">
<div class="stat-card {{ 'info' if res.counts.new else '' }}"><div class="label"><i class="bi bi-plus-circle"></i> New files</div><div class="value">{{ res.counts.new }}</div></div>
<div class="stat-card {{ 'warning' if res.counts.changed else '' }}"><div class="label"><i class="bi bi-arrow-repeat"></i> Changed</div><div class="value">{{ res.counts.changed }}</div></div>
<div class="stat-card {{ 'danger' if res.counts.removed else '' }}"><div class="label"><i class="bi bi-dash-circle"></i> Removed</div><div class="value">{{ res.counts.removed }}</div></div>
<div class="stat-card success"><div class="label"><i class="bi bi-check2"></i> Unchanged</div><div class="value">{{ res.counts.unchanged }}</div></div>
</div>
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> MCL packages</div>
{% if res.ppkg %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Tenant / purpose</th><th>Live now</th><th></th><th>After activation</th></tr></thead>
<tbody>
{% for p in res.ppkg %}
<tr>
<td class="mono">{{ p.prefix }}</td>
<td class="wrap">{% for o in p.old %}<div><span class="badge badge-secondary">{{ o.version }}</span> <span class="mono small fname">{{ fname(o.name) }}</span></div>{% else %}<span class="text-muted">none</span>{% endfor %}</td>
<td class="text-muted">to</td>
<td class="wrap">{% for n in p.new %}<div><span class="badge badge-info">{{ n.version }}</span> <span class="mono small fname">{{ fname(n.name) }}</span></div>{% else %}<span class="text-danger">none (removed)</span>{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted mb-0">No package changes.</p>{% endif %}
</div>
{% if res.zips or res.bom_fix %}
<div class="section-card">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Large files and fixes</div>
{% if res.zips %}
<div class="table-container mb-2">
<table class="data-table">
<thead><tr><th>Zip</th><th>Live size</th><th>New size</th></tr></thead>
<tbody>{% for z in res.zips %}<tr><td class="mono wrap">{{ z.path }}</td><td>{{ size(z.old_size) if z.old_size is not none else 'new' }}</td><td>{{ size(z.new_size) }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{% endif %}
{% if res.bom_fix %}
<p class="small mb-1">Activation adds a UTF-8 byte order mark to {{ res.bom_fix | length }} script{{ 's' if res.bom_fix | length != 1 }}
that hold{{ 's' if res.bom_fix | length == 1 }} non-ASCII text without one (Windows PowerShell 5.1 would read them as ANSI):</p>
<ul class="file-list small mono">{% for f in res.bom_fix %}<li>{{ f }}</li>{% endfor %}</ul>
{% endif %}
</div>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-ul"></i> Files</div>
{% for key, label in [('new', 'New'), ('changed', 'Changed'), ('removed', 'Removed (not in the vendor folder any more; kept in the archive)'), ('unchanged', 'Unchanged')] %}
{% set rows = res.diff[key] %}
{% if rows %}
<details class="mb-2" {% if key != 'unchanged' and rows | length <= 40 %}open{% endif %}>
<summary><strong>{{ label }}</strong> <span class="badge badge-secondary">{{ rows | length }}</span></summary>
<ul class="file-list small mono">{% for f in rows %}<li>{{ f }}{% if f in renamed %} <span class="text-muted">(kept as {{ renamed[f] }})</span>{% endif %}</li>{% endfor %}</ul>
</details>
{% endif %}
{% endfor %}
<p class="small text-muted mb-0">Site files carried over from the live BPRT as they are:
{% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %}.</p>
</div>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-rocket-takeoff"></i> Activate</div>
{{ imaging_alert(imaging) }}
<p class="small text-muted">
Builds <span class="mono">BPRT.new</span> beside the live folder (hard links: unchanged files take no space), puts the site
files back on top, checks it once more, then swaps it in with two renames: the live folder becomes
<span class="mono">BPRT-archive/full-&lt;time&gt;</span> (roll back to it at any time) and <span class="mono">BPRT.new</span> becomes
<span class="mono">BPRT</span>. Every image links to that path, so they all switch at once.
</p>
<div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('bprt_sync.activate', name=b.name) }}"
onsubmit="return confirm('Replace the live BPRT with batch {{ b.name }}?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived and can be rolled back.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirm" value="{{ b.name }}">
<button type="submit" class="pxe-btn pxe-btn-success" {% if res.state != 'valid' or res.stale or job.running %}disabled{% endif %}><i class="bi bi-check2-circle"></i> Activate</button>
</form>
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate again</button>
</form>
<span class="small text-muted align-self-center">Validated {{ res.validated | local_dt }}.</span>
</div>
</div>
{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" class="mb-4"
onsubmit="return confirm('Delete incoming batch {{ b.name }} and its uploaded files? The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running and job.arg == b.name %}disabled{% endif %}><i class="bi bi-trash"></i> Delete this batch</button>
</form>
{% else %}
{# ======================= overview ======================================= #}
<div class="page-header">
<h1><i class="bi bi-arrow-left-right"></i> BPRT Sync</h1>
<div class="header-actions">
{% if live.missing_required %}<span class="badge badge-danger badge-lg">{{ live.missing_required | length }} required file{{ 's' if live.missing_required | length != 1 }} missing</span>
{% elif live.exists %}<span class="badge badge-success badge-lg">{{ packages | selectattr('prefix') | list | length }} MCL packages live</span>{% endif %}
<form method="post" action="{{ url_for('bprt_sync.rebuild') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %}
title="Hash any live file the cache does not know; Sync-BPRT.ps1 starts this itself when it needs it"><i class="bi bi-fingerprint"></i> Rehash live folder</button>
</form>
</div>
</div>
<p class="text-muted">
<span class="mono">_shared/Applications/BPRT</span> is the vendor's bulk-enroll folder (MCL packages, Office zips, orchestrator
scripts) that every image links to and PESetup copies to every PC. The vendor keeps it current on the GE work PC with
MediaCreator Lite; <strong>Sync-BPRT.ps1</strong> on that PC compares it with this server's copy and uploads only what changed,
over SSH, into <span class="mono">BPRT-incoming</span>. Here a batch is validated, its changes shown, and activated with the
old folder archived. The site's own files ({% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %})
are never touched by a sync.
</p>
{{ imaging_alert(imaging) }}
{{ job_panel(job) }}
{# ---- Live folder --------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-folder-check"></i> Live BPRT</div>
{% if not live.exists %}
<div class="alert alert-danger mb-0"><span class="mono">{{ live.path }}</span> does not exist.</div>
{% else %}
<p class="small text-muted"><span class="mono">{{ live.path }}</span>: {{ live.count }} files, {{ size(live.bytes) }}{% if live.newest %}, newest file {{ live.newest | timestamp_fmt }}{% endif %}.</p>
{% if live.is_link %}<div class="alert alert-danger"><span class="mono">{{ live.path }}</span> is a symlink; activation needs it to be a real folder and refuses to run.</div>{% endif %}
{% for l in links if l.state in ('copy', 'elsewhere', 'none') %}
<div class="alert alert-warning py-2 mb-2">
<strong>{{ l.image }}</strong>
{% if l.state == 'copy' %}has its own copy of BPRT (<span class="mono">{{ l.path }}</span> is a real folder; an image import does this), so a sync here does not reach its PCs.
{% elif l.state == 'elsewhere' %}links BPRT to <span class="mono">{{ l.target or '?' }}</span>, not to the shared folder, so a sync here does not reach its PCs.
{% else %}has no <span class="mono">Deploy/Applications/BPRT</span>.{% endif %}
</div>
{% endfor %}
{% if live.missing_required %}
<div class="alert alert-danger py-2">Missing files the bulk-enroll flow calls by path: {% for r in live.missing_required %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}.</div>
{% endif %}
{% if live.bom_needed %}
<div class="alert alert-warning py-2">{{ live.bom_needed | length }} script{{ 's' if live.bom_needed | length != 1 }} hold{{ 's' if live.bom_needed | length == 1 }} non-ASCII text without a UTF-8 byte order mark, which Windows PowerShell 5.1 misreads:
{% for r in live.bom_needed %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}. The next activation fixes the copies it brings in.</div>
{% endif %}
<div class="row g-3">
<div class="col-xl-7">
<h6>MCL packages</h6>
{% if packages %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Tenant / purpose</th><th>File</th><th>Version</th><th>Size</th></tr></thead>
<tbody>
{% for p in packages %}
<tr>
<td class="mono text-nowrap">{{ p.prefix or '-' }}{% if p.conflict %} <span class="badge badge-danger" title="Also matches {{ p.conflict_with | join(', ') }}">Duplicate</span>{% endif %}</td>
<td class="mono small fname">{{ fname(p.filename) }}</td>
<td><span class="badge badge-info">{{ p.version or '?' }}</span></td>
<td class="text-nowrap">{{ size(p.size) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted">No packages.</p>{% endif %}
<div class="small text-muted mt-1">A single package can also be replaced on <a href="{{ url_for('enrollment') }}#mcl">Enrollment Packages</a>.</div>
</div>
<div class="col-xl-5">
<h6>Site files (kept on every sync)</h6>
<table class="table table-sm mb-0">
<tbody>
{% for o in live.overlay %}
<tr>
<td class="mono small wrap">{{ o.name }}</td>
<td class="text-nowrap">
{% if o.ok %}<span class="badge badge-success">Present</span>
{% elif o.present %}<span class="badge badge-danger" title="This is the vendor's dialog, not our shim">Vendor copy</span>
{% else %}<span class="badge badge-{{ 'danger' if o.name == 'Register-BulkEnrollTask-Standard.ps1' or o.name in renamed else 'secondary' }}">Missing</span>{% endif %}
{% if o.vendor_copy is defined %}{% if o.vendor_copy %}<span class="badge badge-secondary" title="{{ renamed[o.name] }}">vendor dialog kept</span>{% else %}<span class="badge badge-warning" title="{{ renamed[o.name] }} is missing: the shim cannot fall back to the vendor dialog">no vendor dialog</span>{% endif %}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="small text-muted mt-1">
<span class="mono">Select-PCConfig.ps1</span> is our shim (repo <span class="mono">playbook/shopfloor-setup/BPRT</span>); the vendor's
dialog is kept as <span class="mono">Select-PCConfig-vendor.ps1</span>. The standard and engineer unattends run
<span class="mono">Register-BulkEnrollTask-Standard.ps1</span> (specialize Order 39).
</div>
</div>
</div>
{% endif %}
</div>
{# ---- Incoming batches ---------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-inboxes"></i> Incoming batches <span class="badge badge-secondary">{{ batches | length }}</span></div>
{% if batches %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Batch</th><th>From</th><th>Uploaded</th><th>Status</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in batches %}
<tr>
<td class="mono small wrap">{% if b.uploading %}{{ b.name }}{% else %}<a href="{{ url_for('bprt_sync.batch', name=b.name) }}">{{ b.name }}</a>{% endif %}</td>
<td class="small wrap">{% if b.sync %}{{ b.sync.computer or '-' }}{% if b.sync.created %}<div class="text-muted">{{ b.sync.created | local_dt }}</div>{% endif %}{% else %}-{% endif %}</td>
<td class="small text-nowrap">{{ b.files }} file{{ 's' if b.files != 1 }}, {{ size(b.bytes) }}</td>
<td class="small">
{% if b.uploading %}<span class="badge badge-info" title="Sync-BPRT.ps1 renames the folder when the upload is complete">Uploading</span>
{% elif b.sync_error %}<span class="badge badge-danger" title="{{ b.sync_error }}">Bad sync.json</span>
{% elif not b.sync %}<span class="badge badge-danger" title="sync.json is uploaded last; this batch is incomplete">No sync.json</span>
{% else %}{{ val_badge(b.validation) }}{% endif %}
</td>
<td class="text-end text-nowrap">
{% if not b.uploading and b.sync %}
<a class="pxe-btn pxe-btn-ghost pxe-btn-sm" href="{{ url_for('bprt_sync.batch', name=b.name) }}"><i class="bi bi-eye"></i> {{ 'Review' if b.validation else 'Open' }}</a>
{% if not b.validation %}
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary pxe-btn-sm" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate</button>
</form>
{% endif %}
{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" style="display:inline;"
onsubmit="return confirm('Delete incoming batch {{ b.name }}?{% if b.uploading %} If Sync-BPRT.ps1 is still uploading it, its upload fails.{% endif %} The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete batch"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Nothing waiting. Run Sync-BPRT.ps1 on the work PC (below), or upload a zip.</p>
{% endif %}
</div>
{# ---- How to sync --------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-terminal"></i> Sync from the work PC</div>
<ol class="mb-2">
<li>Let MediaCreator Lite finish updating its cache on the GE work PC.</li>
<li>Get <span class="mono">Sync-BPRT.ps1</span>
{% if script_available %}(<a href="{{ url_for('bprt_sync.script') }}">download it from here</a>, or from the pxe repo, <span class="mono">scripts/windows/</span>)
{% else %}from the pxe repo, <span class="mono">scripts/windows/Sync-BPRT.ps1</span>{% endif %},
and run it in Windows PowerShell. Look first with <span class="mono">-DryRun</span> (nothing is uploaded):
<pre class="cmd mono small">powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1 -DryRun
powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1</pre>
It reads this server's manifest, hashes the local folder (<span class="mono">C:\ProgramData\GEAerospace\MediaCenter\Cache\Deploy\Applications\BPRT</span>
unless <span class="mono">-Source</span> says otherwise), parse-checks the vendor scripts, and uploads the new and changed files with one
<span class="mono">sftp</span> session: type the <span class="mono">pxe</span> password once.</li>
<li>Come back here, open the new batch, <strong>Validate</strong>, read the changes, <strong>Activate</strong>.</li>
</ol>
<p class="small text-muted mb-0">
No SSH from that PC? Run it with <span class="mono">-ZipOut C:\Temp\bprt.zip</span> and upload the zip below. A zip of the whole
vendor folder works too, but is taken as the complete new folder: anything not in it is removed from BPRT (and kept in the archive).
Uploads land in <span class="mono">{{ incoming }}</span>.
</p>
</div>
{# ---- Zip upload ---------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Upload a zip instead</div>
<form id="bprtUpload" method="post" action="{{ url_for('bprt_sync.upload') }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="bprtZip" class="form-label">Zip (.zip)</label>
<input type="file" class="form-control" id="bprtZip" name="zip_file" accept=".zip" required>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-upload"></i> Upload and validate</button>
</form>
<div id="bprtUploadProgress" class="mt-2" style="display: none;">
<div class="progress" style="height: 0.5rem;"><div id="bprtUploadBar" class="progress-bar" style="width: 0%;"></div></div>
<div id="bprtUploadText" class="small text-muted mt-1"></div>
</div>
{% if zips %}
<div class="table-container mt-3">
<table class="table table-sm mb-0">
<thead><tr><th>Uploaded zip, not unpacked</th><th>Size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for z in zips %}
<tr>
<td class="mono small wrap">{{ z.name }}</td><td class="text-nowrap">{{ size(z.size) }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('bprt_sync.unzip', name=z.name) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" {% if job.running %}disabled{% endif %}>Unpack</button>
</form>
<form method="post" action="{{ url_for('bprt_sync.delete_zip', name=z.name) }}" style="display:inline;" onsubmit="return confirm('Delete {{ z.name }}?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{# ---- Archives ------------------------------------------------------------ #}
<div class="section-card">
<div class="section-title"><i class="bi bi-archive"></i> Previous BPRT folders <span class="badge badge-secondary">{{ archives | length }}</span></div>
{% if archives %}
<p class="small text-muted">Each activation and rollback keeps the folder it replaced in <span class="mono">BPRT-archive</span>. Unchanged files are
hard links shared with the live folder, so an archive only costs the files that differ ("own size").</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Archive</th><th>MCL packages</th><th>Files</th><th>Own size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for a in archives %}
<tr>
<td class="mono small text-nowrap">{{ a.name }}<div class="text-muted">{{ a.modified | timestamp_fmt }}</div></td>
<td class="small wrap">{% for p in a.packages %}<div class="mono fname">{{ fname(p) }}</div>{% else %}<span class="text-danger">none</span>{% endfor %}
{% if a.missing_required %}<div class="text-danger">missing {{ a.missing_required | join(', ') }}</div>{% endif %}</td>
<td class="small text-nowrap">{{ a.count }}, {{ size(a.bytes) }}</td>
<td class="small text-nowrap">{{ size(a.own_bytes) }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('bprt_sync.rollback') }}" style="display:inline;"
onsubmit="return confirm('Make {{ a.name }} the live BPRT again?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="archive" value="{{ a.name }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" {% if job.running %}disabled{% endif %}><i class="bi bi-arrow-counterclockwise"></i> Roll back</button>
</form>
<form method="post" action="{{ url_for('bprt_sync.delete_archive') }}" style="display:inline;"
onsubmit="return confirm('Delete archive {{ a.name }}? This frees {{ size(a.own_bytes) }}; it cannot be rolled back to afterwards.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="archive" value="{{ a.name }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete archive" {% if job.running %}disabled{% endif %}><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">None yet. The first activation archives the current folder here.</p>
{% endif %}
</div>
{% if history %}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> History</div>
<div class="table-container">
<table class="data-table">
<thead><tr><th>When</th><th>What</th><th>Previous folder</th></tr></thead>
<tbody>
{% for h in history %}
<tr>
<td class="small text-nowrap">{{ h.at | local_dt }}</td>
<td class="small wrap">
{% if h.kind == 'rollback' %}Rolled back to <span class="mono">{{ h.restored }}</span>
{% else %}Activated <span class="mono">{{ h.batch }}</span>{% if h.sync and h.sync.computer %} from {{ h.sync.computer }}{% endif %}:
{{ h.counts.new or 0 }} new, {{ h.counts.changed or 0 }} changed, {{ h.counts.removed or 0 }} removed
{% for p in h.ppkg %}<div class="mono">{{ p.prefix }}: {{ p.old | map(attribute='version') | join(', ') or 'none' }} to {{ p.new | map(attribute='version') | join(', ') or 'none' }}</div>{% endfor %}
{% endif %}
</td>
<td class="mono small">{{ h.archive }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// Zip upload: stream the raw file (no multipart parse, no copy in /tmp) with a progress bar.
var form = document.getElementById('bprtUpload');
if (form) {
form.addEventListener('submit', function (ev) {
var input = document.getElementById('bprtZip');
if (!input.files.length || !window.XMLHttpRequest) { return; }
ev.preventDefault();
var file = input.files[0];
var box = document.getElementById('bprtUploadProgress');
var bar = document.getElementById('bprtUploadBar');
var text = document.getElementById('bprtUploadText');
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action + '?name=' + encodeURIComponent(file.name));
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (e) {
if (!e.lengthComputable) { return; }
bar.style.width = (100 * e.loaded / e.total).toFixed(1) + '%';
text.textContent = 'Uploading ' + file.name + ': ' + Math.round(e.loaded / 1048576) + ' of ' +
Math.round(e.total / 1048576) + ' MB';
};
xhr.onload = function () {
var r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status === 200 && r.ok) { location.reload(); return; }
text.textContent = 'Upload failed: ' + (r.error || ('HTTP ' + xhr.status));
bar.classList.add('bg-danger');
form.querySelector('button[type=submit]').disabled = false;
};
xhr.onerror = function () { text.textContent = 'Upload failed: connection lost.'; bar.classList.add('bg-danger'); };
box.style.display = '';
form.querySelector('button[type=submit]').disabled = true;
xhr.send(file);
});
}
// Follow a running job; reload when it ends.
var job = document.getElementById('bprtJob');
if (!job) { return; }
var URL = '{{ url_for("bprt_sync.status") }}';
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.running) { location.reload(); return; }
document.getElementById('bprtJobStep').textContent = d.step || '';
document.getElementById('bprtJobCurrent').textContent = d.current || '';
if (d.percent != null) {
document.getElementById('bprtJobBar').style.width = d.percent + '%';
document.getElementById('bprtJobPct').textContent = d.percent + '%';
}
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,441 @@
{% extends "base.html" %}
{% block title %}Dell Drivers and BIOS - PXE Server Manager{% endblock %}
{% macro size(n) -%}
{%- if not n -%}-{%- elif n >= 1000000000 -%}{{ '%.2f' | format(n / 1000000000) }} GB{%- elif n >= 1000000 -%}{{ '%.0f' | format(n / 1000000) }} MB{%- else -%}{{ '%.0f' | format(n / 1000) }} KB{%- endif -%}
{%- endmacro %}
{% macro drv_badge(s) -%}
{% if s == 'update' %}<span class="badge badge-warning">Update</span>
{% elif s == 'current' %}<span class="badge badge-success">Current</span>
{% elif s == 'no-match' %}<span class="badge badge-secondary" title="No Win11 pack in Dell's catalog matches these model names">No match</span>
{% elif s == 'unknown-version' %}<span class="badge badge-info" title="The current file name has no A-number to compare">Unknown version</span>
{% else %}<span class="badge badge-secondary">{{ s }}</span>{% endif %}
{%- endmacro %}
{% macro bios_badge(s) -%}
{% if s == 'update' %}<span class="badge badge-warning">Update</span>
{% elif s == 'current' %}<span class="badge badge-success">Current</span>
{% elif s == 'different-file-family' %}<span class="badge badge-danger" title="Dell's latest BIOS for these models is a different file than ours">Different file</span>
{% elif s == 'ambiguous' %}<span class="badge badge-danger" title="Several BIOS file families match this line">Ambiguous</span>
{% elif s == 'no-match' %}<span class="badge badge-secondary" title="No Dell model in the catalog index matches this line">No match</span>
{% elif s == 'error' %}<span class="badge badge-danger">Catalog error</span>
{% else %}<span class="badge badge-secondary">{{ s }}</span>{% endif %}
{%- endmacro %}
{% macro stage_badge(s) -%}
{% if s == 'ready' %}<span class="badge badge-success">Ready</span>
{% elif s == 'applied' %}<span class="badge badge-primary">Applied</span>
{% elif s in ('error', 'interrupted', 'cancelled') %}<span class="badge badge-danger">{{ s | capitalize }}</span>
{% else %}<span class="badge badge-info">{{ s | capitalize }}</span>{% endif %}
{%- endmacro %}
{% block extra_head %}
<style>
/* Long Dell file names wrap instead of pushing Status off the page. */
.dell-updates td.wrap { min-width: 9rem; max-width: 22rem; }
</style>
{% endblock %}
{% block content %}
<div class="dell-updates">
{% set chk = prog.check %}
{% set dl = prog.download %}
{% set n_drv = scan.drivers | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %}
{% set n_bios = scan.bios | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %}
<div class="page-header">
<h1><i class="bi bi-motherboard"></i> Dell Drivers and BIOS</h1>
<div class="header-actions">
{% if scan %}
{% if n_drv or n_bios %}<span class="badge badge-warning badge-lg">{{ n_drv }} driver / {{ n_bios }} BIOS update{{ 's' if n_drv + n_bios != 1 }}</span>
{% else %}<span class="badge badge-success badge-lg">Up to date</span>{% endif %}
{% endif %}
{% if prog.check_running %}
<form method="post" action="{{ url_for('dell_updates.cancel') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="job" value="check">
<button type="submit" class="pxe-btn pxe-btn-ghost"><i class="bi bi-x-circle"></i> Stop check</button>
</form>
{% else %}
<form method="post" action="{{ url_for('dell_updates.check') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary" {% if prog.download_running %}disabled title="A download is running"{% endif %}>
<i class="bi bi-arrow-repeat"></i> Check for updates
</button>
</form>
{% endif %}
</div>
</div>
<p class="text-muted">
Compares every Win11 entry in the three images' <span class="mono">HardwareDriver.json</span> and every line of the WinPE
BIOS manifest <span class="mono">models.txt</span> with Dell's current catalogs. Downloads are checked against Dell's sha256,
driver packs are repacked into the zip PESetup expects, and nothing on the share changes until you preview and apply.
Applying puts the new file next to the one it replaces, rewrites the maps and <span class="mono">models.txt</span> (backed
up first, never reordered) and never deletes old zips or BIOS files.
</p>
{# ---- Catalogs, check job, speed cap ------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Dell catalogs</div>
<div class="row g-3 align-items-end">
<div class="col-lg-8">
{% if prog.check_running %}
<div id="checkJob">
<div class="small mb-1"><span class="spinner-border spinner-border-sm"></span>
<span id="checkStep">{{ chk.step or 'Starting' }}...</span></div>
</div>
{% elif chk.state == 'error' %}
<div class="alert alert-danger py-2 mb-2">Last check failed: {{ chk.error }}</div>
{% elif chk.state == 'interrupted' %}
<div class="alert alert-warning py-2 mb-2">The last check stopped before it finished. Run it again.</div>
{% endif %}
{% if scan %}
<div>Last check: <strong>{{ scan.checked | local_dt }}</strong></div>
<div class="small text-muted">
{% set cats = scan.catalogs or {} %}
Driver pack catalog dated <span class="mono">{{ cats.drivers | local_dt }}</span>,
catalog index dated <span class="mono">{{ cats.index | local_dt }}</span>.
Maps read: {{ (scan.images or []) | join(', ') or 'none' }}.
{% if scan.cab_errors %}<span class="text-danger">{{ scan.cab_errors | length }} per-model catalog(s) failed to load.</span>{% endif %}
</div>
{% for e in scan.map_errors %}<div class="small text-danger"><i class="bi bi-exclamation-triangle"></i> {{ e }}</div>{% endfor %}
{% elif not prog.check_running %}
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> fetches Dell's two catalog files (under 1 MB)
and the small per-model BIOS catalogs it needs, then compares.</p>
{% endif %}
</div>
<div class="col-lg-4 d-flex justify-content-lg-end">
<form method="post" action="{{ url_for('dell_updates.save_settings') }}" class="d-flex gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0">
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
</div>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
</form>
</div>
</div>
</div>
{% if scan %}
<form method="post" action="{{ url_for('dell_updates.download') }}" id="dlForm"
onsubmit="var n=this.querySelectorAll('input[name=drv]:checked,input[name=bios]:checked').length; if(!n){alert('Select at least one row.');return false;} return confirm('Download ' + n + ' selected row(s) from downloads.dell.com at the speed cap? Driver packs are 1 to 4 GB each.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{# ---- Driver packs ------------------------------------------------------ #}
<div class="section-card">
<div class="section-title d-flex flex-wrap align-items-center gap-2">
<span><i class="bi bi-hdd-network"></i> Win11 driver packs</span>
<span class="badge badge-secondary">{{ scan.drivers | length }}</span>
<label class="small text-muted ms-auto"><input type="checkbox" class="form-check-input js-show-current" data-table="drvTable"> Show current rows</label>
</div>
<p class="small text-muted">
One row per zip in use; standard and engineer share their zips. Updates are ticked unless a warning needs a decision.
An entry whose current pack carries NVIDIA drivers is skipped at apply time if the Dell pack has no NVIDIA INFs.
</p>
<div class="table-container">
<table class="data-table" id="drvTable">
<thead>
<tr><th></th><th>Models (WMI)</th><th>Images</th><th>Current file</th><th>Dell pack</th><th>Status</th></tr>
</thead>
<tbody>
{% for r in scan.drivers %}
<tr data-status="{{ r.status }}">
<td>{% if r.pack and r.status != 'current' or r.pack and r.warnings %}<input type="checkbox" class="form-check-input" name="drv" value="{{ r.id }}" {% if r.default %}checked{% endif %}>{% endif %}</td>
<td class="small wrap">{{ r.tokens | join(', ') }}</td>
<td class="small" style="white-space: normal; min-width: 6rem;">{% for i in r.images %}{{ i | replace('gea-', '') }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td class="wrap"><span class="mono small">{{ r.current }}</span>{% if r.cur_ver %} <span class="badge badge-secondary">{{ r.cur_ver }}</span>{% endif %}</td>
<td class="wrap">
{% if r.pack %}
<span class="mono small" title="{{ r.pack.models | join(', ') }}">{{ r.pack.name }}</span>
<div class="small text-muted">{{ r.pack.ver }}, {{ r.pack.date }}, {{ size(r.pack.size) }}, stages as <span class="mono">{{ r.pack.zip }}</span></div>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td class="wrap">{{ drv_badge(r.status) }}
{% for w in r.warnings %}<div class="small text-danger mt-1"><i class="bi bi-exclamation-triangle"></i> {{ w }}</div>{% endfor %}
{% for n in r.notes %}<div class="small text-muted mt-1">{{ n }}</div>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# ---- BIOS -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title d-flex flex-wrap align-items-center gap-2">
<span><i class="bi bi-cpu"></i> BIOS (models.txt)</span>
<span class="badge badge-secondary">{{ scan.bios | length }}</span>
<label class="small text-muted ms-auto"><input type="checkbox" class="form-check-input js-show-current" data-table="biosTable"> Show current rows</label>
</div>
<p class="small text-muted">
One row per manifest line. check-bios.cmd flashes the file of the FIRST line whose text is in the machine's model name,
so each Dell model is credited to the first line that catches it. Applying changes only the file and version fields
of the ticked lines; their order and the comments stay as they are.
</p>
<div class="table-container">
<table class="data-table" id="biosTable">
<thead>
<tr><th></th><th>Line</th><th>Current file</th><th>Dell latest</th><th>Status</th></tr>
</thead>
<tbody>
{% for r in scan.bios %}
<tr data-status="{{ r.status }}">
<td>{% if r.candidates and (r.status != 'current') %}<input type="checkbox" class="form-check-input" name="bios" value="{{ r.id }}" {% if r.default %}checked{% endif %}>{% endif %}</td>
<td class="mono small text-nowrap">{{ r.sub }}</td>
<td class="wrap"><span class="mono small">{{ r.current }}</span> <span class="badge badge-secondary">{{ r.cur_ver }}</span></td>
<td class="wrap">
{% if r.candidates | length > 1 %}
<select class="form-control form-control-sm" name="pick_{{ r.id }}" style="max-width: 28rem;">
{% for c in r.candidates %}<option value="{{ loop.index0 }}">{{ c.file }} ({{ c.version }}, {{ c.date }}) - {{ c.models | join(', ') | truncate(70) }}</option>{% endfor %}
</select>
{% elif r.candidates %}
{% set c = r.candidates[0] %}
<span class="mono small" title="sha256 {{ c.sha256 }}">{{ c.file }}</span>
<div class="small text-muted">{{ c.version }}, {{ c.date }}, {{ size(c.size) }}; {{ c.models | join(', ') | truncate(90) }}</div>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td class="wrap">{{ bios_badge(r.status) }}
{% for w in r.warnings %}<div class="small text-danger mt-1"><i class="bi bi-exclamation-triangle"></i> {{ w }}</div>{% endfor %}
{% for n in r.notes %}<div class="small text-muted mt-1">{{ n }}</div>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
<button type="submit" class="pxe-btn pxe-btn-primary" {% if prog.download_running or prog.check_running %}disabled{% endif %}>
<i class="bi bi-download"></i> Download selected
</button>
<span class="small text-muted">Runs in the background at {{ '%g MB/s' | format(settings.max_mbps) if settings.max_mbps else 'full speed (no cap)' }};
you can leave this page. Already staged items are not fetched again.</span>
</div>
</form>
{# ---- Missing coverage ---------------------------------------------------- #}
{% if scan.missing %}
<div class="section-card">
<div class="section-title"><i class="bi bi-question-diamond"></i> Seen models without coverage <span class="badge badge-secondary">{{ scan.missing | length }}</span></div>
<p class="small text-muted">Machines that booted WinPE here with no Win11 driver entry or no BIOS line. For information only:
new entries are added by hand (see Hardware Coverage).</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Model</th><th>SKU</th><th>Missing</th><th>Dell pack that would cover it</th><th>Dell BIOS</th></tr></thead>
<tbody>
{% for m in scan.missing %}
<tr>
<td class="mono small">{{ m.model }}</td>
<td class="mono small">{{ m.skus | join(', ') or '-' }}</td>
<td class="small wrap">{% if m.no_drivers %}drivers ({{ m.no_drivers | map('replace', 'gea-', '') | join(', ') }}){% endif %}{% if m.no_drivers and m.no_bios %}, {% endif %}{% if m.no_bios %}BIOS line{% endif %}</td>
<td class="small wrap">{% if m.pack %}<span class="mono">{{ m.pack.name }}</span> ({{ m.pack.ver }}, {{ size(m.pack.size) }}){% elif m.no_drivers %}<span class="text-muted">none found</span>{% else %}-{% endif %}</td>
<td class="small wrap">{% if m.bios %}<span class="mono">{{ m.bios.file }}</span> ({{ m.bios.version }}){% elif m.no_bios %}<span class="text-muted">none found</span>{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endif %}
{# ---- Staged downloads ---------------------------------------------------- #}
<div class="section-card" id="staged">
<div class="section-title d-flex flex-wrap align-items-center gap-2">
<span><i class="bi bi-box-seam"></i> Staged downloads</span>
{% if dl.state %}<span class="small text-muted">last run {{ dl.state }}{% if dl.finished %} {{ dl.finished | local_dt }}{% endif %}{% if dl.error %}: {{ dl.error }}{% endif %}</span>{% endif %}
{% if prog.download_running %}
<form method="post" action="{{ url_for('dell_updates.cancel') }}" class="ms-auto"
onsubmit="return confirm('Stop the download? Partial files are kept and resume next time.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="job" value="download">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm"><i class="bi bi-x-circle"></i> Cancel download</button>
</form>
{% endif %}
</div>
{% if staged %}
<form method="post" action="{{ url_for('dell_updates.preview') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="table-container">
<table class="data-table">
<thead><tr><th></th><th>Item</th><th>Replaces</th><th>Size</th><th>Status</th></tr></thead>
<tbody>
{% for it in staged %}
<tr>
<td>{% if it.status in ('ready', 'error', 'cancelled', 'interrupted', 'applied') %}<input type="checkbox" class="form-check-input" name="key" value="{{ it.key }}" {% if it.status == 'ready' %}checked{% endif %}>{% endif %}</td>
<td class="wrap">
<span class="mono small">{{ it.zip if it.kind == 'driver' else it.name }}</span>
<div class="small text-muted">{{ 'Driver pack' if it.kind == 'driver' else 'BIOS' }} {{ it.ver }}{% if it.kind == 'driver' %} from <span class="mono">{{ it.name }}</span>{% endif %}
{% if it.infs %}; {{ it.infs }} INFs, {{ it.nvidia_infs }} NVIDIA{% endif %}</div>
</td>
<td class="small wrap">
{% if it.kind == 'driver' %}{% for c in it.replaces %}<div class="mono">{{ c }}</div>{% endfor %}
{% else %}{% for ln in it.lines %}<div class="mono">{{ ln.sub }}</div>{% endfor %}{% endif %}
</td>
<td class="small text-nowrap">{{ size(it.size_file or it.size) }}</td>
<td id="st-{{ it.key }}" class="wrap" style="min-width: 10rem;">
{{ stage_badge(it.status) }}
{% if it.status in ('queued', 'downloading', 'verified', 'extracting', 'zipping') %}
<div class="progress mt-1" style="height: 6px;"><div class="progress-bar" style="width: {{ it.percent or 0 }}%;"></div></div>
<div class="small text-muted js-pct">{{ it.percent if it.percent is not none else 0 }}%</div>
{% endif %}
{% if it.error %}<div class="small text-danger">{{ it.error }}</div>{% endif %}
{% if it.applied %}<div class="small text-muted">apply {{ it.applied }}</div>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="submit" class="pxe-btn pxe-btn-primary" {% if prog.download_running %}disabled{% endif %}><i class="bi bi-eye"></i> Preview apply</button>
<button type="submit" class="pxe-btn pxe-btn-ghost" formaction="{{ url_for('dell_updates.discard') }}"
onclick="return confirm('Delete the ticked items from the staging area? Files on the share are not touched.');"
{% if prog.download_running %}disabled{% endif %}><i class="bi bi-trash"></i> Discard selected</button>
</div>
</form>
{% else %}
<p class="text-muted mb-0">Nothing staged. Tick rows above and use <strong>Download selected</strong>.</p>
{% endif %}
</div>
{# ---- Preview ------------------------------------------------------------- #}
{% if preview %}
<div class="section-card" id="preview">
<div class="section-title"><i class="bi bi-file-diff"></i> Preview: what Apply will change</div>
{% for image, rows in preview.drivers.items() %}
<h6 class="mt-2">{{ image }} <span class="text-muted small">HardwareDriver.json</span></h6>
{% if rows %}
<div class="table-container">
<table class="table table-sm mb-2">
<thead><tr><th>Entry models</th><th>Old file</th><th></th><th>New file</th><th>Folder</th></tr></thead>
<tbody>
{% for r in rows %}
<tr><td class="small wrap">{{ r.models }}</td><td class="mono small wrap">{{ r.old }}</td><td class="text-muted">to</td>
<td class="mono small wrap">{{ r.new }}</td><td class="mono small text-muted wrap">{{ r.dir }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="small text-muted">No change.</p>{% endif %}
{% endfor %}
<h6 class="mt-3">models.txt</h6>
{% if preview.bios %}
<div class="table-container">
<table class="table table-sm mb-2">
<thead><tr><th>Old line</th><th></th><th>New line</th></tr></thead>
<tbody>
{% for b in preview.bios %}<tr><td class="mono small wrap">{{ b.old }}</td><td class="text-muted">to</td><td class="mono small wrap">{{ b.new }}</td></tr>{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="small text-muted">No change.</p>{% endif %}
{% for e in preview.errors %}<div class="small text-danger"><i class="bi bi-exclamation-triangle"></i> {{ e }}</div>{% endfor %}
{% for s in preview.skipped %}<div class="small text-warning"><i class="bi bi-exclamation-triangle"></i> Will skip {{ s }}</div>{% endfor %}
{% if preview.missing %}<div class="small text-muted">Not ready, ignored: {{ preview.missing | join(', ') }}</div>{% endif %}
<form method="post" action="{{ url_for('dell_updates.apply') }}" class="mt-3"
onsubmit="return confirm('Apply these changes to the live share? The maps and models.txt are backed up first; old files are kept.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% for k in preview_keys %}<input type="hidden" name="key" value="{{ k }}">{% endfor %}
<button type="submit" class="pxe-btn pxe-btn-success"><i class="bi bi-check2-circle"></i> Apply</button>
<a href="{{ url_for('dell_updates.page') }}" class="pxe-btn pxe-btn-ghost">Cancel</a>
</form>
</div>
{% endif %}
{# ---- History ------------------------------------------------------------- #}
{% if history %}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Applied</div>
<div class="table-container">
<table class="data-table">
<thead><tr><th>When</th><th>Changes</th><th>Backups</th><th class="text-end"></th></tr></thead>
<tbody>
{% for h in history %}
<tr>
<td class="mono small text-nowrap">{{ h.at | local_dt }}<div class="text-muted">{{ h.id }}</div></td>
<td class="small wrap">
{% for d in h.drivers %}<div><span class="mono">{{ d.old }}</span> to <span class="mono">{{ d.new }}</span></div>{% endfor %}
{% for b in h.bios %}<div><span class="mono">{{ b.new }}</span></div>{% endfor %}
{% for s in h.skipped %}<div class="text-warning">skipped {{ s }}</div>{% endfor %}
{% for e in h.errors %}<div class="text-danger">{{ e }}</div>{% endfor %}
</td>
<td class="small wrap">{% for p, b in h.backups.items() %}<div class="mono">{{ b.rsplit('/', 1)[-1] }}</div>{% endfor %}</td>
<td class="text-end text-nowrap">
{% if h.rolled_back %}<span class="badge badge-secondary">Rolled back</span>
{% elif h.backups %}
<form method="post" action="{{ url_for('dell_updates.rollback') }}" style="display:inline;"
onsubmit="return confirm('Put the maps and models.txt back as they were before {{ h.id }}? Files changed again since are left alone. The new zips and BIOS files stay on the share.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="apply_id" value="{{ h.id }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<script>
// Hide rows that need nothing unless asked; without JS every row shows.
(function () {
var boxes = document.querySelectorAll('.js-show-current');
function paint(box) {
var t = document.getElementById(box.dataset.table);
if (!t) return;
t.querySelectorAll('tbody tr[data-status=current]').forEach(function (tr) {
var tick = tr.querySelector('input[type=checkbox]');
if (!tick || !tick.checked) tr.style.display = box.checked ? '' : 'none';
});
}
boxes.forEach(function (b) { b.addEventListener('change', function () { paint(b); }); paint(b); });
})();
</script>
</div>
{% if prog.check_running or prog.download_running %}
<script>
// Poll the background jobs; update progress in place, reload when they end.
(function () {
var URL = "{{ url_for('dell_updates.status') }}";
// A preview is a POST response: reloading it would resubmit the form,
// so there the poller only stops when the jobs end.
var RELOAD = {{ 'false' if preview else 'true' }};
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.check_running && !j.download_running) {
if (RELOAD) location.replace("{{ url_for('dell_updates.page') }}");
return;
}
var step = document.getElementById('checkStep');
if (step && j.check.step) step.textContent = j.check.step + '...';
Object.keys(j.download.items || {}).forEach(function (k) {
var it = j.download.items[k], cell = document.getElementById('st-' + k);
if (!cell) return;
var badge = cell.querySelector('.badge');
if (badge) badge.textContent = it.status.charAt(0).toUpperCase() + it.status.slice(1);
var bar = cell.querySelector('.progress-bar'), pct = cell.querySelector('.js-pct');
if (bar && it.percent != null) bar.style.width = it.percent + '%';
if (pct && it.percent != null) pct.textContent = it.percent + '%';
});
setTimeout(tick, 3000);
})
.catch(function () { setTimeout(tick, 6000); });
}
setTimeout(tick, 2000);
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,345 @@
{% extends "base.html" %}
{% block title %}iPXE Boot Files - PXE Server Manager{% endblock %}
{% macro sig_badge(info, chain_ok=none) -%}
{% if info.kind == 'microsoft-2011' %}<span class="badge badge-warning" title="Signed only under Microsoft Corporation UEFI CA 2011. That CA's signing period ended in 2026: machines that trust only the 2023 CA, or a DBX/SBAT revocation update, will refuse this file.">Microsoft UEFI CA 2011 only</span>
{% elif info.kind == 'microsoft-2023' %}<span class="badge badge-success" title="Signed under Microsoft UEFI CA 2023">Microsoft UEFI CA 2023</span>
{% elif info.kind == 'microsoft' %}<span class="badge badge-info">Microsoft (other CA)</span>
{% elif info.kind == 'ipxe' %}<span class="badge badge-success" title="Chains to the iPXE Secure Boot CA, which the iPXE shim trusts">iPXE Secure Boot CA</span>
{% elif info.kind == 'bios' %}<span class="badge badge-secondary" title="Legacy BIOS has no Secure Boot">Legacy BIOS, unsigned</span>
{% elif info.kind == 'unsigned' %}<span class="badge badge-danger" title="Secure Boot firmware will refuse it">Unsigned</span>
{% elif info.kind == 'error' %}<span class="badge badge-danger" title="{{ info.sig_error }}">Not checked</span>
{% else %}<span class="badge badge-warning">Other signer</span>{% endif %}
{% if chain_ok == false %}<span class="badge badge-danger" title="The installed shim trusts {{ info.trusts_needed }}; this file is not signed under it, so the shim will refuse to start it">Shim will refuse</span>{% endif %}
{%- endmacro %}
{% macro chain(info) -%}
{% if info.signers %}
<details class="mt-1">
<summary class="small text-muted" style="cursor:pointer;">Signer chain ({{ info.signers | length }})</summary>
{% for subject, issuer in info.signers %}
<div class="small"><span class="mono">{{ subject }}</span> <span class="text-muted">issued by</span> <span class="mono">{{ issuer }}</span></div>
{% endfor %}
</details>
{% endif %}
{% if info.trusts %}<div class="small text-muted">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %}
{% if info.sbat %}<div class="small text-muted">SBAT: <span class="mono">{{ info.sbat }}</span></div>{% endif %}
{%- endmacro %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-shield-lock"></i> iPXE Boot Files</h1>
<div class="header-actions">
{% if status == 'update' %}<span class="badge badge-warning badge-lg">Update available: {{ latest.tag }}</span>
{% elif status == 'current' %}<span class="badge badge-success badge-lg">Up to date ({{ latest.tag }})</span>
{% elif status == 'newer' %}<span class="badge badge-info badge-lg">Ahead of {{ latest.tag }}</span>
{% elif latest %}<span class="badge badge-secondary badge-lg">Latest: {{ latest.tag }}</span>{% endif %}
<form method="post" action="{{ url_for('ipxe.check') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-arrow-repeat"></i> Check for updates</button>
</form>
</div>
</div>
<p class="text-muted">
The Secure Boot chain that dnsmasq hands out over TFTP: the Microsoft-signed shim (<span class="mono">ipxe-shim.efi</span>)
starts <span class="mono">ipxe.efi</span>, which is signed by the iPXE Secure Boot CA. Updates come from the upstream
iPXE release tarball on GitHub; its sha256 is checked against GitHub's published digest and each file's signer chain is
read with <span class="mono">sbverify</span> before it can be installed. <span class="mono">ipxe-bcm.efi</span>,
<span class="mono">dnsmasq.conf</span> and the Blancco files are never changed here.
</p>
{# ---- Upstream ------------------------------------------------------------ #}
<div class="section-card">
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Upstream release</div>
<div class="row g-3 align-items-end">
<div class="col-lg-7">
{% if latest %}
<div>
<strong class="mono">{{ latest.tag }}</strong>
{% if latest.name and latest.name != latest.tag %}<span class="text-muted">{{ latest.name }}</span>{% endif %}
{% if status == 'update' %}<span class="badge badge-warning">newer than installed</span>
{% elif status == 'current' %}<span class="badge badge-success">installed</span>
{% elif status == 'newer' %}<span class="badge badge-info">installed build is newer</span>
{% else %}<span class="badge badge-secondary">installed version unknown</span>{% endif %}
</div>
<div class="small text-muted">
Published {{ latest.published | local_dt }} | checked {{ latest.checked | local_dt }}
{% if latest.size %}| {{ "%.1f"|format(latest.size / 1048576) }} MB{% endif %}
| <a href="{{ latest.page }}" target="_blank" rel="noopener">release page</a>
</div>
<div class="small">sha256 <span class="mono" style="white-space: normal; word-break: break-all;">{{ latest.sha256 or 'not published' }}</span></div>
{% else %}
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> asks the GitHub API for the latest release (a few seconds).</p>
{% endif %}
</div>
<div class="col-lg-5 d-flex flex-wrap gap-2 justify-content-lg-end align-items-end">
<form method="post" action="{{ url_for('ipxe.save_settings') }}" class="d-flex gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0">
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
</div>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
</form>
<form method="post" action="{{ url_for('ipxe.stage') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary"
{% if not latest or not latest.sha256 or job.state in ('queued', 'running') %}disabled{% endif %}>
<i class="bi bi-download"></i> Download and verify
</button>
</form>
</div>
</div>
<div class="form-text">Speed cap applies to every download on the Updates pages (Dell and iPXE); 0 means no cap.</div>
{% if job.state %}
<div id="ipxeJob" class="mt-3" data-state="{{ job.state }}">
{% if job.state in ('queued', 'running') %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm" role="status"></span>
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
</div>
<div class="progress" style="height: 0.5rem;">
<div id="ipxeJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
</div>
{% elif job.state == 'error' %}
<div class="alert alert-danger mb-0"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
{% elif job.state == 'interrupted' %}
<div class="alert alert-warning mb-0">The download of {{ job.tag }} stopped without finishing (server restart?). Start it again; it resumes the partial file.</div>
{% elif job.state == 'done' and job.failed %}
<div class="alert alert-warning mb-0">{{ job.tag }} downloaded, but {{ job.failed | join(', ') }} failed verification; see below.</div>
{% endif %}
</div>
{% endif %}
</div>
{# ---- Installed ----------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd"></i> Installed on the TFTP root</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr><th>File</th><th>Version</th><th>Size</th><th>Modified</th><th>sha256</th><th>Signature</th></tr>
</thead>
<tbody>
{% for r in rows %}
{% set i = r.info %}
<tr>
<td class="text-nowrap">
<span class="mono">{{ r.name }}</span>
{% if not r.managed %}<div class="small text-muted">read-only here</div>{% endif %}
</td>
{% if i %}
<td class="mono">{{ i.version or '-' }}{% if i.release %}<div class="small text-muted">from {{ i.release }}</div>{% endif %}</td>
<td class="text-nowrap">{{ "{:,}".format(i.size) }} B</td>
<td class="mono text-nowrap">{{ i.modified | timestamp_fmt }}</td>
<td class="mono" title="{{ i.sha256 }}">{{ i.sha256[:12] }}</td>
<td class="wrap">{{ sig_badge(i, i.chain_ok if i.chain_ok is defined else none) }}{{ chain(i) }}</td>
{% else %}
<td colspan="5" class="text-muted">Not present</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# ---- Staged -------------------------------------------------------------- #}
{% if staged %}
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> Downloaded and verified</div>
{% for s in staged %}
<form method="post" action="{{ url_for('ipxe.install') }}" class="{{ 'mt-4' if not loop.first }}" data-tag="{{ s.tag }}"
onsubmit="var n=[].slice.call(this.querySelectorAll('input[name=names]:checked')).map(function(c){return c.value;}); if(!n.length){alert('Select at least one file.');return false;} return confirm('Install ' + n.join(', ') + ' from ' + this.dataset.tag + ' into the TFTP root? The current files are backed up first and every client that boots from now on gets the new ones.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="tag" value="{{ s.tag }}">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<strong class="mono">{{ s.tag }}</strong>
<span class="small text-muted">verified {{ s.staged | local_dt }} | tarball sha256 <span class="mono" title="{{ s.tarball_sha256 }}">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr><th style="width:2rem;"></th><th>File</th><th>From tarball</th><th>Version</th><th>sha256</th><th>Check</th></tr>
</thead>
<tbody>
{% for name, f in s.files.items() %}
<tr>
<td><input type="checkbox" class="form-check-input" name="names" value="{{ name }}" id="inst-{{ loop.index }}-{{ s.tag }}"
{% if not f.ok %}disabled{% elif name in default_install %}checked{% endif %}></td>
<td class="text-nowrap"><label for="inst-{{ loop.index }}-{{ s.tag }}" class="mono">{{ name }}</label>
{% if name not in default_install %}<div class="small text-muted">optional</div>{% endif %}</td>
<td class="mono small">{{ f.source or '-' }}</td>
<td class="mono">{{ f.version or '-' }}</td>
<td class="mono" title="{{ f.sha256 }}">{{ (f.sha256 or '')[:12] }}
{% if f.sha256 and f.sha256 == installed_sha[name] %}<div class="small text-muted">same as installed</div>{% endif %}</td>
<td class="wrap">
{% if f.ok %}<span class="badge badge-success">OK</span> {% if f.kind %}{{ sig_badge(f) }}{% endif %}
{% else %}<span class="badge badge-danger">Failed</span> <div class="small">{{ f.error }}</div>{% endif %}
{% if f.signers is defined %}{{ chain(f) }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-box-arrow-in-down"></i> Install selected</button>
<span class="form-text">ipxe-shim.efi is only installed together with ipxe.efi from the same release. ipxe.pxe is for legacy BIOS clients.</span>
</div>
</form>
{% endfor %}
</div>
{% endif %}
{# ---- dnsmasq routing ----------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-diagram-3"></i> Who gets which file (dnsmasq, read-only)</div>
{% if routing.boots %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Boot file</th><th>Requires tag</th><th>Unless tagged</th><th>Clients</th></tr></thead>
<tbody>
{% for b in routing.boots %}
<tr>
<td class="mono">{{ b.file }}
{% if b.managed %}<span class="badge badge-info">updated here</span>{% elif b.read_only %}<span class="badge badge-secondary">never touched</span>
{% elif b.other_copy %}<span class="badge badge-warning" title="This page installs into the TFTP root only; this copy in a subfolder is not updated">other copy, not updated here</span>{% endif %}</td>
<td class="mono">{{ b.need | join(', ') or '-' }}</td>
<td class="mono">{{ b.exclude | join(', ') or '-' }}</td>
<td class="wrap">
{% if b.macs %}<span class="mono">{{ b.macs | join(', ') }}</span>
{% elif b.need %}<span class="text-muted">clients matching {{ b.need | join(' and ') }}</span>
{% else %}<span class="text-muted">every client not excluded</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<details class="mt-2">
<summary class="eyebrow" style="cursor:pointer;">Relevant dnsmasq lines ({{ routing.lines | length }})</summary>
<pre class="mono mb-0 mt-2" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for l in routing.lines %}{{ l }}
{% endfor %}</pre>
</details>
{% else %}
<p class="text-muted mb-0">No dhcp-boot lines found in the dnsmasq configuration.</p>
{% endif %}
</div>
{# ---- Backups ------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
{% if backups %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Backup</th><th>Reason</th><th>Files</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in backups %}
<tr>
<td class="mono text-nowrap">{{ b.id }}</td>
<td>{{ b.reason or '-' }}</td>
<td>
{% for f in b.files %}<div class="small"><span class="mono">{{ f }}</span> <span class="text-muted">{{ b.versions[f] or '' }}</span></div>{% endfor %}
{% if not b.files %}<span class="text-muted">empty</span>{% endif %}
</td>
<td class="text-end text-nowrap">
{% if b.files %}
{# The id is a folder name (hand-made backups too): keep it out of the JS source. #}
<form method="post" action="{{ url_for('ipxe.rollback') }}" style="display:inline;"
data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first."
onsubmit="return confirm(this.dataset.confirm);">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="backup" value="{{ b.id }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No backups yet. One is made automatically before every install and rollback.</p>
{% endif %}
</div>
{# ---- How-to -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-journal-text"></i> How to find and install the latest signed build by hand</div>
<p>Use this when the buttons above cannot reach GitHub, or to check what they do. All commands run on the PXE server as root.</p>
<ol>
<li class="mb-3"><strong>Find the release.</strong> The latest release is at
<a href="{{ releases_page }}" target="_blank" rel="noopener" class="mono">{{ releases_page }}</a>. The signed boot files are the
asset <span class="mono">ipxeboot.tar.gz</span>; GitHub shows its sha256 next to the asset (or ask the API:
<span class="mono">curl -s https://api.github.com/repos/ipxe/ipxe/releases/latest | grep -B2 -A2 '"digest"'</span>).</li>
<li class="mb-3"><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">cd /tmp
curl -fLO {{ tarball_url }}
sha256sum ipxeboot.tar.gz</pre>
<span class="small text-muted">GitHub goes through Zscaler. A certificate error from curl means the Zscaler root CA is missing from
<span class="mono">/usr/local/share/ca-certificates/</span> (the playbook installs it).</span></li>
<li class="mb-3"><strong>Unpack.</strong> The Secure Boot files are in <span class="mono">ipxeboot/x86_64-sb/</span>:
<span class="mono">shimx64.efi</span> (the shim), <span class="mono">ipxe.efi</span> and <span class="mono">snponly.efi</span>.
<span class="mono">ipxe-shim.efi</span> and <span class="mono">snponly-shim.efi</span> there are only symlinks to
<span class="mono">shimx64.efi</span>. The legacy BIOS build is <span class="mono">ipxeboot/x86_64/ipxe.pxe</span>.
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">tar xzf ipxeboot.tar.gz
ls -l ipxeboot/x86_64-sb/</pre></li>
<li class="mb-3"><strong>Check the signatures.</strong>
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">sbverify --list ipxeboot/x86_64-sb/shimx64.efi
sbverify --list ipxeboot/x86_64-sb/ipxe.efi</pre>
The shim must be issued by <span class="mono">CN=Microsoft Corporation UEFI CA 2011</span> or, on a newer build,
<span class="mono">CN=Microsoft UEFI CA 2023</span> (prefer a 2023-signed shim when upstream ships one).
<span class="mono">ipxe.efi</span> must show <span class="mono">CN=iPXE Secure Boot Intermediate ...</span> issued by
<span class="mono">CN=iPXE Secure Boot CA</span>. The shim's <span class="mono">warning: data remaining ... gaps between PE/COFF sections?</span>
is normal. Do not rely on <span class="mono">sbverify --cert</span>: it reports OK even for the wrong certificate.</li>
<li class="mb-3"><strong>Back up the current files.</strong> A backup made in this folder also shows in the Backups list above, with Rollback.
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">B=/var/lib/pxe-webapp/updates/ipxe/backups/$(date +%Y%m%d-%H%M%S)
mkdir -p "$B" &amp;&amp; cp -p /srv/tftp/ipxe-shim.efi /srv/tftp/ipxe.efi "$B"/</pre></li>
<li class="mb-3"><strong>Install both files, from the same release.</strong> The shim only starts an <span class="mono">ipxe.efi</span> signed under the
CA it carries, and it always loads <span class="mono">ipxe.efi</span> from the TFTP root, so the name must stay
<span class="mono">ipxe.efi</span>. Copy to a temporary name and rename, so a client that is booting never reads half a file:
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">install -m 0644 ipxeboot/x86_64-sb/ipxe.efi /srv/tftp/.ipxe.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi
install -m 0644 ipxeboot/x86_64-sb/shimx64.efi /srv/tftp/.ipxe-shim.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe-shim.efi.new /srv/tftp/ipxe-shim.efi</pre>
<span class="small text-muted">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.</span></li>
<li class="mb-3"><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as
<span class="mono">dhcp-host=&lt;mac&gt;,set:ipxe2</span> get the shim through
<span class="mono">dhcp-boot=tag:ipxe2,tag:!ipxe,tag:!bios,ipxe-shim.efi</span>; every other UEFI client gets
<span class="mono">ipxe-bcm.efi</span> through <span class="mono">dhcp-boot=tag:!bios,tag:!ipxe2,ipxe-bcm.efi</span>. The table above shows the
live lines. Test on one tagged machine before tagging more.</li>
<li class="mb-3"><strong>Roll back.</strong> Use Rollback above, or copy the backed-up files back the same way (temporary name, then
<span class="mono">mv -f</span>).</li>
<li><strong>Normal log noise.</strong> While booting, the shim asks TFTP for optional files such as
<span class="mono">revocations_*.efi</span> and <span class="mono">shim_certificate_0.efi</span>. The "not found" lines for them in the
dnsmasq log are expected and harmless.</li>
</ol>
</div>
{% if job.state in ('queued', 'running') %}
<script>
// Poll the background download; reload once it finishes to show the result.
(function () {
var URL = "{{ url_for('ipxe.status') }}";
var text = document.getElementById('ipxeJobText');
var bar = document.getElementById('ipxeJobBar');
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.state !== 'queued' && j.state !== 'running') { location.reload(); return; }
var step = j.step === 'download' ? 'Downloading' : j.step === 'verify' ? 'Verifying' : 'Starting';
if (text) text.textContent = step + ' ' + (j.tag || '') + (j.step === 'download' && j.percent != null ? ' (' + j.percent + '%)' : '') + '...';
if (bar) bar.style.width = (j.step === 'verify' ? 100 : (j.percent || 0)) + '%';
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endif %}
{% endblock %}

37
webapp/views/__init__.py Normal file
View File

@@ -0,0 +1,37 @@
"""Feature blueprints registered by app.py (one module per page group).
views.dell_updates - Updates > Dell drivers and BIOS
views.ipxe - Updates > iPXE Secure Boot files
views.blancco_update - Updates > Blancco Drive Eraser releases
Shared here: the download speed cap form handler. The cap is one setting
(services/updates_common.py settings.json) used by every download; each
Updates page with downloads shows the same form and posts it to its own
route, which calls save_speed_cap() and redirects back to that page.
"""
import math
from flask import flash, request
from services import updates_common
from services.audit import audit
def save_speed_cap():
"""Validate and save the shared speed cap from the posted form, audit and
flash the result. Returns the saved settings, or None when refused."""
try:
value = float(request.form.get("max_mbps", ""))
if not math.isfinite(value): # float() accepts "nan"
raise ValueError("not a number")
data = updates_common.save_settings(value)
except ValueError as ex:
msg = str(ex) if "between" in str(ex) else "enter a number of MB/s (0 = no cap)"
flash(f"Speed cap not saved: {msg}", "danger")
return None
audit("UPDATES_SPEED_CAP", f"{data['max_mbps']} MB/s")
cap = data["max_mbps"]
flash(f"Download speed cap set to {cap:g} MB/s (shared by Dell and iPXE downloads)."
if cap else "Download speed cap removed (shared by Dell and iPXE downloads).", "success")
return data

View File

@@ -0,0 +1,214 @@
"""Updates > Blancco: install a new Blancco Drive Eraser release, test first.
Upload (or pick) the vendor ISO / USB image, stage it as a background job
into WEB_ROOT/blancco/staged/<version>/, boot it from a test entry in the
iPXE menu, then activate it (backup + atomic swap) or roll back. Staging is
a background job (systemd-run) whose progress JSON the page polls; the rest
are renames and small file writes and run inline. Logic lives in
services/blancco_update.py; this module is the route surface.
"""
from flask import (Blueprint, abort, flash, jsonify, redirect, render_template, request,
send_file, url_for)
from services import blancco_update as bu
from services.audit import audit
bp = Blueprint("blancco_update", __name__, url_prefix="/updates/blancco")
def _back():
return redirect(url_for("blancco_update.page"))
@bp.route("/")
def page():
job = bu.job_state()
return render_template(
"updates_blancco.html",
current=bu.current(),
staged=bu.staged(),
backups=bu.backups(),
sources=bu.sources(),
job=job,
job_running=job.get("state") == "running",
test=bu.test_entry(),
initrd=bu.initrd_supports_base(),
upload_dir=bu.upload_dir(),
source_dirs=bu.source_dirs(),
server=bu.SERVER,
)
@bp.route("/status.json")
def status():
job = bu.job_state()
return jsonify({k: job.get(k) for k in ("state", "step", "percent", "message", "log",
"version", "source", "started", "finished")})
@bp.route("/upload", methods=["POST"])
def upload():
"""Raw body (the page's fetch upload, name in ?name=) streams straight to
disk; a plain multipart form works too."""
f = request.files.get("release_file")
try:
if f is not None:
name, size = bu.save_upload(f.stream, f.filename)
else:
name, size = bu.save_upload(request.stream, request.args.get("name", ""),
request.content_length)
except (ValueError, RuntimeError, OSError) as ex:
if f is None:
return jsonify({"ok": False, "error": str(ex)}), 400
flash(f"Upload failed: {ex}", "danger")
return _back()
audit("BLANCCO_UPLOAD", f"{name} ({size} bytes)")
if f is None:
return jsonify({"ok": True, "name": name, "size": size})
flash(f"Uploaded {name}.", "success")
return _back()
@bp.route("/stage", methods=["POST"])
def stage():
key, _, name = request.form.get("source", "").partition(":")
try:
path = bu.resolve_source(key, name)
bu.start_stage(path)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Staging not started: {ex}", "danger")
return _back()
audit("BLANCCO_STAGE", path)
flash(f"Staging {name}. This takes a minute or two; the page follows the job.", "info")
return _back()
@bp.route("/cancel", methods=["POST"])
def cancel():
bu.cancel_stage()
audit("BLANCCO_STAGE_CANCEL", "")
flash("Staging cancelled; partial files removed.", "warning")
return _back()
@bp.route("/prefs/<version>", methods=["POST"])
def merged_prefs(version):
f = request.files.get("prefs_file")
if not f or not f.filename:
flash("Choose the merged preferences.xml first.", "danger")
return _back()
try:
meta = bu.save_merged_prefs(version, f.read(4 << 20))
except bu.StageBlocked as ex:
flash(str(ex), "danger")
return _back()
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Preferences not accepted: {ex}", "danger")
return _back()
audit("BLANCCO_PREFS_MERGE", f"{version} preferences {meta.get('prefs_version')}")
flash(f"Merged preferences accepted; Blancco {version} is staged and ready to test. "
"Commit the same file to playbook/blancco-preferences.xml so a playbook run "
"does not put the old one back.", "success")
return _back()
@bp.route("/vendor-prefs/<version>.xml")
def vendor_prefs(version):
try:
path = bu.vendor_prefs_file(version)
except ValueError:
abort(404)
if not path:
abort(404)
return send_file(path, mimetype="application/xml", as_attachment=True,
download_name=f"vendor-preferences-{version}.xml")
@bp.route("/test/<version>", methods=["POST"])
def test_add(version):
try:
backup = bu.add_test_entry(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Test entry not added: {ex}", "danger")
return _back()
audit("BLANCCO_TEST_ENTRY_ADD", f"{version} (menu backup {backup})")
flash(f"Boot menu now has \"Blancco Drive Eraser {version} (STAGED - TEST)\". "
"PXE boot a test machine and pick it.", "success")
return _back()
@bp.route("/test/remove", methods=["POST"])
def test_remove():
try:
backup = bu.remove_test_entry()
except (RuntimeError, OSError) as ex:
flash(f"Test entry not removed: {ex}", "danger")
return _back()
audit("BLANCCO_TEST_ENTRY_REMOVE", f"menu backup {backup}" if backup else "none present")
flash("Test entry removed from the boot menu." if backup else "No test entry in the menu.", "success")
return _back()
@bp.route("/activate/<version>", methods=["POST"])
def activate(version):
try:
backup, files, removed = bu.activate(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not activated: {ex}", "danger")
return _back()
audit("BLANCCO_ACTIVATE", f"{version}; previous release saved in {backup}")
flash(f"Blancco {version} is live ({len(files)} files swapped). The previous release is in "
f"{backup}." + (" The test menu entry was removed." if removed is True else ""), "success")
if isinstance(removed, str):
flash(f"Blancco {version} is live, but {removed}.", "warning")
return _back()
@bp.route("/rollback/<name>", methods=["POST"])
def rollback(name):
try:
backup, files = bu.rollback(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not rolled back: {ex}", "danger")
return _back()
audit("BLANCCO_ROLLBACK", f"restored {name}; the replaced release saved in {backup}")
flash(f"Restored {name} ({len(files)} files); its folder is released now that the files are "
f"live. What was live before is saved in {backup}.", "success")
return _back()
@bp.route("/staged/<version>/delete", methods=["POST"])
def delete_staged(version):
try:
bu.delete_staged(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_STAGED", version)
flash(f"Deleted staged {version}.", "success")
return _back()
@bp.route("/backup/<name>/delete", methods=["POST"])
def delete_backup(name):
try:
bu.delete_backup(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_BACKUP", name)
flash(f"Deleted {name}.", "success")
return _back()
@bp.route("/uploads/<name>/delete", methods=["POST"])
def delete_upload(name):
try:
bu.delete_upload(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_UPLOAD", name)
flash(f"Deleted {name}.", "success")
return _back()

258
webapp/views/bprt_sync.py Normal file
View File

@@ -0,0 +1,258 @@
"""Updates > BPRT: bring the vendor bulk-enroll folder over from the work PC.
The work PC runs scripts/windows/Sync-BPRT.ps1, which reads manifest.json
here, uploads the new and changed files over SSH into BPRT-incoming/<batch>/
and points the user at this page. Here a batch is validated, its diff shown,
and activated (a new BPRT built from hard links and swapped in by rename);
any earlier BPRT can be rolled back to. Hashing, validation, unpacking and
activation run as one background job at a time; logic lives in
services/bprt_sync.py, this module is the route surface.
"""
from flask import (Blueprint, abort, flash, jsonify, redirect, render_template,
request, send_file, url_for)
from services import bprt_sync as bs
from services import mcl_ppkg
from services.audit import audit
bp = Blueprint("bprt_sync", __name__, url_prefix="/updates/bprt")
def _back(batch=None):
if batch:
return redirect(url_for("bprt_sync.batch", name=batch))
return redirect(url_for("bprt_sync.page"))
def _recover():
try:
done = bs.recover()
except OSError as ex:
flash(f"BPRT is missing and could not be put back automatically: {ex}", "danger")
return
if done:
flash(f"Recovered: {done}.", "warning")
@bp.route("/")
def page():
_recover()
bs.ensure_incoming()
return render_template(
"updates_bprt.html",
live=bs.live_summary(),
packages=mcl_ppkg.packages(),
links=mcl_ppkg.image_links(),
job=bs.job_state(),
batches=bs.batches(),
zips=bs.uploaded_zips(),
archives=bs.archives(),
history=bs.history()[:20],
imaging=bs.active_imaging(),
script_available=bool(bs.sync_script()),
overlay=bs.OVERLAY,
renamed=bs.VENDOR_RENAMED,
incoming=bs.incoming_root(),
detail=None,
)
@bp.route("/batch/<name>")
def batch(name):
_recover()
try:
bs.check_batch(name)
except ValueError:
abort(404)
row = next((b for b in bs.batches() if b["name"] == name), None)
if row is None:
flash(f"There is no incoming batch {name} (activated batches are removed from the list).", "warning")
return _back()
return render_template(
"updates_bprt.html",
live=bs.live_summary(),
job=bs.job_state(),
imaging=bs.active_imaging(),
detail=row,
res=row["validation"],
incoming=bs.incoming_root(),
overlay=bs.OVERLAY,
renamed=bs.VENDOR_RENAMED,
)
@bp.route("/manifest.json")
def manifest():
"""What Sync-BPRT.ps1 compares against. While the live folder is being
hashed (the first time takes minutes) or swapped: 503 + Retry-After."""
bs.ensure_incoming() # Sync-BPRT.ps1 uploads into it as the SSH user
job = bs.job_state()
if job.get("running") and job.get("kind") == "activate":
return _busy("BPRT is being replaced right now; try again in a minute.", job, 60)
try:
data = bs.manifest()
except RuntimeError as ex:
return jsonify({"state": "error", "error": str(ex)}), 500
if data is not None:
data["finish_url"] = url_for("bprt_sync.page", _external=True)
return jsonify(data)
if not job.get("running"):
try:
bs.start("manifest")
except Exception as ex:
return _busy(f"the server could not start hashing BPRT: {ex}", bs.job_state(), 60)
job = bs.job_state()
audit("BPRT_MANIFEST_BUILD", "hashing the live BPRT for manifest.json")
if job.get("kind") == "manifest":
msg = "The server is hashing its BPRT folder (only the first time reads all of it)."
else:
msg = f"The server is busy with a BPRT {job.get('kind')} job."
return _busy(msg, job, 30)
def _busy(message, job, retry):
resp = jsonify({"state": "building", "message": message, "job": job.get("kind", ""),
"percent": job.get("percent"), "step": job.get("step", "")})
resp.status_code = 503
resp.headers["Retry-After"] = str(retry)
return resp
@bp.route("/status.json")
def status():
job = bs.job_state()
return jsonify({k: job.get(k) for k in ("kind", "arg", "state", "running", "step", "current",
"percent", "done_bytes", "total_bytes", "error", "result")})
@bp.route("/Sync-BPRT.ps1")
def script():
path = bs.sync_script()
if not path:
abort(404)
return send_file(path, mimetype="text/plain", as_attachment=True, download_name="Sync-BPRT.ps1")
def _start(kind, arg, ok_msg, audit_action):
try:
bs.start(kind, arg)
except (ValueError, RuntimeError) as ex:
flash(f"Not started: {ex}", "danger")
return False
audit(audit_action, arg)
flash(ok_msg, "info")
return True
@bp.route("/manifest/rebuild", methods=["POST"])
def rebuild():
_start("manifest", "", "Hashing the live BPRT; unchanged files are taken from the cache.",
"BPRT_MANIFEST_BUILD")
return _back()
@bp.route("/batch/<name>/validate", methods=["POST"])
def validate(name):
_start("validate", name, f"Validating {name}: every uploaded file is hashed, so a batch with "
"an Office zip takes a minute or two.", "BPRT_VALIDATE")
return _back(name)
@bp.route("/batch/<name>/activate", methods=["POST"])
def activate(name):
if request.form.get("confirm") != name:
flash("Activation not confirmed.", "danger")
return _back(name)
if _start("activate", name, f"Activating {name}. The page follows the job; the old BPRT is "
"archived, not deleted.", "BPRT_ACTIVATE_START"):
return _back()
return _back(name)
@bp.route("/batch/<name>/delete", methods=["POST"])
def delete(name):
try:
bs.delete_batch(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BPRT_BATCH_DELETE", name)
flash(f"Deleted incoming batch {name}. The live BPRT is not affected.", "success")
return _back()
@bp.route("/upload", methods=["POST"])
def upload():
"""Zip fallback: the raw body (the page's XHR upload, name in ?name=)
streams straight to disk next to BPRT; then the unzip job unpacks it
into a new batch and validates it."""
f = request.files.get("zip_file")
try:
if f is not None:
name, size = bs.save_zip(f.stream, f.filename)
else:
name, size = bs.save_zip(request.stream, request.args.get("name", ""),
request.content_length)
except (ValueError, RuntimeError, OSError) as ex:
if f is None:
return jsonify({"ok": False, "error": str(ex)}), 400
flash(f"Upload failed: {ex}", "danger")
return _back()
audit("BPRT_ZIP_UPLOAD", f"{name} ({size} bytes)")
started, error = True, ""
try:
bs.start("unzip", name)
except (ValueError, RuntimeError) as ex:
started, error = False, str(ex)
if f is None:
return jsonify({"ok": True, "name": name, "size": size, "started": started, "error": error})
if started:
flash(f"Uploaded {name}; unpacking and validating it now.", "info")
else:
flash(f"Uploaded {name}, but unpacking did not start: {error}", "warning")
return _back()
@bp.route("/zip/<name>/unzip", methods=["POST"])
def unzip(name):
_start("unzip", name, f"Unpacking {name} into a new batch, then validating it.", "BPRT_UNZIP_START")
return _back()
@bp.route("/zip/<name>/delete", methods=["POST"])
def delete_zip(name):
try:
bs.delete_zip(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BPRT_ZIP_DELETE", name)
flash(f"Deleted {name}.", "success")
return _back()
@bp.route("/rollback", methods=["POST"])
def rollback():
name = request.form.get("archive", "")
try:
entry = bs.rollback(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Rollback refused: {ex}", "danger")
return _back()
audit("BPRT_ROLLBACK", f"{name} (previous BPRT in {entry['archive']})")
flash(f"{name} is the live BPRT again; the one it replaced is archived as {entry['archive']}.", "success")
return _back()
@bp.route("/archive/delete", methods=["POST"])
def delete_archive():
name = request.form.get("archive", "")
try:
bs.delete_archive(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BPRT_ARCHIVE_DELETE", name)
flash(f"Deleted archive {name}. Files it shared with the live BPRT are still there.", "success")
return _back()

View File

@@ -0,0 +1,179 @@
"""Updates > Dell: Win11 driver packs and WinPE BIOS files from Dell's catalogs.
Check and download run as background jobs (systemd-run) that write progress
JSON the page polls; preview, apply, discard and rollback are quick renames
and small file rewrites, so they run inline. Logic lives in
services/dell_updates.py; this module is the route surface.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from services import dell_updates, updates_common
from services.audit import audit
from views import save_speed_cap
bp = Blueprint("dell_updates", __name__, url_prefix="/updates/dell")
def _back():
return redirect(url_for("dell_updates.page"))
def _render(preview=None, preview_keys=()):
prog = dell_updates.progress()
items = sorted((prog["download"].get("items") or {}).values(),
key=lambda i: (i.get("status") == "applied", i.get("kind", ""), i.get("name", "")))
return render_template(
"updates_dell.html",
scan=dell_updates.last_scan(),
prog=prog,
staged=items,
history=dell_updates.history()[:15],
settings=updates_common.settings(),
preview=preview,
preview_keys=list(preview_keys),
)
@bp.route("/")
def page():
return _render()
@bp.route("/status.json")
def status():
prog = dell_updates.progress()
items = {k: {"status": v.get("status"), "percent": v.get("percent"), "error": v.get("error", "")}
for k, v in (prog["download"].get("items") or {}).items()}
return jsonify({
"check": {"state": prog["check"].get("state", ""), "step": prog["check"].get("step", ""),
"error": prog["check"].get("error", "")},
"download": {"state": prog["download"].get("state", ""), "items": items},
"check_running": prog["check_running"],
"download_running": prog["download_running"],
})
@bp.route("/check", methods=["POST"])
def check():
try:
dell_updates.start("check")
except Exception as ex:
flash(f"Check not started: {ex}", "danger")
return _back()
audit("DELL_CHECK", "catalog check started")
flash("Checking Dell's catalogs; this page updates when the scan is ready (a minute or two).", "info")
return _back()
@bp.route("/download", methods=["POST"])
def download():
drivers = set(request.form.getlist("drv"))
picks = {}
for row_id in request.form.getlist("bios"):
try:
picks[row_id] = int(request.form.get(f"pick_{row_id}", "0"))
except ValueError:
picks[row_id] = 0
try:
count = dell_updates.select(drivers, picks)
dell_updates.start("download")
except Exception as ex:
flash(f"Download not started: {ex}", "danger")
return _back()
audit("DELL_DOWNLOAD_START", f"{count} item(s): {len(drivers)} driver row(s), {len(picks)} BIOS line(s)")
cap = updates_common.settings().get("max_mbps") or 0
flash(f"Downloading {count} item(s) at {'%g MB/s' % cap if cap else 'full speed'}. "
"Driver packs are hash checked and repacked here; nothing on the share changes until you apply.", "info")
return _back()
@bp.route("/cancel", methods=["POST"])
def cancel():
job = request.form.get("job", "download")
if job == "check":
updates_common.stop_job(dell_updates.UNITS["check"])
audit("DELL_CHECK_CANCEL", "")
flash("Check stopped. The previous scan, if any, is still shown.", "warning")
else:
dell_updates.cancel_download()
audit("DELL_DOWNLOAD_CANCEL", "")
flash("Download stopped. Partial files are kept; downloading the same items again resumes them.", "warning")
return _back()
@bp.route("/preview", methods=["POST"])
def preview():
keys = request.form.getlist("key")
if not keys:
flash("Select at least one staged item to apply.", "warning")
return _back()
return _render(preview=dell_updates.preview(keys), preview_keys=keys)
@bp.route("/apply", methods=["POST"])
def apply():
keys = request.form.getlist("key")
if not keys:
flash("Nothing selected to apply.", "warning")
return _back()
if updates_common.job_running(dell_updates.UNITS["download"]):
flash("A download is still running; apply when it has finished.", "danger")
return _back()
try:
report = dell_updates.apply(keys)
except Exception as ex:
flash(f"Apply failed: {ex}", "danger")
return _back()
parts = []
if report["drivers"]:
parts.append(f"{len(report['drivers'])} driver pack(s) replaced in the maps")
if report["bios"]:
parts.append(f"{len(report['bios'])} models.txt line(s) updated")
if parts:
flash("Applied: " + ", ".join(parts) + ". Old zips and BIOS files were kept; the maps and "
"models.txt were backed up first (Rollback in the history below).", "success")
for msg in report["skipped"]:
flash(f"Skipped {msg}.", "warning")
for msg in report["errors"]:
flash(f"Not applied: {msg}", "danger")
if not parts and not report["skipped"] and not report["errors"]:
flash("Nothing was applied (no selected item was ready).", "warning")
return _back()
@bp.route("/discard", methods=["POST"])
def discard():
keys = request.form.getlist("key")
if updates_common.job_running(dell_updates.UNITS["download"]):
flash("A download is running; cancel it first.", "danger")
return _back()
gone = dell_updates.discard(keys)
if gone:
audit("DELL_DISCARD", ", ".join(gone))
flash(f"Removed {len(gone)} staged item(s) from the staging area.", "success")
else:
flash("Nothing removed.", "warning")
return _back()
@bp.route("/rollback", methods=["POST"])
def rollback():
apply_id = request.form.get("apply_id", "")
try:
restored, refused = dell_updates.rollback(apply_id)
except Exception as ex:
flash(f"Rollback failed: {ex}", "danger")
return _back()
if restored:
flash(f"Restored {len(restored)} file(s) from before apply {apply_id}. The new zips/BIOS files stay "
"on the share, unused.", "success")
for msg in refused:
flash(f"Not restored: {msg}.", "danger")
return _back()
@bp.route("/settings", methods=["POST"])
def save_settings():
save_speed_cap() # shared with the other Updates pages (views/__init__.py)
return _back()

201
webapp/views/images.py Normal file
View File

@@ -0,0 +1,201 @@
"""GE Aerospace image pages: the Overview tab and the OS and Packages tab.
Every image page shares templates/_image_header.html (name, OS, tabs); the
tab table and URL helpers are in services/image_pages.py. The Drivers tab
lives in views/images_drivers.py, the Unattend tab is unattend_editor in
app.py.
OS and Packages edits three files with one Save: OperatingSystem.json,
packages.json (Deploy/Control) and the HardwareModelSelection list of
Tools/user_selections.json. The page posts the edited lists as JSON to
/os/preview (diff only, nothing written), then the same body plus the
previewed file hashes to /os/save. Writes go through services/control_files
(format kept, .bak-<ts>-webui backup, atomic, undo of the last save).
"""
import json
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
import config
from services import control_files, image_pages
from services.audit import audit
bp = Blueprint("images", __name__, url_prefix="/images")
UNDO_SCOPE = "os"
def _unknown():
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
@bp.route("/<image_type>")
def overview(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
return render_template(
"image_overview.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="overview",
ov=image_pages.overview(image_type),
)
@bp.route("/<image_type>/os")
def os_packages(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
sel, _ = image_pages.user_selections(image_type)
entries = image_pages.drivers(image_type)
# Per driver family: how many map entries carry it, and how many of
# those have their zip on disk (the old "On Disk" column, made exact).
fam = {}
for e in entries:
f = fam.setdefault(str(e.get("family") or ""), {"entries": 0, "on_disk": 0})
f["entries"] += 1
if image_pages.zip_path(image_type, e):
f["on_disk"] += 1
models = [dict(m=m, fam=fam.get(str(m.get("Id") or ""), {"entries": 0, "on_disk": 0}))
for m in (sel.get("HardwareModelSelection") or []) if isinstance(m, dict)]
oses = image_pages.operating_systems(image_type)
pkgs = image_pages.packages(image_type)
return render_template(
"image_os.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="os",
oses=oses,
packages=pkgs,
models=models,
os_selection=sel.get("OperatingSystemSelection"),
has_selections=bool(sel),
last_save=control_files.last_save(image_type, UNDO_SCOPE),
)
# --- save path: preview, save, undo ---------------------------------------------------
def _clean_list(value, what):
if not isinstance(value, list) or not all(isinstance(x, dict) for x in value):
raise ValueError(f"{what}: expected a list of objects")
# Keys starting with _ are page-side annotations, never file content.
return [{k: v for k, v in x.items() if not str(k).startswith("_")} for x in value]
def _check_parses(path, name):
"""Refuse to save over a file that exists but is not a JSON list: the
page showed it as empty, so a save would replace content nobody saw
(user_selections.json would lose every key but the model list)."""
text = control_files.read_text(path)
if not text.strip():
return
try:
data = json.loads(text.lstrip("\ufeff"))
except ValueError:
data = None
if not isinstance(data, list) or (data and name == "user_selections.json"
and not isinstance(data[0], dict)):
raise ValueError(f"{name} on the share is not a JSON list this page can read "
"(bad JSON or not UTF-8); fix or restore it by hand first")
def _enabled_ok(value):
"""packages.json "enabled": the MCL files use 1/0, ours true/false;
either is kept as it is (PESetup's type must not change under it)."""
return isinstance(value, bool) or (isinstance(value, int) and value in (0, 1))
def _plans(image_type, body):
"""control_files plans for the sections present in the request body."""
plans = []
if "operating_systems" in body:
data = _clean_list(body["operating_systems"], "operating systems")
path = image_pages.control_file(image_type, "OperatingSystem.json")
_check_parses(path, "OperatingSystem.json")
plans.append(control_files.plan_json(path, data, "OperatingSystem.json"))
if "packages" in body:
data = _clean_list(body["packages"], "packages")
for p in data:
if "enabled" in p and not _enabled_ok(p["enabled"]):
raise ValueError("packages: enabled must be true/false or 1/0")
path = image_pages.control_file(image_type, "packages.json")
_check_parses(path, "packages.json")
plans.append(control_files.plan_json(path, data, "packages.json"))
if "hardware_models" in body:
models = _clean_list(body["hardware_models"], "hardware models")
for m in models:
m["Id"] = str(m.get("Id") or "").strip()
m["Model"] = str(m.get("Model") or "").strip()
if not m["Id"] or not m["Model"]:
raise ValueError("hardware models: every row needs a Model and a Driver Family ID")
_check_parses(image_pages.user_selections_path(image_type), "user_selections.json")
sel, raw = image_pages.user_selections(image_type)
first = dict(sel) # keeps key order; only the model list changes
first["HardwareModelSelection"] = models
new = [first] + list(raw[1:]) if raw else [first]
plans.append(control_files.plan_json(
image_pages.user_selections_path(image_type), new, "user_selections.json"))
return plans
def _body():
body = request.get_json(silent=True)
if not isinstance(body, dict):
raise ValueError("expected a JSON object")
return body
@bp.route("/<image_type>/os/preview", methods=["POST"])
def os_preview(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
plans = _plans(image_type, _body())
except ValueError as ex:
return jsonify({"error": str(ex)}), 400
files = [control_files.public(p) for p in plans]
return jsonify({"files": files, "changed": sum(1 for f in files if f["changed"])})
@bp.route("/<image_type>/os/save", methods=["POST"])
def os_save(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
body = _body()
plans = _plans(image_type, body)
expect = body.get("expect") if isinstance(body.get("expect"), dict) else {}
changed = control_files.commit(image_type, UNDO_SCOPE, plans,
action="OS and Packages save", expect=expect)
except ValueError as ex:
return jsonify({"error": str(ex)}), 400
except control_files.Conflict as ex:
return jsonify({"error": str(ex)}), 409
except OSError as ex:
return jsonify({"error": f"write failed: {ex}"}), 500
names = [p["name"] for p in changed]
if names:
audit("IMAGE_OS_SAVE", f"{image_type} {', '.join(names)}")
flash(f"Saved {', '.join(names)}. A backup of each is next to it (.bak-...-webui).", "success")
else:
flash("Nothing changed, nothing written.", "info")
return jsonify({"ok": True, "saved": names})
@bp.route("/<image_type>/os/undo", methods=["POST"])
def os_undo(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
try:
rec = control_files.undo(image_type, UNDO_SCOPE)
names = ", ".join(f["name"] for f in rec["files"])
audit("IMAGE_OS_UNDO", f"{image_type} {names} (save of {rec.get('ts')})")
flash(f"Undid the last save: {names} restored.", "success")
except control_files.Conflict as ex:
flash(f"Undo refused: {ex}", "danger")
except OSError as ex:
flash(f"Undo failed: {ex}", "danger")
return redirect(image_pages.tab_url(image_type, "os"))

View File

@@ -0,0 +1,268 @@
"""GE Aerospace image pages: the Drivers tab (Deploy/Control/HardwareDriver.json).
The page holds the whole driver map in the browser: search and filters,
move up/down (PESetup takes the FIRST matching entry, so order is policy),
edit an entry in a modal, remove, add an unregistered zip. Nothing is
written until Review and save:
POST /drivers/check the staged map -> zip status + lint findings per
row (services/driver_lint), nothing written
POST /drivers/preview per-entry changes with old vs new JSON, the whole
file diff, lint before/after; nothing written
POST /drivers/save the same body + the previewed hash; backup to
HardwareDriver.json.bak-<ts>-webui, atomic write in
the file's own format (services/control_files)
POST /drivers/undo put the last save back (only while the file still
holds what that save wrote)
POST /drivers/orphans/remove delete unregistered zips no image uses
Every JSON body carries "base", the map's sha256 when the page was loaded:
a map rewritten since (another tab, the Dell updater) is never overwritten
with a stale copy. Uploads stay on /drivers/upload in app.py.
The endpoint images_drivers.page is the "drivers" row of
services/image_pages.IMAGE_TABS; keep the name, or change that row.
"""
import json
import os
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from markupsafe import Markup
import config
from services import control_files, driver_lint, driver_map, hardware_coverage, image_pages
from services.audit import audit
bp = Blueprint("images_drivers", __name__, url_prefix="/images")
UNDO_SCOPE = "drivers"
def _unknown():
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
def _script_json(value):
"""JSON for a <script type="application/json"> block, key order kept
(Flask's tojson sorts keys). <, > and & are escaped so file content can
never close the script tag."""
text = json.dumps(value, ensure_ascii=False)
text = text.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
return Markup(text)
def _folders(image_type):
"""Driver folders under Out-of-box Drivers, as destinationDir values,
for the edit modal's suggestions."""
root = driver_map._drivers_root(image_type)
out = []
if not root:
return out
for dirpath, dirnames, _files in os.walk(root):
dirnames.sort()
rel = os.path.relpath(dirpath, root)
if rel != ".":
out.append(driver_map.DEST_PREFIX + rel.replace(os.sep, "\\"))
return out
@bp.route("/<image_type>/drivers")
def page(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
entries, error = driver_map.load(image_type)
rows, _findings, counts = driver_map.rows(image_type, entries)
orphans = driver_map.orphans(image_type)
families = sorted({str(e.get("family")) for e in entries
if isinstance(e, dict) and e.get("family")}, key=str.lower)
hw_extra = image_pages.control_file(image_type, "hw_drivers.json")
data = {
"entries": entries,
"rows": rows,
"counts": counts,
"base": driver_map.file_sha(image_type),
"families": families,
"folders": _folders(image_type),
"urls": {
"check": url_for("images_drivers.check", image_type=image_type),
"preview": url_for("images_drivers.preview", image_type=image_type),
"save": url_for("images_drivers.save", image_type=image_type),
},
}
return render_template(
"image_drivers.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="drivers",
page_json=_script_json(data),
load_error=error,
counts=counts,
entry_count=len(entries),
orphans=orphans,
hw_drivers_extra=os.path.isfile(hw_extra),
last_save=control_files.last_save(image_type, UNDO_SCOPE),
)
# --- staged map: check, preview, save ------------------------------------------------------
class Stale(Exception):
"""The map changed on disk since the page was loaded."""
def _body():
body = request.get_json(silent=True)
if not isinstance(body, dict):
raise ValueError("expected a JSON object")
return body
def _staged(image_type, body, check_base=True):
"""(old entries, new entries, origs, moved flags) for a posted map."""
old, error = driver_map.load(image_type)
if error:
raise ValueError(error + ". Fix the file by hand first.")
if check_base and body.get("base") != driver_map.file_sha(image_type):
raise Stale("HardwareDriver.json changed on disk after this page was loaded "
"(another save, an undo or the Dell updater). Reload the page and "
"make the edits again.")
new, origs, moved = driver_map.staged(body, len(old))
return old, new, origs, moved
def _blocking(image_type, old, new, origs):
"""Lint of the map before and after; a save may not add CRITICAL
findings (each one breaks imaging for many or all PCs)."""
before_findings = driver_lint.lint_entries(old, image_type)
after_findings = driver_lint.lint_entries(new, image_type)
before = driver_lint.counts(before_findings)
after = driver_lint.counts(after_findings)
# Compare the CRITICAL findings themselves, not their count: fixing one
# while adding another keeps the count and must still be refused. Every
# CRITICAL check is about one entry, named here by its position in the
# file (origs), so an entry that already had the finding may still be
# edited or moved; a new entry or a new check on an old one is refused.
had = {(f["check"], f["index"]) for f in before_findings if f["level"] == driver_lint.CRITICAL}
new_critical = []
for f in after_findings:
if f["level"] != driver_lint.CRITICAL:
continue
i = f["index"]
o = origs[i] if i is not None and 0 <= i < len(origs) else None
if o is None or (f["check"], o) not in had:
new_critical.append(f["message"])
return before, after, after_findings, new_critical
def _json_error(exc):
if isinstance(exc, Stale):
return jsonify({"error": str(exc), "stale": True}), 409
return jsonify({"error": str(exc)}), 400
@bp.route("/<image_type>/drivers/check", methods=["POST"])
def check(image_type):
"""Row facts for the staged map (the page re-lints after every edit)."""
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
_old, new, _origs, _moved = _staged(image_type, _body(), check_base=False)
except ValueError as exc:
return _json_error(exc)
rows, _findings, counts = driver_map.rows(image_type, new)
return jsonify({"rows": rows, "counts": counts})
@bp.route("/<image_type>/drivers/preview", methods=["POST"])
def preview(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
old, new, origs, moved = _staged(image_type, _body())
except (ValueError, Stale) as exc:
return _json_error(exc)
plan = control_files.plan_json(driver_map.map_path(image_type), new, driver_map.FILE_NAME)
entry_changes, summary = driver_map.changes(old, new, origs, moved)
before, after, after_findings, new_critical = _blocking(image_type, old, new, origs)
return jsonify({
"files": [control_files.public(plan)],
"changes": entry_changes,
"summary": summary,
"lint": {"before": before, "after": after,
"after_errors": [f["message"] for f in after_findings
if f["level"] in (driver_lint.CRITICAL, driver_lint.ERROR)]},
"blocked": new_critical,
})
@bp.route("/<image_type>/drivers/save", methods=["POST"])
def save(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
body = _body()
old, new, origs, moved = _staged(image_type, body)
_before, _after, _f, new_critical = _blocking(image_type, old, new, origs)
if new_critical:
raise ValueError("refused: this map adds a CRITICAL finding: " + new_critical[0])
plan = control_files.plan_json(driver_map.map_path(image_type), new, driver_map.FILE_NAME)
_changes, summary = driver_map.changes(old, new, origs, moved)
expect = body.get("expect") if isinstance(body.get("expect"), dict) else {}
if driver_map.FILE_NAME not in expect:
raise ValueError("missing the previewed file hash; review the changes first")
changed = control_files.commit(image_type, UNDO_SCOPE, [plan],
action="Drivers save", expect=expect)
except (ValueError, Stale) as exc:
return _json_error(exc)
except control_files.Conflict as exc:
return jsonify({"error": str(exc)}), 409
except OSError as exc:
return jsonify({"error": f"write failed: {exc}"}), 500
if changed:
hardware_coverage.invalidate()
detail = ", ".join(f"{k} {v}" for k, v in summary.items() if v) or "whitespace only"
audit("IMAGE_DRIVERS_SAVE", f"{image_type} {driver_map.FILE_NAME}: {detail}")
flash(f"Saved {driver_map.FILE_NAME} ({detail}). The previous file is next to it "
"(.bak-...-webui) and Undo last save puts it back.", "success")
else:
flash("Nothing changed, nothing written.", "info")
return jsonify({"ok": True, "saved": bool(changed)})
@bp.route("/<image_type>/drivers/undo", methods=["POST"])
def undo(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
try:
rec = control_files.undo(image_type, UNDO_SCOPE)
hardware_coverage.invalidate()
audit("IMAGE_DRIVERS_UNDO", f"{image_type} {driver_map.FILE_NAME} (save of {rec.get('ts')})")
flash(f"Undid the last save: {driver_map.FILE_NAME} restored.", "success")
except control_files.Conflict as exc:
flash(f"Undo refused: {exc}", "danger")
except OSError as exc:
flash(f"Undo failed: {exc}", "danger")
return redirect(image_pages.tab_url(image_type, "drivers"))
# --- unregistered zips ---------------------------------------------------------------------
@bp.route("/<image_type>/drivers/orphans/remove", methods=["POST"])
def orphans_remove(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
rels = request.form.getlist("rel")
back = redirect(image_pages.tab_url(image_type, "drivers"))
if not rels:
flash("No files selected.", "warning")
return back
rec = driver_map.delete_orphans(image_type, rels)
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} "
f"refused={len(rec['refused'])} {', '.join(rec['removed'])}")
if rec["removed"]:
flash(f"Deleted {len(rec['removed'])} unregistered zip(s).", "success")
for rel, why in rec["refused"]:
flash(f"Not deleted: {rel} ({why}).", "warning")
return back

View File

@@ -0,0 +1,219 @@
"""GE Aerospace image pages: the Unattend tab's review, save and undo.
The page itself is unattend_editor in app.py (the "unattend" row of
services/image_pages.IMAGE_TABS, URL /images/<t>/unattend); it renders
templates/unattend_editor.html with page_context() from here. Saving is a
two-step JSON flow driven by static/unattend-editor.js:
POST /images/<t>/unattend/preview {mode, form | raw_xml, base_sha}
-> {files: [control_files.public(plan)], lint, xml}; nothing written
POST /images/<t>/unattend/save same body + expect {name: sha}
-> {ok: true} and a flash; the page reloads
POST /images/<t>/unattend/undo (form post) puts the last save back
mode "form" writes the form's values INTO the current file text
(services/unattend.apply_form: comments, *arch*, attribute layout and every
untouched line stay as they are); mode "raw" writes the Raw XML tab as is.
Writes go through services/control_files (FlatUnattendW10.xml.bak-<ts>-webui
next to the file, atomic, owner and mode kept, undo of the last save, scope
"unattend"). A save that adds a lint ERROR (scripts/lint-unattend.py
checks: one over-long command invalidates the whole answer file for its
pass) is refused.
save() is also used by the old form post in unattend_editor and by
/api/images/<t>/unattend, so every way of writing this file gets the same
backup, undo point and lint gate.
"""
import glob
import os
import threading
from flask import Blueprint, flash, jsonify, redirect, request, url_for
from lxml import etree
import config
from services import control_files, fs, image_pages, unattend
from services.audit import audit
bp = Blueprint("images_unattend", __name__, url_prefix="/images")
UNDO_SCOPE = "unattend"
FILE_NAME = "FlatUnattendW10.xml"
# One write at a time (the app serves requests on threads): the stale check,
# backup, write and undo record of one save must not interleave with another
# save or an undo, or the second would silently overwrite the first.
_write_lock = threading.Lock()
class Refused(Exception):
"""A save or preview that cannot go ahead; carries the HTTP status."""
def __init__(self, message, status=400):
super().__init__(message)
self.status = status
def page_context(image_type):
"""Template values for the Unattend tab besides the parsed data."""
path = fs.unattend_path(image_type)
exists = os.path.isfile(path)
text = control_files.read_text(path) if exists else ""
st = os.stat(path) if exists else None
preferred = image_pages.preferred_unattend_path(image_type)
return {
"file_exists": exists,
"file_sha": control_files.file_sha256(path),
"file_mtime": st.st_mtime if st else None,
"file_size": st.st_size if st else None,
"backup_count": len(glob.glob(glob.escape(path) + ".bak-*")) if exists else 0,
"lint": unattend.lint_text(text) if exists else [],
"last_save": control_files.last_save(image_type, UNDO_SCOPE),
"unattend_override": (os.path.relpath(preferred, fs.image_root(image_type))
if os.path.isfile(preferred) else None),
"limits": {"path": unattend.MAX_PATH, "commandline": unattend.MAX_COMMANDLINE,
"description": unattend.MAX_DESCRIPTION},
}
def _new_text(current, body):
"""The text body asks to write, from the form or the Raw XML tab."""
mode = body.get("mode")
if mode == "raw":
raw = body.get("raw_xml")
if not isinstance(raw, str):
raise Refused("raw_xml must be text")
try:
etree.fromstring(raw.lstrip("\ufeff").encode("utf-8"))
except etree.XMLSyntaxError as exc:
raise Refused(f"Invalid XML: {exc}")
# Keep the file's own line endings and BOM: a textarea hands back
# LF (a plain form post sends CRLF), and a browser may drop a
# leading BOM. The diff (and the file) then only show what was edited.
bom = raw.startswith("\ufeff")
raw = raw.lstrip("\ufeff").replace("\r\n", "\n")
if "\r\n" in current:
raw = raw.replace("\n", "\r\n")
if bom or current.startswith("\ufeff"):
raw = "\ufeff" + raw
return raw
if mode == "form":
form = body.get("form")
if not isinstance(form, dict):
raise Refused("form must be an object")
if "\ufffd" in current:
# read_text replaced bytes that are not UTF-8; writing the form
# into that text would change those bytes on lines nobody edited.
raise Refused(f"{FILE_NAME} holds bytes that are not UTF-8, so the form cannot "
"edit it without changing them. Fix the file in Raw XML first.")
try:
return unattend.apply_form(current, form)
except unattend.ApplyError as exc:
raise Refused(str(exc))
raise Refused("mode must be form or raw")
def plan(image_type, body):
"""(control_files plan, lint comparison) for a request body."""
if not isinstance(body, dict):
raise Refused("expected a JSON object")
path = fs.unattend_path(image_type)
base = body.get("base_sha")
if base and base != control_files.file_sha256(path):
raise Refused(f"{FILE_NAME} changed on disk since this page was opened. "
"Reload the page (your edits here will be lost) and make them again.", 409)
current = control_files.read_text(path)
new = _new_text(current, body)
return control_files.plan_text(path, new, FILE_NAME), unattend.lint_compare(current, new)
def save(image_type, body, action):
"""Plan, lint-gate, back up, write and record the undo point. Returns
the plan (plan["changed"] False when there was nothing to write)."""
with _write_lock:
p, lint = plan(image_type, body)
if p["changed"] and lint["new_errors"]:
msgs = "; ".join(f["message"] for f in lint["findings"] if f["level"] == "ERROR" and f["new"])
raise Refused(f"Not saved: this change would break the answer file. {msgs}")
expect = body.get("expect") if isinstance(body.get("expect"), dict) else None
if not expect:
# Callers without a preview (the API, the old form post): the
# file must still be what this plan was built from when it is
# written (commit checks it under its own lock).
expect = {p["name"]: p["sha_before"]}
try:
control_files.commit(image_type, UNDO_SCOPE, [p], action=action, expect=expect)
except control_files.Conflict as exc:
raise Refused(str(exc), 409)
except OSError as exc:
raise Refused(f"write failed: {exc}", 500)
return p
def save_and_flash(image_type, body, action):
"""save() for the form-post routes: audit and flash the outcome."""
try:
p = save(image_type, body, action)
except Refused as exc:
flash(str(exc), "danger")
return None
if p["changed"]:
audit("UNATTEND_SAVE", f"{image_type} ({body.get('mode')})")
flash(f"Saved {FILE_NAME}. The previous version is next to it "
f"({FILE_NAME}.bak-...-webui) and Undo puts it back.", "success")
else:
flash(f"{FILE_NAME}: nothing changed, nothing written.", "info")
return p
def _unknown_json():
return jsonify({"error": "Unknown image type"}), 404
@bp.route("/<image_type>/unattend/preview", methods=["POST"])
def preview(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown_json()
try:
p, lint = plan(image_type, request.get_json(silent=True))
except Refused as exc:
return jsonify({"error": str(exc)}), exc.status
return jsonify({"files": [control_files.public(p)], "lint": lint, "xml": p["new"],
"changed": p["changed"]})
@bp.route("/<image_type>/unattend/save", methods=["POST"])
def save_route(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown_json()
body = request.get_json(silent=True)
try:
p = save(image_type, body, action=f"Unattend save ({(body or {}).get('mode')})")
except Refused as exc:
return jsonify({"error": str(exc)}), exc.status
if p["changed"]:
audit("UNATTEND_SAVE", f"{image_type} ({body.get('mode')})")
flash(f"Saved {FILE_NAME}. The previous version is next to it "
f"({FILE_NAME}.bak-...-webui) and Undo puts it back.", "success")
else:
flash(f"{FILE_NAME}: nothing changed, nothing written.", "info")
return jsonify({"ok": True, "changed": p["changed"]})
@bp.route("/<image_type>/unattend/undo", methods=["POST"])
def undo(image_type):
if image_type not in config.IMAGE_TYPES:
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
try:
with _write_lock:
rec = control_files.undo(image_type, UNDO_SCOPE)
audit("UNATTEND_UNDO", f"{image_type} (save of {rec.get('ts')})")
flash(f"Undid the last save: {FILE_NAME} is back to how it was before it. "
f"The undone version is kept as {FILE_NAME}.bak-...-webui-undo.", "success")
except control_files.Conflict as exc:
flash(f"Undo refused: {exc}", "danger")
except OSError as exc:
flash(f"Undo failed: {exc}", "danger")
return redirect(image_pages.tab_url(image_type, "unattend"))

113
webapp/views/ipxe.py Normal file
View File

@@ -0,0 +1,113 @@
"""Updates > iPXE: the Secure Boot boot files on the TFTP root.
Shows what is installed (version, signer chain, Microsoft CA warning), how
dnsmasq routes clients to those files, and checks GitHub for a newer
upstream release. Download and verify runs as a background job; install
and rollback copy a few MB and run inline. Logic lives in
services/ipxe_update.py; this module is the route surface.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from services import ipxe_update, updates_common
from services.audit import audit
from views import save_speed_cap
bp = Blueprint("ipxe", __name__, url_prefix="/updates/ipxe")
def _back():
return redirect(url_for("ipxe.page"))
@bp.route("/")
def page():
rows = ipxe_update.installed()
by_name = {r["name"]: r["info"] for r in rows}
latest = ipxe_update.last_check()
ipxe_efi = by_name.get("ipxe.efi") or {}
status = ipxe_update.compare(latest["tag"], ipxe_efi.get("version")) if latest else None
return render_template(
"updates_ipxe.html",
rows=rows,
installed_sha={n: (i or {}).get("sha256") for n, i in by_name.items()},
routing=ipxe_update.dnsmasq_routing(),
latest=latest,
status=status,
job=ipxe_update.job_state(),
staged=ipxe_update.staged(),
backups=ipxe_update.backups(),
settings=updates_common.settings(),
default_install=ipxe_update.DEFAULT_INSTALL,
tarball_url=ipxe_update.LATEST_TARBALL_URL,
releases_page=ipxe_update.RELEASES_PAGE,
)
@bp.route("/status.json")
def status():
return jsonify(ipxe_update.job_state())
@bp.route("/check", methods=["POST"])
def check():
try:
info = ipxe_update.check_latest()
except Exception as ex:
flash(f"Could not check GitHub: {ex}", "danger")
return _back()
if not info["sha256"]:
flash(f"Latest release is {info['tag']}, but GitHub lists no sha256 for {ipxe_update.TARBALL}; "
"install it by hand (see below).", "warning")
else:
flash(f"Latest upstream release: {info['tag']}.", "info")
return _back()
@bp.route("/stage", methods=["POST"])
def stage():
try:
ipxe_update.start_stage()
except Exception as ex:
flash(f"Download not started: {ex}", "danger")
return _back()
latest = ipxe_update.last_check() or {}
audit("IPXE_STAGE", f"download and verify {latest.get('tag')}")
flash(f"Downloading {latest.get('tag')}; this page updates when it is verified.", "info")
return _back()
@bp.route("/install", methods=["POST"])
def install():
tag = request.form.get("tag", "")
# Only known names reach the audit log (form values could carry newlines).
names = [n for n in ipxe_update.MANAGED if n in request.form.getlist("names")]
try:
backup = ipxe_update.install(tag, names)
except Exception as ex:
flash(f"Install refused: {ex}", "danger")
return _back()
audit("IPXE_INSTALL", f"{tag}: {', '.join(names)} (backup {backup.rsplit('/', 1)[-1]})")
flash(f"Installed {', '.join(names)} from {tag}. "
"The next client to boot gets them; the old files are in the backups list.", "success")
return _back()
@bp.route("/rollback", methods=["POST"])
def rollback():
# rollback() looks the id up by its last path part only; log that.
backup_id = request.form.get("backup", "").replace("\\", "/").rsplit("/", 1)[-1]
try:
files = ipxe_update.rollback(backup_id)
except Exception as ex:
flash(f"Rollback failed: {ex}", "danger")
return _back()
audit("IPXE_ROLLBACK", f"{backup_id}: {', '.join(files)}")
flash(f"Restored {', '.join(files)} from backup {backup_id}.", "success")
return _back()
@bp.route("/settings", methods=["POST"])
def save_settings():
save_speed_cap() # shared with the other Updates pages (views/__init__.py)
return _back()