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:
445
scripts/windows/Sync-BPRT.ps1
Normal file
445
scripts/windows/Sync-BPRT.ps1
Normal 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
|
||||||
|
}
|
||||||
360
webapp/app.py
360
webapp/app.py
@@ -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()),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1459
webapp/services/blancco_update.py
Normal file
1459
webapp/services/blancco_update.py
Normal file
File diff suppressed because it is too large
Load Diff
1467
webapp/services/bprt_sync.py
Normal file
1467
webapp/services/bprt_sync.py
Normal file
File diff suppressed because it is too large
Load Diff
478
webapp/services/control_files.py
Normal file
478
webapp/services/control_files.py
Normal 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
|
||||||
1422
webapp/services/dell_updates.py
Normal file
1422
webapp/services/dell_updates.py
Normal file
File diff suppressed because it is too large
Load Diff
321
webapp/services/driver_lint.py
Normal file
321
webapp/services/driver_lint.py
Normal 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)
|
||||||
416
webapp/services/driver_map.py
Normal file
416
webapp/services/driver_map.py
Normal 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))
|
||||||
@@ -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"])
|
||||||
|
|||||||
348
webapp/services/image_pages.py
Normal file
348
webapp/services/image_pages.py
Normal 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")},
|
||||||
|
}
|
||||||
724
webapp/services/ipxe_update.py
Normal file
724
webapp/services/ipxe_update.py
Normal 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
371
webapp/services/mcl_ppkg.py
Normal 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
184
webapp/services/updates_common.py
Normal file
184
webapp/services/updates_common.py
Normal 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)
|
||||||
114
webapp/static/image-pages.js
Normal file
114
webapp/static/image-pages.js
Normal 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 };
|
||||||
|
})();
|
||||||
512
webapp/static/unattend-editor.js
Normal file
512
webapp/static/unattend-editor.js
Normal 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);
|
||||||
|
})();
|
||||||
150
webapp/templates/_image_header.html
Normal file
150
webapp/templates/_image_header.html
Normal 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-<date>-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>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 — 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 %}
|
|
||||||
893
webapp/templates/image_drivers.html
Normal file
893
webapp/templates/image_drivers.html
Normal 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 %}
|
||||||
399
webapp/templates/image_os.html
Normal file
399
webapp/templates/image_os.html
Normal 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 %}
|
||||||
261
webapp/templates/image_overview.html
Normal file
261
webapp/templates/image_overview.html
Normal 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
398
webapp/templates/updates_blancco.html
Normal file
398
webapp/templates/updates_blancco.html
Normal 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"><version></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/<version></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-<time>-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"><version></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 %}
|
||||||
556
webapp/templates/updates_bprt.html
Normal file
556
webapp/templates/updates_bprt.html
Normal 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-<time></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 %}
|
||||||
441
webapp/templates/updates_dell.html
Normal file
441
webapp/templates/updates_dell.html
Normal 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 %}
|
||||||
345
webapp/templates/updates_ipxe.html
Normal file
345
webapp/templates/updates_ipxe.html
Normal 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" && 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 && 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 && 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=<mac>,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
37
webapp/views/__init__.py
Normal 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
|
||||||
214
webapp/views/blancco_update.py
Normal file
214
webapp/views/blancco_update.py
Normal 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
258
webapp/views/bprt_sync.py
Normal 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()
|
||||||
179
webapp/views/dell_updates.py
Normal file
179
webapp/views/dell_updates.py
Normal 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
201
webapp/views/images.py
Normal 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"))
|
||||||
268
webapp/views/images_drivers.py
Normal file
268
webapp/views/images_drivers.py
Normal 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
|
||||||
219
webapp/views/images_unattend.py
Normal file
219
webapp/views/images_unattend.py
Normal 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
113
webapp/views/ipxe.py
Normal 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()
|
||||||
Reference in New Issue
Block a user