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.unattend - parse + build + form-extract for unattend.xml
|
||||
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
|
||||
@@ -35,7 +39,6 @@ from flask import (
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
from lxml import etree
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
import config
|
||||
@@ -43,17 +46,29 @@ from services import (
|
||||
blancco_evidence,
|
||||
hardware_coverage,
|
||||
blancco_report,
|
||||
dell_updates,
|
||||
deploy,
|
||||
driver_map,
|
||||
fs,
|
||||
control_files,
|
||||
image_pages,
|
||||
image_registry,
|
||||
images,
|
||||
imaging_status,
|
||||
mcl_ppkg,
|
||||
system,
|
||||
unattend,
|
||||
wim,
|
||||
)
|
||||
from services.audit import audit
|
||||
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.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.
|
||||
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
|
||||
@@ -186,38 +216,15 @@ def unattend_editor(image_type):
|
||||
xml_file = fs.unattend_path(image_type)
|
||||
|
||||
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")
|
||||
|
||||
if save_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
|
||||
body = {"mode": "raw", "raw_xml": request.form.get("raw_xml", "")}
|
||||
else:
|
||||
form_data = unattend.extract_form_data(request.form)
|
||||
xml_content = unattend.build_unattend_xml(form_data)
|
||||
|
||||
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")
|
||||
|
||||
body = {"mode": "form", "form": unattend.extract_form_data(request.form)}
|
||||
images_unattend_views.save_and_flash(image_type, body, f"Unattend save ({save_mode}, form post)")
|
||||
return redirect(url_for("unattend_editor", image_type=image_type))
|
||||
|
||||
data = unattend.parse_unattend(xml_file)
|
||||
@@ -225,77 +232,72 @@ def unattend_editor(image_type):
|
||||
"unattend_editor.html",
|
||||
image_type=image_type,
|
||||
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
|
||||
active_tab="unattend",
|
||||
data=data,
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
**images_unattend_views.page_context(image_type),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/config")
|
||||
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:
|
||||
flash("Unknown image type.", "danger")
|
||||
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(
|
||||
"image_config.html",
|
||||
image_type=image_type,
|
||||
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
# Legacy /config/save sections and the tab each one now lives on.
|
||||
_CONFIG_SECTIONS = {
|
||||
"hardware_models": "os",
|
||||
"drivers": "drivers",
|
||||
"operating_systems": "os",
|
||||
"packages": "os",
|
||||
}
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/config/save", methods=["POST"])
|
||||
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:
|
||||
flash("Unknown image type.", "danger")
|
||||
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)
|
||||
tools = fs.tools_path(image_type)
|
||||
|
||||
try:
|
||||
if section == "hardware_models":
|
||||
us_file = os.path.join(tools, "user_selections.json")
|
||||
us_raw = fs.load_json(us_file)
|
||||
us_data = us_raw[0] if us_raw and isinstance(us_raw, list) else {}
|
||||
us_data["HardwareModelSelection"] = data
|
||||
fs.save_json(us_file, [us_data])
|
||||
audit("CONFIG_SAVE", f"{image_type}/hardware_models")
|
||||
|
||||
elif section == "drivers":
|
||||
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
|
||||
fs.save_json(os.path.join(ctrl, "HardwareDriver.json"), clean)
|
||||
audit("CONFIG_SAVE", f"{image_type}/drivers")
|
||||
|
||||
elif section == "operating_systems":
|
||||
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
|
||||
fs.save_json(os.path.join(ctrl, "OperatingSystem.json"), clean)
|
||||
audit("CONFIG_SAVE", f"{image_type}/operating_systems")
|
||||
|
||||
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))
|
||||
def _register_package(image_type, fname, dest):
|
||||
"""Append {fileName, destinationDir} to packages.json through the same
|
||||
path as the OS and Packages tab (format kept, .bak-<ts>-webui backup,
|
||||
atomic, the tab's undo point). False when it is already listed."""
|
||||
path = image_pages.control_file(image_type, "packages.json")
|
||||
text = control_files.read_text(path)
|
||||
entries = []
|
||||
if text.strip():
|
||||
try:
|
||||
entries = json.loads(text.lstrip("\ufeff"))
|
||||
except ValueError:
|
||||
entries = None
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("packages.json is not a JSON list, so the file was not registered")
|
||||
if any(str(e.get("fileName") or e.get("FileName") or "").lower() == fname.lower()
|
||||
for e in entries if isinstance(e, dict)):
|
||||
return False
|
||||
entries.append({"fileName": fname, "destinationDir": dest})
|
||||
plan = control_files.plan_json(path, entries, "packages.json")
|
||||
control_files.commit(image_type, images_views.UNDO_SCOPE, [plan],
|
||||
action=f"Package upload: {fname} registered")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -304,71 +306,65 @@ def image_config_save(image_type):
|
||||
|
||||
@app.route("/images/<image_type>/drivers/upload", methods=["POST"])
|
||||
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:
|
||||
flash(f"Unknown image type: {image_type}", "danger")
|
||||
return redirect(url_for("dashboard"))
|
||||
back = redirect(image_pages.tab_url(image_type, "drivers"))
|
||||
f = request.files.get("driver_file")
|
||||
if not f or not f.filename:
|
||||
flash("No file selected.", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
family = (request.form.get("family") or "").strip()
|
||||
dest = (request.form.get("destination_dir") or "").strip()
|
||||
return back
|
||||
overwrite = request.form.get("overwrite") == "1"
|
||||
try:
|
||||
rec = images.upload_driver(image_type, f, family=family,
|
||||
destination_dir=dest, overwrite=overwrite)
|
||||
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
|
||||
msg = f"Uploaded {rec['filename']}."
|
||||
msg += " Registered to family in HardwareDriver.json." if rec["registered"] else " Now in orphans list until adopted."
|
||||
flash(msg, "success")
|
||||
except (ValueError, FileExistsError, FileNotFoundError) as ex:
|
||||
rec = driver_map.save_upload(image_type, f, overwrite=overwrite)
|
||||
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} replaced={rec['replaced']}")
|
||||
flash(f"Uploaded {rec['filename']}. It is listed under Unregistered zips; "
|
||||
"use Add to map there to give it an entry.", "success")
|
||||
except (ValueError, FileExistsError) as ex:
|
||||
flash(str(ex), "danger")
|
||||
except Exception as ex:
|
||||
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"])
|
||||
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:
|
||||
flash(f"Unknown image type: {image_type}", "danger")
|
||||
return redirect(url_for("dashboard"))
|
||||
filename = (request.form.get("filename") or "").strip()
|
||||
family = (request.form.get("family") or "").strip()
|
||||
dest = (request.form.get("destination_dir") or "").strip()
|
||||
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))
|
||||
flash("Use Add to map under Unregistered zips: the new entry needs model tokens and is "
|
||||
"saved through Review and save. Nothing was written.", "info")
|
||||
return redirect(image_pages.tab_url(image_type, "drivers"))
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/drivers/orphans/delete", methods=["POST"])
|
||||
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:
|
||||
flash(f"Unknown image type: {image_type}", "danger")
|
||||
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")
|
||||
if not filenames:
|
||||
flash("No files selected for removal.", "warning")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
rec = images.remove_orphans(image_type, filenames)
|
||||
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} missing={len(rec['missing'])}")
|
||||
parts = []
|
||||
return back
|
||||
rec = driver_map.delete_orphans(image_type, filenames)
|
||||
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} "
|
||||
f"refused={len(rec['refused'])} {', '.join(rec['removed'])}")
|
||||
if rec["removed"]:
|
||||
parts.append(f"Removed {len(rec['removed'])} orphan(s).")
|
||||
if rec["missing"]:
|
||||
parts.append(f"{len(rec['missing'])} not found / errored.")
|
||||
flash(" ".join(parts) or "No-op.", "success" if rec["removed"] else "warning")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
if not f or not f.filename:
|
||||
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()
|
||||
overwrite = request.form.get("overwrite") == "1"
|
||||
try:
|
||||
rec = images.upload_package(image_type, f, destination_dir=dest, overwrite=overwrite)
|
||||
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
|
||||
# The file only; packages.json is edited here through control_files
|
||||
# (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 += " 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")
|
||||
except (ValueError, FileExistsError) as ex:
|
||||
flash(str(ex), "danger")
|
||||
except Exception as ex:
|
||||
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")
|
||||
def hardware_page():
|
||||
"""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(),
|
||||
images=hardware_coverage.IMAGES)
|
||||
images=hardware_coverage.IMAGES, hints=hints)
|
||||
|
||||
|
||||
@app.route("/hardware/dismiss", methods=["POST"])
|
||||
@@ -1048,6 +1060,9 @@ def enrollment():
|
||||
return render_template(
|
||||
"enrollment.html",
|
||||
packages=packages,
|
||||
mcl_packages=mcl_ppkg.packages(),
|
||||
mcl_archive=mcl_ppkg.archived(),
|
||||
mcl_links=mcl_ppkg.image_links(),
|
||||
active_ppkg=read_ppkg_conf(),
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
@@ -1069,6 +1084,13 @@ def enrollment_upload():
|
||||
if not filename.lower().endswith(".ppkg"):
|
||||
flash("Only .ppkg files are accepted.", "danger")
|
||||
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)
|
||||
dest = os.path.join(config.ENROLLMENT_PPKG_DIR, filename)
|
||||
@@ -1093,6 +1115,56 @@ def enrollment_upload():
|
||||
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>")
|
||||
def enrollment_download(filename):
|
||||
filename = secure_filename(filename)
|
||||
@@ -1492,31 +1564,24 @@ def api_save_unattend(image_type):
|
||||
xml_file = fs.unattend_path(image_type)
|
||||
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
|
||||
|
||||
# {"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:
|
||||
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
|
||||
body = {"mode": "raw", "raw_xml": payload["raw_xml"]}
|
||||
else:
|
||||
try:
|
||||
xml_content = unattend.build_unattend_xml(payload)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"Failed to build XML: {exc}"}), 400
|
||||
|
||||
body = {"mode": "form", "form": payload}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
||||
with open(xml_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(xml_content)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"Failed to write file: {exc}"}), 500
|
||||
plan = images_unattend_views.save(image_type, body, action="Unattend save (API)")
|
||||
except images_unattend_views.Refused as exc:
|
||||
return jsonify({"error": str(exc)}), exc.status
|
||||
|
||||
audit("UNATTEND_SAVE_API", image_type)
|
||||
return jsonify({"status": "ok", "path": xml_file})
|
||||
if plan["changed"]:
|
||||
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)
|
||||
|
||||
|
||||
@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
|
||||
def inject_globals():
|
||||
return {
|
||||
"all_image_types": config.IMAGE_TYPES,
|
||||
"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(),
|
||||
hardware=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")
|
||||
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")
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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 = (
|
||||
("gea-standard", "Win11"),
|
||||
("gea-shopfloor", "Win11 LTSC"),
|
||||
("gea-engineer", "Win10"),
|
||||
("gea-engineer", "Win11"),
|
||||
)
|
||||
KEEP = 10 # serials / versions remembered per model
|
||||
|
||||
@@ -239,7 +241,7 @@ def coverage(include_ok=True):
|
||||
drivers = driver_status(model)
|
||||
bios = bios_status(model, rec.get("last_bios_version", ""))
|
||||
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")
|
||||
row = dict(rec, model=model, drivers=drivers, bios=bios, missing=missing,
|
||||
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>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section">GE Aerospace Images</div>
|
||||
<div class="nav-section">Updates</div>
|
||||
<ul class="sidebar-nav nav flex-column">
|
||||
{% for it in all_image_types if it.startswith('gea-') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||
<i class="bi bi-windows"></i> {{ all_friendly_names[it] }}
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('dell_updates.') %}active{% endif %}"
|
||||
href="{{ url_for('dell_updates.page') }}">
|
||||
<i class="bi bi-motherboard"></i> Dell Drivers and BIOS
|
||||
</a>
|
||||
</li>
|
||||
<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 %}"
|
||||
href="{{ url_for('image_config', image_type=it) }}">
|
||||
<i class="bi bi-gear"></i> Configuration
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('ipxe.') %}active{% endif %}"
|
||||
href="{{ url_for('ipxe.page') }}">
|
||||
<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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
</td>
|
||||
<td><span class="mono">{{ img.deploy_path }}</span></td>
|
||||
<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">
|
||||
<i class="bi bi-gear"></i> Config
|
||||
<i class="bi bi-clipboard-data"></i> Open
|
||||
</a>
|
||||
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-primary btn-row-action">
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div class="card">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@@ -82,6 +82,107 @@
|
||||
</p>
|
||||
</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 -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
{% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %}
|
||||
{%- 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 %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-cpu"></i> Hardware Coverage</h1>
|
||||
@@ -53,8 +60,8 @@
|
||||
</td>
|
||||
<td class="mono">{{ r.skus | join(', ') 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 %}
|
||||
<td>{{ bios_badge(r.bios) }}</td>
|
||||
{% 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) }}{{ 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="text-end text-nowrap">
|
||||
{% 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