Compare commits
65 Commits
renumber-1
...
pxe-webapp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d9c4fdac4 | ||
|
|
54cbe6b5d6 | ||
|
|
b075939c38 | ||
|
|
6d5fee786c | ||
|
|
0e1b80b903 | ||
|
|
8907a86c42 | ||
|
|
a7cb7164d9 | ||
|
|
28efde7d76 | ||
|
|
d5cf255443 | ||
|
|
7ed30f9b85 | ||
|
|
c4aeaaaa17 | ||
|
|
54176e591f | ||
|
|
d185e2b810 | ||
|
|
68f2ce29e4 | ||
|
|
f442ff2f65 | ||
|
|
a0aceb4680 | ||
|
|
bb08392b84 | ||
|
|
9d51c0b987 | ||
|
|
68df59e117 | ||
|
|
d2200e8522 | ||
|
|
8c21282024 | ||
|
|
36be60e9ae | ||
|
|
b96ff7e970 | ||
|
|
d04865da0f | ||
|
|
2b6189f739 | ||
|
|
385acfefa7 | ||
|
|
d5f7abc19f | ||
|
|
049dd7bde6 | ||
|
|
c7b17696d0 | ||
|
|
dfa026b4ee | ||
|
|
936902dc4d | ||
|
|
27e76467a8 | ||
|
|
66c24b5d59 | ||
|
|
1a175bc4fe | ||
|
|
01f524e8b3 | ||
|
|
3880151e71 | ||
|
|
5f97e81dec | ||
|
|
626561a1fa | ||
|
|
f1d9bdf478 | ||
|
|
cc391529bd | ||
|
|
234295931e | ||
|
|
0ffdcc79ed | ||
|
|
3b63697176 | ||
|
|
0cb6b26c27 | ||
|
|
8fbae24b4d | ||
|
|
18db077475 | ||
|
|
8df30017aa | ||
|
|
b240cfea12 | ||
|
|
e7be0f5391 | ||
|
|
0c4c2a245d | ||
|
|
fdf63ab32f | ||
|
|
64e1f7e088 | ||
|
|
287ec86c12 | ||
|
|
85e7d91b1a | ||
|
|
2461804fc8 | ||
|
|
48bc609eb5 | ||
|
|
132c57ab39 | ||
|
|
99deaa6882 | ||
|
|
948d013539 | ||
|
|
6e218f3daf | ||
|
|
b5b644a360 | ||
|
|
bc89ba1cf3 | ||
|
|
1487abdba5 | ||
|
|
7af66575d0 | ||
|
|
8e11f0824a |
95
docs/OWNERSHIP.md
Normal file
95
docs/OWNERSHIP.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Who owns what
|
||||
|
||||
Every bug that cost a day in this pipeline has had the same shape: two systems
|
||||
both setting the same thing, and whichever ran last silently won. This file says
|
||||
who owns each concern so the next person deletes the loser instead of adding a
|
||||
third writer.
|
||||
|
||||
All four examples below are real, from 2026-08-06.
|
||||
|
||||
## Computer name - the PPKG owns it
|
||||
|
||||
The provisioning package declares:
|
||||
|
||||
```xml
|
||||
<DNSComputerName>F%SERIAL%</DNSComputerName>
|
||||
```
|
||||
|
||||
so a bay comes up as `F<serial>`.
|
||||
|
||||
`run-enrollment.ps1` used to also run `Rename-Computer -NewName "E$serial"`.
|
||||
Both are pending renames; last writer wins at reboot. The script ran twice, and
|
||||
its second run landed after the package had already queued `F579C144`, so the
|
||||
bay came up `E579C144` with the package reporting no errors at all.
|
||||
|
||||
**Rule:** nothing in this repo renames a machine. If the naming convention
|
||||
changes, it changes in the package.
|
||||
|
||||
## Drive letters - PESetup owns them
|
||||
|
||||
PESetup hardcodes `W:` in nine places - every copy destination, both DISM
|
||||
offline sessions, `bcdboot`, `reagentc` - and creates it during its own disk
|
||||
preparation.
|
||||
|
||||
`startnet.cmd` briefly had a volume finder that scanned for "the applied
|
||||
Windows volume" and `diskpart`-assigned it to `W:`. On a re-image it found the
|
||||
*previous* install, relabelled a partition PESetup was about to erase, and every
|
||||
staging copy failed into a volume that no longer existed.
|
||||
|
||||
**Rule:** wait for `W:\Windows\System32\config\system` - the hive only exists
|
||||
once the WIM apply has written it. Never run `diskpart` while PESetup is
|
||||
running.
|
||||
|
||||
See `docs/PESETUP-INTERNALS.md`.
|
||||
|
||||
## Enrollment - the PPKG owns it, the orchestrator drives reboots
|
||||
|
||||
The SFLD package joins Entra using the BPRT token in
|
||||
`0__Accounts_Azure.provxml`. A human then assigns the device category in Intune.
|
||||
|
||||
The vendor's `Start-BulkEnrollOrchestrator.ps1` has two branches. The normal one
|
||||
aborts the package's own reboot, registers `AutoSecondReboot`, and drives the
|
||||
Entra join to completion. The `-ManualFallback` one runs `sysprep /oobe /reboot`
|
||||
- it is an interactive escape hatch for handing a machine back to OOBE, meant to
|
||||
be triggered by a person.
|
||||
|
||||
The shopfloor unattend registered `-ManualFallback` as an at-logon scheduled
|
||||
task. So OOBE completed, autologon fired, and four seconds later the machine
|
||||
syspreped itself back to OOBE, losing the deployment chain permanently.
|
||||
|
||||
**Rule:** shopfloor bays enrol. Never wire `-ManualFallback` to anything
|
||||
automatic.
|
||||
|
||||
## Kiosk URLs - GE-Enforce owns them
|
||||
|
||||
`plugins/geenforce/seed_display_scope.py` in shopdb-flask is authoritative:
|
||||
|
||||
| display-type.txt | route |
|
||||
|---|---|
|
||||
| `Dashboard` | `/shopdb/shopfloor` |
|
||||
| `Lobby` | `/shopdb/tv` |
|
||||
| `3DPrintRoom` | `/shopdb/parts-kiosk` |
|
||||
|
||||
Its dispatcher prefers the server-side role from Settings > Dashboard Defaults
|
||||
(resolved by device IP), falls back to `C:\Enrollment\display-type.txt`, writes
|
||||
the Startup shortcut itself, and **sweeps** any shortcut matching
|
||||
`shopfloor-dashboard` or `/shopdb/`.
|
||||
|
||||
`site-config.json` also carries `edgeHomepage` per display type. Those are a
|
||||
backstop for the window before the kiosk installer runs - a stale value there
|
||||
gets deleted on the next enforce cycle rather than honoured.
|
||||
|
||||
**Rule:** if a kiosk points at the wrong page, fix the GE-Enforce scope first.
|
||||
Keep `site-config.json` correct, but do not expect it to win.
|
||||
|
||||
## Repo vs share
|
||||
|
||||
The share is production; the repo is meant to describe it. Drift runs both ways -
|
||||
live hand-edits nobody committed, and repo fixes never deployed.
|
||||
|
||||
`scripts/share-drift.py` classifies every mapped pair as `git-owned` (repo wins,
|
||||
safe to push) or `unreconciled` (diverged, nobody has decided). It reports and
|
||||
never writes. Run it before a build day; `scripts/preflight.py` covers the rest.
|
||||
|
||||
The unattends are `unreconciled` on purpose: the live copies are ~17 KB and the
|
||||
repo copies ~12 KB, so pushing the repo would regress production.
|
||||
278
docs/PESETUP-INTERNALS.md
Normal file
278
docs/PESETUP-INTERNALS.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# PESetup.exe internals
|
||||
|
||||
What GE Image Setup actually does, start to finish. Written from the decompiled
|
||||
assembly, not from observation, because several long-standing beliefs about this
|
||||
tool turned out to be wrong and cost weeks of debugging.
|
||||
|
||||
**Version documented:** 4.0.0.17 (`Sources/PESetup.exe`, PE32+ native apphost
|
||||
wrapping a .NET 6 single-file bundle, 468 embedded files).
|
||||
|
||||
**Version in production:** 4.0.0.20, as of 2026-08-06 — the media reports it in
|
||||
its own log (`AppVersion: 4.0.0.20`). Everything in this document was verified
|
||||
against a 4.0.0.20 run on bay 579C144 that day: the media drive resolved to `Z:\`,
|
||||
`W:` was created by `PrepareDisk` and used for every copy destination, the fallback
|
||||
unattend at `Deploy\FlatUnattendW10.xml` was the one loaded, and driver selection
|
||||
matched `win11_optiplexd13mlk7020_a09.zip` by model. The decompiled detail below has
|
||||
not been re-derived from the 4.0.0.20 binary, so treat exact line-level claims as
|
||||
4.0.0.17 and the observed behaviour as current.
|
||||
|
||||
## How to re-derive this
|
||||
|
||||
The managed code is not directly readable - the outer PE has no managed
|
||||
metadata, so ILSpy refuses it. Extract `PESetup.dll` from the bundle first:
|
||||
|
||||
```python
|
||||
# .NET single-file bundle: signature is SHA-256 of ".net core bundle";
|
||||
# the int64 EIGHT BYTES BEFORE it is the bundle header offset.
|
||||
SIG = bytes([0x8b,0x12,0x02,0xb9,0x6a,0x61,0x20,0x38,
|
||||
0x72,0x7b,0x93,0x02,0x14,0xd7,0xa0,0x32])
|
||||
i = data.find(SIG)
|
||||
header = struct.unpack_from('<q', data, i - 8)[0]
|
||||
# header: uint32 major, uint32 minor, int32 count, 7-bit-prefixed bundle id,
|
||||
# then (v2+) 4 x int64 deps/runtimeconfig, then uint64 flags.
|
||||
# each entry: int64 offset, int64 size, (v6+) int64 compressedSize,
|
||||
# byte type, 7-bit-prefixed path. Compressed entries are raw
|
||||
# deflate (zlib.decompress(raw, -15)).
|
||||
```
|
||||
|
||||
Then `ilspycmd -t <TypeName> PESetup.dll`.
|
||||
|
||||
## Paths it hardcodes
|
||||
|
||||
From `PESetup.Models.GlobalSettings`. All are relative to the MEDIA drive
|
||||
(`Z:\`, whichever winpeapps share startnet mapped) unless stated.
|
||||
|
||||
```
|
||||
ControlDir \Deploy\Control
|
||||
ToolsDir \Deploy\Tools\GE
|
||||
SoftwarePackagesDir \Deploy\Applications
|
||||
HWPackagesDir \Deploy\HW_Apps
|
||||
UnattendFile \Deploy\Tools\GE\XML\FlatUnattendW10.xml
|
||||
UnattendFile2 \Deploy\FlatUnattendW10.xml (fallback)
|
||||
DiskPartDir <exe dir>\DiskPart
|
||||
ConfigSkipPackagesFile skip.json
|
||||
ConfigDisableAutoStartFile disableauto.json
|
||||
ExpirationDuration 30 days
|
||||
AutoStartCountDown 31
|
||||
MinRequiredSpaceWithoutCompression 128849018880 (120 GB)
|
||||
```
|
||||
|
||||
**`W:` IS HARDCODED, EVERYWHERE.** Not derived, not configurable, not a
|
||||
convention this project invented:
|
||||
|
||||
```csharp
|
||||
CopyPackages destDir = "W:\\" + SoftwarePackagesDir
|
||||
CopyHWPackages destDir = "W:\\" + HWPackagesDir
|
||||
CopyTools destDir = "W:\\GE"
|
||||
CopyDrivers destinationPath = "W:\\Drivers\\" + <zip name without extension>
|
||||
ApplyImage destination = "W:\\"
|
||||
CreateBCD "W:\\Windows\\System32\\bcdboot.exe", "W:\\Windows /l en-US"
|
||||
ApplyPackages DismApi.OpenOfflineSession("W:\\")
|
||||
CopyLogs tags -> "w:\\windows\\system32\\"
|
||||
```
|
||||
|
||||
PESetup's own disk preparation is what makes W: exist. Any downstream script
|
||||
that hunts for "the applied volume" is solving a problem this tool does not
|
||||
have. `diskpart list volume` on a machine mid-image shows `Volume 0 W Windows
|
||||
NTFS 237GB Healthy`.
|
||||
|
||||
## The five working steps
|
||||
|
||||
Keys as they appear in the log: `workingstep_gatherdata`, `workingstep_selectos`,
|
||||
`workingstep_prepare`, `workingstep_copy`, `workingstep_apply`,
|
||||
`workingstep_finalize`, `workingstep_reboot`.
|
||||
|
||||
### 1. GATHER DATA
|
||||
|
||||
Four operations, all automatic:
|
||||
|
||||
| Operation | What it establishes |
|
||||
|---|---|
|
||||
| `GatherDataMediaDrive` | `ImageInfo.MediaDrive`, e.g. `Z:\` |
|
||||
| `GatherDataBootDrive` | `ImageInfo.BootDrive`, always `X:\` (WinPE RAM disk) |
|
||||
| `GatherDataSelection` | BIOS version/type, serial, secure boot, OS, languages, model, **driver** |
|
||||
| `GatherDataImageDisk` | picks the physical disk and logs its partitions |
|
||||
|
||||
`GatherDataSelection` is where most decisions are made:
|
||||
|
||||
1. **Secure boot is mandatory.** `SecurebootEnabled != 1` fails the step outright.
|
||||
2. Reads `Control\LanguagePacks.json`.
|
||||
3. `SystemHelpers.GetModel()` and `GetManufacturer()` off WMI.
|
||||
4. Reads `Control\HardwareDriver.json` and `Control\hw_applications.json`.
|
||||
NOTE: it reads **HardwareDriver.json**, not the `hw_drivers.json` that also
|
||||
sits in that folder. Editing the wrong one changes nothing.
|
||||
5. **Virtual platform check.** If ANY driver entry has a manufacturer containing
|
||||
"virtual platform", the tool goes into virtual-only mode: it takes
|
||||
`list[0]` as the driver and then REQUIRES the machine to look virtual
|
||||
(model/serial/BIOS containing VIRTUAL, VMWARE, XEN, QEMU, VirtualBox, vmw).
|
||||
On real hardware that is a hard failure. A stray "virtual platform" entry in
|
||||
the catalogue therefore breaks imaging for every physical machine.
|
||||
6. Otherwise `GetDriverByModel` (below).
|
||||
7. If `Control\skip.json` exists, package installation is skipped.
|
||||
|
||||
### 2. SELECT OS
|
||||
|
||||
Operator-facing. Warns when the media holds an LTSC image ("99% of the time only
|
||||
for ShopFloor"). Also shows days-to-expiry - the media expires 30 days after
|
||||
build (`ExpirationDuration`).
|
||||
|
||||
### 3. PREPARE
|
||||
|
||||
| Operation | What it does |
|
||||
|---|---|
|
||||
| `PrepareDoDisking` | copies `<exedir>\DiskPart\diskpartEFI.txt` to `X:\diskpartEFI<n>.txt`, then `cmd /c DISKPART /S X:\diskpartEFI<n>.txt` |
|
||||
| `PrepareUnattend` | loads the unattend, substitutes, saves to `X:\Unattend.xml` |
|
||||
|
||||
`PrepareUnattend` in detail:
|
||||
|
||||
```csharp
|
||||
text = MediaDrive + UnattendFile; // \Deploy\Tools\GE\XML\FlatUnattendW10.xml
|
||||
if (!File.Exists(text)) text = MediaDrive + UnattendFile2; // \Deploy\FlatUnattendW10.xml
|
||||
xml.Load(text);
|
||||
xml.InnerXml = xml.InnerXml.Replace("%serialnumber%", imageinfo.Serialnumber);
|
||||
xml.InnerXml = xml.InnerXml.Replace("*arch*", arch);
|
||||
SetPackages(arch, xml, imageinfo); // needs Control\Packages.xml
|
||||
xml.Save("X:\\Unattend.xml");
|
||||
```
|
||||
|
||||
Two tokens are substituted: `%serialnumber%` and `*arch*`. `SetPackages` merges
|
||||
in servicing packages from `Control\Packages.xml` and `Control\PackageGroups.xml`.
|
||||
|
||||
The unattend that is USED is the one on the media at `Tools\GE\XML\` if present,
|
||||
otherwise `Deploy\FlatUnattendW10.xml`. A shopfloor variant only takes effect if
|
||||
it occupies one of those two paths.
|
||||
|
||||
### 4. COPY
|
||||
|
||||
Four operations. **None of them filters, and none of them reads the unattend.**
|
||||
|
||||
| Operation | Source | Destination | Rule |
|
||||
|---|---|---|---|
|
||||
| `CopyTools` | `Z:\Deploy\Tools\GE` | `W:\GE` | whole directory, recursive |
|
||||
| `CopyPackages` | `Z:\Deploy\Applications` | `W:\Deploy\Applications` | whole directory, recursive |
|
||||
| `CopyHWPackages` | `Z:\Deploy\HW_Apps` | `W:\Deploy\HW_Apps` | whole directory; **skipped if `IsVirtual` or `HWApps == null`** |
|
||||
| `CopyDrivers` | one `.zip` chosen by model | `W:\Drivers\<zipname>` | **unzipped**, not copied |
|
||||
|
||||
`ExecuteInternalCopy` walks `GetDirectoriesRecursive(source, includeSubDirs:
|
||||
true)` then `Directory.GetFiles(item, "*")`. Every file, every subdirectory. It
|
||||
clears the read-only attribute on each copy.
|
||||
|
||||
**Consequences worth designing around:**
|
||||
|
||||
- Anything dropped into `Deploy\Applications` on the media lands on the target.
|
||||
No manifest, no registration, no unattend reference required.
|
||||
- The copy is FAIL-FAST. One exception on one file returns false and fails the
|
||||
whole step with `copy_packages_error`. A locked or unreadable file in
|
||||
`Applications` fails imaging, it does not get skipped.
|
||||
- Progress is computed from total directory size up front, so bulky additions
|
||||
visibly lengthen this phase.
|
||||
|
||||
### 5. APPLY
|
||||
|
||||
| Operation | What it does |
|
||||
|---|---|
|
||||
| `ApplyImage` | `install.wim` from `OperatingSystem.json`'s `destinationDir`, at `wimindex`, applied to `W:\` |
|
||||
| `ApplyPackages` | DISM offline session on `W:\`, adds servicing packages; logs to `X:\ApplyOSPatch_<n>.log` |
|
||||
| `ApplyLanguagePacks` | same pattern, `X:\ApplyLanguagePack_<n>.log` |
|
||||
| `ApplyUnattend` | DISM offline session on `W:\`, applies `X:\Unattend.xml`; logs to `X:\ApplyUnattend.log` |
|
||||
| `CreateBCD` | `W:\Windows\System32\bcdboot.exe W:\Windows /l en-US` |
|
||||
| `CopyWinRE` | `reagentc /setreimage /path T:\Recovery\WindowsRE /target W:\Windows` |
|
||||
|
||||
All DISM work uses `W:\imagetemp` as scratch and deletes it afterwards.
|
||||
|
||||
### 6. FINALIZE
|
||||
|
||||
`CopyLogs`:
|
||||
- `CopyTagFiles(ToolsDir)` - every `*.tag` from the tools dir to
|
||||
**`w:\windows\system32\`**. This is how build/media tags reach the OS.
|
||||
- `CopyLogFiles("X:\\")` and `CopyXMLFile("X:\\")` - the PESetup log and the
|
||||
generated unattend are preserved.
|
||||
|
||||
## Driver selection, in full
|
||||
|
||||
This is the part most worth understanding, because a miss is nearly silent.
|
||||
|
||||
```csharp
|
||||
// GatherDataSelection
|
||||
List<HardwareDriversRootObject> list =
|
||||
JSONHelpers.ReadJSON_Driver(MediaDrive + ControlDir + "\\HardwareDriver.json");
|
||||
...
|
||||
imageinfo.HWDriver = GetDriverByModel(list, imageinfo.Model);
|
||||
if (imageinfo.HWDriver == null) {
|
||||
// status = Warning, message "driver for [MODEL] not found"
|
||||
return operationResult;
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
private static HardwareDriversRootObject? GetDriverByModel(List<...> drivers, string model)
|
||||
{
|
||||
string modelFamily = "";
|
||||
if (model.ToUpper().Contains("LATITUDE")) modelFamily = "Latitude";
|
||||
if (model.ToUpper().Contains("OPTIPLEX")) modelFamily = "Optiplex";
|
||||
if (model.ToUpper().Contains("PRECISION")) modelFamily = "Precision";
|
||||
|
||||
return drivers.Where(d => {
|
||||
if (!string.IsNullOrEmpty(modelFamily) &&
|
||||
!d.family.ToLower().Contains(modelFamily.ToLower())) return false;
|
||||
foreach (string token in d.modelswminame.Split(','))
|
||||
if (model.ToLower().Contains(token.ToLower())) return true; // SUBSTRING
|
||||
return false;
|
||||
}).ToList()?.FirstOrDefault();
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```csharp
|
||||
sourceFilePath = MediaDrive + HWDriver.destinationDir.Replace("*destinationdir*","")
|
||||
+ "\\" + HWDriver.fileName;
|
||||
destinationPath = "W:\\Drivers\\" + Path.GetFileNameWithoutExtension(sourceFilePath);
|
||||
StartUnzipAsync(...)
|
||||
```
|
||||
|
||||
### Four traps in that logic
|
||||
|
||||
1. **A miss is a WARNING, not a failure.** Imaging continues and the machine
|
||||
comes up with no drivers - no NIC, no WiFi, so DNS fails and anything
|
||||
network-dependent at first boot fails with it. Symptoms appear far from the
|
||||
cause.
|
||||
2. **Matching is substring, first match wins.** A token like `7020` matches any
|
||||
model string containing 7020. Order in the JSON decides ties.
|
||||
3. **The family filter knows only three Dell lines.** Anything else - MicroPCs,
|
||||
NUCs, non-Dell - skips the filter and depends entirely on
|
||||
`modelswminame` substrings being right.
|
||||
4. **One "virtual platform" entry hijacks the whole catalogue** (see GATHER
|
||||
DATA step 5) and fails every physical machine.
|
||||
|
||||
## What this means for startnet.cmd
|
||||
|
||||
Three beliefs encoded in `startnet.cmd` do not survive contact with the source:
|
||||
|
||||
- *"The applied volume might not be W:."* PESetup hardcodes W: in nine places
|
||||
and creates it during disk prep. The volume finder, the diskpart
|
||||
reassignment and the `W:` wait loop are machinery around a non-problem.
|
||||
- *"We must copy our payload from the enrollment share after imaging."* Anything
|
||||
in `Deploy\Applications` on the media is copied to `W:\Deploy\Applications`
|
||||
by PESetup itself, and is readable at `C:\Deploy\Applications` at first boot.
|
||||
- *"Display MicroPCs failed because the applied volume was not W:."* Worth
|
||||
re-testing. A missing `HardwareDriver.json` match produces the same
|
||||
end-state - no drivers, no network - via a completely different route, and
|
||||
that route only logs a warning.
|
||||
|
||||
## Files PESetup reads from the media
|
||||
|
||||
```
|
||||
Deploy\Control\HardwareDriver.json driver catalogue (NOT hw_drivers.json)
|
||||
Deploy\Control\hw_applications.json per-model applications
|
||||
Deploy\Control\OperatingSystem.json OS list, wim path, wimindex
|
||||
Deploy\Control\LanguagePacks.json language packs
|
||||
Deploy\Control\packages.json servicing packages
|
||||
Deploy\Control\Packages.xml unattend package merge
|
||||
Deploy\Control\PackageGroups.xml package grouping
|
||||
Deploy\Control\Media.tag media identity
|
||||
Deploy\Control\skip.json presence = skip package install
|
||||
Deploy\Control\disableauto.json presence = disable autostart
|
||||
Deploy\Tools\GE\XML\FlatUnattendW10.xml preferred unattend
|
||||
Deploy\FlatUnattendW10.xml fallback unattend
|
||||
```
|
||||
695
docs/ZERO-TOUCH-IMAGING-PROPOSAL.md
Normal file
695
docs/ZERO-TOUCH-IMAGING-PROPOSAL.md
Normal file
@@ -0,0 +1,695 @@
|
||||
# Zero-touch, ShopDB-driven imaging - proposal
|
||||
|
||||
**Status:** proposal, nothing built
|
||||
**Written:** 2026-08-06
|
||||
**Author's note:** every claim about current behaviour below is from evidence
|
||||
gathered on 2026-08-06 - decompiled binaries, live logs off bay 579C144, and the
|
||||
live share. Where something is inferred rather than observed it says so.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we want
|
||||
|
||||
Two ideas, one architecture.
|
||||
|
||||
**A. The image becomes thin.** It carries only what must happen at image time -
|
||||
partition, apply the WIM, join Entra, install the enforce client. Everything else
|
||||
(PC type, subtype, tool association, apps, settings) is data, pulled at first
|
||||
boot from the ShopDB-Flask API and enforced continuously by GE-Enforce.
|
||||
|
||||
**B. Imaging becomes a job, not a menu.** Open a machine in ShopDB, click
|
||||
*Create this PC*, pick the zeroed box that is sitting in WinPE waiting, and it
|
||||
images start to finish with nobody touching the keyboard - reporting progress
|
||||
onto that machine's record in real time.
|
||||
|
||||
Between them: no menus in WinPE, no per-type media, no decisions baked into text
|
||||
files by whoever happened to be standing at the bay.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why - what today actually costs
|
||||
|
||||
Today every decision is made in WinPE, written to files, and carried through a
|
||||
chain where each link can fail silently:
|
||||
|
||||
```
|
||||
startnet.cmd -> unattend (4 passes) -> FirstLogonCommands (18 orders)
|
||||
-> ppkg -> DSC -> GE-Enforce
|
||||
```
|
||||
|
||||
On 2026-08-06 alone, that chain produced:
|
||||
|
||||
| Failure | Root cause | How long it hid |
|
||||
|---|---|---|
|
||||
| Every build stopped at a dialog | one unattend `Path` at 676 chars against a 259 limit | ~1 day, and it masked the next two |
|
||||
| Bays staged nothing, silently | a volume scan raced PESetup's `diskpart` and staged into a partition being erased | weeks (4 Display bays sat "green") |
|
||||
| Deployment destroyed 4s after autologon | an at-logon task ran `-ManualFallback`, which is `sysprep /oobe /reboot` | until traced through Panther |
|
||||
| Machines named `E<serial>` not `F<serial>` | our script re-applied a rename over the package's own | unknown |
|
||||
| Six copies reported success having moved 0 bytes | unconditional `echo` after `robocopy` | weeks |
|
||||
|
||||
The pattern is identical in every case: **a decision made early, carried far, and
|
||||
verified nowhere.** Moving decisions to a running, networked machine with an API
|
||||
and a UI makes them recoverable - re-runnable, correctable, and observable.
|
||||
|
||||
---
|
||||
|
||||
## 3. What we already have to build on
|
||||
|
||||
This is not a greenfield project. Most of the mechanism exists.
|
||||
|
||||
### 3.1 ShopDB-Flask
|
||||
- Assets keyed by serial; CMM bays, measuring tools, machine numbers already modelled
|
||||
- Collector API: `POST /shopdb/api/collector/computers` (X-API-Key), ingests inventory by serial
|
||||
- GE-Enforce plugin: scopes, manifests, **content-addressed payload store**
|
||||
(`instance/geenforce/payloads/<sha256>`), `GET /api/geenforce/payload/<sha256>`
|
||||
- Display scope (`plugins/geenforce/seed_display_scope.py`) already proves the pattern:
|
||||
read a local subtype, prefer a **server-side role resolved by device IP**, pull
|
||||
payloads over HTTPS, write the kiosk shortcut, sweep stale ones, self-heal each cycle
|
||||
- Per-device role API: `GET /api/dashboarddefaults/display-role`
|
||||
|
||||
### 3.2 PXE server
|
||||
- Per-bay state: `/var/log/pxe-imaging/<serial>.json` with stage history
|
||||
- `winpe-status-push.ps1` already POSTs stage updates from WinPE
|
||||
- Data-driven menus already: `menu.json` on the share, rendered by
|
||||
`select-shopfloor-type.ps1`, edited by the webapp
|
||||
- Imaging dashboard with per-bay tiles
|
||||
- As of today: staging verification, imaging-log harvest to
|
||||
`enrollment/imaging-logs/<serial>/`, `preflight.ps1` at the bay,
|
||||
`preflight.py` + `lint-unattend.py` + `lint-driver-catalogue.py` +
|
||||
`share-drift.py` on the server
|
||||
|
||||
### 3.3 Hardware
|
||||
- Second NIC already present and unused: `enp0s31f6` (onboard, `e1000e`, currently down)
|
||||
- PXE LAN currently on a USB adapter, `enx34c8d6b11010`
|
||||
- That adapter was silently capped at USB 2.0 (327 Mb/s measured); moved to a
|
||||
USB 3 port on 2026-08-06 and now runs at line-rate gigabit (**937 Mb/s measured**)
|
||||
|
||||
---
|
||||
|
||||
## 4. Target architecture
|
||||
|
||||
### 4.1 Three (or one) pre-imaged types
|
||||
|
||||
Proposed: **Displays, Inspection, Shopfloor** as pre-imaged stock, with the
|
||||
subtype chosen later by the user at first boot.
|
||||
|
||||
**Open question worth settling early:** if the base image is identical and all
|
||||
configuration comes from the API, what actually differs between the three?
|
||||
Drivers are selected by PESetup per *model*, not per type. Unless disk layout or
|
||||
a must-be-offline application differs, **one image** is better - three media trees
|
||||
mean three things to keep in sync, and drift is what caused the day-long outage.
|
||||
|
||||
### 4.2 First-boot selection
|
||||
|
||||
A friendly PS1 runs when no PC type is recorded yet:
|
||||
|
||||
```
|
||||
Inspection -> CMM | Genspect | Wax Trace | Keyence
|
||||
└─ CMM / Keyence / WaxTrace -> which measuring tool?
|
||||
(list served from ShopDB assets, not a CSV)
|
||||
Displays -> Lobby | Dashboard | 3D Print Kiosk
|
||||
Shopfloor -> Collections | No Collections | Common | Heattreat | Part Marker | ...
|
||||
```
|
||||
|
||||
The tool selection is the important half: it drives PC-DMIS version, FormTracePak
|
||||
version, DODA flag, NTLARS/eDNC registry, UDC settings, machine number.
|
||||
|
||||
**The network constraint that shapes this.** ShopDB is only reachable once the
|
||||
bay has joined the **AESFMA wifi SSID** - not on the imaging LAN, and not
|
||||
necessarily at first boot. So a first-boot selection UI cannot assume the API is
|
||||
there. It must either work offline and sync later, or wait. Confirmed on
|
||||
579C144: the bay held `172.16.9.81` and `172.24.19.142`, neither in the
|
||||
production ranges, and `sync_intune` sat retrying every 30 seconds.
|
||||
|
||||
**The pattern that solves it, already built and worth reusing.**
|
||||
`Install-ShopdbKiosk-WhenOnline.ps1` is a SYSTEM task armed at imaging that does
|
||||
nothing until ShopDB answers, then acts once, verifies, and unregisters itself.
|
||||
Any step in the new architecture that needs the API - selection sync, config
|
||||
pull, enrollment completion - can use the same shape rather than assuming
|
||||
connectivity.
|
||||
|
||||
**Design points:**
|
||||
|
||||
- **Server is the source of truth.** The selection POSTs to ShopDB keyed on
|
||||
serial; the local file is a cache. A mis-selection is then fixable from a
|
||||
browser, not by walking to the bay.
|
||||
- **Re-runnable.** Available from the Start menu, shows what was chosen and what
|
||||
was installed. People pick wrong; that must not mean re-imaging.
|
||||
- **Audited.** ShopDB records who chose what and when - that is what separates
|
||||
"misconfigured" from "chose wrong" six months later.
|
||||
- **Do not require the LAPS password.** Handing a shop-floor user the local admin
|
||||
password to run a selection dialog is a real control change, and the app runs
|
||||
elevated. Prefer a SYSTEM-run UI on the logon desktop, or a kiosk shell.
|
||||
|
||||
### 4.3 Per-machine configuration records
|
||||
|
||||
Today a machine's identity is scattered:
|
||||
|
||||
| What | Where today | Size |
|
||||
|---|---|---|
|
||||
| eDNC / NTLARS registry | `_ntlars-backups/` - 147 per-bay `.reg` files | ~1.5 MB total |
|
||||
| WaxTrace bay config | `bay-config.csv` - 15 bays, 7 FTPak versions, per-bay user ID | small |
|
||||
| CMM bay config | `cmm-bay-config.csv` -> version.txt, doda.txt | small |
|
||||
| UDC settings | `pre-install/udc-backups/`, per-bay | ~240 MB per bay |
|
||||
| Machine number | `machine-number.txt` written by startnet | trivial |
|
||||
|
||||
Every bay gets **all** of it staged, then picks its own.
|
||||
|
||||
Proposed: one record per machine in ShopDB.
|
||||
|
||||
```
|
||||
ShopDB asset (key: serial and/or machine number)
|
||||
├─ pctype + subtype -> selects the GE-Enforce scope
|
||||
├─ tool association -> CMM bay, Keyence model, FTPak version, DODA
|
||||
├─ machine number -> UDC / eDNC naming
|
||||
└─ payload refs (sha256) -> NTLARS .reg, UDC backup, per-bay settings
|
||||
```
|
||||
|
||||
**Reference payloads, do not embed them.** NTLARS `.reg` files carry binary
|
||||
values and UDC backups are hundreds of MB. GE-Enforce already has a
|
||||
content-addressed blob store with an HTTP fetch endpoint - the record names the
|
||||
payload, the store holds it.
|
||||
|
||||
This also removes a live bug class. `Restore-UDCData.ps1` and
|
||||
`Update-MachineNumber.ps1` each mount SMB backup shares on their own drive
|
||||
letters, which collided with GE-Enforce's `W:` and killed the manifest entry
|
||||
running next (fixed in `66c24b5` by moving them to `R:` and `N:`). Payloads over
|
||||
HTTPS remove drive-letter juggling entirely.
|
||||
|
||||
Migration is a one-time import of two CSVs and 147 `.reg` files. A script, not a
|
||||
project.
|
||||
|
||||
---
|
||||
|
||||
## 5. Zero-touch: ShopDB-triggered imaging
|
||||
|
||||
### 5.1 Flow
|
||||
|
||||
```
|
||||
1. Zeroed PC, boot order = IPv4 only. PXE boots into WinPE.
|
||||
2. WinPE reports serial + MAC + model, then WAITS.
|
||||
3. ShopDB shows it under "awaiting assignment".
|
||||
4. Operator opens the target machine -> "Create this PC" -> picks the waiting box.
|
||||
5. WinPE polls, receives a job:
|
||||
{ pctype, subtype, tool/bay, tenant, purpose, target asset id }
|
||||
and images with NO menus.
|
||||
6. Progress streams to that machine's record in real time.
|
||||
7. On completion the PC binds to the asset record.
|
||||
```
|
||||
|
||||
Every menu in `startnet.cmd` becomes a field in the job. If no job exists, it
|
||||
falls back to today's interactive menu and displays its own serial so one can be
|
||||
created.
|
||||
|
||||
### 5.2 Safety - this is a remote-triggered disk wipe
|
||||
|
||||
Clicking a web button destroys a machine, and the target is chosen from a list.
|
||||
The nightmare is picking the wrong row.
|
||||
|
||||
**Mitigation:** the waiting PC displays a short code on screen; the job cannot be
|
||||
claimed unless that code matches what the operator confirms in ShopDB. Cheap, and
|
||||
it makes wrong-machine wipes structurally hard rather than merely unlikely.
|
||||
|
||||
Additionally: a job may only target a machine currently in the waiting state, and
|
||||
the wipe must be logged against both the operator and the asset.
|
||||
|
||||
### 5.3 Real-time record updates
|
||||
|
||||
Each phase already produces data; it simply is not joined up.
|
||||
|
||||
| Phase | Existing source | Lands on the record as |
|
||||
|---|---|---|
|
||||
| PXE boot | dnsmasq lease + WinPE check-in | serial, MAC, model, "imaging started" |
|
||||
| WIM apply | `winpe-status-push.ps1` | live stage / progress |
|
||||
| Staging | `winpe-staging.log` + harvested bundle | what payload actually landed |
|
||||
| First boot | collector API | CPU, RAM, disk, OS build |
|
||||
| Enrollment | `criticalChecks.json`, `TokenMatch.json` | Entra join state, tenant, purpose |
|
||||
| Steady state | GE-Enforce report | installed apps, drift, self-heal events |
|
||||
|
||||
End state is one timeline per machine: *imaged 14:32 -> staged
|
||||
gea-shopfloor-cmm, bay CMM4 -> enrolled GCCH_SH -> PC-DMIS 2019 R2 installed ->
|
||||
enforcing clean since*.
|
||||
|
||||
**Two rules so it does not rot:**
|
||||
|
||||
1. **Push, best-effort.** If ShopDB is unreachable the bay must still image.
|
||||
Queue events on the PXE server and drain them; the harvested logs are the
|
||||
durable local copy.
|
||||
2. **One writer per field.** Imaging status from the PXE server, inventory from
|
||||
the collector, app state from GE-Enforce. Two writers on one field is the
|
||||
exact bug that ate 2026-08-06.
|
||||
|
||||
---
|
||||
|
||||
## 6. Network design
|
||||
|
||||
### 6.1 Dual-homed PXE server
|
||||
|
||||
- **PXE side** - isolated `172.16.9.0/24`, dnsmasq DHCP bound to that adapter
|
||||
- **GE side** - corporate, reachable from anywhere; carries ShopDB API traffic,
|
||||
the job queue, status pushes, and (if wanted) the BPRT token fetch
|
||||
|
||||
The onboard `enp0s31f6` is free for this today.
|
||||
|
||||
### 6.2 Security prerequisites - these gate the whole thing
|
||||
|
||||
Checked 2026-08-06:
|
||||
|
||||
**Already safe.** dnsmasq has `listen-address=172.16.9.1` + `bind-dynamic`, so it
|
||||
binds only to the interface holding that address. DHCP will **not** leak onto a
|
||||
GE-side NIC. This was the failure I most expected and it is already handled.
|
||||
|
||||
**Not safe yet:**
|
||||
|
||||
```
|
||||
smb.conf no `interfaces =` / `bind interfaces only` -> Samba listens on ALL interfaces
|
||||
ufw every rule is ALLOW IN Anywhere, not interface-scoped:
|
||||
22/tcp SSH (account is pxe/pxe)
|
||||
445/tcp shares (pxe-upload, password in scripts)
|
||||
69/udp TFTP
|
||||
9009,9011 webapp
|
||||
```
|
||||
|
||||
The moment a GE-side interface comes up, all of that is corporate-reachable.
|
||||
|
||||
**Required before dual-homing:**
|
||||
1. Scope every UFW rule to the PXE interface (`ufw allow in on <pxe-if> ...`)
|
||||
2. `interfaces = <pxe-if>` + `bind interfaces only = yes` in `smb.conf`
|
||||
3. Expose deliberately on the GE side only what must be - realistically the
|
||||
webapp, behind real authentication
|
||||
4. Reconsider SSH credentials if the host becomes corporate-reachable
|
||||
|
||||
*(Correction to an earlier note: the shares are **not** guest-accessible -
|
||||
`guest ok = no` on every share, with `map to guest = bad user`. They require the
|
||||
`pxe-upload` account. That account's password is in scripts, so it is not a
|
||||
control once 445 is corporate-reachable.)*
|
||||
|
||||
### 6.3 What dual-homing unlocks
|
||||
|
||||
- Job queue sync and live status without the share as intermediary
|
||||
- BPRT token fetch (`mcl.dwcdn.geaerospace.com`) reachable at imaging time
|
||||
- Possibly the Entra join during imaging rather than after re-cabling - see §8.2
|
||||
|
||||
---
|
||||
|
||||
## 7. The PESetup question
|
||||
|
||||
### 7.1 Autostart does not apply to our media
|
||||
|
||||
`AutoStartCountDown = 31` exists in the decompiled settings, and
|
||||
`disableauto.json` - whose *presence* disables autostart - is absent from the
|
||||
media. On the surface that suggests a 31-second countdown, and the observed log
|
||||
is consistent with an operator simply clicking before it fired:
|
||||
|
||||
```
|
||||
11:30:07.74 'Selection View' started (LTSC warning displayed)
|
||||
11:30:18.63 Btn 'Next' pressed.
|
||||
```
|
||||
|
||||
**However:** per the team's operational experience, **LTSC builds cannot
|
||||
autostart**. The SELECT OS step warns when the media holds an LTSC image
|
||||
("99% of the time only for ShopFloor. Are you sure you want 'LTSC'?") and that
|
||||
confirmation is mandatory regardless of the countdown. Our media is
|
||||
`Windows 11 LTSC 24H2` (`OperatingSystem.json`, build 26100, id 20), so every
|
||||
image we produce hits it.
|
||||
|
||||
Consequence: **the countdown is not a route to zero-touch for us.** The 40-second
|
||||
hands-off test is still worth doing once to confirm the behaviour first-hand and
|
||||
record it - but plan on the answer being "it waits".
|
||||
|
||||
That promotes §7.2 from a contingency to the actual decision.
|
||||
|
||||
### 7.2 Re-implementation - now the likely path, and tractable
|
||||
|
||||
Nothing in the mechanism is proprietary - only the data, which we already own and
|
||||
already parse.
|
||||
|
||||
| PESetup step | Equivalent |
|
||||
|---|---|
|
||||
| `PrepareDisk` | `diskpart /s diskpartEFI.txt` |
|
||||
| `PrepareUnattend` | token substitution (`%serialnumber%`, `*arch*`) + `Packages.xml` merge |
|
||||
| `CopyTools` / `CopyPackages` / `CopyHWPackages` | robocopy |
|
||||
| `CopyDrivers` | select by model from `HardwareDriver.json`, unzip |
|
||||
| `ApplyImage` | `DISM /Apply-Image` per `OperatingSystem.json` |
|
||||
| `ApplyPackages` / `ApplyLanguagePacks` | `DISM /Add-Package` |
|
||||
| `ApplyUnattend` | `DISM /Apply-Unattend` |
|
||||
| `CreateBCD` / `CopyWinRE` | `bcdboot`, `reagentc` |
|
||||
|
||||
A few hundred lines of PowerShell. We would also get to fix the traps documented
|
||||
in `docs/PESETUP-INTERNALS.md`: substring first-match-wins driver matching, a
|
||||
family filter that knows only Latitude/OptiPlex/Precision, and a driver miss
|
||||
being a **warning** rather than a failure.
|
||||
|
||||
**The cost is ownership, not effort.** `Release.json` points at MCL auto-update
|
||||
URLs, so GE expects this media to self-update. Diverging means tracking their
|
||||
format changes indefinitely, plus a likely compliance conversation about the
|
||||
mandated imaging tool. Since the LTSC gate means autostart will not save us
|
||||
(§7.1), this is a decision to take deliberately rather than a fallback.
|
||||
|
||||
### 7.3 What we would keep either way
|
||||
|
||||
Image-time preflight stays valuable regardless: secure boot (PESetup hard-fails
|
||||
without it), >=120 GB disk (`MinRequiredSpaceWithoutCompression`), driver match
|
||||
for the model, media age (30-day expiry). Already built as
|
||||
`playbook/scripts/preflight.ps1`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Enrollment
|
||||
|
||||
### 8.1 How it works today
|
||||
|
||||
- Six MCL packages on the media: `PERS` / `SH` / `LOAN` x `GCCH` / `RoW`, plus
|
||||
the SFLD package for shopfloor
|
||||
- `Start-BulkEnrollOrchestrator.ps1` asks tenant + purpose via
|
||||
`Select-PCConfig.ps1` (a GUI), then applies the matching package
|
||||
- `Insert-BPRTToPPKG.ps1` fetches an **encrypted token table** from
|
||||
`mcl.dwcdn.geaerospace.com`, decrypts with a static passphrase
|
||||
(PBKDF2 310k / SHA-256 / AES-CBC), matches on **Tenant AND Purpose**, injects
|
||||
the token and stamps the real expiry into the filename
|
||||
(`Exp_XXXXXXXX` -> `Exp_yyyyMMdd`)
|
||||
- The package itself sets `<DNSComputerName>F%SERIAL%</DNSComputerName>` and
|
||||
performs the Entra join
|
||||
- A human then assigns the device category in Intune
|
||||
|
||||
As of 2026-08-06 the tenant/purpose choice can be pre-seeded at the PXE menu
|
||||
(`C:\Enrollment\pc-config.txt`, consumed by a shim at the path the orchestrator
|
||||
already calls). Injection deliberately stays at first logon - it needs internet.
|
||||
|
||||
### 8.2 The constraint that shapes pre-imaged stock
|
||||
|
||||
The BPRT token is stamped into the package with an expiry. A PC imaged, powered
|
||||
off, and left in a cupboard for three months has a **dead token** and will never
|
||||
join - and the failure looks identical to "not on the production network yet",
|
||||
because `Entra ID Joined: false` right after imaging is normal.
|
||||
|
||||
Three options, pick deliberately:
|
||||
|
||||
1. **Join at imaging time** - requires the production network at the bench
|
||||
(which dual-homing could provide), and pre-imaged stock is then already joined
|
||||
2. **Join at first power-on** - cleaner logistically, but stock has a shelf life
|
||||
bounded by token expiry
|
||||
3. **Re-inject on demand** - first boot detects a dead token and fetches a fresh
|
||||
one; needs the machine to reach the token blob
|
||||
|
||||
Whichever is chosen, the *silent* failure must be closed: a check that
|
||||
distinguishes "token expired" from "no network yet".
|
||||
|
||||
---
|
||||
|
||||
## 9. Ownership model
|
||||
|
||||
Four times on 2026-08-06 two systems owned one thing and the last writer won
|
||||
silently. A clean slate is the moment to fix that. See `docs/OWNERSHIP.md`.
|
||||
|
||||
| Concern | Owner | Not |
|
||||
|---|---|---|
|
||||
| Computer name | the provisioning package (`F%SERIAL%`) | any script in this repo |
|
||||
| Drive letters during imaging | PESetup (`W:`, hardcoded 9 places) | anything running `diskpart` concurrently |
|
||||
| Entra enrollment | the package + orchestrator | `run-enrollment.ps1` |
|
||||
| Kiosk / display targets | GE-Enforce scope | `site-config.json` (backstop only) |
|
||||
| Imaging status | PXE server | the collector |
|
||||
| Inventory | collector API | the PXE server |
|
||||
| Installed app state | GE-Enforce reports | anything else |
|
||||
| Job queue | **ShopDB** (owns assets) | the PXE webapp (executor only) |
|
||||
|
||||
---
|
||||
|
||||
## 9a. Design rules learned the hard way (2026-08-06)
|
||||
|
||||
A day of fixing the current pipeline produced five rules. They are cheap to
|
||||
honour in a new design and expensive to retrofit.
|
||||
|
||||
### Absent and empty are different
|
||||
|
||||
Four separate scripts tested config as
|
||||
`$null -ne $cfg -and $cfg.Count -gt 0`, so an explicitly empty list fell through
|
||||
to a hardcoded default. A Display kiosk configured with
|
||||
|
||||
```json
|
||||
"desktopApps": [], "startupItems": [], "taskbarPins": []
|
||||
```
|
||||
|
||||
was given UDC, eDNC, NTLARS, WJ Shopfloor, Defect Tracker and Plant Apps -
|
||||
**configuring "none" produced "everything"**. Plant Apps launched
|
||||
`msedge --new-window` on a screen with no keyboard.
|
||||
|
||||
Measured, not assumed: an empty JSON array round-trips as `Object[]` with
|
||||
`Count = 0`, and an absent key as `$null`. They are distinguishable; the code
|
||||
simply conflated them.
|
||||
|
||||
*Rule: in the API model, "no items" and "not configured" must be different
|
||||
values on the wire, and the default for an unrecognised or missing scope is
|
||||
NOTHING, not a fallback set.*
|
||||
|
||||
### Deny by default, always
|
||||
|
||||
`preinstall.json` had 16 of 21 entries at `PCTypes: ["*"]`, so a kiosk installed
|
||||
Adobe, OpenText, Defect Tracker, the serial drivers and twelve legacy VC++
|
||||
redistributables. The SMB manifest had 18 of 25 entries unfiltered. In both
|
||||
cases the filter worked perfectly - it simply was not applied.
|
||||
|
||||
*Rule: every payload declares which PC types it is for. An undeclared payload
|
||||
installs nowhere and fails validation, rather than installing everywhere.*
|
||||
|
||||
### Gate at the point of action, not the call site
|
||||
|
||||
`Run-ShopfloorSetup.ps1` gated both the S: mapper and the machine-number prompt
|
||||
on PC type. Both registered anyway on a Display, with no "Skipping" line in the
|
||||
log - something in the finalization phase reaches those registrars past the
|
||||
call-site gate. The fix was to gate inside each registrar.
|
||||
|
||||
*Rule: authority checks belong with the thing being done. A caller-side check is
|
||||
a convenience, never the control.*
|
||||
|
||||
### The image itself carries configuration
|
||||
|
||||
`WJ Shopfloor.lnk`, dated April, sits in the all-users Startup folder of every
|
||||
bay - **inside the WIM**. No script change removes it, and no amount of thinning
|
||||
the pipeline touches it.
|
||||
|
||||
*Rule: "thin image" must include auditing what is baked into the WIM, not just
|
||||
what the pipeline adds afterwards.*
|
||||
|
||||
### The provisioning package is a hard boundary
|
||||
|
||||
These are installed by the ppkg, not by anything PXE controls:
|
||||
|
||||
```
|
||||
Google Chrome (+2 updater tasks) RealVNC Tanium
|
||||
CyberArk EPM .NET 3.5 Report IP Sysinternals Autologon DSC
|
||||
PowerShell 7 x64 AND x86
|
||||
```
|
||||
|
||||
*Rule: thinning the image does not thin the package. Anything that must not be
|
||||
on a kiosk and comes from the ppkg needs a package change or an explicit
|
||||
post-install removal - it cannot be solved by scoping our own payloads.*
|
||||
|
||||
---
|
||||
|
||||
## 10. Phased delivery
|
||||
|
||||
Each phase is useful standing alone. No phase requires the next.
|
||||
|
||||
**Phase 0 - settle the unknowns (hours)**
|
||||
- Confirm the LTSC autostart gate first-hand (one bay, hands off, 40 seconds)
|
||||
and record it - expected to wait, per operational experience
|
||||
- Decide: re-implement the imaging step, or accept one click per build
|
||||
- Decide: one image or three
|
||||
- Decide: join at imaging time, at first power-on, or re-inject
|
||||
|
||||
**Phase 1 - visibility (small)**
|
||||
- WinPE reports serial / MAC / model on boot; "awaiting assignment" list in the webapp
|
||||
- Relay imaging status to ShopDB over the GE side
|
||||
- *Useful immediately: you can see what is booting and what it did.*
|
||||
|
||||
**Phase 2 - dual-home safely (small, gating)**
|
||||
- Interface-scope UFW, bind Samba, bring up `enp0s31f6`
|
||||
- Nothing else proceeds safely until this is done
|
||||
|
||||
**Phase 3 - job queue**
|
||||
- Job model + API in ShopDB (create / claim / complete), with the on-screen
|
||||
confirmation code
|
||||
- WinPE polls once where the menu is today; falls back to the menu if no job
|
||||
|
||||
**Phase 4 - first-boot selection**
|
||||
- Selection UI, server-backed lists, POST the choice, re-runnable
|
||||
- Retire the WinPE sub-menus for subtype and tool association
|
||||
|
||||
**Phase 5 - per-machine records**
|
||||
- Import the CSVs and `.reg` files into ShopDB with payload refs
|
||||
- Retire `bay-config.csv`, `cmm-bay-config.csv`, and staging all 147 `.reg` files
|
||||
to every bay
|
||||
|
||||
**Phase 6 - thin the image**
|
||||
- Remove per-type media (`_media/<pctype>`), the staging block, the Office ppkg
|
||||
variants, the CMM bay picker, the WaxTrace ISO cherry-pick
|
||||
- WinPE's job becomes: partition, apply, join, install enforce client, reboot
|
||||
|
||||
---
|
||||
|
||||
## 11. Risks
|
||||
|
||||
| Risk | Why it matters | Mitigation |
|
||||
|---|---|---|
|
||||
| Remote-triggered wipe hits the wrong machine | destructive, irreversible | on-screen confirmation code; job may only target a waiting machine; log against operator and asset |
|
||||
| ShopDB becomes a single point of failure | today a bay configures from the share with ShopDB down | GE-Enforce ETag / last-known-good cache; explicit failure UI, never a silent stall |
|
||||
| Dual-homing exposes the PXE server | SSH `pxe/pxe`, SMB, TFTP currently `ALLOW IN Anywhere` | Phase 2 gates everything |
|
||||
| BPRT expiry on shelf stock | silent, indistinguishable from "no network yet" | pick a join strategy (§8.2) and add an explicit expiry check |
|
||||
| Selection UI becomes the new single point of correctness | wrong subtype = wrong PC-DMIS, wrong bay config | re-runnable, server-corrected, audited |
|
||||
| Re-implementing PESetup | ongoing MCL format tracking + a likely compliance conversation | scope it against "one click per build" - zero-touch is the only thing that needs it |
|
||||
| Big payloads over the corporate network | PC-DMIS, FormTracePak ISOs (~2 GB each), Keyence | GE-Enforce supports `smb` / `http` / `inline` per entry - decide per payload |
|
||||
| Three images drift apart | drift caused the 2026-08-06 outage | prefer one image; `share-drift.py` guards what remains |
|
||||
| API unreachable at first boot | ShopDB needs AESFMA; the imaging LAN cannot reach it | wait-for-network task pattern (§4.2); never assume connectivity |
|
||||
| ppkg-delivered apps on a kiosk | Chrome, RealVNC, Tanium etc. are outside PXE control | package change or explicit post-install removal - scoping our payloads cannot fix it |
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions
|
||||
|
||||
1. One image or three? What genuinely differs between them?
|
||||
2. Join at imaging time, at first power-on, or re-inject on demand?
|
||||
3. Is the LTSC confirmation truly unskippable? (expected yes - it decides
|
||||
whether zero-touch requires re-implementing the imaging step)
|
||||
4. Is replacing the GE-mandated imaging tool acceptable, if it comes to that?
|
||||
5. Does the selection UI run as SYSTEM (no LAPS password), or as an admin user?
|
||||
6. Which payloads stay on SMB and which move to HTTPS?
|
||||
7. Who operates the job queue day to day - is "Create this PC" a technician
|
||||
action, or does it need approval?
|
||||
|
||||
---
|
||||
|
||||
## 13. Where everything referenced here lives
|
||||
|
||||
### 13.1 This repo (`/home/camp/projects/pxe`)
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `docs/PESETUP-INTERNALS.md` | decompiled imaging tool behaviour; the source for every PESetup claim above |
|
||||
| `docs/OWNERSHIP.md` | who owns what, with the 2026-08-06 collisions |
|
||||
| `playbook/startnet.cmd` | the WinPE script this proposal thins out - menus, staging, verify, log harvest |
|
||||
| `playbook/scripts/preflight.ps1` | bay-side checks (secure boot, disk, driver match, media age) |
|
||||
| `playbook/shopfloor-setup/run-enrollment.ps1` | applies the SFLD package via `provtool` |
|
||||
| `playbook/shopfloor-setup/Run-ShopfloorSetup.ps1` | post-boot setup; the enrollment-model comment is here |
|
||||
| `playbook/shopfloor-setup/Fetch-StagingPayload.ps1`, `Verify-And-Heal-Staging.ps1` | first-logon self-heal; read `C:\Enrollment\fetch-source.txt` |
|
||||
| `playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1` | the pre-seed shim (vendor GUI preserved as `-vendor.ps1` on the share) |
|
||||
| `playbook/shopfloor-setup/BIOS/{check-bios.cmd,models.txt}` | firmware matching |
|
||||
| `scripts/preflight.py` | runs every server-side lint in one command |
|
||||
| `scripts/lint-unattend.py` | schema limits: Path 259, CommandLine 1024, Description 256 |
|
||||
| `scripts/lint-driver-catalogue.py` | reimplements `GetDriverByModel` |
|
||||
| `scripts/share-drift.py` | repo vs live share, git-owned vs unreconciled |
|
||||
| `playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1` | one-pass bay diagnostics - identity, enrollment, installed apps, all four autostart surfaces, GE-Enforce reachability |
|
||||
| `scripts/build-pctype-media.py` | per-PCTYPE media views (retired by Phase 6) |
|
||||
|
||||
### 13.2 PXE server (`pxe@172.16.9.1`)
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `/srv/samba/winpeapps/<image>/Deploy/Control/` | `HardwareDriver.json`, `OperatingSystem.json`, `Media.tag`, `Release.json` |
|
||||
| `/srv/samba/winpeapps/<image>/Deploy/FlatUnattendW10.xml` | the answer file that actually boots machines |
|
||||
| `/srv/samba/winpeapps/_shared/Applications/BPRT/` | the six MCL packages, `Insert-BPRTToPPKG.ps1`, `Start-BulkEnrollOrchestrator.ps1`, `Select-PCConfig*.ps1` |
|
||||
| `/srv/samba/winpeapps/_shared/BIOS/` | firmware EXEs + `models.txt` (served as the `winpeapps_bios` share) |
|
||||
| `/srv/samba/winpeapps/_media/<pctype>/` | per-PCTYPE media views |
|
||||
| `/srv/samba/enrollment/scripts/` | `run-enrollment.ps1`, `preflight.ps1`, `winpe-status-push.ps1` |
|
||||
| `/srv/samba/enrollment/shopfloor-setup/` | per-PC-type trees, `menu.json`, `_ntlars-backups/` (147 `.reg`) |
|
||||
| `/srv/samba/enrollment/shopfloor-setup/gea-shopfloor-waxtrace/bay-config.csv` | 15 bays, FTPak versions |
|
||||
| `/srv/samba/enrollment/installers-post/cmm/cmm-bay-config.csv` | CMM bay -> version, DODA |
|
||||
| `/srv/samba/enrollment/pre-install/udc-backups/` | per-bay UDC settings |
|
||||
| `/srv/samba/enrollment/ppkgs/` | the SFLD package (8.2 GB) |
|
||||
| `/srv/samba/enrollment/imaging-logs/<serial>/` | harvested imaging logs (added 2026-08-06) |
|
||||
| `/var/www/html/win11/sources/boot.wim` | the booted WinPE image |
|
||||
| `/var/log/pxe-imaging/<serial>.json` | per-bay stage history |
|
||||
| `/opt/pxe-webapp/` | Flask webapp (unit `pxe-webapp`, :9009) |
|
||||
| `/etc/dnsmasq.conf`, `/etc/samba/smb.conf` | the binding config §6.2 depends on |
|
||||
|
||||
### 13.3 shopdb-flask (`/home/camp/projects/shopdb-flask`)
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `plugins/geenforce/seed_display_scope.py` | the pattern this proposal generalises - subtype map, server role by IP, sweep |
|
||||
| `plugins/geenforce/client/` | `ShopdbEnforceClient.psm1`, `Invoke-ShopdbEnforce.ps1` |
|
||||
| `shopdb/core/api/` | collector endpoint, dashboard-defaults display-role |
|
||||
|
||||
### 13.4 Evidence behind the claims in this document
|
||||
|
||||
| Path | What it shows |
|
||||
|---|---|
|
||||
| `/home/camp/pxe-images/now/pesetuplog.txt` | PESetup 4.0.0.20 run - media drive, disking, copy, driver selection, the `Btn 'Next'` timing in §7.1 |
|
||||
| `/home/camp/pxe-images/now/debug.txt` | the WinPE console for the failed volume-finder run |
|
||||
| `/home/camp/pxe-images/pxe2/Panther/` | `setupact.log` / `setuperr.log` naming the unattend `Path` failure |
|
||||
| `/home/camp/pxe-images/debug2/` | the autologon registry state and the sysprep-loop timeline |
|
||||
| `/home/camp/pxe-images/debug3/Logs/BPRT/` | the 13 package components, `criticalChecks.json`, `packageInfo.json` |
|
||||
| `/home/camp/pxe-images/debug3/Logs/PPKG/` | the package itself, `provisioning-sessions.json`, the diagnostics evtx |
|
||||
| `/home/camp/pxe-images/debug3/Logs/enrollment.log` | the double run and the rename race in §9 |
|
||||
| `/home/camp/pxe-images/debug3/Logs/FilteredReportIP.log` | proves the bay had no route to Entra (§8.2) |
|
||||
|
||||
### 13.5 On-bay locations (for anyone diagnosing one)
|
||||
|
||||
```
|
||||
C:\Enrollment\ staged payload, pc-type.txt, display-type.txt,
|
||||
pc-config.txt, fetch-source.txt, winpe-staging.log
|
||||
C:\Deploy\Applications\BPRT\ the packages + orchestrator, copied by PESetup
|
||||
C:\Logs\BPRT\<Component>\ per-component package logs (UTF-16)
|
||||
C:\Logs\BPRT\criticalChecks.json PPKG matched / Entra joined / OOBE execution
|
||||
C:\Logs\PPKG\ provisioning diagnostics + session state
|
||||
C:\Logs\SFLD\ DSC deployment, sync_intune, shopfloor-setup
|
||||
C:\Logs\enrollment.log run-enrollment.ps1
|
||||
C:\Windows\Panther\ setupact.log, setuperr.log, unattend.xml, PESetup*.log
|
||||
C:\Windows\Panther\UnattendGC\ oobeSystem pass
|
||||
```
|
||||
|
||||
### 13.5a Gotchas any new tooling will hit
|
||||
|
||||
- **`HardwareDriver.json` cannot be read by `ConvertFrom-Json`.** It carries both
|
||||
casings of the same fields (`fileName`/`FileName`, `destinationDir`/
|
||||
`DestinationDir`) and PowerShell rejects the document - on 5.1 *and* 7.
|
||||
`-AsHashtable` is PS6+ and WinPE runs 5.1. Extract fields by regex, or parse in
|
||||
Python where duplicate keys are tolerated.
|
||||
- **`Compress-Archive` caps at 2 GB**, and `run-enrollment` harvests the 8 GB
|
||||
provisioning package into `C:\Logs\PPKG`, so any log collection must exclude
|
||||
`*.ppkg`.
|
||||
- **Backslash-vs-forward-slash**: PowerShell Core normalises `\` to `/` on Linux,
|
||||
so `Join-Path` with Windows separators works cross-platform - handy for testing
|
||||
bay scripts on the dev box.
|
||||
|
||||
### 13.6 How to re-derive things
|
||||
|
||||
```bash
|
||||
# read a file off the share
|
||||
sshpass -p pxe ssh -o StrictHostKeyChecking=no pxe@172.16.9.1 'cat <path>'
|
||||
smbclient //172.16.9.1/enrollment -U pxe-upload%pxe -c 'ls'
|
||||
|
||||
# what is actually inside the booted WinPE
|
||||
sudo wimextract /var/www/html/win11/sources/boot.wim 1 \
|
||||
/Windows/System32/startnet.cmd --dest-dir=/tmp/x
|
||||
sudo wimdir /var/www/html/win11/sources/boot.wim 1 | grep -i <name>
|
||||
|
||||
# a .ppkg is a WIM - pull out the spec without extracting 8 GB
|
||||
7z l <pkg>.ppkg
|
||||
7z e <pkg>.ppkg -o<dir> "Multivariant/0/customizations.xml" \
|
||||
"Multivariant/0/Prov/RunTime/0__Accounts_Azure.provxml"
|
||||
|
||||
# provisioning diagnostics (python-evtx is installed)
|
||||
python3 -c "from Evtx.Evtx import Evtx;
|
||||
[print(r.xml()) for r in Evtx('Provisioning-Diagnostics-Admin.evtx').records()]"
|
||||
|
||||
# decompile PESetup - the bundle-extraction recipe is in PESETUP-INTERNALS.md
|
||||
ilspycmd -t <TypeName> PESetup.dll
|
||||
|
||||
# validate before deploying
|
||||
./scripts/preflight.py # every server-side lint
|
||||
./scripts/share-drift.py --diff # repo vs live
|
||||
```
|
||||
|
||||
### 13.7 The 2026-08-06 commits this document draws on
|
||||
|
||||
```
|
||||
dfa026b lint-driver-catalogue c7b1769 build-pctype-media
|
||||
049dd7b startnet per-PCTYPE media d5f7abc BIOS 7020 family
|
||||
385acfe lint-unattend 2b6189f startnet W: wait + honest reporting
|
||||
d04865d PESetup version + LogonCount b96ff7e retire shopfloor-dashboard route
|
||||
36be60e run-enrollment stops renaming 8c21282 staging verify + log harvest
|
||||
d2200e8 preflight (bay + server) 68df59e OWNERSHIP.md + share-drift
|
||||
9d51c0b purpose menu + 3D kiosk a0aceb4 PXESERVER consolidation
|
||||
bb08392 download-drivers case fix
|
||||
```
|
||||
@@ -136,7 +136,7 @@
|
||||
</Password>
|
||||
<Enabled>true</Enabled>
|
||||
<Username>SupportUser</Username>
|
||||
<LogonCount>7</LogonCount>
|
||||
<LogonCount>12</LogonCount>
|
||||
</AutoLogon>
|
||||
<FirstLogonCommands>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
@@ -156,36 +156,41 @@
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>4</Order>
|
||||
<CommandLine>powershell.exe -ep Bypass -Command "net use Z: \\172.16.9.1\enrollment /user:pxe-upload pxe; robocopy Z:\shopfloor-setup C:\Enrollment Fetch-StagingPayload.ps1 Verify-And-Heal-Staging.ps1 /R:2 /W:2; robocopy Z:\scripts C:\Enrollment run-enrollment.ps1 wait-for-internet.ps1 migrate-to-wifi.ps1 /R:2 /W:2"</CommandLine>
|
||||
<Description>Bootstrap self-heal: pull Fetch/Verify-Heal + enrollment scripts from the PXE share so Order 5/6 run after an early WinPE staging failure.</Description>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>5</Order>
|
||||
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Fetch-StagingPayload.ps1"</CommandLine>
|
||||
<Description>Fetch bulk staging (shopfloor-setup tree + preinstall bundle) from the PXE share on a fresh mount, BEFORE the production-network switch takes the bay off the imaging LAN. Detailed log at C:\Logs\Fetch\.</Description>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>5</Order>
|
||||
<Order>6</Order>
|
||||
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Verify-And-Heal-Staging.ps1"</CommandLine>
|
||||
<Description>Verify every imaging payload arrived and re-pull anything missing from the PXE share (incl the CMM bundle + selected-bay backup) while still on the imaging LAN, BEFORE wait-for-internet switches the bay to the production network. Log at C:\Logs\Fetch\.</Description>
|
||||
<Description>Verify + re-pull any missing imaging payload from the PXE share (CMM/Keyence/WaxTrace bundles + bay backup) on the imaging LAN before the production-network switch. Log C:\Logs\Fetch.</Description>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>6</Order>
|
||||
<Order>7</Order>
|
||||
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine>
|
||||
<Description>Prompt to connect production network then wait for TCP 443 connectivity</Description>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>7</Order>
|
||||
<Order>8</Order>
|
||||
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine>
|
||||
<Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>8</Order>
|
||||
<Order>9</Order>
|
||||
<CommandLine>msiexec.exe /i "C:\PreInstall\installers\powershell7\PowerShell-7.5.4-win-x64.msi" /qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1</CommandLine>
|
||||
<Description>Install PowerShell 7 BEFORE PPKG so Intune SetupCredentials Win32App finds pwsh.exe (race fix)</Description>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>9</Order>
|
||||
<Order>10</Order>
|
||||
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"</CommandLine>
|
||||
<Description>Run GCCH Enrollment</Description>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>10</Order>
|
||||
<Order>11</Order>
|
||||
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine>
|
||||
<Description>Run shopfloor PC type setup</Description>
|
||||
</SynchronousCommand>
|
||||
|
||||
@@ -1,307 +1,302 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend"
|
||||
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
|
||||
<!-- 1. windowsPE is intentionally empty -->
|
||||
<settings pass="windowsPE" />
|
||||
|
||||
<!-- 2. Offline servicing (drivers) -->
|
||||
<settings pass="offlineServicing">
|
||||
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<DriverPaths>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
|
||||
<Path>W:\Drivers</Path>
|
||||
</PathAndCredentials>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
|
||||
<Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path>
|
||||
</PathAndCredentials>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
|
||||
<Path>W:\Deploy\Applications\extra\printdrivers\HP</Path>
|
||||
</PathAndCredentials>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
|
||||
<Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path>
|
||||
</PathAndCredentials>
|
||||
</DriverPaths>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<!-- 3. specialize: computer naming + RunSynchronous all in ONE component -->
|
||||
<settings pass="specialize">
|
||||
<!-- 3a. Shell-Setup for naming/owner/org -->
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<ComputerName>H%serialnumber%</ComputerName>
|
||||
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
|
||||
<RegisteredOwner>GE</RegisteredOwner>
|
||||
<TimeZone>Eastern Standard Time</TimeZone>
|
||||
</component>
|
||||
|
||||
<!-- 3b. RunSynchronous for all of your installers, copies, etc. -->
|
||||
<component name="Microsoft-Windows-Deployment"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<RunSynchronous>
|
||||
<!-- EAP-PEAP MSI -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Path>msiexec /i "C:\Deploy\Applications\extra\wireless\EAP-PEAP.msi" /quiet /norestart</Path>
|
||||
<Description>Install EAP-PEAP</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Wi-Fi profiles -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\BLUESSO.xml" user=all</Path>
|
||||
<Description>Add BLUESSO WiFi profile</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
|
||||
<Description>Add generic WiFi profile</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Certificates -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>4</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path>
|
||||
<Description>Install External Root Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>5</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path>
|
||||
<Description>Install External Intermediate Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>6</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path>
|
||||
<Description>Install Enterprise Root Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>7</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install Enterprise Device Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>8</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install Enterprise Server Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>9</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install SmartCard Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>10</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install User Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>11</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path>
|
||||
<Description>Install Aerospace Enterprise Root CA</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>12</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path>
|
||||
<Description>Install Zscaler Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Fonts -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>13</Order>
|
||||
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path>
|
||||
<Description>Copy 3OF9 Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>14</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path>
|
||||
<Description>Register 3OF9 Barcode Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>15</Order>
|
||||
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path>
|
||||
<Description>Copy Code39 Azalea Narrow Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>16</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path>
|
||||
<Description>Register Code39 Azalea Narrow Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>17</Order>
|
||||
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path>
|
||||
<Description>Copy Code39 Azalea Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>18</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path>
|
||||
<Description>Register Code39 Azalea Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- OpenText installers -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>19</Order>
|
||||
<Path>C:\Deploy\Applications\extra\opentext\opentext_hostexplorer_sp1_15.0_v01.exe /quiet /norestart</Path>
|
||||
<Description>Install OpenText HostExplorer SP1</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>20</Order>
|
||||
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
|
||||
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>21</Order>
|
||||
<Path>C:\Deploy\Applications\extra\opentext\unattended.bat</Path>
|
||||
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Adobe -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>22</Order>
|
||||
<Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path>
|
||||
<Description>Install Adobe</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>23</Order>
|
||||
<Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path>
|
||||
<Description>Apply Adobe Reader Update</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Java JRE 8 Enterprise Installation -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>26</Order>
|
||||
<Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path>
|
||||
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Updates via Registry (64-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>27</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Updates (64-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Updates via Registry (32-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>28</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Updates (32-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Update Check (64-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>29</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Update Check (64-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Update Check (32-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>30</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Update Check (32-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Cortana -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>31</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Cortana</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Web Search in Start Menu -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>33</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path>
|
||||
<Description>Disable Web Search in Start Menu</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Bing / web search suggestions in Start menu.
|
||||
BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search
|
||||
(not HKLM), so the old HKLM policy placement was a no-op. The current documented
|
||||
suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer.
|
||||
Writing it into the Default User hive so every newly-created account inherits it. -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>34</Order>
|
||||
<Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT & reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f & reg unload HKU\TempDU</Path>
|
||||
<Description>Disable Bing/web search suggestions (Default User hive)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Search Web when searching Windows -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>35</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Connected Search Use Web</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Cortana Speech Recognition -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>36</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Cortana Speech Recognition</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Search Highlights (news/trending) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>38</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Search Highlights</Description>
|
||||
</RunSynchronousCommand>
|
||||
</RunSynchronous>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<!-- 4. oobeSystem: hide OEM/EULA screens -->
|
||||
<settings pass="oobeSystem">
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<OOBE>
|
||||
<HideEULAPage>true</HideEULAPage>
|
||||
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
|
||||
<HideOnlineAccountScreens>false</HideOnlineAccountScreens>
|
||||
<HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE>
|
||||
<HideLocalAccountScreen>true</HideLocalAccountScreen>
|
||||
<NetworkLocation>Work</NetworkLocation>
|
||||
<ProtectYourPC>3</ProtectYourPC>
|
||||
<SkipUserOOBE>false</SkipUserOOBE>
|
||||
<SkipMachineOOBE>false</SkipMachineOOBE>
|
||||
</OOBE>
|
||||
<FirstLogonCommands>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<CommandLine>shutdown -a</CommandLine>
|
||||
<Description>Cancel any scheduled shutdown from Office installation</Description>
|
||||
</SynchronousCommand>
|
||||
|
||||
<!-- Install Zscaler -->
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<CommandLine>C:\Deploy\Applications\extra\zscaler\zscaler.bat</CommandLine>
|
||||
<Description>Install Zscaler Client Connector</Description>
|
||||
</SynchronousCommand>
|
||||
|
||||
<!-- Install Microsoft Office -->
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<CommandLine>cmd /c "cd /d C:\Deploy\Applications\extra\office\ && install.bat"</CommandLine>
|
||||
<Description>Install Microsoft Office</Description>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
</unattend>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend"
|
||||
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
|
||||
<!-- 1. windowsPE is intentionally empty -->
|
||||
<settings pass="windowsPE" />
|
||||
|
||||
<!-- 2. Offline servicing (drivers) -->
|
||||
<settings pass="offlineServicing">
|
||||
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<DriverPaths>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
|
||||
<Path>W:\Drivers</Path>
|
||||
</PathAndCredentials>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
|
||||
<Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path>
|
||||
</PathAndCredentials>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
|
||||
<Path>W:\Deploy\Applications\extra\printdrivers\HP</Path>
|
||||
</PathAndCredentials>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
|
||||
<Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path>
|
||||
</PathAndCredentials>
|
||||
</DriverPaths>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<!-- 3. specialize: computer naming + RunSynchronous all in ONE component -->
|
||||
<settings pass="specialize">
|
||||
<!-- 3a. Shell-Setup for naming/owner/org -->
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<ComputerName>H%serialnumber%</ComputerName>
|
||||
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
|
||||
<RegisteredOwner>GE</RegisteredOwner>
|
||||
<TimeZone>Eastern Standard Time</TimeZone>
|
||||
</component>
|
||||
|
||||
<!-- 3b. RunSynchronous for all of your installers, copies, etc. -->
|
||||
<component name="Microsoft-Windows-Deployment"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<RunSynchronous>
|
||||
<!-- EAP-PEAP MSI -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Path>msiexec /i "C:\Deploy\Applications\extra\wireless\EAP-PEAP.msi" /quiet /norestart</Path>
|
||||
<Description>Install EAP-PEAP</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Wi-Fi profiles -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\BLUESSO.xml" user=all</Path>
|
||||
<Description>Add BLUESSO WiFi profile</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
|
||||
<Description>Add generic WiFi profile</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Certificates -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>4</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path>
|
||||
<Description>Install External Root Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>5</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path>
|
||||
<Description>Install External Intermediate Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>6</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path>
|
||||
<Description>Install Enterprise Root Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>7</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install Enterprise Device Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>8</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install Enterprise Server Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>9</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install SmartCard Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>10</Order>
|
||||
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path>
|
||||
<Description>Install User Issuing Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>11</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path>
|
||||
<Description>Install Aerospace Enterprise Root CA</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>12</Order>
|
||||
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path>
|
||||
<Description>Install Zscaler Certificate</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Fonts -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>13</Order>
|
||||
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path>
|
||||
<Description>Copy 3OF9 Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>14</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path>
|
||||
<Description>Register 3OF9 Barcode Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>15</Order>
|
||||
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path>
|
||||
<Description>Copy Code39 Azalea Narrow Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>16</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path>
|
||||
<Description>Register Code39 Azalea Narrow Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>17</Order>
|
||||
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path>
|
||||
<Description>Copy Code39 Azalea Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>18</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path>
|
||||
<Description>Register Code39 Azalea Font</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- OpenText installers -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>19</Order>
|
||||
<Path>cmd /c "C:\Deploy\Applications\extra\opentext\Setup-OpenText.cmd"</Path>
|
||||
<Description>Install OpenText HostExplorer SP1 (shopfloor method)</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>20</Order>
|
||||
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
|
||||
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Adobe -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>22</Order>
|
||||
<Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path>
|
||||
<Description>Install Adobe</Description>
|
||||
</RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>23</Order>
|
||||
<Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path>
|
||||
<Description>Apply Adobe Reader Update</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Java JRE 8 Enterprise Installation -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>26</Order>
|
||||
<Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path>
|
||||
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Updates via Registry (64-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>27</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Updates (64-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Updates via Registry (32-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>28</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Updates (32-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Update Check (64-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>29</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Update Check (64-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Java Auto Update Check (32-bit) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>30</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Java Auto Update Check (32-bit)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Cortana -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>31</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Cortana</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Web Search in Start Menu -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>33</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path>
|
||||
<Description>Disable Web Search in Start Menu</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Bing / web search suggestions in Start menu.
|
||||
BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search
|
||||
(not HKLM), so the old HKLM policy placement was a no-op. The current documented
|
||||
suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer.
|
||||
Writing it into the Default User hive so every newly-created account inherits it. -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>34</Order>
|
||||
<Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT & reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f & reg unload HKU\TempDU</Path>
|
||||
<Description>Disable Bing/web search suggestions (Default User hive)</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Search Web when searching Windows -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>35</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Connected Search Use Web</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Cortana Speech Recognition -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>36</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Cortana Speech Recognition</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Disable Search Highlights (news/trending) -->
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>38</Order>
|
||||
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path>
|
||||
<Description>Disable Search Highlights</Description>
|
||||
</RunSynchronousCommand>
|
||||
</RunSynchronous>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<!-- 4. oobeSystem: hide OEM/EULA screens -->
|
||||
<settings pass="oobeSystem">
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<OOBE>
|
||||
<HideEULAPage>true</HideEULAPage>
|
||||
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
|
||||
<HideOnlineAccountScreens>false</HideOnlineAccountScreens>
|
||||
<HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE>
|
||||
<HideLocalAccountScreen>true</HideLocalAccountScreen>
|
||||
<NetworkLocation>Work</NetworkLocation>
|
||||
<ProtectYourPC>3</ProtectYourPC>
|
||||
<SkipUserOOBE>false</SkipUserOOBE>
|
||||
<SkipMachineOOBE>false</SkipMachineOOBE>
|
||||
</OOBE>
|
||||
<FirstLogonCommands>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<CommandLine>shutdown -a</CommandLine>
|
||||
<Description>Cancel any scheduled shutdown from Office installation</Description>
|
||||
</SynchronousCommand>
|
||||
|
||||
<!-- Install Zscaler -->
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<CommandLine>C:\Deploy\Applications\extra\zscaler\zscaler.bat</CommandLine>
|
||||
<Description>Install Zscaler Client Connector</Description>
|
||||
</SynchronousCommand>
|
||||
|
||||
<!-- Install Microsoft Office -->
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<CommandLine>cmd /c "cd /d C:\Deploy\Applications\extra\office\ && install.bat"</CommandLine>
|
||||
<Description>Install Microsoft Office</Description>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
</unattend>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{9BE518E6-ECC6-35A9-88E4-87755C07200F}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2010 x86 - same fix as 2008. Bootstrapper ignores /norestart; extracted MSI with REBOOT=ReallySuppress does not.",
|
||||
@@ -44,7 +44,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F0C3E5D1-1ADE-321E-8167-68EF0DE699A5}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2012 x86 Minimum Runtime - extracted from vcredist2012_x86.exe Burn bundle. Same REBOOT=ReallySuppress fix.",
|
||||
@@ -54,7 +54,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BD95A8CD-1D9F-35AD-981A-3E7925026EBB}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"Name": "VC++ Redistributable 2012 x86 (Additional)",
|
||||
@@ -63,7 +63,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{B175520C-86A2-35A7-8619-86DC379688B9}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2013 x86 Minimum Runtime - extracted from vcredist2013_x86.exe Burn bundle.",
|
||||
@@ -73,7 +73,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{13A4EE12-23EA-3371-91EE-EFB36DDFFF3E}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"Name": "VC++ Redistributable 2013 x86 (Additional)",
|
||||
@@ -82,7 +82,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F8CFEB22-A2E7-3971-9EDA-4B11EDEFC185}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2013 x64 Minimum Runtime - required by Keyence VR-6000 Series Software. Extracted from the Keyence installer's Windows Installer cache. Same REBOOT=ReallySuppress pattern as the x86 variants.",
|
||||
@@ -92,7 +92,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{A749D8E6-B613-3BE3-8F5F-045C84EBA29B}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2013 x64 Additional Runtime - required by Keyence VR-6000 Series Software. Pairs with the Minimum Runtime above.",
|
||||
@@ -102,7 +102,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{929FBD26-9020-399B-9A7A-751D61F0B942}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2010 x64 - required by PC-DMIS 2016/2019 R2 on CMM PCs. PCDLRN.exe links against msvcr100.dll and the VS 2010 MFC DLLs which are only provided by this redistributable. Extracted from the PC-DMIS 2016 bundle's attached container (a1 payload). Silent install: /q /norestart. Detection: Uninstall key under the native x64 hive with fixed product GUID.",
|
||||
@@ -114,7 +114,7 @@
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1D8E6291-B0D5-35EC-8441-6616F567A0F7}",
|
||||
"DetectionName": "DisplayVersion",
|
||||
"DetectionValue": "10.0.40219",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2012 x64 - required by PC-DMIS 2016/2019 R2. Same rationale as 2010 x64; PC-DMIS links against msvcr110.dll / MFC110. Extracted from the PC-DMIS 2016 bundle's attached container (a2 payload). Detection on the Minimum Runtime GUID (the main redist wrapper installs both Minimum and Additional sub-packages).",
|
||||
@@ -126,7 +126,7 @@
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{5AF4E09F-5C9B-3AAF-B731-544D3DC821DD}",
|
||||
"DetectionName": "DisplayVersion",
|
||||
"DetectionValue": "11.0.51106",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2015-2022 x86 - extracted from vcredist2015_2017_2019_2022_x86.exe Burn bundle. The bundle contains 2022 14.44.35211 plus 8 chained KB updates for older 2015/2017/2019 releases. We install only the 2022 Min+Add MSIs - the CRT v140 ABI is shared across 2015/2017/2019/2022, so the latest pair covers all four versions on Windows 10/11.",
|
||||
@@ -136,7 +136,7 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{922480B5-CAEB-4B1B-AAA4-9716EFDCE26B}",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"Name": "VC++ Redistributable 2022 x86 (Additional)",
|
||||
@@ -145,6 +145,16 @@
|
||||
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{C18FB403-1E88-43C8-AD8A-CED50F23DE8B}",
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "VC++ 2015-2022 x64 - required by PC-DMIS 2026 (PCDLRN.exe links vcruntime140_1.dll, added in VC++ 2019 / v14.20). The x64 redist line above previously stopped at 2013, so 64-bit PC-DMIS 2026 was missing its runtime and PCDLRN.exe failed to start with 'vcruntime140_1.dll was not found'. The patched PC-DMIS MSI bypasses Hexagon's Burn bundle, which is what would otherwise have pulled this in. Full vc_redist.x64.exe (14.44.35211, 2015-2022 shared CRT). Modern bootstrapper honors /norestart. Detect on the DLL itself so it is version-independent.",
|
||||
"Name": "VC++ Redistributable 2015-2022 x64",
|
||||
"Installer": "vcredist/2022-x64/vc_redist.x64.exe",
|
||||
"Type": "EXE",
|
||||
"InstallArgs": "/install /quiet /norestart",
|
||||
"DetectionMethod": "File",
|
||||
"DetectionPath": "C:\\Windows\\System32\\vcruntime140_1.dll",
|
||||
"PCTypes": ["*"]
|
||||
},
|
||||
{
|
||||
@@ -155,7 +165,7 @@
|
||||
"InstallArgs": "",
|
||||
"DetectionMethod": "File",
|
||||
"DetectionPath": "C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroRd32.exe",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "OpenText HostExplorer 15 SP1 ShopFloor - replaced the WJDT-built OpenText.exe Inno wrapper with our own Setup-OpenText.ps1 that does the same install steps (msiexec base + ShopFloor.mst transform + SP1 .msp patch) PLUS fans the per-user profile/keymap/menu/macro content out to Default User and every existing user profile. The Inno wrapper deployed per-user content to {userappdata} which resolves to whichever user is running the installer (SYSTEM under DSC, single user under PreInstall) so Azure-AD users never saw the profiles. Setup-OpenText.cmd is a tiny launcher that hands off to Setup-OpenText.ps1 because the runner only handles MSI/EXE types. All bundled files live in vcredist-style subtree at dependencies/opentext/ and get xcopied through WinPE staging. NO DetectionMethod here on purpose - Setup-OpenText.ps1 owns its own version check by reading version.txt next to itself and comparing to HKLM:\\SOFTWARE\\GE\\OpenText\\Installed; the runner always invokes the wrapper (cost: ~1s of PowerShell launch when up-to-date) so the version constant lives in exactly one place: dependencies/opentext/version.txt.",
|
||||
@@ -164,7 +174,7 @@
|
||||
"Type": "EXE",
|
||||
"InstallArgs": "",
|
||||
"LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "UDC_Setup.exe spawns a hidden WPF window (UDC.exe) after install and never exits, so the runner needs KillAfterDetection: true to terminate UDC_Setup.exe + UDC.exe once the registry detection passes. This is an OPT-IN flag - normal installers should NOT set it because killing msiexec mid-install leaves msiserver holding the install mutex and the next msiexec call returns 1618 (Oracle hit this exact bug).",
|
||||
@@ -196,7 +206,7 @@
|
||||
"InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress TARGETDIR=\"C:\\Program Files (x86)\\WJF_Defect_Tracker\"",
|
||||
"DetectionMethod": "File",
|
||||
"DetectionPath": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
},
|
||||
{
|
||||
"_comment": "Shopfloor Standard serial-port drivers: StarTech PCIe serial adapter (MosChip-based) + Prolific PL2303 USB-to-serial. Install-Drivers.cmd runs pnputil /add-driver with /subdirs /install so every bundled INF under drivers/ lands in the Windows driver store and auto-binds to matching hardware present now or plugged in later. Installed on every PC type (PCTypes ['*']) because serial hardware turns up across bays; a serial driver on a PC without the hardware is harmless - it just sits in the driver store until matching hardware is plugged in.",
|
||||
@@ -207,7 +217,7 @@
|
||||
"LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log",
|
||||
"DetectionMethod": "File",
|
||||
"DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker",
|
||||
"PCTypes": ["*"]
|
||||
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,10 +38,6 @@
|
||||
- gea-standard
|
||||
- gea-engineer
|
||||
- gea-shopfloor
|
||||
- ge-standard
|
||||
- ge-engineer
|
||||
- ge-shopfloor-lockdown
|
||||
- ge-shopfloor-mce
|
||||
shopfloor_types:
|
||||
- gea-shopfloor
|
||||
# Image variants that share the generic Win10/Win11 unattend
|
||||
@@ -50,8 +46,6 @@
|
||||
standard_types:
|
||||
- gea-standard
|
||||
- gea-engineer
|
||||
- ge-standard
|
||||
- ge-engineer
|
||||
deploy_subdirs:
|
||||
- Applications
|
||||
- Control
|
||||
@@ -145,8 +139,8 @@
|
||||
backup: yes
|
||||
content: |
|
||||
port=0
|
||||
interface={{ pxe_iface }}
|
||||
bind-interfaces
|
||||
listen-address=172.16.9.1
|
||||
bind-dynamic
|
||||
dhcp-range=172.16.9.10,172.16.9.100,12h
|
||||
# No default gateway (option 3) and no DNS (option 6) handed out:
|
||||
# the PXE network is isolated and the PXE server does not forward
|
||||
@@ -169,14 +163,18 @@
|
||||
dhcp-option=6
|
||||
enable-tftp
|
||||
tftp-root={{ tftp_dir }}
|
||||
# Arch-aware NBP: legacy BIOS PXE ROMs (client-arch=0) cannot run
|
||||
# the EFI iPXE binary and report "NBP is too big to fit in free
|
||||
# base memory" because ipxe.efi (~675KB) exceeds the BIOS PXE
|
||||
# NBP cap. Serve undionly.kpxe (~70KB, BIOS-mode iPXE) to them
|
||||
# instead. Everything else (UEFI x86_64 = arch 7 or 9, plus any
|
||||
# future arches) keeps getting ipxe.efi - default-safe.
|
||||
dhcp-match=set:bios,option:client-arch,0
|
||||
dhcp-boot=tag:bios,undionly.kpxe
|
||||
# Arch-aware NBP for Blancco/WinPE PXE clients:
|
||||
# - legacy BIOS (vendorclass PXEClient:Arch:00000) -> ipxe.pxe, a
|
||||
# BIOS full-feature iPXE build (boot.ipxe.org) with an HTTP/TCP
|
||||
# stack. The old undionly.kpxe client-arch=0 scheme was stale -
|
||||
# undionly.kpxe was never staged in tftp-root so it broke BIOS
|
||||
# boot; this vendorclass+ipxe.pxe scheme is what runs on the box.
|
||||
# - iPXE-running clients (userclass "iPXE") -> chain to the HTTP menu
|
||||
# - everything else (UEFI x86_64) -> ipxe.efi
|
||||
dhcp-vendorclass=set:bios,PXEClient:Arch:00000
|
||||
dhcp-userclass=set:ipxe,iPXE
|
||||
dhcp-boot=tag:bios,tag:!ipxe,ipxe.pxe
|
||||
dhcp-boot=tag:ipxe,http://172.16.9.1:4433/Altiris/iPXE/GetPxeScript.aspx
|
||||
dhcp-boot=tag:!bios,ipxe.efi
|
||||
log-dhcp
|
||||
# Per-lease state cleanup: flush conntrack + port-445 sockets for
|
||||
@@ -265,6 +263,30 @@
|
||||
boot
|
||||
|
||||
:blancco
|
||||
iseq ${platform} pcbios && goto blancco_bios || goto blancco_efi
|
||||
|
||||
:blancco_bios
|
||||
echo Loading Blancco for legacy BIOS (canonical)...
|
||||
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initramfs-bde-linux.img,config.img,intel-ucode.img,amd-ucode.img archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10 systemd.battery-check=0 || goto blancco_bios_failed
|
||||
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_bios_failed
|
||||
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_bios_failed
|
||||
initrd --name config.img http://${server}/blancco/config.img || goto blancco_bios_failed
|
||||
initrd --name initramfs-bde-linux.img http://${server}/blancco/initramfs-bde-linux.img || goto blancco_bios_failed
|
||||
boot
|
||||
:blancco_bios_failed
|
||||
echo
|
||||
echo Blancco BIOS boot FAILED.
|
||||
prompt Press any key to return to menu... && goto menu
|
||||
|
||||
:blancco_efi
|
||||
echo Loading Blancco UEFI (direct kernel)...
|
||||
kernel http://${server}/blancco/vmlinuz-ubuntu initrd=kexec-initrd.img ip=dhcp nomodeset vga=normal console=tty0 i915.modeset=0 nouveau.modeset=0 modprobe.blacklist=nouveau,nvidiafb,nvidia,nvidia_drm,mei_me,btusb,dcdbas libata.allow_tpm=1 rd.udev.timeout=10 efi=noruntime usbcore.autosuspend=-1 pcie_aspm=off e1000e.IntMode=0 e1000e.eee_enable=0 consoleblank=0 systemd.battery-check=0 systemd.mask=suspend.target,sleep.target,hibernate.target,hybrid-sleep.target no_console_suspend mem_sleep_default=s2idle || goto blancco_efi_grub
|
||||
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_efi_grub
|
||||
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_efi_grub
|
||||
initrd --name kexec-initrd.img http://${server}/blancco/kexec-initrd.img || goto blancco_efi_grub
|
||||
boot
|
||||
:blancco_efi_grub
|
||||
echo Direct kernel boot failed - trying GRUB chain fallback...
|
||||
chain http://${server}/blancco/grubx64.efi || goto secureboot_warn
|
||||
|
||||
:memtest
|
||||
@@ -503,6 +525,14 @@
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: "Ensure driver store is owned by pxe (download-drivers.py pushes here over ssh as the pxe user; root/webapp still write it fine)"
|
||||
file:
|
||||
path: "/srv/samba/winpeapps/_shared/Out-of-box Drivers"
|
||||
state: directory
|
||||
owner: pxe
|
||||
group: pxe
|
||||
mode: '0775'
|
||||
|
||||
- name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/"
|
||||
# Path matches what startnet.cmd reads at WinPE boot:
|
||||
# net use B: \\172.16.9.1\winpeapps\_shared
|
||||
@@ -555,6 +585,10 @@
|
||||
follow symlinks = yes
|
||||
wide links = yes
|
||||
unix extensions = no
|
||||
# Modern Samba silently disables wide links WITHOUT this. The shared
|
||||
# image dirs are served via symlinks into _shared, so it is required
|
||||
# or WinPE clients cannot follow them.
|
||||
allow insecure wide links = yes
|
||||
|
||||
- name: "Samba SMB session handling for WinPE re-image robustness"
|
||||
blockinfile:
|
||||
@@ -630,6 +664,14 @@
|
||||
level2 oplocks = no
|
||||
strict sync = yes
|
||||
|
||||
[winpeapps_bios]
|
||||
path = /srv/samba/winpeapps/_shared/BIOS
|
||||
browseable = no
|
||||
read only = yes
|
||||
guest ok = no
|
||||
valid users = pxe-upload
|
||||
comment = Dell BIOS update bundle (read-only, served to WinPE)
|
||||
|
||||
- name: "Create Samba users (pxe-upload and blancco)"
|
||||
shell: |
|
||||
id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload
|
||||
|
||||
170
playbook/scripts/preflight.ps1
Executable file
170
playbook/scripts/preflight.ps1
Executable file
@@ -0,0 +1,170 @@
|
||||
# preflight.ps1 - check the things PESetup fails on, before it fails on them.
|
||||
#
|
||||
# Called by startnet.cmd once the media is mapped. Prints a short report a tech
|
||||
# can read at the bay and exits non-zero if a check is fatal.
|
||||
#
|
||||
# WHY EACH CHECK IS HERE - all four come from PESetup's own behaviour
|
||||
# (docs/PESETUP-INTERNALS.md, decompiled 4.0.0.17, observed on 4.0.0.20):
|
||||
#
|
||||
# Secure boot GatherDataSelection fails the step outright when
|
||||
# SecurebootEnabled != 1. Hard failure, minutes into a build.
|
||||
# Disk size MinRequiredSpaceWithoutCompression is 128849018880 (120 GB).
|
||||
# Driver match GetDriverByModel returns null on a miss and PESetup logs a
|
||||
# WARNING and keeps going. The bay images with no drivers, so no
|
||||
# NIC, so DNS fails at first boot and enrollment cannot reach the
|
||||
# CDN. The symptom appears far from the cause - this is the check
|
||||
# that earns the script.
|
||||
# Media age The media expires 30 days after build. PESetup shows days-left
|
||||
# on a screen nobody reads.
|
||||
#
|
||||
# Lives on the enrollment share so it can be fixed without rebuilding boot.wim.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$MediaDrive = 'Z:',
|
||||
[int]$MinDiskGB = 120,
|
||||
[int]$MediaWarnDays = 25
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$fatal = 0
|
||||
$warn = 0
|
||||
|
||||
function Ok { param($m) Write-Host (" [ OK ] " + $m) }
|
||||
function Warn { param($m) Write-Host (" [WARN] " + $m); $script:warn++ }
|
||||
function Fail { param($m) Write-Host (" [FAIL] " + $m); $script:fatal++ }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "======== Pre-imaging checks ========"
|
||||
|
||||
# --- 1. Secure boot ------------------------------------------------------
|
||||
# Confirm-SecureBootUEFI is not always present in WinPE; read the state the
|
||||
# firmware exposes instead.
|
||||
try {
|
||||
$sb = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State' `
|
||||
-Name UEFISecureBootEnabled -ErrorAction Stop
|
||||
if ($sb.UEFISecureBootEnabled -eq 1) {
|
||||
Ok "Secure boot enabled"
|
||||
} else {
|
||||
Fail "Secure boot is OFF. PESetup will fail at GatherData. Enable it in BIOS."
|
||||
}
|
||||
} catch {
|
||||
Warn "Could not read secure boot state - if this is a legacy/CSM boot, PESetup will fail."
|
||||
}
|
||||
|
||||
# --- 2. Disk size --------------------------------------------------------
|
||||
try {
|
||||
$disk = Get-CimInstance Win32_DiskDrive -ErrorAction Stop |
|
||||
Where-Object { $_.MediaType -like '*Fixed*' } |
|
||||
Sort-Object Index | Select-Object -First 1
|
||||
if ($disk) {
|
||||
$gb = [math]::Round($disk.Size / 1GB, 1)
|
||||
if ($gb -ge $MinDiskGB) {
|
||||
Ok ("Disk 0 is {0} GB ({1})" -f $gb, $disk.Model)
|
||||
} else {
|
||||
Fail ("Disk 0 is only {0} GB; PESetup needs {1} GB. ({2})" -f $gb, $MinDiskGB, $disk.Model)
|
||||
}
|
||||
} else {
|
||||
Fail "No fixed disk found. PESetup has nothing to image."
|
||||
}
|
||||
} catch {
|
||||
Warn "Could not enumerate disks: $_"
|
||||
}
|
||||
|
||||
# --- 3. Driver match for THIS model --------------------------------------
|
||||
# Reimplements GetDriverByModel: family filter first (it knows only Latitude,
|
||||
# OptiPlex and Precision), then a substring test of comma-separated tokens,
|
||||
# first match wins. Tokens are NOT trimmed, matching the C#.
|
||||
try {
|
||||
$model = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).Model
|
||||
$catalogue = Join-Path $MediaDrive 'Deploy\Control\HardwareDriver.json'
|
||||
if (-not (Test-Path $catalogue)) {
|
||||
Warn "HardwareDriver.json not found at $catalogue - cannot check drivers."
|
||||
} else {
|
||||
# NOT ConvertFrom-Json. HardwareDriver.json carries both casings of the
|
||||
# same fields - "fileName" and "FileName", "destinationDir" and
|
||||
# "DestinationDir". Windows PowerShell 5.1 (which is what WinPE runs)
|
||||
# treats object keys case-insensitively and throws:
|
||||
# "cannot convert the JSON string because a dictionary that was
|
||||
# converted from the string contains the duplicated keys 'FileName'
|
||||
# and 'FileName'"
|
||||
# -AsHashtable would handle it but that is PowerShell 6+. So pull the
|
||||
# four fields we need out of each entry by text instead. Prefers the
|
||||
# lowercase key, falls back to the capitalised one.
|
||||
$raw = Get-Content $catalogue -Raw
|
||||
$entries = @()
|
||||
foreach ($chunk in ([regex]::Split($raw, '\}\s*,\s*\{'))) {
|
||||
$get = {
|
||||
param($names)
|
||||
foreach ($n in $names) {
|
||||
$m = [regex]::Match($chunk, '"' + $n + '"\s*:\s*"((?:[^"\\]|\\.)*)"')
|
||||
if ($m.Success) { return $m.Groups[1].Value -replace '\\\\', '\' }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
$e = [pscustomobject]@{
|
||||
modelswminame = (& $get @('modelswminame','models'))
|
||||
family = (& $get @('family'))
|
||||
fileName = (& $get @('fileName','FileName'))
|
||||
destinationDir = (& $get @('destinationDir','DestinationDir'))
|
||||
}
|
||||
if ($e.modelswminame) { $entries += $e }
|
||||
}
|
||||
if (-not $entries.Count) {
|
||||
Warn "Could not extract any entries from $catalogue - driver check skipped."
|
||||
}
|
||||
$family = ''
|
||||
if ($model.ToUpper().Contains('LATITUDE')) { $family = 'Latitude' }
|
||||
if ($model.ToUpper().Contains('OPTIPLEX')) { $family = 'Optiplex' }
|
||||
if ($model.ToUpper().Contains('PRECISION')) { $family = 'Precision' }
|
||||
|
||||
$hit = $null
|
||||
foreach ($e in $entries) {
|
||||
if ($family -and ($e.family -notlike "*$family*")) { continue }
|
||||
foreach ($tok in ([string]$e.modelswminame).Split(',')) {
|
||||
if ($tok -and $model.ToLower().Contains($tok.ToLower())) { $hit = $e; break }
|
||||
}
|
||||
if ($hit) { break }
|
||||
}
|
||||
if ($hit) {
|
||||
$zip = Join-Path $MediaDrive (([string]$hit.destinationDir) -replace '\*destinationdir\*\\?','')
|
||||
$zip = Join-Path $zip $hit.fileName
|
||||
if (Test-Path $zip) {
|
||||
Ok ("Driver pack for '{0}': {1}" -f $model, $hit.fileName)
|
||||
} else {
|
||||
Fail ("Driver pack for '{0}' is listed but MISSING on the media: {1}" -f $model, $hit.fileName)
|
||||
}
|
||||
} else {
|
||||
Fail ("NO driver pack matches '{0}'. PESetup logs this as a warning only - the bay will image with NO network drivers." -f $model)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Warn "Driver check failed: $_"
|
||||
}
|
||||
|
||||
# --- 4. Media age --------------------------------------------------------
|
||||
# Approximate: PESetup expires media 30 days after build and Media.tag is
|
||||
# rewritten when the media is rebuilt, so its timestamp is the best local proxy.
|
||||
try {
|
||||
$tag = Join-Path $MediaDrive 'Deploy\Control\Media.tag'
|
||||
if (Test-Path $tag) {
|
||||
$age = [int]((Get-Date) - (Get-Item $tag).LastWriteTime).TotalDays
|
||||
if ($age -ge $MediaWarnDays) {
|
||||
Warn ("Media is about {0} days old; it expires at 30. Rebuild it soon." -f $age)
|
||||
} else {
|
||||
Ok ("Media is about {0} days old" -f $age)
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
Write-Host "===================================="
|
||||
if ($fatal -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host " $fatal BLOCKING problem(s) found. Imaging this bay will not work."
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
if ($warn -gt 0) { Write-Host " $warn warning(s), no blockers." }
|
||||
else { Write-Host " All checks passed." }
|
||||
Write-Host ""
|
||||
exit 0
|
||||
@@ -28,6 +28,12 @@ Latitude 7450|OptiPlex_7450_1.34.0.exe|1.34.0
|
||||
Micro 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
|
||||
SFF Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
|
||||
Tower Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
|
||||
Micro 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
Micro Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
SFF 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
SFF Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
Tower 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
Micro QCM1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2
|
||||
OptiPlex 3000|OptiPlex_3000_1.38.0.exe|1.38.0
|
||||
OptiPlex 7000|OptiPlex_7000_1.38.0.exe|1.38.0
|
||||
@@ -47,5 +53,4 @@ Precision 7820 Tower|Precision_7820_7920_2.50.0.exe|2.50.0
|
||||
Precision 7865 Tower|Precision_7865_1.6.1.exe|1.6.1
|
||||
Precision 7875 Tower|Precision_7875_SHP_02.07.03.exe|2.7.3
|
||||
Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.13.1.exe|1.13.1
|
||||
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2
|
||||
|
||||
84
playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1
Normal file
84
playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1
Normal file
@@ -0,0 +1,84 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Return the tenant + PC purpose, pre-seeded if WinPE already asked.
|
||||
|
||||
.DESCRIPTION
|
||||
Start-BulkEnrollOrchestrator.ps1 calls this at first logon and captures
|
||||
stdout:
|
||||
|
||||
$pcType = powershell.exe -File 'C:\Deploy\Applications\BPRT\Select-PCConfig.ps1' -LogoPath '...'
|
||||
|
||||
It uses the answer twice - to pick which MCL package to apply, and to select
|
||||
which bulk token Insert-BPRTToPPKG.ps1 injects (it matches on Tenant AND
|
||||
Purpose). The vendor's version asks with a GUI, which means an otherwise
|
||||
unattended build stops at a dialog until somebody walks over.
|
||||
|
||||
startnet.cmd now asks the same question at the PXE menu, while the tech who
|
||||
knows what the machine is for is standing at it, and writes the answer to
|
||||
C:\Enrollment\pc-config.txt. This shim returns that value when present and
|
||||
falls through to the vendor dialog when it is not - so nothing is lost if the
|
||||
tech picks "ask at first logon", or on a bay imaged before this existed.
|
||||
|
||||
ONLY the choice is pre-seeded. Token injection still happens at first logon:
|
||||
Insert-BPRTToPPKG.ps1 fetches an encrypted token table from
|
||||
mcl.dwcdn.geaerospace.com, which is unreachable from the isolated imaging
|
||||
LAN.
|
||||
|
||||
.OUTPUTS
|
||||
"{Tenant}_{PCType}" - Tenant GCCH|RoW, PCType PERS|SH|LOAN.
|
||||
Identical contract to the vendor script.
|
||||
|
||||
.NOTES
|
||||
The vendor script is kept alongside as Select-PCConfig-vendor.ps1. Replace
|
||||
THAT file when the vendor ships a new one; this shim only needs changing if
|
||||
the return contract changes.
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$LogoPath
|
||||
)
|
||||
|
||||
$seedFile = 'C:\Enrollment\pc-config.txt'
|
||||
$vendor = Join-Path $PSScriptRoot 'Select-PCConfig-vendor.ps1'
|
||||
$logDir = 'C:\Logs\BPRT\Orchestrator'
|
||||
$shimLog = Join-Path $logDir 'Select-PCConfig-shim.log'
|
||||
|
||||
function Log {
|
||||
param([string]$Message)
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
Add-Content -Path $shimLog -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
# Valid values, so a typo in the seed file falls back to the dialog instead of
|
||||
# handing the orchestrator a string that matches no token and no package.
|
||||
$validTenants = @('GCCH', 'RoW')
|
||||
$validPurposes = @('PERS', 'SH', 'LOAN')
|
||||
|
||||
if (Test-Path $seedFile) {
|
||||
$seed = (Get-Content $seedFile -First 1 -ErrorAction SilentlyContinue)
|
||||
if ($seed) { $seed = $seed.Trim() }
|
||||
$parts = if ($seed) { $seed.Split('_') } else { @() }
|
||||
|
||||
if ($parts.Count -eq 2 -and $validTenants -contains $parts[0] -and $validPurposes -contains $parts[1]) {
|
||||
Log "Pre-seeded value '$seed' from $seedFile - dialog skipped."
|
||||
Write-Output $seed
|
||||
exit 0
|
||||
}
|
||||
|
||||
Log "Ignoring malformed pre-seed '$seed' in $seedFile; falling back to the dialog."
|
||||
}
|
||||
|
||||
if (Test-Path $vendor) {
|
||||
Log "No usable pre-seed; showing the vendor dialog."
|
||||
if ($LogoPath) { & $vendor -LogoPath $LogoPath }
|
||||
else { & $vendor }
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Neither a seed nor the vendor dialog. Say so in the log rather than returning
|
||||
# something invented - the orchestrator would inject the wrong token.
|
||||
Log "ERROR: no pre-seed at $seedFile and no vendor script at $vendor."
|
||||
exit 1
|
||||
409
playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1
Normal file
409
playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1
Normal file
@@ -0,0 +1,409 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collect everything needed to diagnose an imaged bay, into one zip.
|
||||
|
||||
.DESCRIPTION
|
||||
Read-only. Changes nothing on the machine.
|
||||
|
||||
Written because every imaging investigation so far has meant walking to a bay
|
||||
and hand-copying files, and each time we discovered another thing we wished
|
||||
we had grabbed at the same moment. This takes the lot in one pass.
|
||||
|
||||
Run it on a freshly imaged bay BEFORE lockdown.
|
||||
|
||||
This captures KNOWN-CURRENT, not known-good. A bay straight off the line has
|
||||
applications on it that should not be there - preinstall.json entries without
|
||||
a PCTypes filter install everywhere, so a Display bay picks up Adobe,
|
||||
OpenText and Defect Tracker. The point of collecting is to have an exact
|
||||
record of what imaging really produces, so the unnecessary items can be
|
||||
identified and filtered out.
|
||||
|
||||
Before lockdown specifically, because afterwards you cannot tell whether
|
||||
something is absent because lockdown removed it or because imaging never
|
||||
installed it.
|
||||
|
||||
.PARAMETER Destination
|
||||
Where to drop the zip. Defaults to C:\Logs. Point it at a share to collect
|
||||
centrally, e.g. -Destination \\172.16.9.1\enrollment\imaging-logs
|
||||
(only reachable while the bay is still on the imaging LAN).
|
||||
|
||||
.PARAMETER Label
|
||||
Optional tag folded into the filename, e.g. -Label pre-lockdown.
|
||||
|
||||
.EXAMPLE
|
||||
.\Collect-ImagingDiagnostics.ps1 -Label pre-lockdown
|
||||
|
||||
.EXAMPLE
|
||||
.\Collect-ImagingDiagnostics.ps1 -Destination \\172.16.9.1\enrollment\imaging-logs
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Destination = 'C:\Logs',
|
||||
[string]$Label = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$serial = try { (Get-CimInstance Win32_BIOS).SerialNumber.Trim() } catch { 'unknown' }
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$name = if ($Label) { "$serial-$Label-$stamp" } else { "$serial-$stamp" }
|
||||
$work = Join-Path $env:TEMP "imgdiag-$name"
|
||||
New-Item -ItemType Directory -Path $work -Force | Out-Null
|
||||
|
||||
function Section { param([string]$File, [scriptblock]$Body)
|
||||
$p = Join-Path $work $File
|
||||
try { & $Body 2>&1 | Out-File -FilePath $p -Encoding utf8 -Width 500 }
|
||||
catch { "COLLECTION ERROR: $_" | Out-File -FilePath $p -Encoding utf8 }
|
||||
Write-Host (" {0}" -f $File)
|
||||
}
|
||||
|
||||
function CopyTree { param([string]$Src, [string]$Dst)
|
||||
# /XF *.ppkg and /MAX are not optional. run-enrollment harvests
|
||||
# C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
|
||||
# provisioning package exists TWICE under the trees we copy. Without these
|
||||
# the collection is 16 GB and Compress-Archive dies with "stream was too
|
||||
# long" - it cannot exceed 2 GB.
|
||||
if (Test-Path $Src) {
|
||||
$d = Join-Path $work $Dst
|
||||
New-Item -ItemType Directory -Path $d -Force | Out-Null
|
||||
robocopy $Src $d /E /R:0 /W:0 /NFL /NDL /NJH /NJS /XF *.ppkg *.wim *.iso /MAX:104857600 | Out-Null
|
||||
Write-Host (" {0}\ <- {1}" -f $Dst, $Src)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Collecting imaging diagnostics for $serial ..."
|
||||
Write-Host ""
|
||||
|
||||
# --- 1. Identity ---------------------------------------------------------
|
||||
# ActiveComputerName is the name in use; ComputerName is the PENDING one. They
|
||||
# differ when a rename is queued for the next reboot - which is exactly how the
|
||||
# H<serial> -> F<serial> transition works, so capturing both tells you whether
|
||||
# the package's rename landed or is still waiting.
|
||||
Section 'identity.txt' {
|
||||
'== computer names =='
|
||||
'Active (in use): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -EA SilentlyContinue).ComputerName
|
||||
'Pending (next boot): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -EA SilentlyContinue).ComputerName
|
||||
'NV Hostname: ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -EA SilentlyContinue).'NV Hostname'
|
||||
''
|
||||
'== hardware =='
|
||||
Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer, Model, Domain, PartOfDomain, TotalPhysicalMemory | Format-List
|
||||
Get-CimInstance Win32_BIOS | Select-Object SerialNumber, SMBIOSBIOSVersion, ReleaseDate | Format-List
|
||||
Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, InstallDate, LastBootUpTime | Format-List
|
||||
''
|
||||
'== uptime / install =='
|
||||
'Windows installed: ' + (Get-CimInstance Win32_OperatingSystem).InstallDate
|
||||
}
|
||||
|
||||
# --- 2. Enrollment state -------------------------------------------------
|
||||
# dsregcmd is the authority on whether the Entra join actually happened.
|
||||
# criticalChecks.json is the package's own self-assessment and the two can
|
||||
# disagree - "Entra ID Joined: false" right after imaging is normal because the
|
||||
# bay has not reached the production network yet.
|
||||
Section 'enrollment.txt' {
|
||||
'== dsregcmd /status =='
|
||||
& dsregcmd /status
|
||||
''
|
||||
'== package self-checks =='
|
||||
foreach ($f in 'C:\Logs\BPRT\criticalChecks.json','C:\Logs\BPRT\packageInfo.json',
|
||||
'C:\Logs\BPRT\Orchestrator\TokenMatch.json') {
|
||||
if (Test-Path $f) { "--- $f ---"; Get-Content $f -Raw }
|
||||
}
|
||||
''
|
||||
'== staged selections =='
|
||||
foreach ($f in 'C:\Enrollment\pc-type.txt','C:\Enrollment\display-type.txt',
|
||||
'C:\Enrollment\pc-config.txt','C:\Enrollment\machine-number.txt',
|
||||
'C:\Enrollment\fetch-source.txt') {
|
||||
if (Test-Path $f) { "$f = " + ((Get-Content $f -First 1) -replace 'pxe$','<redacted>') }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 3. Installed applications ------------------------------------------
|
||||
# Registry Uninstall keys, both views. Deliberately NOT Win32_Product: querying
|
||||
# it triggers an MSI reconfigure of every installed product, which is slow and
|
||||
# can actually change the machine.
|
||||
Section 'installed-apps.txt' {
|
||||
$paths = @(
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
Get-ItemProperty $paths -EA SilentlyContinue |
|
||||
Where-Object { $_.DisplayName } |
|
||||
Sort-Object DisplayName |
|
||||
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
|
||||
Format-Table -AutoSize
|
||||
}
|
||||
|
||||
# --- 4. Everything that starts by itself --------------------------------
|
||||
# THE section for "why did this app launch". Installers plant their own startup
|
||||
# entries, so an app being installed IS an app being started - there is no
|
||||
# separate switch. Capturing all four surfaces shows which app planted what.
|
||||
# Note both registry views: a 32-bit installer's Run key lands under
|
||||
# Wow6432Node and 64-bit tooling never sees it.
|
||||
Section 'autostart.txt' {
|
||||
'== HKLM Run / RunOnce (native + WOW64) =='
|
||||
foreach ($k in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') {
|
||||
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
|
||||
}
|
||||
''
|
||||
'== HKCU Run / RunOnce (current user) =='
|
||||
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
|
||||
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce') {
|
||||
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
|
||||
}
|
||||
''
|
||||
'== Startup folders =='
|
||||
foreach ($d in "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp",
|
||||
"$env:AppData\Microsoft\Windows\Start Menu\Programs\StartUp") {
|
||||
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize }
|
||||
}
|
||||
''
|
||||
'== per-user Startup folders (all profiles) =='
|
||||
Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue | ForEach-Object {
|
||||
$d = Join-Path $_.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup'
|
||||
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name | Format-Table -AutoSize }
|
||||
}
|
||||
''
|
||||
'== enabled/disabled state (StartupApproved) =='
|
||||
# Disabling a startup item via Task Manager or Settings does NOT remove the
|
||||
# Run key or the Startup shortcut - it writes a flag here. So an entry can
|
||||
# appear above and still be switched off. First byte 02/06 = enabled,
|
||||
# 03/07 = disabled. Capturing this is what tells "imaging installed it and
|
||||
# it runs" apart from "imaging installed it and somebody turned it off",
|
||||
# which matters because the fix is not to install it at all.
|
||||
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
|
||||
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder',
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32',
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder') {
|
||||
if (Test-Path $k) {
|
||||
"--- $k ---"
|
||||
$props = Get-Item $k
|
||||
foreach ($n in $props.Property) {
|
||||
$v = (Get-ItemProperty $k -Name $n).$n
|
||||
$state = if ($v -is [byte[]] -and $v.Length -ge 1) {
|
||||
switch ($v[0]) { 2 {'ENABLED'} 4 {'ENABLED'} 6 {'ENABLED'} 3 {'disabled'} 5 {'disabled'} 7 {'disabled'} default {"unknown(0x{0:X2})" -f $v[0]} }
|
||||
} else { 'unknown' }
|
||||
"{0,-10} {1}" -f $state, $n
|
||||
}
|
||||
}
|
||||
}
|
||||
''
|
||||
'== scheduled tasks (non-Microsoft) =='
|
||||
Get-ScheduledTask -EA SilentlyContinue |
|
||||
Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
|
||||
Select-Object TaskPath, TaskName, State,
|
||||
@{n='Triggers';e={ ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ',' }},
|
||||
@{n='Action';e={ ($_.Actions | ForEach-Object { $_.Execute }) -join ',' }} |
|
||||
Sort-Object TaskPath, TaskName | Format-Table -AutoSize -Wrap
|
||||
''
|
||||
'== services set to auto-start (non-Microsoft paths) =='
|
||||
Get-CimInstance Win32_Service -EA SilentlyContinue |
|
||||
Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -notmatch 'C:\\Windows\\' } |
|
||||
Select-Object Name, DisplayName, State, StartMode, PathName |
|
||||
Sort-Object Name | Format-Table -AutoSize -Wrap
|
||||
}
|
||||
|
||||
# --- 5. Kiosk / display specifics ---------------------------------------
|
||||
Section 'display-kiosk.txt' {
|
||||
'== kiosk shortcuts anywhere in Startup =='
|
||||
Get-ChildItem 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp' -EA SilentlyContinue |
|
||||
ForEach-Object {
|
||||
$sh = (New-Object -ComObject WScript.Shell).CreateShortcut($_.FullName)
|
||||
"{0}`n target: {1}`n args: {2}" -f $_.Name, $sh.TargetPath, $sh.Arguments
|
||||
}
|
||||
''
|
||||
'== Edge policies =='
|
||||
foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Edge',
|
||||
'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System') {
|
||||
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 6. GE-Enforce -------------------------------------------------------
|
||||
Section 'ge-enforce.txt' {
|
||||
'== registry config =='
|
||||
foreach ($k in 'HKLM:\SOFTWARE\GE\SFLD\Credentials','HKLM:\SOFTWARE\GE\SFLD\DSC',
|
||||
'HKLM:\SOFTWARE\GE\ShopDB') {
|
||||
# values only - do not dump anything that looks like a secret
|
||||
if (Test-Path $k) {
|
||||
"--- $k ---"
|
||||
Get-Item $k | Select-Object -ExpandProperty Property | ForEach-Object {
|
||||
$v = (Get-ItemProperty $k -Name $_).$_
|
||||
if ($_ -match '(?i)key|token|secret|password|sas') { "$_ = <redacted, length $($v.ToString().Length)>" }
|
||||
else { "$_ = $v" }
|
||||
}
|
||||
}
|
||||
}
|
||||
''
|
||||
'== enforce client / DSC on disk =='
|
||||
foreach ($d in 'C:\ProgramData\SFLD','C:\Deploy\Applications\BPRT') {
|
||||
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse -Depth 1 | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 6b. IS IT ACTUALLY TALKING TO THE SHOPDB GE-ENFORCE API? -----------
|
||||
# Config on disk proves nothing - the client can be present, configured, and
|
||||
# never once succeed. This answers the actual question: is there a client, is it
|
||||
# scheduled, has it run, and can this bay reach the endpoint right now.
|
||||
#
|
||||
# A 401 from the manifest endpoint is a GOOD result here: it proves DNS, routing
|
||||
# and TLS all work and the service answered. Only a timeout or DNS failure means
|
||||
# genuinely unreachable. No token is sent, so nothing here can enrol or change
|
||||
# anything.
|
||||
Section 'geenforce-api.txt' {
|
||||
'== is the client installed? =='
|
||||
$clientPaths = @(
|
||||
'C:\Program Files\ShopDB', 'C:\ProgramData\ShopDB',
|
||||
'C:\Deploy\Applications\BPRT\ShopdbEnforceClient.psm1',
|
||||
'C:\Enrollment\ShopdbEnforceClient.psm1'
|
||||
)
|
||||
$found = $false
|
||||
foreach ($p in $clientPaths) {
|
||||
if (Test-Path $p) { $found = $true; "FOUND: $p"
|
||||
Get-ChildItem $p -Recurse -EA SilentlyContinue |
|
||||
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
|
||||
}
|
||||
if (-not $found) { 'NOT FOUND - no ShopDB enforce client on this machine.' }
|
||||
''
|
||||
'== scheduled tasks that would run it =='
|
||||
$tasks = Get-ScheduledTask -EA SilentlyContinue |
|
||||
Where-Object { $_.TaskName -match '(?i)shopdb|ge-?enforce|asset report' }
|
||||
if ($tasks) {
|
||||
foreach ($t in $tasks) {
|
||||
$i = $t | Get-ScheduledTaskInfo -EA SilentlyContinue
|
||||
"{0}\{1} State={2} LastRun={3} LastResult={4} NextRun={5}" -f `
|
||||
$t.TaskPath, $t.TaskName, $t.State, $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime
|
||||
($t.Actions | ForEach-Object { " action: $($_.Execute) $($_.Arguments)" })
|
||||
}
|
||||
} else { 'NONE - nothing scheduled to call the API.' }
|
||||
''
|
||||
'== configured base URL =='
|
||||
$base = $null
|
||||
foreach ($k in 'HKLM:\SOFTWARE\GE\ShopDB','HKLM:\SOFTWARE\GE\SFLD\Credentials') {
|
||||
if (Test-Path $k) {
|
||||
$p = Get-ItemProperty $k
|
||||
foreach ($n in $p.PSObject.Properties.Name) {
|
||||
# Match the NAME tightly and validate the VALUE looks like a URL.
|
||||
# A loose "base" match picked up baseVersion=2.0.2 and the probe
|
||||
# then tried to fetch "2.0.2/api/docs".
|
||||
if ($n -match '(?i)^(baseurl|serverurl|shopdburl|endpoint|url)$') {
|
||||
"$k\$n = $($p.$n)"
|
||||
if (-not $base -and "$($p.$n)" -match '^https?://') { $base = $p.$n }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $base) { '(no BaseUrl configured - falling back to the known prod URL for the reachability test)' }
|
||||
if (-not $base) { $base = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' }
|
||||
''
|
||||
"== can this bay reach it right now? (base: $base) =="
|
||||
$targets = @(
|
||||
@{ Name = 'API docs'; Url = "$base/api/docs" },
|
||||
@{ Name = 'GE-Enforce manifest'; Url = "$base/api/geenforce/manifest?pctype=gea-shopfloor-display" }
|
||||
)
|
||||
foreach ($t in $targets) {
|
||||
try {
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
$r = Invoke-WebRequest -Uri $t.Url -UseBasicParsing -TimeoutSec 20 -EA Stop
|
||||
$sw.Stop()
|
||||
"{0,-22} HTTP {1} in {2} ms <- reachable" -f $t.Name, $r.StatusCode, $sw.ElapsedMilliseconds
|
||||
} catch {
|
||||
$code = try { $_.Exception.Response.StatusCode.value__ } catch { $null }
|
||||
if ($code) {
|
||||
"{0,-22} HTTP {1} <- REACHABLE (service answered; 401/403 just means no token was sent)" -f $t.Name, $code
|
||||
} else {
|
||||
"{0,-22} UNREACHABLE: {1}" -f $t.Name, $_.Exception.Message
|
||||
' NOTE: ShopDB is only reachable once the bay has joined the AESFMA wifi SSID.'
|
||||
' On the imaging LAN or plain wired, unreachable here is EXPECTED, not a fault.'
|
||||
}
|
||||
}
|
||||
}
|
||||
''
|
||||
'== name resolution / route =='
|
||||
try {
|
||||
$h = ([uri]$base).Host
|
||||
"host: $h"
|
||||
Resolve-DnsName $h -EA Stop | Select-Object Name, Type, IPAddress | Format-Table -AutoSize
|
||||
Test-NetConnection -ComputerName $h -Port 443 -InformationLevel Detailed -WarningAction SilentlyContinue |
|
||||
Select-Object ComputerName, RemoteAddress, TcpTestSucceeded, PingSucceeded | Format-List
|
||||
} catch { "DNS/route check failed: $_" }
|
||||
''
|
||||
'== client logs, if any =='
|
||||
foreach ($d in 'C:\Logs\ShopDB','C:\Logs\GE-Enforce','C:\ProgramData\ShopDB\Logs') {
|
||||
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 7. Drivers, disk, network ------------------------------------------
|
||||
Section 'drivers-disk-network.txt' {
|
||||
'== third-party drivers =='
|
||||
& pnputil /enum-drivers
|
||||
''
|
||||
'== disk layout =='
|
||||
Get-Disk -EA SilentlyContinue | Format-Table -AutoSize
|
||||
Get-Partition -EA SilentlyContinue | Format-Table -AutoSize
|
||||
Get-Volume -EA SilentlyContinue | Format-Table -AutoSize
|
||||
''
|
||||
'== network =='
|
||||
Get-NetAdapter -EA SilentlyContinue | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress | Format-Table -AutoSize
|
||||
Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixOrigin | Format-Table -AutoSize
|
||||
Get-DnsClientServerAddress -AddressFamily IPv4 -EA SilentlyContinue | Format-Table -AutoSize
|
||||
}
|
||||
|
||||
# --- 8. Provisioning session state --------------------------------------
|
||||
Section 'provisioning-sessions.txt' {
|
||||
'== sessions =='
|
||||
$k = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Sessions'
|
||||
if (Test-Path $k) {
|
||||
Get-ChildItem $k | ForEach-Object {
|
||||
$p = Get-ItemProperty $_.PSPath
|
||||
"{0} State={1} RebootCount={2}" -f $_.PSChildName, $p.State, $p.RebootCount
|
||||
}
|
||||
} else { '(no provisioning sessions key)' }
|
||||
}
|
||||
|
||||
# --- 9. Log trees --------------------------------------------------------
|
||||
CopyTree 'C:\Logs' 'Logs'
|
||||
CopyTree 'C:\Windows\Panther' 'Panther'
|
||||
CopyTree 'C:\ProgramData\Microsoft\Provisioning' 'ProvisioningData'
|
||||
foreach ($f in 'C:\Enrollment\winpe-staging.log','C:\Enrollment\setupcomplete.log') {
|
||||
if (Test-Path $f) { Copy-Item $f $work -Force -EA SilentlyContinue }
|
||||
}
|
||||
|
||||
# Enrollment dir listing only - it holds an 8 GB package we do not want.
|
||||
Section 'enrollment-dir-listing.txt' {
|
||||
if (Test-Path 'C:\Enrollment') {
|
||||
Get-ChildItem 'C:\Enrollment' -Recurse -EA SilentlyContinue |
|
||||
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize
|
||||
}
|
||||
}
|
||||
|
||||
# --- 10. Provisioning event log -----------------------------------------
|
||||
try {
|
||||
$evtx = Join-Path $work 'Provisioning-Diagnostics-Admin.evtx'
|
||||
& wevtutil epl 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' $evtx /ow:true 2>$null
|
||||
if (Test-Path $evtx) { Write-Host ' Provisioning-Diagnostics-Admin.evtx' }
|
||||
} catch { }
|
||||
|
||||
# --- zip -----------------------------------------------------------------
|
||||
New-Item -ItemType Directory -Path $Destination -Force -EA SilentlyContinue | Out-Null
|
||||
$zip = Join-Path $Destination "imgdiag-$name.zip"
|
||||
try {
|
||||
Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -Force -EA Stop
|
||||
Remove-Item $work -Recurse -Force -EA SilentlyContinue
|
||||
Write-Host ""
|
||||
Write-Host "Wrote $zip"
|
||||
Write-Host ("Size: {0:N1} MB" -f ((Get-Item $zip).Length / 1MB))
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "Could not zip to $Destination : $_"
|
||||
Write-Host "Raw collection left at: $work"
|
||||
}
|
||||
Write-Host ""
|
||||
@@ -62,7 +62,7 @@ cmd /c "shutdown /a 2>nul" *>$null
|
||||
# done work and continues from where it left off.
|
||||
#
|
||||
# Also top up AutoLogonCount so the SupportUser autologon budget
|
||||
# (LogonCount=7 from unattend XML) survives extra unplanned reboots.
|
||||
# (LogonCount=12 from unattend XML) survives extra unplanned reboots.
|
||||
$selfResumeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
|
||||
$selfResumeName = 'ResumeRunShopfloorSetup'
|
||||
$selfResumeCmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + $PSCommandPath + '"'
|
||||
@@ -94,9 +94,51 @@ $enrollDir = "C:\Enrollment"
|
||||
$typeFile = Join-Path $enrollDir "pc-type.txt"
|
||||
$setupDir = Join-Path $enrollDir "shopfloor-setup"
|
||||
|
||||
# NOTE: there is deliberately NO wait for an Entra join here - but NOT because
|
||||
# shopfloor bays skip enrollment. They do enrol: the SFLD provisioning package
|
||||
# joins Entra using the BPRT token it carries, and a human then assigns the
|
||||
# device category in Intune.
|
||||
#
|
||||
# The wait is absent because the join cannot happen yet. At this point the bay is
|
||||
# still on the isolated PXE LAN with no route to Entra - observed on 579C144
|
||||
# 2026-08-06, holding 172.16.9.81 and 172.24.19.142, neither in the production
|
||||
# ranges. sync_intune retries every 30 seconds until the tech re-cables to
|
||||
# production, which is the right place to wait. An earlier version blocked here
|
||||
# for 45 minutes and then warned about a failure that had not happened.
|
||||
# "Entra ID Joined: false" in C:\Logs\BPRT\criticalChecks.json straight after
|
||||
# imaging is therefore NORMAL, not a fault.
|
||||
#
|
||||
# CORRECTION (2026-08-06): this comment previously claimed shopfloor PCs are
|
||||
# "vanilla by design" and that the orchestrator runs with -ManualFallback to skip
|
||||
# BPRT injection and the package entirely. That was wrong and dangerous.
|
||||
# -ManualFallback runs sysprep /oobe /reboot, so wiring it to an at-logon task
|
||||
# syspreped finished machines seconds after autologon and destroyed the
|
||||
# deployment chain. See docs/OWNERSHIP.md.
|
||||
|
||||
if (-not (Test-Path $typeFile)) {
|
||||
Write-Host "No pc-type.txt found - skipping shopfloor setup."
|
||||
exit 0
|
||||
# A missing pc-type.txt means one of two very different things, and the old
|
||||
# blanket "skip + exit 0" hid the bad one for weeks: four Display bays sat
|
||||
# at imaging stage 2 with a green exit code and nobody noticed.
|
||||
# - no C:\Enrollment at all -> this machine was never staged by WinPE
|
||||
# (pre-imaging, or the staging block never ran). That is a FAILURE on a
|
||||
# machine that has clearly just been imaged, so say so loudly.
|
||||
# - C:\Enrollment exists but no pc-type.txt -> staging ran and the write
|
||||
# failed. Also a failure.
|
||||
$stagingLog = Join-Path $enrollDir 'winpe-staging.log'
|
||||
$detail = if (Test-Path $enrollDir) {
|
||||
"C:\Enrollment exists but pc-type.txt is missing - WinPE staging ran but did not write it. Check $stagingLog."
|
||||
} else {
|
||||
"C:\Enrollment does not exist - WinPE staging never ran. The Windows volume was probably not found in startnet.cmd, so pc-type.txt, the enrollment package and shopfloor-setup were ALL skipped."
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "================================================================"
|
||||
Write-Host " FAILED: no pc-type.txt at $typeFile"
|
||||
Write-Host " $detail"
|
||||
Write-Host " Shopfloor setup cannot run. This PC is imaged but NOT configured."
|
||||
Write-Host "================================================================"
|
||||
Write-Host ""
|
||||
Report-Stage -Stage 'Run-ShopfloorSetup: FAILED - no pc-type.txt' -Index 2 -Status 'failed' -Error_ $detail
|
||||
exit 1
|
||||
}
|
||||
|
||||
$pcType = (Get-Content $typeFile -First 1).Trim()
|
||||
|
||||
@@ -330,7 +330,16 @@ function Add-ShopfloorToolsApps {
|
||||
# Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk
|
||||
$cfgApps = Get-ProfileValue 'desktopApps'
|
||||
|
||||
if ($null -ne $cfgApps -and $cfgApps.Count -gt 0) {
|
||||
# An EMPTY configured list means "no desktop apps" and must be honoured.
|
||||
# Get-ProfileValue returns $null only when the key is absent from both the
|
||||
# profile and site-config, so $null is the real "not configured" signal.
|
||||
#
|
||||
# The old test also required .Count -gt 0, which meant "desktopApps": []
|
||||
# fell through to the hardcoded shopfloor list below - so a Display kiosk,
|
||||
# which explicitly configures an empty list, was given UDC, eDNC, NTLARS,
|
||||
# WJ Shopfloor and Defect_Tracker shortcuts. Configuring "none" produced
|
||||
# "everything". Observed on 579C144, 2026-08-06.
|
||||
if ($null -ne $cfgApps) {
|
||||
$apps = @($cfgApps | ForEach-Object {
|
||||
$entry = @{ Name = $_.name; Kind = $_.kind }
|
||||
if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath }
|
||||
@@ -556,6 +565,9 @@ if (Test-Path -LiteralPath $overridesPath) {
|
||||
|
||||
$cfgStartup = Get-ProfileValue 'startupItems'
|
||||
|
||||
# Same rule as desktopApps: $null = not configured, empty array = configured as
|
||||
# none. Harmless here today because the else branch has no hardcoded fallback,
|
||||
# but kept consistent so the two keys cannot drift apart again.
|
||||
if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) {
|
||||
if (-not (Test-Path $startupDir)) {
|
||||
New-Item -ItemType Directory -Path $startupDir -Force | Out-Null
|
||||
|
||||
@@ -47,7 +47,15 @@ $layoutXmlPath = Join-Path $defaultUserShell 'LayoutModification.xml'
|
||||
# ============================================================================
|
||||
$cfgPins = Get-ProfileValue 'taskbarPins'
|
||||
|
||||
if ($null -ne $cfgPins -and $cfgPins.Count -gt 0) {
|
||||
# An EMPTY configured list means "no pins" and must be honoured. Get-ProfileValue
|
||||
# returns $null only when the key is absent from BOTH the profile and
|
||||
# site-config, so $null is the real "not configured" signal. Requiring
|
||||
# .Count -gt 0 as well makes "taskbarPins": [] fall through to the hardcoded
|
||||
# shopfloor list - configuring "none" producing "everything", the same bug that
|
||||
# put Plant Apps and Defect Tracker in a Display kiosk's Startup folder
|
||||
# (Configure-PC.ps1) and machine-tool shortcuts on its desktop
|
||||
# (06-OrganizeDesktop.ps1).
|
||||
if ($null -ne $cfgPins) {
|
||||
$pinSpec = @($cfgPins | ForEach-Object {
|
||||
@{
|
||||
Name = $_.name
|
||||
|
||||
@@ -203,7 +203,13 @@ $cfgTabs = Get-ProfileValue 'edgeStartupTabs'
|
||||
|
||||
$startupTabs = @()
|
||||
|
||||
if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) {
|
||||
# Empty configured list = "no startup tabs", and must be honoured. $null (key
|
||||
# absent from both profile and site-config) is the only "not configured" signal.
|
||||
# Requiring .Count -gt 0 would make "edgeStartupTabs": [] fall through to the
|
||||
# Plant Apps + WJ Shopfloor + Dashboard fallback below - the same
|
||||
# configuring-none-gives-everything bug fixed in Configure-PC.ps1,
|
||||
# 06-OrganizeDesktop.ps1 and 07-TaskbarLayout.ps1.
|
||||
if ($null -ne $cfgTabs) {
|
||||
foreach ($tab in $cfgTabs) {
|
||||
$fallback = if ($tab.fallbackUrlKey -and $siteConfig.urls) { $siteConfig.urls.$($tab.fallbackUrlKey) } else { '' }
|
||||
$url = Resolve-StartupUrl -BaseName $tab.baseName -Fallback $fallback
|
||||
@@ -216,7 +222,7 @@ if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) {
|
||||
$shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'https://tsgwp00525.wjs.geaerospace.net'
|
||||
if ($shopFloorHome) { $startupTabs += $shopFloorHome }
|
||||
|
||||
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/'
|
||||
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor'
|
||||
if ($dashboard) { $startupTabs += $dashboard }
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,21 @@ $edgePath = @(
|
||||
|
||||
$cfgItems = Get-ProfileValue 'startupItems'
|
||||
|
||||
if ($null -ne $cfgItems -and $cfgItems.Count -gt 0) {
|
||||
# An EMPTY configured list means "no startup items" and must be honoured.
|
||||
# Get-ProfileValue returns $null only when the key is absent from BOTH the
|
||||
# profile and site-config, so $null is the real "not configured" signal.
|
||||
#
|
||||
# The old test also required .Count -gt 0, so "startupItems": [] fell through to
|
||||
# the hardcoded list below and a Display kiosk was given UDC, eDNC, Defect
|
||||
# Tracker, WJ Shopfloor and Plant Apps startup shortcuts - Plant Apps launching
|
||||
# Edge in a new window on a kiosk. Confirmed on 579C144 2026-08-06:
|
||||
#
|
||||
# C:\ProgramData\...\StartUp\Defect Tracker.lnk 16:46:03
|
||||
# C:\ProgramData\...\StartUp\Plant Apps.lnk 16:46:03
|
||||
#
|
||||
# Configuring "none" produced "everything". Same bug as desktopApps in
|
||||
# 06-OrganizeDesktop.ps1 and taskbarPins in 07-TaskbarLayout.ps1.
|
||||
if ($null -ne $cfgItems) {
|
||||
$items = @()
|
||||
$num = 0
|
||||
foreach ($si in $cfgItems) {
|
||||
|
||||
@@ -50,6 +50,36 @@ try {
|
||||
}
|
||||
} catch { Write-RegLog "Could not unregister legacy '$oldTaskName': $_" }
|
||||
|
||||
# PC types that have no machine number by design. Arming the prompt on these
|
||||
# guarantees a dialog nobody can answer: startnet only collects a machine number
|
||||
# for the machine-tool types, so everything else is left on the 9999 placeholder
|
||||
# and the check below would otherwise fire on every one of them.
|
||||
#
|
||||
# Observed on 579C144 (a Display) 2026-08-06: PreInstall logged
|
||||
# "Machine number: 9999 (default placeholder)" and the logon prompt armed itself
|
||||
# on a kiosk with no keyboard.
|
||||
$noMachineNumberTypes = @(
|
||||
'gea-shopfloor-display'
|
||||
)
|
||||
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
|
||||
$pcType = ''
|
||||
if (Test-Path -LiteralPath $pcTypeFile) {
|
||||
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
|
||||
if ($pcType) { $pcType = $pcType.Trim() }
|
||||
}
|
||||
if ($pcType -and ($noMachineNumberTypes -contains $pcType)) {
|
||||
Write-RegLog "PC type '$pcType' has no machine number by design. Not registering the prompt."
|
||||
foreach ($t in @($promptTaskName, $applyTaskName)) {
|
||||
try {
|
||||
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
|
||||
Write-RegLog "Unregistered stale task '$t'"
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Only arm the tasks if the bay was imaged with the 9999 placeholder. If
|
||||
# the tech entered a real machine number during PXE imaging it's already
|
||||
# in C:\Enrollment\machine-number.txt; no prompt needed on first logon.
|
||||
|
||||
@@ -16,6 +16,42 @@
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# PC types that are self-contained and must NOT map S:. A Display kiosk is
|
||||
# Entra-joined with local accounts and no SFLD credentials, so the mapping can
|
||||
# only ever fail - once per logon, forever.
|
||||
#
|
||||
# Run-ShopfloorSetup.ps1 already gates the CALL to this script on the same list,
|
||||
# but on 579C144 (2026-08-06) the Run entry was registered on a Display anyway:
|
||||
#
|
||||
# [15:07:55] Set HKLM:\...\Run\GE Map SFLD Share = ...Map-SfldShare.ps1
|
||||
#
|
||||
# with no "Skipping S: drive logon mapper" in the log, so something in the
|
||||
# finalization phase reaches this script past the call-site gate. Gating here as
|
||||
# well makes the outcome correct regardless of who invokes it.
|
||||
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
|
||||
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
|
||||
$pcType = ''
|
||||
if (Test-Path -LiteralPath $pcTypeFile) {
|
||||
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
|
||||
if ($pcType) { $pcType = $pcType.Trim() }
|
||||
}
|
||||
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
|
||||
$d = 'C:\Logs\SFLD'
|
||||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force -EA SilentlyContinue | Out-Null }
|
||||
Add-Content -Path (Join-Path $d 'register-mapshare.log') -EA SilentlyContinue `
|
||||
-Value ("[{0}] [INFO] PC type '{1}' is self-contained - not registering the S: mapper." -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $pcType)
|
||||
Write-Host "PC type '$pcType' is self-contained - skipping S: drive mapper."
|
||||
# Remove a stale entry from an earlier image or an earlier code path.
|
||||
try {
|
||||
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
|
||||
if ((Get-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -EA SilentlyContinue)) {
|
||||
Remove-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -Force -EA Stop
|
||||
Write-Host " removed stale 'GE Map SFLD Share' Run entry."
|
||||
}
|
||||
} catch { }
|
||||
return
|
||||
}
|
||||
|
||||
$installRoot = 'C:\Program Files\GE\SfldShare'
|
||||
$mapScript = Join-Path $installRoot 'Map-SfldShare.ps1'
|
||||
$logDir = 'C:\Logs\SFLD'
|
||||
|
||||
@@ -113,6 +113,28 @@ if ($siteConfig -and $siteConfig.pcProfiles -and $profileKey) {
|
||||
}
|
||||
}
|
||||
}
|
||||
# Subtype-strip fallback: a compound key like "gea-shopfloor-cmm-doda" (DODA
|
||||
# CMM bays set pc-subtype.txt=doda) has no profile of its own. Fall back to
|
||||
# the BARE pc-type ("gea-shopfloor-cmm" -> "CMM"). Without this the profile
|
||||
# resolves to $null and callers silently use hardcoded defaults (wrong
|
||||
# desktop icons + unwanted startup items).
|
||||
if (-not $pcProfile -and $pcSubtype) {
|
||||
$bare = $pcType
|
||||
$pcProfile = $siteConfig.pcProfiles.$bare
|
||||
if (-not $pcProfile) {
|
||||
foreach ($g in $pcProfileAliasGroups) {
|
||||
if ($g -icontains $bare) {
|
||||
foreach ($alias in $g) {
|
||||
if ($alias -ieq $bare) { continue }
|
||||
$candidate = $siteConfig.pcProfiles.$alias
|
||||
if ($candidate) { $pcProfile = $candidate; break }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($pcProfile) { $profileKey = $bare }
|
||||
}
|
||||
}
|
||||
|
||||
if ($pcProfile) {
|
||||
|
||||
@@ -41,6 +41,135 @@ function Get-CurrentMachineNumber {
|
||||
return $result
|
||||
}
|
||||
|
||||
function Update-MTConnectVariantName {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Renames an MTConnect device across every file of a variant that carries it.
|
||||
|
||||
.DESCRIPTION
|
||||
Makino and Fanuc do not fit the one-file-one-service shape the other
|
||||
variants do.
|
||||
Per the vendor's "Installation Configuration and Trouble Shooting" guide,
|
||||
the device name lives in several files that must be identical or the agent
|
||||
may not start, and the Adapter Manager service rewrites the COMPLETE
|
||||
configuration when it stops - so an edit made while it runs is thrown away.
|
||||
|
||||
Makino, from a real bay capture:
|
||||
Agent\Devices.xml <Device name="7502" uuid="7502" id="MC41">
|
||||
Agent\Devices.EDM.xml <Device name="MAKINO-1" uuid="MAKINO-1" id="EDM41">
|
||||
Data\AdapterDataFile.xml <Name>MAKINO-1</Name>
|
||||
Agent\Agent.cfg Adapters { MAKINO-1 { Host=localhost ... } }
|
||||
|
||||
Fanuc, likewise:
|
||||
Agent\devices.xml <Device id="3101" name="3101" uuid="3101">
|
||||
Adapter\devices.xml the adapter's own generated copy
|
||||
Adapter\adapter.xml <DeviceName>/<DeviceID>/<DeviceUUID>3101
|
||||
|
||||
Editing only the agent's copy leaves the adapter streaming under the old
|
||||
identity. Seven of thirty-four captured Fanuc bays are in that state -
|
||||
agent on the right machine number, adapter still on the bay it was
|
||||
imaged from (4007 on a 7801 Toshulin, 3031 on a 7804).
|
||||
|
||||
The vendor's own path is ConfigurationManager.exe, a GUI tool, which a
|
||||
remote renumber cannot drive. This does what it does, in the required
|
||||
order: stop the manager and the agent, rewrite, start again.
|
||||
|
||||
Anything that cannot be renamed is reported rather than half-applied -
|
||||
a bay whose files disagree is worse than one still on its old name.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$NewNumber,
|
||||
[Parameter(Mandatory)][hashtable[]]$Targets,
|
||||
# Overridable so the rename can be exercised against a captured bay.
|
||||
[Parameter(Mandatory)][string]$Root,
|
||||
# Whatever owns the files goes down first - for Makino the Adapter
|
||||
# Manager rewrites its configuration from memory as it stops, and for
|
||||
# Fanuc the adapter holds its own copy. Overridable only so a test can
|
||||
# drive the stop/start ordering with real services.
|
||||
[string[]]$Services = @()
|
||||
)
|
||||
|
||||
$out = @{ Updated = @(); Errors = @() }
|
||||
|
||||
$present = @($Targets |
|
||||
ForEach-Object { @{ Path = (Join-Path $Root $_.Path); Kind = $_.Kind } } |
|
||||
Where-Object { Test-Path -LiteralPath $_.Path })
|
||||
if (-not $present) { return $out }
|
||||
|
||||
# Collect EVERY name in play, not just one. A bay half-renamed by the old
|
||||
# code carries two at once - Devices.xml already on the machine number while
|
||||
# the adapter files still say MAKINO-n - and replacing only the name we
|
||||
# happened to discover leaves the other behind, which is the very state this
|
||||
# is meant to end. Gather them all, then converge every file on the target.
|
||||
$names = New-Object System.Collections.Generic.HashSet[string]
|
||||
foreach ($t in $present) {
|
||||
$raw = Get-Content -LiteralPath $t.Path -Raw -ErrorAction SilentlyContinue
|
||||
if (-not $raw) { continue }
|
||||
switch ($t.Kind) {
|
||||
'attr' { if ($raw -match '<Device[^>]+name="([^"]+)"') { [void]$names.Add($matches[1]) } }
|
||||
'name' { if ($raw -match '<Name>\s*([^<]+?)\s*</Name>') { [void]$names.Add($matches[1]) } }
|
||||
'cfg' {
|
||||
# The adapter block's key is the bare line inside Adapters { }.
|
||||
if ($raw -match '(?ms)Adapters\s*\{\s*\r?\n\s*([^\s{]+)') { [void]$names.Add($matches[1]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
[void]$names.Remove($NewNumber)
|
||||
if ($names.Count -eq 0) { return $out } # every file already on the target
|
||||
|
||||
# Agent is stopped too, so it re-reads Agent.cfg on the way back up.
|
||||
$stopped = @()
|
||||
foreach ($name in $Services) {
|
||||
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||||
if (-not $svc) { continue }
|
||||
if ($svc.Status -ne 'Stopped') {
|
||||
try {
|
||||
Stop-Service -Name $name -Force -ErrorAction Stop
|
||||
$stopped += $name
|
||||
} catch {
|
||||
$out.Errors += "MTConnect: could not stop '$name' ($_); nothing was changed"
|
||||
foreach ($back in $stopped) {
|
||||
try { Start-Service -Name $back -ErrorAction Stop } catch {}
|
||||
}
|
||||
return $out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($t in $present) {
|
||||
try {
|
||||
$content = Get-Content -LiteralPath $t.Path -Raw -ErrorAction Stop
|
||||
$updated = $content
|
||||
$from = @()
|
||||
foreach ($old in $names) {
|
||||
$escaped = [regex]::Escape($old)
|
||||
switch ($t.Kind) {
|
||||
'attr' { $next = $updated -replace ('"' + $escaped + '"'), ('"' + $NewNumber + '"') }
|
||||
'name' { $next = $updated -replace ('<Name>\s*' + $escaped + '\s*</Name>'), ("<Name>$NewNumber</Name>") }
|
||||
# The adapter block is keyed by a bare name on its own line.
|
||||
'cfg' { $next = $updated -replace ('(?m)^(\s*)' + $escaped + '(\s*)$'), ("`${1}$NewNumber`${2}") }
|
||||
# <DeviceName>/<DeviceID>/<DeviceUUID> in the Fanuc adapter config
|
||||
'elem' { $next = $updated -replace ('(<Device(?:Name|ID|UUID)>)\s*' + $escaped + '\s*(</Device(?:Name|ID|UUID)>)'), ("`${1}$NewNumber`${2}") }
|
||||
}
|
||||
if ($next -ne $updated) { $from += $old; $updated = $next }
|
||||
}
|
||||
if ($updated -eq $content) { continue } # this file was already done
|
||||
Set-Content -LiteralPath $t.Path -Value $updated -NoNewline -ErrorAction Stop
|
||||
$out.Updated += "$($t.Path) ($($from -join ', ') -> $NewNumber)"
|
||||
} catch {
|
||||
$out.Errors += "MTConnect: rewrite failed for $($t.Path): $_"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($name in $stopped) {
|
||||
try { Start-Service -Name $name -ErrorAction Stop }
|
||||
catch { $out.Errors += "MTConnect: restart of '$name' failed: $_" }
|
||||
}
|
||||
|
||||
return $out
|
||||
}
|
||||
|
||||
function Update-MachineNumber {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
@@ -163,10 +292,13 @@ function Update-MachineNumber {
|
||||
}
|
||||
if ($udcSharePath) {
|
||||
try {
|
||||
$mountedUdc = Mount-SFLDShare -SharePath $udcSharePath -DriveLetter 'W:'
|
||||
# N:, never W:. GE-Enforce owns W: for its whole cycle, and this
|
||||
# runs as SYSTEM in the same drive namespace - taking W: here kills
|
||||
# the share out from under an in-flight enforce cycle.
|
||||
$mountedUdc = Mount-SFLDShare -SharePath $udcSharePath -DriveLetter 'N:'
|
||||
if ($mountedUdc) {
|
||||
try {
|
||||
$bayDir = Join-Path 'W:\' $NewNumber
|
||||
$bayDir = Join-Path 'N:\' $NewNumber
|
||||
$srcCur = Join-Path $bayDir 'CurrentData.json'
|
||||
$srcArc = Join-Path $bayDir 'ArchivedData'
|
||||
if (Test-Path -LiteralPath $srcCur) {
|
||||
@@ -226,7 +358,7 @@ function Update-MachineNumber {
|
||||
Write-Host " Update-MachineNumber: no UDC backup at $bayDir (fresh PC, no prior data)"
|
||||
}
|
||||
} finally {
|
||||
& net use W: /delete /y 2>$null | Out-Null
|
||||
& net use N: /delete /y 2>$null | Out-Null
|
||||
}
|
||||
} else {
|
||||
Write-Host " Update-MachineNumber: UDC backup share unreachable - skipping UDC restore."
|
||||
@@ -304,11 +436,12 @@ function Update-MachineNumber {
|
||||
# devices.xml / Devices.xml entries collapse to the same file). Without
|
||||
# this filter, the Okuma branch on an Okuma PC sees the file already
|
||||
# rewritten by the (no-op) Fanuc branch and skips the service restart.
|
||||
# Okuma and eDNC keep the name in one file only - verified across 7 Okuma
|
||||
# and 15 eDNC bay captures - so the simple edit is right for them. Fanuc and
|
||||
# Makino are handled below instead.
|
||||
$mtcVariants = @(
|
||||
@{ Service='MTConnect Agent Fanuc'; Path='C:\MTConnect\Agent\devices.xml' },
|
||||
@{ Service='MTConnect Agent Okuma'; Path='C:\MTConnect\Agent\Devices.xml' },
|
||||
@{ Service='MTConnect eDNC Agent'; Path='C:\MTConnect_eDNC\Agent\Devices.xml' },
|
||||
@{ Service='Makino MTConnect Agent'; Path='C:\Makino-MTConnect\Agent\Devices.xml' }
|
||||
@{ Service='MTConnect Agent Okuma'; Path='C:\MTConnect\Agent\Devices.xml' },
|
||||
@{ Service='MTConnect eDNC Agent'; Path='C:\MTConnect_eDNC\Agent\Devices.xml' }
|
||||
)
|
||||
foreach ($v in $mtcVariants) {
|
||||
$svc = Get-Service -Name $v.Service -ErrorAction SilentlyContinue
|
||||
@@ -343,6 +476,43 @@ function Update-MachineNumber {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Multi-file variants: rename every copy, services stopped first ---
|
||||
# Fanuc and Makino each keep the device name in more than one file, and the
|
||||
# agent's copy alone is not enough - the adapter goes on streaming under the
|
||||
# old identity, which is how seven captured Fanuc bays ended up with the
|
||||
# agent on the right machine number and the adapter on the bay it was
|
||||
# imaged from.
|
||||
$multiFileVariants = @(
|
||||
@{
|
||||
Probe = 'MTConnect Agent Fanuc'
|
||||
Root = 'C:\MTConnect'
|
||||
Services = @('MTConnect Adapter Fanuc', 'MTConnect Agent Fanuc')
|
||||
Targets = @(
|
||||
@{ Path = 'Agent\devices.xml'; Kind = 'attr' },
|
||||
@{ Path = 'Adapter\devices.xml'; Kind = 'attr' },
|
||||
@{ Path = 'Adapter\adapter.xml'; Kind = 'elem' }
|
||||
)
|
||||
},
|
||||
@{
|
||||
Probe = 'Makino MTConnect Agent'
|
||||
Root = 'C:\Makino-MTConnect'
|
||||
Services = @('Makino Adapter Manager', 'Makino MTConnect Agent')
|
||||
Targets = @(
|
||||
@{ Path = 'Agent\Devices.xml'; Kind = 'attr' },
|
||||
@{ Path = 'Agent\Devices.EDM.xml'; Kind = 'attr' },
|
||||
@{ Path = 'Data\AdapterDataFile.xml'; Kind = 'name' },
|
||||
@{ Path = 'Agent\Agent.cfg'; Kind = 'cfg' }
|
||||
)
|
||||
}
|
||||
)
|
||||
foreach ($variant in $multiFileVariants) {
|
||||
if (-not (Get-Service -Name $variant.Probe -ErrorAction SilentlyContinue)) { continue }
|
||||
$renamed = Update-MTConnectVariantName -NewNumber $NewNumber `
|
||||
-Root $variant.Root -Targets $variant.Targets -Services $variant.Services
|
||||
$out.MTConnectUpdated += $renamed.Updated
|
||||
$out.Errors += $renamed.Errors
|
||||
}
|
||||
|
||||
# Keep C:\Enrollment\machine-number.txt in sync. Post-imaging GE-Enforce
|
||||
# prefers eDNC reg, but imaging-time scripts (Install-FromManifest
|
||||
# TargetMachineNumbers filter, 01-eDNC.ps1, 03-RestoreEDncConfig.ps1)
|
||||
|
||||
@@ -7,10 +7,12 @@ enrollment share. Runs in full Windows (reliable network), so it is immune to th
|
||||
WinPE samba-idle-drop that loses copies during the WIM apply.
|
||||
|
||||
Covers the generic Fetch payload (shopfloor-setup tree + preinstall bundle) AND
|
||||
the heavy per-type payload that Fetch-StagingPayload does NOT pull today: the CMM
|
||||
bundle (C:\CMM-Install) and the selected bay's backup set
|
||||
(C:\CMM-Install\backups\<cmmid>). That is the one that silently goes missing when
|
||||
WinPE staging runs out of time before reboot.
|
||||
the heavy per-type payloads that Fetch-StagingPayload does NOT pull today:
|
||||
- CMM C:\CMM-Install (+ selected bay's backup C:\CMM-Install\backups\<cmmid>)
|
||||
- Keyence C:\KeyenceInstall\<model> (MSI + Data*.cab)
|
||||
- WaxTrace C:\WaxTrace-Install (bundle + bay-matched FormTracePak ISO)
|
||||
These are the ones that silently go missing when WinPE staging runs out of time
|
||||
(idle-dead Y: mount) before reboot.
|
||||
|
||||
Designed to be:
|
||||
- run manually on a problem PC (Verify-And-Heal-Staging.bat), or
|
||||
@@ -89,6 +91,31 @@ if ($pcType -eq 'gea-shopfloor-cmm') {
|
||||
Add-Item "CMM backup ($cmmid)" "installers-post\cmm\backups\$cmmid" "C:\CMM-Install\backups\$cmmid" 'Dir' "C:\CMM-Install\backups\$cmmid" $null $true
|
||||
}
|
||||
}
|
||||
# --- heavy Keyence payload (same gap as CMM: WinPE-only staged, never re-pulled
|
||||
# by Fetch-StagingPayload). Only the selected model bundle lands under
|
||||
# C:\KeyenceInstall\<model>. Verify on the model manifest so a missing/partial
|
||||
# Data1.cab (the 700 MB - 2 GB payload msiexec SECREPAIR-hashes) gets re-pulled. ---
|
||||
if ($pcType -eq 'gea-shopfloor-keyence') {
|
||||
$kmodel = ReadTxt 'C:\Enrollment\keyence-model.txt'
|
||||
if (-not $kmodel) { $kmodel = 'vr6000' }
|
||||
Add-Item "Keyence bundle ($kmodel)" "installers-post\keyence\$kmodel" "C:\KeyenceInstall\$kmodel" 'Dir' "C:\KeyenceInstall\$kmodel\manifest.json"
|
||||
}
|
||||
# --- heavy WaxTrace payload (same gap as CMM/Keyence). Two parts, mirroring the
|
||||
# three-step WinPE stage: (1) the bundle minus the formtracepak\ ISO dir, and
|
||||
# (2) ONLY the bay's matched FORMTRACEPAK-V<ver>.iso, keyed on the version
|
||||
# resolve-bay-config wrote to C:\Enrollment\waxtrace\version.txt during WinPE.
|
||||
# If version.txt is missing (mount died before the resolver ran) the ISO cannot
|
||||
# be re-pulled here - the bundle+resolver still heal, and resolve-bay-config can
|
||||
# be re-run manually to regenerate version.txt then re-run this heal. ---
|
||||
if ($pcType -eq 'gea-shopfloor-waxtrace') {
|
||||
Add-Item 'WaxTrace bundle' 'installers-post\waxtrace' 'C:\WaxTrace-Install' 'Dir' 'C:\WaxTrace-Install\waxtrace-manifest.json' $null $false 'formtracepak'
|
||||
$wtver = ReadTxt 'C:\Enrollment\waxtrace\version.txt'
|
||||
if ($wtver) {
|
||||
Add-Item "WaxTrace FTPak V$wtver" 'installers-post\waxtrace\formtracepak' 'C:\WaxTrace-Install\formtracepak' 'File' "C:\WaxTrace-Install\formtracepak\FORMTRACEPAK-V$wtver.iso" @("FORMTRACEPAK-V$wtver.iso")
|
||||
} else {
|
||||
Log 'WaxTrace: version.txt absent - cannot heal the bay-specific FormTracePak ISO (re-run resolve-bay-config then re-run heal)' 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
# --- robocopy-based verify/heal -----------------------------------------------
|
||||
# Presence alone is NOT trusted: a partially transferred file (e.g. a truncated
|
||||
|
||||
@@ -46,13 +46,76 @@ Write-EnforceLog '==============================================================
|
||||
Write-EnforceLog "=== GE-Enforce session start (PID $PID, user $env:USERNAME) ==="
|
||||
Write-EnforceLog '================================================================'
|
||||
|
||||
$logRoots = @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')
|
||||
|
||||
# --- Roll undated logs so they can age ---
|
||||
# The prune below drops a *.log by LastWriteTime. A log written to a FIXED
|
||||
# filename is appended every cycle, so its LastWriteTime is always now and it
|
||||
# can never be older than any cutoff - it grows forever. ntlars-backup.log,
|
||||
# eventsaver.log and shopdb-collector-key.log all do this; eventsaver.log had
|
||||
# reached 11,000 lines on a single PC.
|
||||
#
|
||||
# Rolling one to <name>-YYYYMMDD.log stops it being written to, so the prune
|
||||
# takes it 30 days later, and the script that owns it needs no change: they all
|
||||
# append with Add-Content or Tee-Object, which recreate a missing file on the
|
||||
# next write. A file already carrying a date stamp is left alone.
|
||||
#
|
||||
# Rolled under the date of its FIRST LINE, so the stamp matches the lines
|
||||
# inside. That timestamp is also the only trustworthy signal available:
|
||||
# CreationTime is not, because of NTFS file tunneling - rename a file away and
|
||||
# let the owner recreate it within 15 seconds, and the new file INHERITS the
|
||||
# old creation time. Keyed on that, a busy log would look stale the moment it
|
||||
# was rolled and would roll again every cycle, forever.
|
||||
$rolledCount = 0
|
||||
$today = (Get-Date).Date
|
||||
foreach ($root in $logRoots) {
|
||||
if (-not (Test-Path $root)) { continue }
|
||||
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.BaseName -notmatch '\d{8}$' -and $_.Length -gt 0 } |
|
||||
ForEach-Object {
|
||||
$firstline = Get-Content -LiteralPath $_.FullName -TotalCount 1 -ErrorAction SilentlyContinue
|
||||
# MUST be pre-typed. TryParse takes [ref][datetime], and passing a
|
||||
# [ref] to an untyped $null throws "cannot find an overload", which
|
||||
# would make this whole roll a silent no-op.
|
||||
$started = [datetime]::MinValue
|
||||
if ($firstline -match '(\d{4}-\d{2}-\d{2})') {
|
||||
[void][datetime]::TryParse($matches[1], [ref]$started)
|
||||
}
|
||||
# No parseable date: fall back to size so a log with a format we do
|
||||
# not recognise still cannot grow without limit.
|
||||
if ($started -eq [datetime]::MinValue) {
|
||||
if ($_.Length -lt 5MB) { return }
|
||||
$started = $today.AddDays(-1)
|
||||
}
|
||||
if ($started.Date -ge $today) { return }
|
||||
|
||||
$stamp = $started.ToString('yyyyMMdd')
|
||||
$target = Join-Path $_.DirectoryName ('{0}-{1}.log' -f $_.BaseName, $stamp)
|
||||
# An existing target means the roll already ran for that day; append
|
||||
# to it rather than losing either side.
|
||||
try {
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
Get-Content -LiteralPath $_.FullName -ErrorAction Stop |
|
||||
Add-Content -LiteralPath $target -ErrorAction Stop
|
||||
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
|
||||
} else {
|
||||
Move-Item -LiteralPath $_.FullName -Destination $target -ErrorAction Stop
|
||||
}
|
||||
$rolledCount++
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if ($rolledCount -gt 0) {
|
||||
Write-EnforceLog "Rolled $rolledCount undated log file(s) so retention can age them"
|
||||
}
|
||||
|
||||
# --- Log retention prune ---
|
||||
# Drops *.log files older than $retentionDays from the shopfloor log roots.
|
||||
# Cheap (flat dir scan, no recursion). Runs every cycle. Today's
|
||||
# enforce-YYYYMMDD.log is never touched (LastWriteTime = now).
|
||||
$retentionDays = 30
|
||||
$prunedCount = 0
|
||||
foreach ($root in @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')) {
|
||||
foreach ($root in $logRoots) {
|
||||
if (-not (Test-Path $root)) { continue }
|
||||
$cutoff = (Get-Date).AddDays(-$retentionDays)
|
||||
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |
|
||||
|
||||
@@ -241,6 +241,24 @@ function Test-AppInstalled {
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
function Join-InstallerPath {
|
||||
# Plain string math, NOT Join-Path. Join-Path resolves the drive qualifier
|
||||
# through the PS provider and emits NOTHING (null) if that drive vanished
|
||||
# mid-cycle - the null then binds into Test-Path -LiteralPath and throws
|
||||
# "Cannot bind argument to parameter 'LiteralPath' because it is null".
|
||||
# [IO.Path]::Combine is no good either: its separator and rooted-path rules
|
||||
# follow the host platform, so it cannot be validated off-Windows.
|
||||
# Returns $null when the entry is unusable; every caller treats null as
|
||||
# "not found" and logs, instead of crashing the entry.
|
||||
param([string]$Root, [string]$Rel)
|
||||
if ([string]::IsNullOrWhiteSpace($Root) -or [string]::IsNullOrWhiteSpace($Rel)) { return $null }
|
||||
$clean = ($Rel -replace '/', '\').Trim()
|
||||
# Reject rooted values ('\x', 'D:\x', '\\server\share'): a manifest entry
|
||||
# must resolve UNDER the share root, never escape it.
|
||||
if ($clean -match '^(\\|[A-Za-z]:)') { return $null }
|
||||
return ($Root.TrimEnd('\') + '\' + $clean)
|
||||
}
|
||||
|
||||
function Invoke-InstallerAction {
|
||||
param($App)
|
||||
|
||||
@@ -251,8 +269,8 @@ function Invoke-InstallerAction {
|
||||
|
||||
switch ($App.Type) {
|
||||
'MSI' {
|
||||
$installerPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||
$installerPath = Join-InstallerPath $InstallerRoot $App.Installer
|
||||
if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
|
||||
Write-InstallLog " MSI not found: $installerPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
@@ -270,8 +288,8 @@ function Invoke-InstallerAction {
|
||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog }
|
||||
}
|
||||
'EXE' {
|
||||
$installerPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||
$installerPath = Join-InstallerPath $InstallerRoot $App.Installer
|
||||
if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
|
||||
Write-InstallLog " EXE not found: $installerPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
@@ -346,8 +364,8 @@ function Invoke-InstallerAction {
|
||||
return [pscustomobject]@{ ExitCode = $exitCode; LogRef = $App.LogFile }
|
||||
}
|
||||
{ $_ -eq 'CMD' -or $_ -eq 'BAT' } {
|
||||
$installerPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||
$installerPath = Join-InstallerPath $InstallerRoot $App.Installer
|
||||
if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
|
||||
Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
@@ -369,8 +387,8 @@ function Invoke-InstallerAction {
|
||||
Write-InstallLog (" PS1 entry '{0}' has no Script/Installer value (Script={1}, Installer={2}) - skipping" -f $App.Name, $App.Script, $App.Installer) 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
$scriptPath = Join-Path $InstallerRoot $rel
|
||||
if (-not (Test-Path -LiteralPath $scriptPath)) {
|
||||
$scriptPath = Join-InstallerPath $InstallerRoot $rel
|
||||
if ([string]::IsNullOrWhiteSpace($scriptPath) -or -not (Test-Path -LiteralPath $scriptPath)) {
|
||||
Write-InstallLog " PS1 not found: $scriptPath (from rel '$rel')" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
@@ -384,8 +402,8 @@ function Invoke-InstallerAction {
|
||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null }
|
||||
}
|
||||
'INF' {
|
||||
$infPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $infPath)) {
|
||||
$infPath = Join-InstallerPath $InstallerRoot $App.Installer
|
||||
if ([string]::IsNullOrWhiteSpace($infPath) -or -not (Test-Path -LiteralPath $infPath)) {
|
||||
Write-InstallLog " INF not found: $infPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
@@ -397,8 +415,8 @@ function Invoke-InstallerAction {
|
||||
}
|
||||
'File' {
|
||||
# Copy a file from the share (configs/*) to an absolute on-PC path.
|
||||
$source = Join-Path $InstallerRoot $App.Source
|
||||
if (-not (Test-Path -LiteralPath $source)) {
|
||||
$source = Join-InstallerPath $InstallerRoot $App.Source
|
||||
if ([string]::IsNullOrWhiteSpace($source) -or -not (Test-Path -LiteralPath $source)) {
|
||||
Write-InstallLog " File source not found: $source" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
@@ -625,6 +643,18 @@ $skipped = 0
|
||||
$failed = 0
|
||||
$pcFiltered = 0
|
||||
|
||||
# Per-entry outcomes for the caller's report (the API enforce runner captures
|
||||
# the object emitted at the end). SMB/GE-Enforce.ps1 ignores stdout + reads the
|
||||
# exit code, so this is additive and does not change the share path.
|
||||
$script:enforceResults = [System.Collections.Generic.List[object]]::new()
|
||||
function Add-EnforceResult {
|
||||
param([string]$Name, [string]$Action, [int]$ExitCode = 0,
|
||||
[bool]$SelfHealed = $false, [string]$Message = '')
|
||||
$script:enforceResults.Add([pscustomobject]@{
|
||||
Name = $Name; Action = $Action; ExitCode = $ExitCode
|
||||
SelfHealed = $SelfHealed; Message = $Message })
|
||||
}
|
||||
|
||||
foreach ($app in $config.Applications) {
|
||||
# Cancel any reboot that a prior MSI queued, so the enforcer never
|
||||
# triggers an unexpected restart on a shopfloor PC.
|
||||
@@ -640,12 +670,14 @@ foreach ($app in $config.Applications) {
|
||||
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
|
||||
Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
|
||||
$pcFiltered++
|
||||
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'PCTypes filter'
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not (Test-HostnameMatches -App $app)) {
|
||||
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping"
|
||||
$pcFiltered++
|
||||
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetHostnames filter'
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -653,6 +685,7 @@ foreach ($app in $config.Applications) {
|
||||
$myNum = Get-CurrentMachineNumber
|
||||
Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping"
|
||||
$pcFiltered++
|
||||
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetMachineNumbers filter'
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -660,12 +693,14 @@ foreach ($app in $config.Applications) {
|
||||
$myVer = Get-CurrentCmmVersion
|
||||
Write-InstallLog " _CmmVersion filter: entry targets $($app._CmmVersion) but bay version is $(if ($myVer) { $myVer } else { '(none)' }) - skipping"
|
||||
$pcFiltered++
|
||||
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message '_CmmVersion filter'
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-AppInstalled -App $app) {
|
||||
Write-InstallLog ' Already installed at expected version - skipping'
|
||||
$skipped++
|
||||
Add-EnforceResult -Name $app.Name -Action 'skipped' -Message 'already installed'
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -705,6 +740,12 @@ foreach ($app in $config.Applications) {
|
||||
if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
|
||||
if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' }
|
||||
$installed++
|
||||
# SelfHealed = a real drift correction (a detected-missing entry we
|
||||
# re-installed). Always/no-detection entries install every cycle by
|
||||
# design and are not self-heals, so the report stays 'ok' for them.
|
||||
Add-EnforceResult -Name $app.Name -Action 'installed' -ExitCode $rc `
|
||||
-SelfHealed ([bool]($app.DetectionMethod -and $app.DetectionMethod -ne 'Always')) `
|
||||
-Message "Exit $rc"
|
||||
|
||||
# Auto-write marker file for MarkerFile-detected entries that just
|
||||
# completed successfully. Keeps one-shot PS1 scripts from running
|
||||
@@ -749,11 +790,13 @@ foreach ($app in $config.Applications) {
|
||||
}
|
||||
|
||||
$failed++
|
||||
Add-EnforceResult -Name $app.Name -Action 'failed' -ExitCode $rc -Message "Exit $rc - FAILED"
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-InstallLog (" UNCAUGHT error processing {0}: {1} | at {2}" -f $app.Name, $_.Exception.Message, ($_.ScriptStackTrace -replace '\s+',' ')) 'ERROR'
|
||||
$failed++
|
||||
Add-EnforceResult -Name $app.Name -Action 'failed' -Message $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,5 +806,17 @@ Write-InstallLog '============================================'
|
||||
|
||||
cmd /c 'shutdown /a 2>nul' *>$null
|
||||
|
||||
# Emit the summary object for the API enforce runner to report. Write-Host log
|
||||
# lines above go to the host stream, so this is the only value on the success
|
||||
# stream that '& $EnginePath' captures. The exit code is unchanged (SMB path).
|
||||
Write-Output ([pscustomobject]@{
|
||||
Installed = $installed
|
||||
Skipped = $skipped
|
||||
Failed = $failed
|
||||
Filtered = $pcFiltered
|
||||
EnforcerVersion = "$LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR"
|
||||
Results = $script:enforceResults.ToArray()
|
||||
})
|
||||
|
||||
if ($failed -gt 0) { exit 1 }
|
||||
exit 0
|
||||
|
||||
375
playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1
Normal file
375
playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1
Normal file
@@ -0,0 +1,375 @@
|
||||
# ShopdbBackupClient - the client half of the ShopDB asset-backup contract.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# ShopDB's backups plugin is a pluggable registry: a "kind" declares how a
|
||||
# config is parsed, rendered and resolved to an asset, and in return gets
|
||||
# revision history, content dedup, retention, diffs and an asset panel. The
|
||||
# server half is a clean contract. The client half was not: exactly one script
|
||||
# on this share posted backups (Backup-NtlarsSettings), and everything around
|
||||
# the post - finding the collector key, reading the interval, throttling,
|
||||
# logging - lived inside it, bespoke.
|
||||
#
|
||||
# The cost showed up the day someone looked. The interval came from a public
|
||||
# settings key the plugin never declared public, so the read silently fell back
|
||||
# to a hardcoded 24 hours and the setting did nothing for months. The log wrote
|
||||
# three lines every five minutes whatever happened, reaching 3,234 lines of
|
||||
# which 3,217 were the same "Throttled" line. Seven other Backup-*.ps1 scripts
|
||||
# on this share capture device configs and post NONE of them, so nothing about
|
||||
# a CMM, a PC-DMIS bay or an MTConnect box has any history in ShopDB.
|
||||
#
|
||||
# Wiring those seven the old way would have produced seven more copies of all
|
||||
# of the above. So the shared parts live here, once, and a per-device script
|
||||
# becomes: capture the config, call Send-ShopdbBackup.
|
||||
#
|
||||
# USAGE
|
||||
#
|
||||
# Import-Module "$PSScriptRoot\..\lib\ShopdbBackupClient.psm1" -Force
|
||||
# $ctx = Initialize-ShopdbBackup -Kind 'gocmm'
|
||||
# if (-not $ctx.Proceed) { exit 0 } # quiet exit, already logged
|
||||
# $bytes = [IO.File]::ReadAllBytes($configPath)
|
||||
# Send-ShopdbBackup -Context $ctx -Bytes $bytes -SourceFileName 'settings.xml'
|
||||
#
|
||||
# Initialize-ShopdbBackup does every check that can say "nothing to do today":
|
||||
# machine number, collector key, base URL, throttle. Each of those is logged
|
||||
# ONCE and then stays quiet while it holds, so a PC with no NTLARS does not
|
||||
# write the same line 288 times a day.
|
||||
#
|
||||
# PER-KIND STATE, deliberately. Marker, state and log files are all named from
|
||||
# the kind, so a bay running two backup kinds does not have them fighting over
|
||||
# one marker file - which is what a single fixed name would have caused the
|
||||
# first time a second kind shipped.
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$script:LOGDIR = 'C:\Logs\Shopfloor'
|
||||
$script:SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
|
||||
$script:PCCONFIG = 'C:\Enrollment\pc-config.txt'
|
||||
|
||||
|
||||
function Get-ShopdbLogPath {
|
||||
param([Parameter(Mandatory)][string]$Kind)
|
||||
# Date-stamped at source. An append-only name can never age out of the
|
||||
# GE-Enforce retention sweep, which drops by LastWriteTime - a file written
|
||||
# every cycle is always "recent" and grows forever.
|
||||
Join-Path $script:LOGDIR ('{0}-backup-{1}.log' -f $Kind, (Get-Date -Format 'yyyyMMdd'))
|
||||
}
|
||||
|
||||
|
||||
function Write-ShopdbBackupLog {
|
||||
param([Parameter(Mandatory)][string]$Kind,
|
||||
[Parameter(Mandatory)][string]$Message)
|
||||
if (-not (Test-Path $script:LOGDIR)) {
|
||||
New-Item -ItemType Directory -Path $script:LOGDIR -Force -EA SilentlyContinue | Out-Null
|
||||
}
|
||||
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Add-Content -Path (Get-ShopdbLogPath -Kind $Kind) -Value $line -EA SilentlyContinue
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
|
||||
function Write-ShopdbQuietState {
|
||||
<#
|
||||
Log a no-change outcome once, then stay silent while it holds.
|
||||
Returns nothing; the caller exits on a quiet outcome.
|
||||
|
||||
GE-Enforce runs every 5 minutes. An outcome that does not change is
|
||||
worth saying once, not 288 times a day. Any real event clears the state
|
||||
so the next quiet spell announces itself - otherwise a PC that stopped
|
||||
working would look exactly like one still posting.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Kind,
|
||||
[Parameter(Mandatory)][string]$State,
|
||||
[Parameter(Mandatory)][string]$Message)
|
||||
$statefile = Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)
|
||||
$previous = ''
|
||||
if (Test-Path $statefile) {
|
||||
try { $previous = (Get-Content -LiteralPath $statefile -First 1 -EA Stop).Trim() } catch { }
|
||||
}
|
||||
if ($previous -ne $State) {
|
||||
Write-ShopdbBackupLog -Kind $Kind -Message $Message
|
||||
Set-Content -Path $statefile -Value $State -EA SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function Clear-ShopdbQuietState {
|
||||
param([Parameter(Mandatory)][string]$Kind)
|
||||
Remove-Item -LiteralPath (Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)) `
|
||||
-Force -EA SilentlyContinue
|
||||
}
|
||||
|
||||
|
||||
function Get-ShopdbRegValue {
|
||||
<#
|
||||
One value from HKLM:\SOFTWARE\GE\ShopDB.
|
||||
|
||||
The SAME contract the display kiosks use: Install-ShopdbKiosk writes
|
||||
BaseUrl / ApiToken / CollectorKey there and ACLs it to SYSTEM and
|
||||
Administrators. Machine bays read the same place rather than inventing
|
||||
a second home for one secret, so one delivery mechanism serves the whole
|
||||
fleet and the diagnostics collector already knows to redact it.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
try {
|
||||
$props = Get-ItemProperty -Path $script:SHOPDBREG -EA Stop
|
||||
if ($props.PSObject.Properties.Name -contains $Name) {
|
||||
$val = [string]$props.$Name
|
||||
if ($val) { return $val.Trim() }
|
||||
}
|
||||
} catch { }
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
function Get-ShopdbCollectorKey {
|
||||
# Registry first (how the fleet is provisioned), then the enrollment file
|
||||
# for a bay staged before that existed.
|
||||
$key = Get-ShopdbRegValue -Name 'CollectorKey'
|
||||
if ($key) { return $key }
|
||||
if (Test-Path $script:KEYFILE) {
|
||||
foreach ($line in (Get-Content -LiteralPath $script:KEYFILE -EA SilentlyContinue)) {
|
||||
if ($line -match '^\s*collector\s*=\s*(.+)$') { return $matches[1].Trim() }
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
function Get-ShopdbMachineNumber {
|
||||
<#
|
||||
The machine number this PC reports, from pc-config.txt.
|
||||
|
||||
A device-specific fallback can be supplied when the application itself
|
||||
knows the number - NTLARS keeps one in its own registry key. Callers
|
||||
without one just get '' and exit quietly.
|
||||
#>
|
||||
param([scriptblock]$Fallback)
|
||||
if (Test-Path $script:PCCONFIG) {
|
||||
foreach ($line in (Get-Content -LiteralPath $script:PCCONFIG -EA SilentlyContinue)) {
|
||||
if ($line -match '^\s*machine(number|no)?\s*=\s*(.+)$') {
|
||||
$val = $matches[2].Trim()
|
||||
# 9999 is the imaging-time placeholder, not a real bay.
|
||||
if ($val -and $val -ne '9999') { return $val }
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($Fallback) {
|
||||
try {
|
||||
$val = & $Fallback
|
||||
if ($val) { return ([string]$val).Trim() }
|
||||
} catch { }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
function Get-ShopdbIntervalHours {
|
||||
<#
|
||||
Minimum hours between attempts, from the backups_intervalhours setting.
|
||||
|
||||
Read UNAUTHENTICATED from /api/settings/public, because this runs before
|
||||
any credential is needed. That endpoint serves an allowlist, and the key
|
||||
must be declared public by the plugin - it was not, for months, so this
|
||||
read returned nothing and the fallback below silently governed the whole
|
||||
fleet. The fallback stays (an unreachable server must not mean a hot
|
||||
loop) but a miss is now LOGGED rather than swallowed, so the same
|
||||
failure cannot hide again.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Kind,
|
||||
[Parameter(Mandatory)][string]$BaseUrl,
|
||||
[int]$Default = 24)
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/settings/public" -Method Get `
|
||||
-TimeoutSec 10 -EA Stop
|
||||
$val = $null
|
||||
if ($resp -and $resp.PSObject.Properties.Name -contains 'data') {
|
||||
if ($resp.data.PSObject.Properties.Name -contains 'backups_intervalhours') {
|
||||
$val = $resp.data.backups_intervalhours
|
||||
}
|
||||
}
|
||||
if ($val) { return [int]$val }
|
||||
Write-ShopdbQuietState -Kind $Kind -State 'interval-not-public' -Message (
|
||||
'backups_intervalhours is not readable from /api/settings/public; ' +
|
||||
"using the built-in ${Default}h. The plugin must declare the key public.")
|
||||
} catch {
|
||||
Write-ShopdbQuietState -Kind $Kind -State 'interval-unreachable' -Message (
|
||||
"Could not read settings from $BaseUrl ($($_.Exception.Message)); using ${Default}h.")
|
||||
}
|
||||
return $Default
|
||||
}
|
||||
|
||||
|
||||
function Initialize-ShopdbBackup {
|
||||
<#
|
||||
Every check that can say "nothing to do", in one call.
|
||||
|
||||
Returns a context object. .Proceed is $false when the run should stop -
|
||||
the reason has already been logged, once. On $true the context carries
|
||||
Kind, BaseUrl, CollectorKey, MachineNumber and IntervalHours, and the
|
||||
marker has NOT yet been stamped (Send-ShopdbBackup does that).
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Kind,
|
||||
[string]$BaseUrl,
|
||||
[scriptblock]$MachineNumberFallback,
|
||||
[switch]$Force)
|
||||
|
||||
$ctx = [pscustomobject]@{
|
||||
Kind = $Kind
|
||||
Proceed = $false
|
||||
BaseUrl = ''
|
||||
CollectorKey = ''
|
||||
MachineNumber = ''
|
||||
IntervalHours = 24
|
||||
}
|
||||
|
||||
$machineNumber = Get-ShopdbMachineNumber -Fallback $MachineNumberFallback
|
||||
if (-not $machineNumber) {
|
||||
Write-ShopdbQuietState -Kind $Kind -State 'no-machine-number' -Message (
|
||||
'No machine number in pc-config.txt and no device fallback. ' +
|
||||
'A backup cannot be filed against an asset - skipping.')
|
||||
return $ctx
|
||||
}
|
||||
|
||||
$collectorKey = Get-ShopdbCollectorKey
|
||||
if (-not $collectorKey) {
|
||||
Write-ShopdbQuietState -Kind $Kind -State 'no-collector-key' -Message (
|
||||
"No collector key in $script:SHOPDBREG\CollectorKey or $script:KEYFILE. " +
|
||||
'The collector endpoint has no IP-allowlist path, unlike the GE-Enforce ' +
|
||||
'manifest fetch, so it always needs a collector-scoped token. Skipping.')
|
||||
return $ctx
|
||||
}
|
||||
|
||||
if (-not $BaseUrl) { $BaseUrl = Get-ShopdbRegValue -Name 'BaseUrl' }
|
||||
if (-not $BaseUrl) {
|
||||
Write-ShopdbQuietState -Kind $Kind -State 'no-base-url' -Message (
|
||||
"No ShopDB BaseUrl in $script:SHOPDBREG and none passed. Skipping.")
|
||||
return $ctx
|
||||
}
|
||||
$BaseUrl = $BaseUrl.TrimEnd('/')
|
||||
|
||||
$intervalHours = Get-ShopdbIntervalHours -Kind $Kind -BaseUrl $BaseUrl
|
||||
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $Kind)
|
||||
if (-not $Force -and (Test-Path $markerfile)) {
|
||||
try {
|
||||
$last = (Get-Item $markerfile).LastWriteTime
|
||||
if (((Get-Date) - $last).TotalHours -lt $intervalHours) {
|
||||
$due = $last.AddHours($intervalHours).ToString('yyyy-MM-dd HH:mm')
|
||||
Write-ShopdbQuietState -Kind $Kind -State ("throttled-$due") -Message (
|
||||
"Throttled: posted within the last ${intervalHours}h, next attempt after $due. " +
|
||||
'Use -Force to override.')
|
||||
return $ctx
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
$ctx.Proceed = $true
|
||||
$ctx.BaseUrl = $BaseUrl
|
||||
$ctx.CollectorKey = $collectorKey
|
||||
$ctx.MachineNumber = $machineNumber
|
||||
$ctx.IntervalHours = $intervalHours
|
||||
|
||||
Clear-ShopdbQuietState -Kind $Kind
|
||||
Write-ShopdbBackupLog -Kind $Kind -Message (
|
||||
"=== $Kind backup start === machine $machineNumber, $BaseUrl, interval ${intervalHours}h")
|
||||
return $ctx
|
||||
}
|
||||
|
||||
|
||||
function Send-ShopdbBackup {
|
||||
<#
|
||||
POST one captured config to the ShopDB collector.
|
||||
|
||||
-Bytes for a kind ShopDB parses and stores (storagebackend 'shopdb').
|
||||
-ContentHash with -SharePath for a kind whose bytes stay on the share
|
||||
(storagebackend 'share'), where ShopDB keeps metadata and a pointer.
|
||||
|
||||
Returns $true when ShopDB accepted it, whether that produced a new
|
||||
revision or a no-op; an unchanged config is a no-op by design and is
|
||||
the expected outcome most cycles.
|
||||
#>
|
||||
param([Parameter(Mandatory)][pscustomobject]$Context,
|
||||
[byte[]]$Bytes,
|
||||
[string]$ContentHash,
|
||||
[string]$SharePath,
|
||||
[string]$SourceFileName,
|
||||
[int]$TimeoutSec = 30)
|
||||
|
||||
$kind = $Context.Kind
|
||||
|
||||
# Marker BEFORE the post, deliberately. If ShopDB is unreachable we do not
|
||||
# want every cycle for the rest of the day retrying; the next window picks
|
||||
# it up.
|
||||
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $kind)
|
||||
Set-Content -Path $markerfile -Value (Get-Date -Format 'o') -EA SilentlyContinue
|
||||
|
||||
# sourcehostname is load-bearing now, not just informational: ShopDB
|
||||
# resolves a part-marker PC's backup to ITS marker through this field, and
|
||||
# dedup keys a revision chain on it. An empty value silently files the
|
||||
# backup against the operation instead and merges two devices' histories,
|
||||
# so fall back to the DNS name rather than posting a blank.
|
||||
$sourcehost = $env:COMPUTERNAME
|
||||
if (-not $sourcehost) {
|
||||
try { $sourcehost = [System.Net.Dns]::GetHostName() } catch { $sourcehost = '' }
|
||||
}
|
||||
|
||||
$payload = @{
|
||||
machinenumber = $Context.MachineNumber
|
||||
backupkind = $kind
|
||||
sourcehostname = $sourcehost
|
||||
collectedat = (Get-Date).ToUniversalTime().ToString('o')
|
||||
}
|
||||
if ($SourceFileName) { $payload['sourcefilename'] = $SourceFileName }
|
||||
if ($Bytes) {
|
||||
$payload['contentbase64'] = [Convert]::ToBase64String($Bytes)
|
||||
$payload['bytesize'] = $Bytes.Length
|
||||
}
|
||||
if ($ContentHash) { $payload['contenthash'] = $ContentHash }
|
||||
if ($SharePath) { $payload['sharepath'] = $SharePath }
|
||||
|
||||
$uri = '{0}/api/collector/backups' -f $Context.BaseUrl
|
||||
$size = if ($Bytes) { '{0} bytes' -f $Bytes.Length } else { $SharePath }
|
||||
Write-ShopdbBackupLog -Kind $kind -Message (
|
||||
'Posting {0} for machine {1}' -f $size, $Context.MachineNumber)
|
||||
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$resp = Invoke-RestMethod -Uri $uri -Method Post `
|
||||
-Body ($payload | ConvertTo-Json -Compress) `
|
||||
-ContentType 'application/json' `
|
||||
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
|
||||
-TimeoutSec $TimeoutSec -EA Stop
|
||||
|
||||
switch ("$($resp.data.action)") {
|
||||
'created' { Write-ShopdbBackupLog -Kind $kind -Message (
|
||||
'New revision {0} recorded.' -f $resp.data.backuprevisionid) }
|
||||
'noop' { Write-ShopdbBackupLog -Kind $kind -Message 'Settings unchanged - no new revision (expected most cycles).' }
|
||||
default { Write-ShopdbBackupLog -Kind $kind -Message (
|
||||
"ShopDB returned action '{0}'." -f $resp.data.action) }
|
||||
}
|
||||
foreach ($warning in @($resp.data.warnings)) {
|
||||
if ($warning) { Write-ShopdbBackupLog -Kind $kind -Message " WARNING: $warning" }
|
||||
}
|
||||
return $true
|
||||
} catch {
|
||||
# A 400 here is usually meaningful rather than transient: an
|
||||
# unconfigured device, or a machine number ShopDB does not know. Log the
|
||||
# server's own message so the cause is visible at the bay.
|
||||
$detail = $_.Exception.Message
|
||||
try {
|
||||
$stream = $_.Exception.Response.GetResponseStream()
|
||||
$reader = New-Object IO.StreamReader($stream)
|
||||
$body = $reader.ReadToEnd()
|
||||
if ($body) { $detail = $body }
|
||||
} catch { }
|
||||
Write-ShopdbBackupLog -Kind $kind -Message "Post failed: $detail"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Export-ModuleMember -Function Initialize-ShopdbBackup, Send-ShopdbBackup,
|
||||
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
|
||||
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
|
||||
Get-ShopdbIntervalHours, Get-ShopdbLogPath
|
||||
@@ -0,0 +1,203 @@
|
||||
# Backup-NtlarsSettings.ps1
|
||||
#
|
||||
# Captures this PC's LIVE NTLARS/DNC registry settings and posts them to ShopDB,
|
||||
# which files them against the MACHINE (not this PC) and keeps a revision
|
||||
# history. A tech can then re-download the .reg from the machine's page instead
|
||||
# of hunting for a per-machine file on the share.
|
||||
#
|
||||
# Runs from the SFLD share every GE-Enforce cycle as a Type=PS1 manifest entry
|
||||
# with DetectionMethod=Always. Updating this file on the share changes fleet
|
||||
# behaviour on the next cycle - there is no local copy to heal.
|
||||
#
|
||||
# WHY WOW6432Node IS EXPLICIT:
|
||||
# NTLARS is a 32-bit app, so its settings physically live under
|
||||
# HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC. GE-Enforce runs this
|
||||
# script in 64-bit PowerShell, where HKLM:\SOFTWARE\GE Aircraft Engines\DNC
|
||||
# is a DIFFERENT (usually absent) key. Reading the unredirected path would
|
||||
# find nothing and back up an empty config - silently, every cycle. The path
|
||||
# below is therefore spelled out and never abbreviated.
|
||||
#
|
||||
# Note the asymmetry with what NTLARS itself writes: its Save... button
|
||||
# exports WITHOUT the WOW6432Node segment. ShopDB accepts either dialect and
|
||||
# stores a dialect-neutral projection, so this script does not need to care.
|
||||
#
|
||||
# THROTTLE, LOGGING, CREDENTIALS:
|
||||
# All handled by common\lib\ShopdbBackupClient.psm1, which every backup kind
|
||||
# shares. GE-Enforce fires this every cycle, so the module holds the marker
|
||||
# file, exits early until backups_intervalhours has elapsed, and logs a
|
||||
# no-change outcome ONCE rather than every five minutes. The interval comes
|
||||
# from ShopDB, so cadence is changed centrally rather than by editing this
|
||||
# file on the share.
|
||||
#
|
||||
# Always exits 0 so the GE-Enforce "last run result" stays clean. Failures are
|
||||
# logged, never thrown.
|
||||
|
||||
param(
|
||||
[string]$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb',
|
||||
[int]$TimeoutSec = 30,
|
||||
# Force a post regardless of the throttle. For a tech capturing a
|
||||
# known-good config on demand.
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# The only path this script still owns. Log, marker, state, collector key,
|
||||
# enrollment and base URL all moved to ShopdbBackupClient, which names its files
|
||||
# per KIND so two backup kinds on one bay cannot collide.
|
||||
$DNCKEY = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
|
||||
|
||||
function Convert-RegTypeName {
|
||||
param($Kind)
|
||||
switch ("$Kind") {
|
||||
'String' { 'REG_SZ' }
|
||||
'ExpandString' { 'REG_EXPAND_SZ' }
|
||||
'DWord' { 'REG_DWORD' }
|
||||
'QWord' { 'REG_QWORD' }
|
||||
'MultiString' { 'REG_MULTI_SZ' }
|
||||
'Binary' { 'REG_BINARY' }
|
||||
default { 'REG_SZ' }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DncKeys {
|
||||
<#
|
||||
The DNC key and every subkey, root first. Split out from
|
||||
Export-DncToReg so the formatting logic can be exercised against mock
|
||||
keys on a machine with no registry (see Test-RegExport.ps1).
|
||||
#>
|
||||
$keys = @(Get-Item -Path $DNCKEY -EA Stop)
|
||||
$keys += @(Get-ChildItem -Path $DNCKEY -Recurse -EA SilentlyContinue)
|
||||
return $keys
|
||||
}
|
||||
|
||||
function Export-DncToReg {
|
||||
<#
|
||||
Emits .reg text in the WOW6432Node dialect for the supplied keys.
|
||||
|
||||
Built by hand rather than shelling out to `reg export` because reg.exe
|
||||
writes UTF-16 to a temp file we would then have to read back, and
|
||||
because this keeps the value types explicit instead of reparsing them.
|
||||
|
||||
Takes the key list as a parameter so it can be tested with mocks; the
|
||||
escaping and dword formatting here are the part that would corrupt a
|
||||
backup silently and only surface at restore time.
|
||||
#>
|
||||
param([Parameter(Mandatory)]$Keys)
|
||||
|
||||
$lines = @('Windows Registry Editor Version 5.00', '')
|
||||
$lines += "; NTLARS DNC Registry Backup"
|
||||
$lines += "; Computer: $env:COMPUTERNAME"
|
||||
$lines += "; Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||
$lines += ''
|
||||
|
||||
foreach ($key in $Keys) {
|
||||
# PSPath -> the literal HKEY_LOCAL_MACHINE\... form a .reg file needs.
|
||||
$path = $key.Name -replace '^HKEY_LOCAL_MACHINE', 'HKEY_LOCAL_MACHINE'
|
||||
$lines += "[$path]"
|
||||
foreach ($name in $key.GetValueNames()) {
|
||||
$kind = Convert-RegTypeName $key.GetValueKind($name)
|
||||
$data = $key.GetValue($name)
|
||||
$lhs = if ($name -eq '') { '@' } else {
|
||||
# .NET replacement strings do NOT process backslash escapes, so
|
||||
# the replacement is the literal output: '\\' emits two
|
||||
# backslashes, which is what .reg escaping wants. Writing
|
||||
# '\\\\' here emits FOUR and silently corrupts every path-valued
|
||||
# setting - verified on Windows before this was fixed.
|
||||
'"{0}"' -f ($name -replace '\\', '\\' -replace '"', '\"')
|
||||
}
|
||||
switch ($kind) {
|
||||
'REG_DWORD' {
|
||||
$lines += ('{0}=dword:{1:x8}' -f $lhs, [uint32]$data)
|
||||
}
|
||||
'REG_BINARY' {
|
||||
$hex = ($data | ForEach-Object { '{0:x2}' -f $_ }) -join ','
|
||||
$lines += ('{0}=hex:{1}' -f $lhs, $hex)
|
||||
}
|
||||
'REG_QWORD' {
|
||||
$bytes = [BitConverter]::GetBytes([uint64]$data)
|
||||
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
|
||||
$lines += ('{0}=hex(b):{1}' -f $lhs, $hex)
|
||||
}
|
||||
'REG_MULTI_SZ' {
|
||||
$joined = (($data -join "`0") + "`0`0")
|
||||
$bytes = [Text.Encoding]::Unicode.GetBytes($joined)
|
||||
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
|
||||
$lines += ('{0}=hex(7):{1}' -f $lhs, $hex)
|
||||
}
|
||||
'REG_EXPAND_SZ' {
|
||||
$bytes = [Text.Encoding]::Unicode.GetBytes(("$data" + "`0"))
|
||||
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
|
||||
$lines += ('{0}=hex(2):{1}' -f $lhs, $hex)
|
||||
}
|
||||
default {
|
||||
$escaped = ("$data" -replace '\\', '\\' -replace '"', '\"')
|
||||
$lines += ('{0}="{1}"' -f $lhs, $escaped)
|
||||
}
|
||||
}
|
||||
}
|
||||
$lines += ''
|
||||
}
|
||||
return ($lines -join "`r`n") + "`r`n"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
#
|
||||
# Guarded so the file can be DOT-SOURCED to get the functions without running a
|
||||
# backup. Test-RegExport.ps1 relies on this to exercise Export-DncToReg against
|
||||
# mock keys on a machine with no registry.
|
||||
# =============================================================================
|
||||
|
||||
if ($MyInvocation.InvocationName -eq '.') { return }
|
||||
|
||||
# The plumbing - key lookup, base URL, interval, throttle, logging, the POST and
|
||||
# its response handling - lives in ShopdbBackupClient. It used to live here, and
|
||||
# being the only implementation meant every defect in it was invisible: the
|
||||
# interval read fell back to a hardcoded 24h for months because nobody had a
|
||||
# second copy to compare against, and the log wrote three lines every five
|
||||
# minutes whatever happened. Seven other Backup-*.ps1 scripts on this share
|
||||
# capture device configs and post none of them; they can now do so without
|
||||
# inheriting a copy of all that.
|
||||
#
|
||||
# What stays here is the part that is actually about NTLARS: where its registry
|
||||
# lives, and how to turn it into .reg text.
|
||||
Import-Module (Join-Path $PSScriptRoot '..\lib\ShopdbBackupClient.psm1') -Force
|
||||
|
||||
if (-not (Test-Path $DNCKEY)) {
|
||||
# Not an error: plenty of PC types have no NTLARS at all. On those PCs this
|
||||
# is the permanent state, so it is said once rather than 288 times a day.
|
||||
Write-ShopdbQuietState -Kind 'ntlars' -State 'no-dnc-key' -Message (
|
||||
"No DNC key at $DNCKEY - NTLARS is not installed on this PC. Nothing to do.")
|
||||
exit 0
|
||||
}
|
||||
|
||||
# NTLARS keeps its own MachineNo, used only when pc-config.txt has none. ShopDB
|
||||
# compares the two and warns on a mismatch rather than silently trusting one.
|
||||
$ctx = Initialize-ShopdbBackup -Kind 'ntlars' -Force:$Force `
|
||||
-BaseUrl $(if ($PSBoundParameters.ContainsKey('BaseUrl')) { $BaseUrl } else { '' }) `
|
||||
-MachineNumberFallback {
|
||||
try {
|
||||
$general = Get-ItemProperty -Path (Join-Path $DNCKEY 'General') -EA Stop
|
||||
if ($general.MachineNo) { return ([string]$general.MachineNo).Trim() }
|
||||
} catch { }
|
||||
return ''
|
||||
}
|
||||
if (-not $ctx.Proceed) { exit 0 }
|
||||
|
||||
try {
|
||||
$regText = Export-DncToReg -Keys (Get-DncKeys)
|
||||
} catch {
|
||||
Write-ShopdbBackupLog -Kind 'ntlars' -Message "Failed to read the DNC key: $_"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# UTF-16LE + BOM, matching what regedit and NTLARS emit. ShopDB sniffs the BOM,
|
||||
# so this is belt-and-braces rather than strictly required.
|
||||
$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($regText)
|
||||
|
||||
[void](Send-ShopdbBackup -Context $ctx -Bytes $bytes `
|
||||
-SourceFileName ("{0}.reg" -f $ctx.MachineNumber) -TimeoutSec $TimeoutSec)
|
||||
|
||||
Write-ShopdbBackupLog -Kind 'ntlars' -Message '=== Backup-NtlarsSettings end ==='
|
||||
exit 0
|
||||
33
playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd
Executable file
33
playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd
Executable file
@@ -0,0 +1,33 @@
|
||||
@echo off
|
||||
REM Install-AcroReader.cmd - Install Adobe Acrobat Reader DC + DC update patch
|
||||
REM
|
||||
REM Two-step install: base MSI with enterprise transform, then DC update patch.
|
||||
REM Script lives in <type>/scripts/; MSI + MST + MSP + CAB live in sibling
|
||||
REM <type>/apps/. pushd into apps\ so the MSI's Media-table CAB reference
|
||||
REM (Data1.cab) resolves against its sibling, and msiexec doesn't choke on
|
||||
REM a mapped-drive path with ..\ normalization (was returning 1619).
|
||||
|
||||
setlocal
|
||||
pushd "%~dp0..\apps"
|
||||
|
||||
echo Installing Adobe Acrobat Reader DC...
|
||||
msiexec /i "AcroRead.msi" TRANSFORMS="AcroRead.mst" /quiet /norestart
|
||||
set RC=%errorlevel%
|
||||
if %RC% neq 0 if %RC% neq 3010 (
|
||||
echo Acrobat Reader MSI failed with exit code %RC%
|
||||
popd
|
||||
exit /b %RC%
|
||||
)
|
||||
|
||||
echo Applying Adobe Reader DC update patch...
|
||||
msiexec /p "AcroRdrDCUpd2500120531.msp" /quiet /norestart
|
||||
set RC=%errorlevel%
|
||||
if %RC% neq 0 if %RC% neq 3010 (
|
||||
echo Acrobat Reader patch failed with exit code %RC%
|
||||
popd
|
||||
exit /b %RC%
|
||||
)
|
||||
|
||||
popd
|
||||
echo Adobe Acrobat Reader DC installed successfully.
|
||||
exit /b 0
|
||||
75
playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd
Executable file
75
playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd
Executable file
@@ -0,0 +1,75 @@
|
||||
@echo off
|
||||
REM Install-Oracle11r2.cmd
|
||||
REM Expands the GE Oracle Client 11.2 Administrator zip to a temp dir and
|
||||
REM runs Oracle Universal Installer silently with the GE-customized
|
||||
REM response file.
|
||||
REM
|
||||
REM Expected layout on the SFLD share (relative to this .cmd):
|
||||
REM ..\apps\Oracle_OracleDatabase_11r2_V03.zip (686 MB)
|
||||
REM
|
||||
REM Called by Install-FromManifest.ps1 (Type=CMD). Exit codes surface back
|
||||
REM to the enforcer.
|
||||
REM
|
||||
REM Oracle 11.2 OUI exit codes worth knowing:
|
||||
REM 0 = success
|
||||
REM 3 = success but with warnings
|
||||
REM 1 = general failure
|
||||
REM 6 = silent install requested but missing / bad response file
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
set "LOG=C:\Logs\OracleClient\install.log"
|
||||
if not exist "C:\Logs\OracleClient" mkdir "C:\Logs\OracleClient"
|
||||
|
||||
REM Emit a datestamp
|
||||
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value 2^>nul ^| find "="') do set LDT=%%I
|
||||
set "STAMP=!LDT:~0,14!"
|
||||
|
||||
echo [%STAMP%] Install-Oracle11r2.cmd starting >> "%LOG%"
|
||||
|
||||
set "SRC_ZIP=%~dp0..\apps\Oracle_OracleDatabase_11r2_V03.zip"
|
||||
set "STAGING=%TEMP%\oracle-11r2-install"
|
||||
set "CLIENT_DIR=%STAGING%\Oracle_OracleDatabase_11r2_V03\client"
|
||||
set "RSP=%CLIENT_DIR%\response\ge_client_install.rsp"
|
||||
|
||||
if not exist "%SRC_ZIP%" (
|
||||
echo [%STAMP%] ERROR: zip not found at %SRC_ZIP% >> "%LOG%"
|
||||
echo ERROR: zip not found at %SRC_ZIP%
|
||||
exit /b 2
|
||||
)
|
||||
|
||||
echo [%STAMP%] Expanding %SRC_ZIP% to %STAGING% >> "%LOG%"
|
||||
if exist "%STAGING%" rmdir /s /q "%STAGING%" >nul 2>&1
|
||||
mkdir "%STAGING%"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"try { Expand-Archive -Path '%SRC_ZIP%' -DestinationPath '%STAGING%' -Force -ErrorAction Stop; exit 0 } catch { Write-Error $_; exit 1 }" ^
|
||||
>> "%LOG%" 2>&1
|
||||
|
||||
if not exist "%CLIENT_DIR%\setup.exe" (
|
||||
echo [%STAMP%] ERROR: expanded setup.exe not found at %CLIENT_DIR%\setup.exe >> "%LOG%"
|
||||
exit /b 3
|
||||
)
|
||||
|
||||
if not exist "%RSP%" (
|
||||
echo [%STAMP%] ERROR: response file missing at %RSP% >> "%LOG%"
|
||||
exit /b 4
|
||||
)
|
||||
|
||||
echo [%STAMP%] Running OUI silent install (this takes 2-8 minutes) >> "%LOG%"
|
||||
"%CLIENT_DIR%\setup.exe" -silent -waitforcompletion -nowait ^
|
||||
-ignoreSysPrereqs ^
|
||||
-responseFile "%RSP%" >> "%LOG%" 2>&1
|
||||
set RC=%ERRORLEVEL%
|
||||
|
||||
echo [%STAMP%] OUI exit code: %RC% >> "%LOG%"
|
||||
|
||||
REM Cleanup staging dir to reclaim ~1.5 GB - OUI copies everything to ORACLE_HOME
|
||||
echo [%STAMP%] Cleaning up staging dir >> "%LOG%"
|
||||
rmdir /s /q "%STAGING%" >nul 2>&1
|
||||
|
||||
REM OUI returns 0 for success, 3 for success-with-warnings. Treat both as OK.
|
||||
if %RC%==3 (
|
||||
echo [%STAMP%] OUI reported warnings but install succeeded - returning 0 >> "%LOG%"
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
exit /b %RC%
|
||||
62
playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1
Executable file
62
playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1
Executable file
@@ -0,0 +1,62 @@
|
||||
# Migrate-PCType.ps1 - One-shot in-place rename of legacy pc-type.txt
|
||||
# values to the gea-shopfloor-* taxonomy.
|
||||
#
|
||||
# Idempotent + safe: no-op if pc-type.txt already starts with
|
||||
# 'gea-shopfloor-'. Decides collections vs nocollections from UDC's
|
||||
# Uninstall reg presence (collections has UDC, nocollections doesn't).
|
||||
# Standard-Timeclock + Lab map to gea-shopfloor-common.
|
||||
#
|
||||
# Runs every cycle (DetectionMethod=Always in manifest). Cheap because
|
||||
# the no-op fast path is just one Get-Content + StartsWith check.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$typeFile = 'C:\Enrollment\pc-type.txt'
|
||||
$subTypeFile = 'C:\Enrollment\pc-subtype.txt'
|
||||
|
||||
if (-not (Test-Path $typeFile)) { exit 0 }
|
||||
|
||||
$current = (Get-Content -LiteralPath $typeFile -First 1 -ErrorAction SilentlyContinue).Trim()
|
||||
if (-not $current) { exit 0 }
|
||||
|
||||
# Already on new taxonomy
|
||||
if ($current.StartsWith('gea-shopfloor-')) { exit 0 }
|
||||
|
||||
$subType = ''
|
||||
if (Test-Path $subTypeFile) {
|
||||
$subType = (Get-Content -LiteralPath $subTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
|
||||
}
|
||||
|
||||
# Map legacy -> new
|
||||
$newType = $null
|
||||
switch -Regex ($current) {
|
||||
'^Standard$' {
|
||||
if ($subType -ieq 'Machine') {
|
||||
$udcReg = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\UDC'
|
||||
$newType = if ($udcReg) { 'gea-shopfloor-collections' } else { 'gea-shopfloor-nocollections' }
|
||||
} elseif ($subType -ieq 'Timeclock') {
|
||||
$newType = 'gea-shopfloor-common'
|
||||
} else {
|
||||
# Standard with no subtype - default to collections (most common)
|
||||
$newType = 'gea-shopfloor-collections'
|
||||
}
|
||||
}
|
||||
'^CMM$' { $newType = 'gea-shopfloor-cmm' }
|
||||
'^Keyence$' { $newType = 'gea-shopfloor-keyence' }
|
||||
'^Lab$' { $newType = 'gea-shopfloor-common' }
|
||||
'^WaxAndTrace$' { $newType = 'gea-shopfloor-waxtrace' }
|
||||
'^Genspect$' { $newType = 'gea-shopfloor-genspect' }
|
||||
'^Display$' { $newType = 'gea-shopfloor-display' }
|
||||
'^Heattreat$' { $newType = 'gea-shopfloor-heattreat' }
|
||||
default { Write-Host "Migrate-PCType: unmapped legacy value '$current' - leaving alone"; exit 0 }
|
||||
}
|
||||
|
||||
Write-Host "Migrate-PCType: '$current' (subType='$subType') -> '$newType'"
|
||||
Set-Content -LiteralPath $typeFile -Value $newType -Encoding ascii -ErrorAction Stop
|
||||
|
||||
# Drop pc-subtype.txt - new taxonomy is single-string
|
||||
if (Test-Path $subTypeFile) {
|
||||
try { Remove-Item -LiteralPath $subTypeFile -Force -ErrorAction Stop; Write-Host " removed pc-subtype.txt" } catch {}
|
||||
}
|
||||
|
||||
exit 0
|
||||
68
playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1
Normal file
68
playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1
Normal file
@@ -0,0 +1,68 @@
|
||||
# Select-KioskType.ps1
|
||||
#
|
||||
# Imaging-time picker that records which kind of kiosk this PC is, so GE-Enforce
|
||||
# (share or Flask API) enforces the right scope. Writes the scope name to
|
||||
# C:\Enrollment\pc-type.txt - the same file every other pc-type reads, and the
|
||||
# value the Flask client passes as -Scope.
|
||||
#
|
||||
# Three kiosk subtypes (split out of the old generic gea-shopfloor-display):
|
||||
# 1 lobbydisplay -> gea-shopfloor-lobbydisplay (lobby TV, dt\tv\slides)
|
||||
# 2 dashboard -> gea-shopfloor-dashboard (shopfloor dashboard)
|
||||
# 3 printerkiosk -> gea-shopfloor-printerkiosk (3D-printer kiosk)
|
||||
#
|
||||
# Usage:
|
||||
# interactive (imaging operator picks): .\Select-KioskType.ps1
|
||||
# unattended (automation / task seq): .\Select-KioskType.ps1 -Type dashboard
|
||||
# already-deployed kiosk (one-time set): .\Select-KioskType.ps1 -Type lobbydisplay
|
||||
#
|
||||
# Idempotent: rewrites pc-type.txt to the chosen scope. Always exits 0.
|
||||
|
||||
param(
|
||||
[ValidateSet('lobbydisplay', 'dashboard', 'printerkiosk')]
|
||||
[string]$Type,
|
||||
|
||||
[string]$EnrollmentFile = 'C:\Enrollment\pc-type.txt'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$map = [ordered]@{
|
||||
'1' = @{ Key = 'lobbydisplay'; Scope = 'gea-shopfloor-lobbydisplay'; Desc = 'Lobby display (lobby TV / slides)' }
|
||||
'2' = @{ Key = 'dashboard'; Scope = 'gea-shopfloor-dashboard'; Desc = 'Shopfloor dashboard' }
|
||||
'3' = @{ Key = 'printerkiosk'; Scope = 'gea-shopfloor-printerkiosk'; Desc = '3D-printer kiosk' }
|
||||
}
|
||||
|
||||
function Resolve-ScopeFromType([string]$t) {
|
||||
foreach ($k in $map.Keys) { if ($map[$k].Key -eq $t) { return $map[$k].Scope } }
|
||||
return $null
|
||||
}
|
||||
|
||||
$scope = $null
|
||||
if ($Type) {
|
||||
$scope = Resolve-ScopeFromType $Type
|
||||
} else {
|
||||
Write-Host ''
|
||||
Write-Host 'Select this PC kiosk type:' -ForegroundColor Cyan
|
||||
foreach ($k in $map.Keys) { Write-Host (" {0}) {1}" -f $k, $map[$k].Desc) }
|
||||
Write-Host ''
|
||||
do {
|
||||
$choice = Read-Host 'Enter 1, 2, or 3'
|
||||
} while (-not $map.Contains($choice))
|
||||
$scope = $map[$choice].Scope
|
||||
}
|
||||
|
||||
if (-not $scope) {
|
||||
Write-Host "ERROR could not resolve a kiosk scope (Type='$Type')." -ForegroundColor Red
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
$dir = Split-Path -Parent $EnrollmentFile
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
$scope | Set-Content -NoNewline -LiteralPath $EnrollmentFile -ErrorAction Stop
|
||||
Write-Host "wrote $EnrollmentFile = $scope" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "ERROR writing ${EnrollmentFile}: $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
exit 0
|
||||
54
playbook/shopfloor-setup/common/scripts/Set-EventSaverDisable.ps1
Executable file
54
playbook/shopfloor-setup/common/scripts/Set-EventSaverDisable.ps1
Executable file
@@ -0,0 +1,54 @@
|
||||
# Set-EventSaverDisable.ps1
|
||||
#
|
||||
# Turn the EventSaver shopfloor screensaver OFF fleet-wide and undo its
|
||||
# side effects. Runs under GE-Enforce (SYSTEM) every cycle. Idempotent +
|
||||
# silent (SYSTEM context - no window).
|
||||
#
|
||||
# Self-excludes the canary test host: pass -ExceptHost <name> and this script
|
||||
# does nothing on that PC, so the (canary-gated) enable entries keep it on
|
||||
# there while every other shopfloor PC is cleaned.
|
||||
#
|
||||
# Undoes: screensaver registry (per-user), the EventSaver-Enable fallback
|
||||
# task, the never-off power policy, and the staged .scr/.ini.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ExceptHost = '',
|
||||
[string]$ScrPath = 'C:\Windows\System32\EventSaver.scr',
|
||||
[string]$TaskName = 'EventSaver-Enable',
|
||||
[int] $MonitorMinutes = 15, # restore a sane monitor-off (was Never)
|
||||
[int] $StandbyMinutes = 20 # restore a sane sleep (was Never)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# leave the canary host alone - the enable entries own it there
|
||||
if ($ExceptHost -and ($env:COMPUTERNAME -ieq $ExceptHost)) { exit 0 }
|
||||
|
||||
function Disable-Saver($deskKey) {
|
||||
if (Test-Path $deskKey) {
|
||||
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '0' -Type String -Force -ErrorAction SilentlyContinue
|
||||
Remove-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# 1. screensaver off in the default profile + every loaded user hive
|
||||
Disable-Saver 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop'
|
||||
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } |
|
||||
ForEach-Object { Disable-Saver "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop" }
|
||||
|
||||
# 2. remove the self-clearing fallback task if present
|
||||
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# 3. restore power (undo the EventSaver never-off)
|
||||
& powercfg /change monitor-timeout-ac $MonitorMinutes 2>&1 | Out-Null
|
||||
& powercfg /change standby-timeout-ac $StandbyMinutes 2>&1 | Out-Null
|
||||
|
||||
# 4. remove staged binary + config (harmless if already gone)
|
||||
Remove-Item -LiteralPath $ScrPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath ($ScrPath -replace '\.scr$', '.ini') -Force -ErrorAction SilentlyContinue
|
||||
|
||||
exit 0
|
||||
29
playbook/shopfloor-setup/common/scripts/Set-EventSaverPower.ps1
Executable file
29
playbook/shopfloor-setup/common/scripts/Set-EventSaverPower.ps1
Executable file
@@ -0,0 +1,29 @@
|
||||
# Set-EventSaverPower.ps1
|
||||
#
|
||||
# Keep shopfloor monitors + PCs awake so the EventSaver ad screensaver is
|
||||
# actually visible. Runs under GE-Enforce (SYSTEM) every cycle (Always).
|
||||
# Sets monitor-off + sleep to Never on the ACTIVE power scheme, so a screen
|
||||
# never blanks out from under the screensaver. Re-applies each cycle, so a
|
||||
# power-plan change is corrected on the next enforce pass.
|
||||
#
|
||||
# Screensaver must trigger before any monitor-off would - with monitor-off
|
||||
# set to Never here, the screensaver (2-10 min idle) always wins.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$logDir = 'C:\Logs\Shopfloor'
|
||||
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
|
||||
$log = Join-Path $logDir 'eventsaver.log'
|
||||
function Write-Log($m) {
|
||||
Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m)
|
||||
}
|
||||
|
||||
# 0 = Never. Cover monitor + standby, AC + DC (shopfloor PCs are AC, DC is
|
||||
# harmless belt-and-suspenders).
|
||||
& powercfg /change monitor-timeout-ac 0 2>&1 | Out-Null
|
||||
& powercfg /change monitor-timeout-dc 0 2>&1 | Out-Null
|
||||
& powercfg /change standby-timeout-ac 0 2>&1 | Out-Null
|
||||
& powercfg /change standby-timeout-dc 0 2>&1 | Out-Null
|
||||
|
||||
Write-Log "power: monitor-off + sleep set to Never (AC+DC) for EventSaver visibility"
|
||||
exit 0
|
||||
123
playbook/shopfloor-setup/common/scripts/Set-EventSaverScreensaver.ps1
Executable file
123
playbook/shopfloor-setup/common/scripts/Set-EventSaverScreensaver.ps1
Executable file
@@ -0,0 +1,123 @@
|
||||
# Set-EventSaverScreensaver.ps1
|
||||
#
|
||||
# Enable the EventSaver shopfloor screensaver for EVERY user on the box. Runs
|
||||
# under GE-Enforce (SYSTEM) every cycle.
|
||||
#
|
||||
# WHY THIS WAS REWRITTEN (2026-08-06)
|
||||
# The previous version wrote the timeout to exactly two places: HKU\.DEFAULT,
|
||||
# which only seeds profiles created AFTERWARDS, and the hive of one profile
|
||||
# whose folder had to be named literally 'Shopfloor'. On any PC where the
|
||||
# operator signs in as anything else, no HKCU was ever touched - the screensaver
|
||||
# then ran on whatever the profile already carried (a domain default, commonly
|
||||
# 120 seconds), while GE-Enforce reported success every cycle because from its
|
||||
# point of view it had done its job.
|
||||
#
|
||||
# That is what the "screensaver after 2 minutes instead of 9" reports were: the
|
||||
# manifest said 480 and the machines had never been told.
|
||||
#
|
||||
# Now: seed .DEFAULT for future profiles, then apply to EVERY loaded user hive.
|
||||
# Same approach Set-DisplayAlwaysOn.ps1 already uses for the kiosks. A user who
|
||||
# signs in between cycles is picked up on the next one.
|
||||
#
|
||||
# IDEMPOTENT + SILENT: hives already correct are skipped, so the common path
|
||||
# writes nothing.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ScrPath = 'C:\Windows\System32\EventSaver.scr',
|
||||
[int] $TimeoutSeconds = 540,
|
||||
# Retired. Kept so an older manifest passing -TargetUser does not fail to
|
||||
# bind; it is deliberately ignored - targeting one named account is the bug
|
||||
# this rewrite removes.
|
||||
[string]$TargetUser = '',
|
||||
[string]$TaskName = 'EventSaver-Enable'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$logDir = 'C:\Logs\Shopfloor'
|
||||
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
|
||||
$log = Join-Path $logDir 'eventsaver.log'
|
||||
function Write-Log($m) {
|
||||
Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m)
|
||||
}
|
||||
|
||||
function Get-Val($deskKey, $name) {
|
||||
return (Get-ItemProperty -Path $deskKey -Name $name -ErrorAction SilentlyContinue).$name
|
||||
}
|
||||
|
||||
# Already what we want? Idempotency gate - keeps the common path silent.
|
||||
function Saver-IsSet($deskKey) {
|
||||
if (-not (Test-Path $deskKey)) { return $false }
|
||||
if ((Get-Val $deskKey 'ScreenSaveActive') -ne '1') { return $false }
|
||||
if ((Get-Val $deskKey 'SCRNSAVE.EXE') -ne $ScrPath) { return $false }
|
||||
if ("$((Get-Val $deskKey 'ScreenSaveTimeOut'))" -ne "$TimeoutSeconds") { return $false }
|
||||
return $true
|
||||
}
|
||||
|
||||
function Set-SaverValues($deskKey) {
|
||||
if (-not (Test-Path $deskKey)) { New-Item -Path $deskKey -Force | Out-Null }
|
||||
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '1' -Type String -Force
|
||||
Set-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -Value $ScrPath -Type String -Force
|
||||
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveTimeOut' -Value "$TimeoutSeconds" -Type String -Force
|
||||
Set-ItemProperty -Path $deskKey -Name 'ScreenSaverIsSecure' -Value '0' -Type String -Force
|
||||
}
|
||||
|
||||
# --- 1. seed .DEFAULT so profiles created later start correct -----------------
|
||||
$defKey = 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop'
|
||||
if (-not (Saver-IsSet $defKey)) {
|
||||
try { Set-SaverValues $defKey; Write-Log "seeded .DEFAULT ($TimeoutSeconds s)" }
|
||||
catch { Write-Log "ERROR seeding .DEFAULT: $_" }
|
||||
}
|
||||
|
||||
# --- 2. apply to every loaded human hive --------------------------------------
|
||||
# Skipped: the three service accounts (SYSTEM, LOCAL SERVICE, NETWORK SERVICE)
|
||||
# and the _Classes companions, which are not user desktops and would just add
|
||||
# noise. Everything else that is loaded belongs to somebody signed in now.
|
||||
$serviceSids = @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')
|
||||
$applied = 0
|
||||
$already = 0
|
||||
|
||||
try {
|
||||
$hives = Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop | ForEach-Object { $_.PSChildName }
|
||||
} catch {
|
||||
Write-Log "ERROR enumerating HKEY_USERS: $_"
|
||||
$hives = @()
|
||||
}
|
||||
|
||||
foreach ($sid in $hives) {
|
||||
if ($sid -eq '.DEFAULT') { continue } # handled above
|
||||
if ($sid -like '*_Classes') { continue }
|
||||
if ($serviceSids -contains $sid) { continue }
|
||||
if ($sid -notlike 'S-1-5-21-*') { continue } # real domain/local users only
|
||||
|
||||
$hiveKey = "Registry::HKEY_USERS\$sid\Control Panel\Desktop"
|
||||
if (Saver-IsSet $hiveKey) { $already++; continue }
|
||||
try {
|
||||
Set-SaverValues $hiveKey
|
||||
$applied++
|
||||
Write-Log "applied to hive $sid ($TimeoutSeconds s)"
|
||||
} catch {
|
||||
Write-Log "ERROR writing hive ${sid}: $_"
|
||||
}
|
||||
}
|
||||
|
||||
if ($applied -eq 0 -and $already -eq 0) {
|
||||
# Nobody signed in - normal during imaging or on an idle bay. .DEFAULT above
|
||||
# covers the next profile, and the next cycle after a logon covers the rest.
|
||||
Write-Log 'no user hives loaded; .DEFAULT seeded, will apply on a later cycle'
|
||||
}
|
||||
|
||||
# --- 3. remove the old per-user fallback task ---------------------------------
|
||||
# The previous version registered an AtLogon task for the hardcoded 'Shopfloor'
|
||||
# account. On machines where nobody signs in as that, it sat queued forever and
|
||||
# never fired. Applying to loaded hives every cycle replaces it, so clear any
|
||||
# that are still registered.
|
||||
try {
|
||||
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Write-Log "removed stale fallback task '$TaskName'"
|
||||
}
|
||||
} catch { }
|
||||
|
||||
exit 0
|
||||
46
playbook/shopfloor-setup/common/scripts/Set-FmsHostsEntry.ps1
Executable file
46
playbook/shopfloor-setup/common/scripts/Set-FmsHostsEntry.ps1
Executable file
@@ -0,0 +1,46 @@
|
||||
# Set-FmsHostsEntry.ps1 - idempotently pin FMS host in C:\Windows\System32\drivers\etc\hosts.
|
||||
#
|
||||
# Why pinned:
|
||||
# eDNC's FMS prescan (CPreScan::Initialise_Sockets in DncMain.exe and
|
||||
# CDoPersonnel::InitializeSocket in DNCdll.dll) resolves the FMS host
|
||||
# via MFC CSocket, which calls inet_addr first then gethostbyname. The
|
||||
# legacy WinSock1 resolver path fails on the GE corporate network for
|
||||
# wjfms3.ae.ge.com (modern getaddrinfo path used by PowerShell works
|
||||
# fine, but eDNC does not use it). Hosts file entry is consulted by
|
||||
# gethostbyname before any DNS query, so the pin short-circuits the
|
||||
# broken legacy path.
|
||||
#
|
||||
# Idempotent: adds line if missing, leaves it alone if already present.
|
||||
# Safe to run every cycle (DetectionMethod=Always in manifest).
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$hostsPath = Join-Path $env:windir 'System32\drivers\etc\hosts'
|
||||
$ip = '10.233.112.158'
|
||||
$fqdn = 'WJFMS3.AE.GE.COM'
|
||||
$line = "$ip`t$fqdn"
|
||||
|
||||
if (-not (Test-Path $hostsPath)) {
|
||||
Write-Host "hosts file not found at $hostsPath - aborting"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content -LiteralPath $hostsPath -ErrorAction Stop
|
||||
|
||||
# Match any non-comment line that maps either the IP or the FQDN.
|
||||
# Drops stale or wrong mappings of the same FQDN/IP, then appends the canonical pin.
|
||||
$pattern = '(?i)^\s*[^#\s]+\s+\S*' + [regex]::Escape($fqdn) + '\b|^\s*' + [regex]::Escape($ip) + '\s'
|
||||
$existing = $content | Where-Object { $_ -match $pattern }
|
||||
$canonical = ($existing | Where-Object { $_ -match "^\s*$([regex]::Escape($ip))\s+$([regex]::Escape($fqdn))\s*$" })
|
||||
|
||||
if ($canonical -and $existing.Count -eq @($canonical).Count) {
|
||||
# Already pinned correctly. No change.
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Either no entry, or an entry exists with wrong IP/FQDN/casing/whitespace. Rewrite.
|
||||
$kept = $content | Where-Object { $_ -notmatch $pattern }
|
||||
$new = @($kept) + $line
|
||||
Set-Content -LiteralPath $hostsPath -Value $new -Encoding ascii -ErrorAction Stop
|
||||
Write-Host "Wrote FMS hosts pin: $line"
|
||||
exit 0
|
||||
@@ -0,0 +1,58 @@
|
||||
# Set-OpenTextToolbar.ps1 - replace the HostExplorer default VT toolbar.
|
||||
#
|
||||
# Copies the toolbar shipped in this share over the local one at:
|
||||
# C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar\
|
||||
#
|
||||
# Runs from the common manifest with DetectionMethod Always, so it also
|
||||
# self-heals if the file is changed locally.
|
||||
#
|
||||
# Deliberately does NOT bump the OpenText version. Setup-OpenText.ps1 skips
|
||||
# unless HKLM:\SOFTWARE\GE\OpenText\Installed differs from version.txt, so a
|
||||
# bump re-runs the whole thing - MSI plus the SP1 patch - on every OpenText
|
||||
# machine over SMB, to ship a 565-byte file. This entry copies just the file.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$fileName = 'Default VT Toolbar.tbv'
|
||||
$src = Join-Path $PSScriptRoot "..\apps\opentext\HostExplorer\Toolbar\$fileName"
|
||||
$dstDir = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar'
|
||||
$dst = Join-Path $dstDir $fileName
|
||||
|
||||
Write-Host '=== OpenText VT toolbar ==='
|
||||
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
Write-Host " source missing on share: $src - nothing to do."
|
||||
return
|
||||
}
|
||||
|
||||
# Gate on OpenText actually being installed. The Shared root only exists once
|
||||
# HostExplorer content has been deployed, so its absence means this machine has
|
||||
# no OpenText and we should not create a stray Hummingbird tree.
|
||||
$sharedRoot = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared'
|
||||
if (-not (Test-Path -LiteralPath $sharedRoot)) {
|
||||
Write-Host ' OpenText not installed on this PC - skipping.'
|
||||
return
|
||||
}
|
||||
|
||||
# Skip when identical, so an Always entry is not rewriting the file every cycle.
|
||||
if (Test-Path -LiteralPath $dst) {
|
||||
$sh = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
|
||||
$dh = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash
|
||||
if ($sh -eq $dh) {
|
||||
Write-Host ' already current - no change.'
|
||||
return
|
||||
}
|
||||
Write-Host ' local copy differs - replacing.'
|
||||
} else {
|
||||
Write-Host ' not present locally - installing.'
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath $dstDir)) {
|
||||
New-Item -ItemType Directory -Path $dstDir -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop
|
||||
Write-Host " replaced $dst"
|
||||
} catch {
|
||||
Write-Warning " failed to replace ${dst}: $_"
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# Set-ShopdbCollectorKey.ps1
|
||||
#
|
||||
# Delivers the ShopDB collector credential to every shopfloor PC by writing
|
||||
# HKLM:\SOFTWARE\GE\ShopDB (BaseUrl + CollectorKey), the same contract the
|
||||
# display kiosks already use. Anything that posts to /api/collector/* reads it
|
||||
# from there - today Backup-NtlarsSettings.ps1, tomorrow whatever else reports.
|
||||
#
|
||||
# WHY A KEY IS NEEDED AT ALL:
|
||||
# The GE-Enforce manifest/payload FETCH honours an IP allowlist, so a bay on a
|
||||
# trusted subnet pulls config with no token. Collector INGEST does not: it
|
||||
# accepts only a collector-scoped token. That asymmetry is deliberate - fetch
|
||||
# reads config we already publish, ingest WRITES asset data, and an allowlist
|
||||
# alone would let anything on the subnet post revisions.
|
||||
#
|
||||
# WHERE THE SECRET LIVES:
|
||||
# NOT in manifest.json, and not in this script. It is read from a sibling file
|
||||
# on the share (see $KEYCONFIG below), so:
|
||||
# - the manifest stays free of secrets and safe to read/diff
|
||||
# - rotating the token is replacing ONE file, not editing a manifest
|
||||
# The file inherits the share's ACL, which grants file-level reads only to the
|
||||
# SFLD user. Treat it as a secret: scope the token to collector.ingest ONLY,
|
||||
# so a leak cannot read or mutate anything else.
|
||||
#
|
||||
# IDEMPOTENT: compares current registry values against desired and writes only
|
||||
# on a difference, so DetectionMethod=Always costs one registry read per cycle
|
||||
# after the first run. Rotating the key on the share re-converges the fleet on
|
||||
# the next cycle with no other action.
|
||||
#
|
||||
# Always exits 0 so the GE-Enforce "last run result" stays clean.
|
||||
|
||||
param(
|
||||
# Override for a one-off run; normally read from the sibling config file.
|
||||
[string]$CollectorKey,
|
||||
[string]$BaseUrl
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
$KEYCONFIG = Join-Path $PSScriptRoot '..\configs\shopdb-collector.txt'
|
||||
$LOGDIR = 'C:\Logs\Shopfloor'
|
||||
$LOGFILE = Join-Path $LOGDIR 'shopdb-collector-key.log'
|
||||
|
||||
if (-not (Test-Path $LOGDIR)) {
|
||||
New-Item -ItemType Directory -Path $LOGDIR -Force -EA SilentlyContinue | Out-Null
|
||||
}
|
||||
|
||||
function Log {
|
||||
param([string]$Message)
|
||||
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Add-Content -Path $LOGFILE -Value $line -EA SilentlyContinue
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
function Read-KeyConfig {
|
||||
<#
|
||||
LABELLED lines only:
|
||||
baseurl=https://host/shopdb
|
||||
collector=<collector.ingest token>
|
||||
A bare line is ignored rather than guessed at - a fetch token landing in
|
||||
the collector slot would leave reporting broken while looking configured.
|
||||
#>
|
||||
$result = @{ BaseUrl = ''; CollectorKey = '' }
|
||||
if (-not (Test-Path $KEYCONFIG)) { return $result }
|
||||
foreach ($line in (Get-Content $KEYCONFIG -EA SilentlyContinue)) {
|
||||
$t = $line.Trim()
|
||||
if (-not $t -or $t.StartsWith('#')) { continue }
|
||||
if ($t -match '^(?i)collector\s*=\s*(.+)$') { $result.CollectorKey = $Matches[1].Trim() }
|
||||
elseif ($t -match '^(?i)baseurl\s*=\s*(.+)$') { $result.BaseUrl = $Matches[1].Trim().TrimEnd('/') }
|
||||
else { Log " ignoring unlabelled line in $(Split-Path $KEYCONFIG -Leaf)" }
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
Log '=== Set-ShopdbCollectorKey start ==='
|
||||
|
||||
$cfg = Read-KeyConfig
|
||||
if (-not $CollectorKey) { $CollectorKey = $cfg.CollectorKey }
|
||||
if (-not $BaseUrl) { $BaseUrl = $cfg.BaseUrl }
|
||||
|
||||
if (-not $CollectorKey) {
|
||||
# Not an error: an unconfigured share is the normal state before a site
|
||||
# issues its token. Say exactly what to do rather than failing silently.
|
||||
Log "No collector key configured. Put a 'collector=<token>' line in:"
|
||||
Log " $KEYCONFIG"
|
||||
Log "Nothing written."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- compare before writing ------------------------------------------------
|
||||
$current = $null
|
||||
try { $current = Get-ItemProperty -Path $SHOPDBREG -EA Stop } catch { }
|
||||
|
||||
$needKey = (-not $current) -or ($current.CollectorKey -ne $CollectorKey)
|
||||
$needUrl = $BaseUrl -and ((-not $current) -or ($current.BaseUrl -ne $BaseUrl))
|
||||
|
||||
if (-not $needKey -and -not $needUrl) {
|
||||
Log 'Registry already matches - nothing to do.'
|
||||
Log '=== Set-ShopdbCollectorKey end ==='
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path $SHOPDBREG)) { New-Item -Path $SHOPDBREG -Force | Out-Null }
|
||||
|
||||
if ($needUrl) {
|
||||
New-ItemProperty -Path $SHOPDBREG -Name BaseUrl -Value $BaseUrl `
|
||||
-PropertyType String -Force -EA Stop | Out-Null
|
||||
Log "Set BaseUrl = $BaseUrl"
|
||||
}
|
||||
if ($needKey) {
|
||||
New-ItemProperty -Path $SHOPDBREG -Name CollectorKey -Value $CollectorKey `
|
||||
-PropertyType String -Force -EA Stop | Out-Null
|
||||
Log "Set CollectorKey (length $($CollectorKey.Length)) - value not logged"
|
||||
}
|
||||
|
||||
# Lock the key down to SYSTEM + Administrators, matching what
|
||||
# Install-ShopdbKiosk does on displays. Without this the value is readable
|
||||
# by any interactive user, and a shopfloor PC is a shared login.
|
||||
try {
|
||||
$acl = Get-Acl $SHOPDBREG
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($who in 'SYSTEM', 'Administrators') {
|
||||
$acl.AddAccessRule((New-Object Security.AccessControl.RegistryAccessRule(
|
||||
$who, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
|
||||
}
|
||||
Set-Acl -Path $SHOPDBREG -AclObject $acl -EA Stop
|
||||
Log 'ACL set: SYSTEM + Administrators only.'
|
||||
} catch {
|
||||
Log "WARNING - could not tighten the ACL: $_"
|
||||
}
|
||||
} catch {
|
||||
Log "FAILED to write ${SHOPDBREG}: $_"
|
||||
}
|
||||
|
||||
Log '=== Set-ShopdbCollectorKey end ==='
|
||||
exit 0
|
||||
15
playbook/shopfloor-setup/common/scripts/Setup-OpenText.cmd
Executable file
15
playbook/shopfloor-setup/common/scripts/Setup-OpenText.cmd
Executable file
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
REM Setup-OpenText.cmd - launcher for Setup-OpenText.ps1.
|
||||
REM
|
||||
REM Lives in <type>/scripts/. Setup-OpenText.ps1 expects to find the four
|
||||
REM OpenText binaries (OpenTextHostExplorer15x64.msi/.cab/.msp + ShopFloorx64.mst)
|
||||
REM plus version.txt in $SourceDir. We pass ..\apps\opentext\ explicitly so the
|
||||
REM script doesn't fall back to its $PSScriptRoot default (which would be the
|
||||
REM scripts/ dir, where the binaries don't live).
|
||||
REM
|
||||
REM Mirrors the Install-AcroReader.cmd / Install-Oracle11r2.cmd pattern of
|
||||
REM script-in-scripts, payload-in-apps. Called by Install-FromManifest.ps1
|
||||
REM (Type=CMD); exit code surfaces back to the enforcer.
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Setup-OpenText.ps1" -SourceDir "%~dp0..\apps\opentext"
|
||||
exit /b %errorlevel%
|
||||
361
playbook/shopfloor-setup/common/scripts/Setup-OpenText.ps1
Executable file
361
playbook/shopfloor-setup/common/scripts/Setup-OpenText.ps1
Executable file
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Setup-OpenText.ps1 - OpenText HostExplorer 15 SP1 ShopFloor installer + profile
|
||||
# deployment, callable from both PXE PreInstall and Intune DSC paths.
|
||||
#
|
||||
# WHY THIS EXISTS:
|
||||
# The vendor-supplied OpenText.exe (Inno Setup wrapper built by WJDT) bundles
|
||||
# the install steps but its [Files] section deploys per-user content to
|
||||
# {userappdata} - which resolves to SYSTEM's profile under DSC and to a single
|
||||
# user under PreInstall. As a result the operator (logging in via Azure AD)
|
||||
# never sees the profiles, keymaps, menus, or macros - only the installed
|
||||
# binaries. This script replaces OpenText.exe entirely, doing the same install
|
||||
# steps via direct msiexec calls AND fanning the per-user content out to:
|
||||
# - %ProgramData%\Hummingbird\Connectivity\15.00\Shared\
|
||||
# - C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00\
|
||||
# - Each existing user profile under C:\Users\
|
||||
#
|
||||
# INVOKED BY:
|
||||
# - PreInstall: Setup-OpenText.cmd wrapper (because the runner only knows MSI/EXE)
|
||||
# - DSC: Install-OpenText.ps1 downloads the bundled tree from blob, then
|
||||
# invokes this script with -SourceDir <temp dir>
|
||||
#
|
||||
# DETECTION:
|
||||
# Skips if HKLM:\SOFTWARE\GE\OpenText\Installed = $expectedVersion. Marker is
|
||||
# written at the end of a successful run, so the runner / DSC wrapper can
|
||||
# no-op on subsequent invocations.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Override when invoked from a temp dir (DSC path) where the bundled files were
|
||||
# just downloaded. Defaults to $PSScriptRoot, but resolved INSIDE the script body
|
||||
# below - PowerShell evaluates `param([string]$X = $PSScriptRoot)` at parameter-
|
||||
# binding time, when $PSScriptRoot may not yet be populated, so the default winds
|
||||
# up as an empty string. Setting it in the body works because $PSScriptRoot is
|
||||
# reliably populated by then.
|
||||
[string]$SourceDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $SourceDir) {
|
||||
$SourceDir = $PSScriptRoot
|
||||
}
|
||||
|
||||
# Normalize $SourceDir to a canonical absolute path. The CMD shim passes
|
||||
# "%~dp0..\apps\opentext" which embeds a literal "..". msiexec / the Windows
|
||||
# Installer service fail to open the package with that unresolved segment
|
||||
# (exit 1619, ERROR_INSTALL_PACKAGE_OPEN_FAILED), even though every other
|
||||
# .NET / PowerShell API resolves it fine. Resolve-Path collapses ".." into
|
||||
# a clean drive-rooted path before any msiexec invocation.
|
||||
if (Test-Path -LiteralPath $SourceDir) {
|
||||
try { $SourceDir = (Resolve-Path -LiteralPath $SourceDir).ProviderPath } catch {}
|
||||
}
|
||||
|
||||
# --- Inline site-config reader (this script runs from C:\PreInstall\installers\opentext\,
|
||||
# NOT from C:\Enrollment\shopfloor-setup\, so it can't dot-source Get-PCProfile.ps1) ---
|
||||
function Get-SiteConfig {
|
||||
$configPath = 'C:\Enrollment\site-config.json'
|
||||
if (-not (Test-Path $configPath)) { return $null }
|
||||
try {
|
||||
return Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# --- Logging (set up FIRST so any startup error - missing version.txt, broken
|
||||
# bundled file, etc. - lands in the log file instead of disappearing into the
|
||||
# runner's stdout void) ---
|
||||
$logDir = 'C:\Logs\PreInstall'
|
||||
$logFile = Join-Path $logDir 'Setup-OpenText.log'
|
||||
$msiLog = Join-Path $logDir 'Setup-OpenText-msi.log'
|
||||
$mspLog = Join-Path $logDir 'Setup-OpenText-msp.log'
|
||||
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
function Write-SetupLog {
|
||||
param([string]$Message)
|
||||
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message"
|
||||
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
Write-SetupLog "================================================================"
|
||||
Write-SetupLog "=== Setup-OpenText.ps1 starting ==="
|
||||
Write-SetupLog "================================================================"
|
||||
Write-SetupLog "SourceDir: $SourceDir"
|
||||
Write-SetupLog "PSScriptRoot: $PSScriptRoot"
|
||||
Write-SetupLog "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
|
||||
|
||||
# --- Config ---
|
||||
# Version is read from version.txt next to this script. ONE source of truth: bumping
|
||||
# version.txt is the only edit needed when shipping a new OpenText build. Setup-
|
||||
# OpenText.ps1 itself, Install-OpenText.ps1 (DSC wrapper), and the registry marker
|
||||
# all derive their notion of "expected version" from this file.
|
||||
$versionFile = Join-Path $SourceDir 'version.txt'
|
||||
Write-SetupLog "Looking for version.txt at: $versionFile"
|
||||
if (-not (Test-Path $versionFile)) {
|
||||
Write-SetupLog "ERROR: version.txt not found in $SourceDir - cannot determine expected version."
|
||||
Write-SetupLog "Directory listing of $SourceDir :"
|
||||
if (Test-Path $SourceDir) {
|
||||
Get-ChildItem $SourceDir -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-SetupLog " $($_.Name) $($_.Length) bytes"
|
||||
}
|
||||
} else {
|
||||
Write-SetupLog " (SourceDir does not exist)"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
$expectedVersion = (Get-Content -Path $versionFile -Raw -ErrorAction Stop).Trim()
|
||||
if (-not $expectedVersion) {
|
||||
Write-SetupLog "ERROR: version.txt at $versionFile is empty."
|
||||
exit 1
|
||||
}
|
||||
Write-SetupLog "Expected version (from version.txt): $expectedVersion"
|
||||
|
||||
# --- Detection: skip if already deployed at expected version ---
|
||||
$markerKey = 'HKLM:\SOFTWARE\GE\OpenText'
|
||||
$markerVal = 'Installed'
|
||||
if (Test-Path $markerKey) {
|
||||
$installed = (Get-ItemProperty -Path $markerKey -Name $markerVal -ErrorAction SilentlyContinue).$markerVal
|
||||
if ($installed -eq $expectedVersion) {
|
||||
Write-SetupLog "OpenText $expectedVersion already deployed (marker present) - skipping."
|
||||
exit 0
|
||||
}
|
||||
Write-SetupLog "OpenText marker mismatch (found '$installed', expected '$expectedVersion') - re-deploying."
|
||||
}
|
||||
else {
|
||||
Write-SetupLog "OpenText marker not present - first install."
|
||||
}
|
||||
|
||||
# --- Verify bundled files are where we expect ---
|
||||
$msiPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.msi'
|
||||
$cabPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.cab'
|
||||
$mspPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64_ServicePack1.msp'
|
||||
$mstPath = Join-Path $SourceDir 'ShopFloorx64.mst'
|
||||
|
||||
foreach ($f in @($msiPath, $cabPath, $mspPath, $mstPath)) {
|
||||
if (-not (Test-Path $f)) {
|
||||
Write-SetupLog "ERROR: required file not found: $f"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Step 1: Install the base MSI with the ShopFloor transform ---
|
||||
# NOTE: We deliberately do NOT pass REBOOT=ReallySuppress here even though we do
|
||||
# for the VC++ MSIs. OpenText HostExplorer installs shell extensions that hook
|
||||
# explorer.exe, and the MSI uses Restart Manager to ask explorer to close so the
|
||||
# in-use shell DLLs can be replaced. With REBOOT=ReallySuppress, RM closes
|
||||
# explorer.exe but interprets "restart explorer" as a reboot action and refuses
|
||||
# to relaunch it - leaving the user without a desktop. /norestart on its own
|
||||
# prevents the actual Windows reboot but lets RM cleanly close-and-relaunch
|
||||
# explorer mid-install. msiexec still returns 3010 ("reboot would be needed"),
|
||||
# which we treat as success below.
|
||||
Write-SetupLog ""
|
||||
Write-SetupLog "Step 1: Installing OpenTextHostExplorer15x64.msi with ShopFloorx64.mst..."
|
||||
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
$msiArgs = "/i `"$msiPath`" TRANSFORMS=`"$mstPath`" /qn /norestart /L*v `"$msiLog`""
|
||||
Write-SetupLog " msiexec.exe $msiArgs"
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = 'msiexec.exe'
|
||||
$psi.Arguments = $msiArgs
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
$msiExit = $proc.ExitCode
|
||||
Write-SetupLog " msiexec exit code: $msiExit"
|
||||
|
||||
if ($msiExit -ne 0 -and $msiExit -ne 3010) {
|
||||
Write-SetupLog "ERROR: base MSI install failed (exit $msiExit). See $msiLog"
|
||||
exit 1
|
||||
}
|
||||
if ($msiExit -eq 3010) {
|
||||
Write-SetupLog " (3010 = reboot needed but suppressed)"
|
||||
}
|
||||
|
||||
# --- Step 2: Apply Service Pack 1 patch ---
|
||||
# Same Restart Manager rationale as Step 1 - skip REBOOT=ReallySuppress so RM
|
||||
# can relaunch explorer.exe after replacing the patched shell extension DLLs.
|
||||
Write-SetupLog ""
|
||||
Write-SetupLog "Step 2: Applying SP1 patch..."
|
||||
if (Test-Path $mspLog) { Remove-Item $mspLog -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
$mspArgs = "/p `"$mspPath`" /qn /norestart /L*v `"$mspLog`""
|
||||
Write-SetupLog " msiexec.exe $mspArgs"
|
||||
|
||||
$psi.Arguments = $mspArgs
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
$mspExit = $proc.ExitCode
|
||||
Write-SetupLog " msiexec exit code: $mspExit"
|
||||
|
||||
if ($mspExit -ne 0 -and $mspExit -ne 3010) {
|
||||
Write-SetupLog "ERROR: SP1 patch failed (exit $mspExit). See $mspLog"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Step 3: Deploy profiles, keymaps, menus, accessories ---
|
||||
# Source layout (bundled with this script):
|
||||
# $SourceDir\Profile\*.hep
|
||||
# $SourceDir\Accessories\EB\*.eb*
|
||||
# $SourceDir\HostExplorer\Keymap\*.kmv
|
||||
# $SourceDir\HostExplorer\Menu\*.hmv
|
||||
#
|
||||
# Target layouts (Hummingbird-canonical):
|
||||
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Profile\
|
||||
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Accessories\EB\
|
||||
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Keymap\
|
||||
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Menu\
|
||||
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Profile\
|
||||
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Accessories\EB\
|
||||
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Keymap\
|
||||
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Menu\
|
||||
#
|
||||
# We deploy to ProgramData\Shared (system-wide fallback), Default User (template
|
||||
# inherited by every NEW user profile), and every existing user profile (so
|
||||
# already-created accounts like SupportUser get them immediately).
|
||||
|
||||
Write-SetupLog ""
|
||||
Write-SetupLog "Step 3: Deploying profiles/keymaps/menus/macros..."
|
||||
|
||||
# --- Resolve exclude lists from site-config.json (falls back to West Jefferson defaults) ---
|
||||
$siteConfig = Get-SiteConfig
|
||||
$profileExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeProfiles) {
|
||||
@($siteConfig.opentext.excludeProfiles)
|
||||
} else {
|
||||
@('WJ_Office.hep', 'IBM_qks.hep', 'mmcs.hep') # West Jefferson defaults
|
||||
}
|
||||
$shortcutExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeShortcuts) {
|
||||
@($siteConfig.opentext.excludeShortcuts)
|
||||
} else {
|
||||
@('WJ_Office.lnk', 'IBM_qks.lnk', 'mmcs.lnk')
|
||||
}
|
||||
if ($siteConfig) {
|
||||
Write-SetupLog "Site config loaded - profile excludes: $($profileExcludes -join ', ')"
|
||||
Write-SetupLog "Site config loaded - shortcut excludes: $($shortcutExcludes -join ', ')"
|
||||
} else {
|
||||
Write-SetupLog "No site-config.json found - using West Jefferson defaults for excludes"
|
||||
}
|
||||
|
||||
# Map of source subdir -> destination subdir relative to the Hummingbird root.
|
||||
# Optional Exclude list drops specific filenames from both the source-to-dest
|
||||
# copy AND from the destination if they were left over from a prior install.
|
||||
$contentMap = @(
|
||||
@{
|
||||
Src = 'Profile'
|
||||
Dst = 'Profile'
|
||||
Exclude = $profileExcludes
|
||||
}
|
||||
@{ Src = 'Accessories\EB'; Dst = 'Accessories\EB' }
|
||||
@{ Src = 'HostExplorer\Keymap'; Dst = 'HostExplorer\Keymap' }
|
||||
@{ Src = 'HostExplorer\Menu'; Dst = 'HostExplorer\Menu' }
|
||||
)
|
||||
|
||||
function Copy-HummingbirdContent {
|
||||
param(
|
||||
[string]$RootDst, # Hummingbird root, e.g. C:\ProgramData\Hummingbird\Connectivity\15.00\Shared
|
||||
[string]$Label
|
||||
)
|
||||
foreach ($entry in $contentMap) {
|
||||
$srcPath = Join-Path $SourceDir $entry.Src
|
||||
if (-not (Test-Path $srcPath)) { continue }
|
||||
$dstPath = Join-Path $RootDst $entry.Dst
|
||||
New-Item -Path $dstPath -ItemType Directory -Force | Out-Null
|
||||
|
||||
# Remove any previously-deployed excluded files from the destination
|
||||
# - handles the case where a PC got them from an older install.
|
||||
if ($entry.Exclude) {
|
||||
foreach ($name in $entry.Exclude) {
|
||||
$stale = Join-Path $dstPath $name
|
||||
if (Test-Path -LiteralPath $stale) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $stale -Force -ErrorAction Stop
|
||||
Write-SetupLog " $Label : removed stale $name"
|
||||
} catch {
|
||||
Write-SetupLog " $Label : failed to remove $stale : $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$files = Get-ChildItem -Path $srcPath -File -ErrorAction SilentlyContinue
|
||||
if ($entry.Exclude) {
|
||||
$files = @($files | Where-Object { $entry.Exclude -notcontains $_.Name })
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
Copy-Item -Path $f.FullName -Destination $dstPath -Force
|
||||
}
|
||||
Write-SetupLog " $Label : $($entry.Src) -> $dstPath ($($files.Count) files)"
|
||||
}
|
||||
}
|
||||
|
||||
# 3a. ProgramData Shared
|
||||
$sharedRoot = Join-Path $env:ProgramData 'Hummingbird\Connectivity\15.00\Shared'
|
||||
Copy-HummingbirdContent -RootDst $sharedRoot -Label 'Shared'
|
||||
|
||||
# 3b. Default User (template for new user profiles)
|
||||
$defaultUserRoot = 'C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00'
|
||||
Copy-HummingbirdContent -RootDst $defaultUserRoot -Label 'Default User'
|
||||
|
||||
# 3c. Every existing user profile under C:\Users\
|
||||
$skipNames = @('Default', 'Default User', 'Public', 'defaultuser0', 'All Users', 'WDAGUtilityAccount')
|
||||
$userDirs = Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $skipNames -notcontains $_.Name -and (Test-Path "$($_.FullName)\AppData\Roaming") }
|
||||
|
||||
foreach ($u in $userDirs) {
|
||||
$userRoot = Join-Path $u.FullName 'AppData\Roaming\Hummingbird\Connectivity\15.00'
|
||||
Copy-HummingbirdContent -RootDst $userRoot -Label $u.Name
|
||||
}
|
||||
|
||||
# --- Step 4: Public Desktop shortcuts ---
|
||||
# Uses $shortcutExcludes (resolved from site-config.json above) to skip
|
||||
# deploying unwanted .lnk files AND remove any a prior install left behind.
|
||||
Write-SetupLog ""
|
||||
Write-SetupLog "Step 4: Deploying public desktop shortcuts..."
|
||||
$shortcutSrc = Join-Path $SourceDir 'W10shortcuts'
|
||||
$publicDesktop = 'C:\Users\Public\Desktop'
|
||||
|
||||
# Clean up stale copies from prior installs first
|
||||
foreach ($name in $shortcutExcludes) {
|
||||
$stale = Join-Path $publicDesktop $name
|
||||
if (Test-Path -LiteralPath $stale) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $stale -Force -ErrorAction Stop
|
||||
Write-SetupLog " removed stale desktop shortcut: $name"
|
||||
} catch {
|
||||
Write-SetupLog " failed to remove stale $stale : $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $shortcutSrc) {
|
||||
$lnkFiles = Get-ChildItem -Path $shortcutSrc -Filter '*.lnk' -File -ErrorAction SilentlyContinue
|
||||
foreach ($l in $lnkFiles) {
|
||||
if ($shortcutExcludes -contains $l.Name) {
|
||||
Write-SetupLog " skip (excluded): $($l.Name)"
|
||||
continue
|
||||
}
|
||||
Copy-Item -Path $l.FullName -Destination $publicDesktop -Force
|
||||
Write-SetupLog " $($l.Name) -> $publicDesktop"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Step 5: Write registry marker ---
|
||||
Write-SetupLog ""
|
||||
Write-SetupLog "Step 5: Writing registry marker..."
|
||||
if (-not (Test-Path $markerKey)) {
|
||||
New-Item -Path $markerKey -Force | Out-Null
|
||||
}
|
||||
Set-ItemProperty -Path $markerKey -Name $markerVal -Value $expectedVersion -Force
|
||||
Set-ItemProperty -Path $markerKey -Name 'InstalledAt' -Value (Get-Date -Format 'o') -Force
|
||||
|
||||
Write-SetupLog ""
|
||||
Write-SetupLog "================================================================"
|
||||
Write-SetupLog "=== OpenText HostExplorer ShopFloor $expectedVersion deployed ==="
|
||||
Write-SetupLog "================================================================"
|
||||
exit 0
|
||||
82
playbook/shopfloor-setup/common/scripts/Test-RegExport.ps1
Normal file
82
playbook/shopfloor-setup/common/scripts/Test-RegExport.ps1
Normal file
@@ -0,0 +1,82 @@
|
||||
# Test-RegExport.ps1
|
||||
#
|
||||
# Exercises Backup-NtlarsSettings.ps1's Export-DncToReg against MOCK registry
|
||||
# keys, so the formatting can be verified on a machine with no registry (a
|
||||
# Linux dev box running PowerShell Core, for instance).
|
||||
#
|
||||
# Why this exists: bad escaping or a wrong dword format produces a .reg that
|
||||
# looks fine, imports without complaint, and only reveals itself when a tech
|
||||
# restores a machine and the settings are subtly wrong. That failure is far too
|
||||
# late and far too expensive, so the formatting gets tested away from the bay.
|
||||
#
|
||||
# Writes the generated .reg to -OutFile so the ShopDB-side Python codec can
|
||||
# parse it in the same run and confirm both ends agree.
|
||||
#
|
||||
# pwsh -NoProfile -File Test-RegExport.ps1 -OutFile /tmp/mock.reg
|
||||
|
||||
param(
|
||||
[string]$OutFile = './mock-export.reg'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
. (Join-Path $PSScriptRoot 'Backup-NtlarsSettings.ps1')
|
||||
|
||||
function New-MockKey {
|
||||
<#
|
||||
A stand-in for a Microsoft.Win32.RegistryKey. PowerShell is duck-typed,
|
||||
so Export-DncToReg only needs .Name, .GetValueNames(), .GetValueKind()
|
||||
and .GetValue().
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][hashtable]$Values # name -> @{ Kind; Data }
|
||||
)
|
||||
$key = [pscustomobject]@{ Name = $Name; _values = $Values }
|
||||
$key | Add-Member ScriptMethod GetValueNames { $this._values.Keys } -Force
|
||||
$key | Add-Member ScriptMethod GetValueKind {
|
||||
param($n) $this._values[$n].Kind } -Force
|
||||
$key | Add-Member ScriptMethod GetValue {
|
||||
param($n) $this._values[$n].Data } -Force
|
||||
return $key
|
||||
}
|
||||
|
||||
$root = 'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
|
||||
|
||||
$keys = @(
|
||||
New-MockKey -Name $root -Values @{
|
||||
'COMPUTERNAME' = @{ Kind = 'String'; Data = 'GGBX0NH3ESF' }
|
||||
}
|
||||
New-MockKey -Name "$root\General" -Values @{
|
||||
'MachineNo' = @{ Kind = 'String'; Data = '3204' }
|
||||
'Cnc' = @{ Kind = 'String'; Data = 'OKUMA' }
|
||||
'HostType' = @{ Kind = 'String'; Data = 'WILM' }
|
||||
}
|
||||
New-MockKey -Name "$root\Btr" -Values @{
|
||||
'BTR Rate' = @{ Kind = 'String'; Data = '300' }
|
||||
'Auto Rewind' = @{ Kind = 'String'; Data = 'YES' }
|
||||
'CmntLag' = @{ Kind = 'DWord'; Data = 0 }
|
||||
'BigCount' = @{ Kind = 'DWord'; Data = 4294967295 }
|
||||
}
|
||||
# The nasty cases: characters that must be escaped, and the wide/binary
|
||||
# value kinds that do not occur in the current corpus but are legal.
|
||||
New-MockKey -Name "$root\EdgeCases" -Values @{
|
||||
'Path With Backslash' = @{ Kind = 'String'; Data = 'C:\Program Files\GE' }
|
||||
'Has "Quotes"' = @{ Kind = 'String'; Data = 'say "hi"' }
|
||||
'Empty' = @{ Kind = 'String'; Data = '' }
|
||||
'Expanded' = @{ Kind = 'ExpandString'; Data = '%SystemRoot%\dnc' }
|
||||
'Multi' = @{ Kind = 'MultiString'; Data = @('one','two') }
|
||||
'Blob' = @{ Kind = 'Binary'; Data = [byte[]](1,2,255) }
|
||||
'Big' = @{ Kind = 'QWord'; Data = [uint64]1234567890123 }
|
||||
}
|
||||
)
|
||||
|
||||
$text = Export-DncToReg -Keys $keys
|
||||
|
||||
# UTF-16LE + BOM, matching what the real script posts.
|
||||
$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($text)
|
||||
[IO.File]::WriteAllBytes($OutFile, $bytes)
|
||||
|
||||
Write-Host "Wrote $OutFile ($($bytes.Length) bytes)"
|
||||
Write-Host '--- first lines ---'
|
||||
($text -split "`r`n" | Select-Object -First 12) | ForEach-Object { Write-Host " $_" }
|
||||
37
playbook/shopfloor-setup/common/scripts/ensure-vnc-firewall.ps1
Executable file
37
playbook/shopfloor-setup/common/scripts/ensure-vnc-firewall.ps1
Executable file
@@ -0,0 +1,37 @@
|
||||
# ensure-vnc-firewall.ps1
|
||||
# Idempotent inbound firewall rules for VNC port 5900 on all network profiles.
|
||||
# Called by Install-FromManifest with Type=PS1, DetectionMethod=Always (runs
|
||||
# every enforcement cycle; the Remove + New pattern makes repeat runs cheap
|
||||
# and always end in a known-good state).
|
||||
#
|
||||
# Exit 0 on success, 1 on failure. SYSTEM context.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$rules = @(
|
||||
@{ Name = 'GE Shopfloor VNC 5900 TCP'; Protocol = 'TCP'; LocalPort = 5900 }
|
||||
@{ Name = 'GE Shopfloor VNC 5900 UDP'; Protocol = 'UDP'; LocalPort = 5900 }
|
||||
)
|
||||
|
||||
$failed = 0
|
||||
foreach ($r in $rules) {
|
||||
try {
|
||||
Remove-NetFirewallRule -DisplayName $r.Name -ErrorAction SilentlyContinue
|
||||
New-NetFirewallRule `
|
||||
-DisplayName $r.Name `
|
||||
-Direction Inbound `
|
||||
-Protocol $r.Protocol `
|
||||
-LocalPort $r.LocalPort `
|
||||
-Action Allow `
|
||||
-Profile Domain,Private,Public `
|
||||
-Description 'VNC remote access for shopfloor ops (5900). Managed by GE Shopfloor Enforce.' `
|
||||
-ErrorAction Stop | Out-Null
|
||||
Write-Host "[OK] $($r.Name) ($($r.Protocol) $($r.LocalPort))"
|
||||
} catch {
|
||||
Write-Host "[FAIL] $($r.Name): $_"
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed -gt 0) { exit 1 }
|
||||
exit 0
|
||||
@@ -175,6 +175,45 @@ foreach ($dir in $pcdmisDirs) {
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 2.5b: GE PC-DMIS FRONT END dir (PCDToIGES.exe error-log path)
|
||||
# ============================================================================
|
||||
# PCDToIGES.exe (in every bay's C:\geaofi\Scripts) writes its error log to
|
||||
# C:\GE PC-DMIS FRONT END\PCDToIGES.ERR inside its catch block. The legacy
|
||||
# "GE PC-DMIS FRONT END" front-end setup that created this dir is not part of
|
||||
# imaging, so on a fresh bay it is absent - and then ANY PCDToIGES failure
|
||||
# becomes an unhandled DirectoryNotFoundException that crashes the export and
|
||||
# MASKS the real error. Create it + grant the operator Modify so errors log.
|
||||
$frontEnd = 'C:\GE PC-DMIS FRONT END'
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath $frontEnd)) { New-Item -ItemType Directory -Path $frontEnd -Force | Out-Null; Write-CMMLog "Created $frontEnd" }
|
||||
foreach ($sid in '*S-1-5-32-545','*S-1-5-11') { # BUILTIN\Users, NT AUTHORITY\Authenticated Users
|
||||
& icacls "$frontEnd" /grant "${sid}:(OI)(CI)M" /T /C 2>&1 | Out-Null
|
||||
}
|
||||
Write-CMMLog "Granted Users + Authenticated Users Modify on $frontEnd"
|
||||
} catch { Write-CMMLog "Failed to ensure ${frontEnd}: $_" "WARN" }
|
||||
|
||||
# ============================================================================
|
||||
# Step 2.5c: Active Setup - seed goCMM 2.12 DataFolder per-user for ALL users
|
||||
# ============================================================================
|
||||
# goCMM 2.12 stores its shared-data-dir in HKCU\Software\General Electric\goCMM
|
||||
# \DataFolder (decompiled: RegistrySettings uses Registry.CurrentUser - PER USER,
|
||||
# not HKLM). Setting it at imaging only touches the imaging user (SupportUser);
|
||||
# the ShopFloor operator's HKCU would be empty -> goCMM prompts them to pick the
|
||||
# folder on first launch. Active Setup runs StubPath once in EACH user's context
|
||||
# at first logon, so every user (SupportUser, ShopFloor, future) gets
|
||||
# DataFolder=C:\geaofi\. Bump Version to re-push. (Uses Set-ItemProperty in the
|
||||
# stub, not reg.exe, to avoid the trailing-backslash quoting trap.)
|
||||
$asKey = 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{8F3B2A10-7C4D-4E9A-B1F6-0A9C5D2E7B43}'
|
||||
try {
|
||||
New-Item -Path $asKey -Force | Out-Null
|
||||
Set-ItemProperty -Path $asKey -Name '(default)' -Value 'goCMM DataFolder (per-user)'
|
||||
Set-ItemProperty -Path $asKey -Name 'Version' -Value '1'
|
||||
$stub = 'powershell.exe -NoProfile -WindowStyle Hidden -Command "New-Item -Path ''HKCU:\SOFTWARE\General Electric\goCMM'' -Force | Out-Null; Set-ItemProperty -Path ''HKCU:\SOFTWARE\General Electric\goCMM'' -Name DataFolder -Value ''C:\geaofi\''"'
|
||||
Set-ItemProperty -Path $asKey -Name 'StubPath' -Value $stub
|
||||
Write-CMMLog "Seeded Active Setup: goCMM DataFolder=C:\geaofi\ (per-user, all users)"
|
||||
} catch { Write-CMMLog "Failed to seed Active Setup goCMM DataFolder: $_" 'WARN' }
|
||||
|
||||
# ============================================================================
|
||||
# Step 2.6: First-run-as-admin for each installed PC-DMIS version
|
||||
# ============================================================================
|
||||
@@ -314,6 +353,28 @@ if (Test-Path -LiteralPath $restoreScript) {
|
||||
Write-CMMLog "Restore-CMM.ps1 not found at $restoreScript - skipping settings restore" 'WARN'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 2.5: drop Register-PCDMIS-COM.bat on the Public desktop
|
||||
# ============================================================================
|
||||
# PC-DMIS COM must be registered for goCMM to connect, but /regserver no-ops
|
||||
# until PC-DMIS is licensed - which happens manually post-image (clmadmin.exe).
|
||||
# So we cannot register at imaging time; instead leave a one-click helper the
|
||||
# tech runs AFTER activating the license. Public Desktop so it is visible to
|
||||
# whoever logs in (operator or SupportUser). NOTE: 06-OrganizeDesktop sweeps the
|
||||
# Public Desktop root into category subfolders, so it may end up under
|
||||
# "Shopfloor Tools" rather than the bare desktop - still on the Public desktop.
|
||||
$regBat = Join-Path $stagingRoot 'Register-PCDMIS-COM.bat'
|
||||
if (Test-Path -LiteralPath $regBat) {
|
||||
$d = 'C:\Users\Public\Desktop'
|
||||
try {
|
||||
if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null }
|
||||
Copy-Item -LiteralPath $regBat -Destination (Join-Path $d 'Register-PCDMIS-COM.bat') -Force
|
||||
Write-CMMLog "Staged Register-PCDMIS-COM.bat -> $d"
|
||||
} catch { Write-CMMLog "Failed to stage Register-PCDMIS-COM.bat to ${d}: $_" 'WARN' }
|
||||
} else {
|
||||
Write-CMMLog "Register-PCDMIS-COM.bat not in staging ($regBat) - desktop helper NOT placed" 'WARN'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 3: Conditional cleanup of the bootstrap staging dir
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,48 +1,165 @@
|
||||
# Install-DODA.ps1 - Extract DODA zip to C:\Apps\DODA\.
|
||||
# Install-DODA.ps1 - full unattended DODA install (runtimes + app + config + ACLs).
|
||||
#
|
||||
# Called by Install-FromManifest as a Type=PS1 entry. The zip is staged
|
||||
# alongside this script in C:\CMM-Install\ by startnet.cmd.
|
||||
# The vendor GE_DODA_build5_Installer.exe is a multi-app NSIS bundler. Its /S
|
||||
# silent mode is BROKEN (a nested sub-install gets a bad package path -> MSI
|
||||
# error 1619), so it cannot be used for imaging. Each bundled component installs
|
||||
# cleanly on its own, so we drive them directly here. (VM-validated 2026-06-17.)
|
||||
#
|
||||
# Components staged in C:\CMM-Install (robocopied from the SFLD/enrollment share
|
||||
# by startnet.cmd), all installed SILENTLY:
|
||||
# python-2.7.16.amd64.msi -> C:\Python27 (msiexec /qn)
|
||||
# java-1.8.0-openjdk-*.msi -> C:\Program Files\RedHat (msiexec /qn)
|
||||
# gs927w64.exe -> C:\Program Files\gs\gs9.27 (/S)
|
||||
# vc_redist.x64.exe -> VC++ runtime (/quiet)
|
||||
# doda_build*.zip -> C:\Apps\DODA (app files)
|
||||
#
|
||||
# DODA at runtime is DovetailAnalysis.exe -> JVM (OpenJDK) + C:\Python27 python +
|
||||
# Ghostscript (PS->PDF). .settings (NOT in the zip - the NSIS installer wrote it)
|
||||
# hardcodes those paths; we write it here.
|
||||
#
|
||||
# LOCKDOWN: DovetailAnalysis.exe runs as the locked-down OPERATOR (not admin) and
|
||||
# writes throughout C:\Apps\DODA (.pyc, PreProcess) + $TMP. We (a) grant Users +
|
||||
# Authenticated Users Full on C:\Apps\DODA and (b) PRECOMPILE the .py to .pyc as
|
||||
# admin so the operator never needs to write bytecode into a protected path.
|
||||
#
|
||||
# Run as administrator / SYSTEM (imaging or GE-Enforce). Idempotent: each step
|
||||
# detects-and-skips if already present, and ACL/.settings/env re-assert every run
|
||||
# (so a post-lockdown re-run repairs a stripped ACE).
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# The doda zip + runtime installers are staged ALONGSIDE this script (same dir).
|
||||
# When the manifest runs it, that dir is C:\CMM-Install (= $PSScriptRoot).
|
||||
$stagingRoot = $PSScriptRoot
|
||||
if (-not $stagingRoot -or -not (Test-Path $stagingRoot)) { $stagingRoot = 'C:\CMM-Install' }
|
||||
$installDir = 'C:\Apps\DODA'
|
||||
$zipPattern = 'doda_build*.zip'
|
||||
$stagingRoot = Split-Path $PSScriptRoot -ErrorAction SilentlyContinue
|
||||
if (-not $stagingRoot) { $stagingRoot = 'C:\CMM-Install' }
|
||||
$python27 = 'C:\Python27\python.exe'
|
||||
$gsExe = 'C:\Program Files\gs\gs9.27\bin\gswin64c.exe'
|
||||
|
||||
$zip = Get-ChildItem -Path $stagingRoot -Filter $zipPattern -File -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $zip) {
|
||||
Write-Host "DODA zip not found in $stagingRoot (pattern: $zipPattern)"
|
||||
exit 1
|
||||
$logDir = 'C:\Logs\CMM'
|
||||
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
|
||||
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$log = Join-Path $logDir "doda-install-$ts.log"
|
||||
function Log($m){ $line = "[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m; Write-Host $line; Add-Content -Path $log -Value $line -EA SilentlyContinue }
|
||||
|
||||
function Find-One($pattern) {
|
||||
Get-ChildItem -Path $stagingRoot -Filter $pattern -File -EA SilentlyContinue | Select-Object -First 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $installDir)) {
|
||||
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
Log "==== DODA install on $env:COMPUTERNAME (staging=$stagingRoot) ===="
|
||||
|
||||
Write-Host "Extracting $($zip.Name) to $installDir..."
|
||||
try {
|
||||
Expand-Archive -LiteralPath $zip.FullName -DestinationPath $installDir -Force -ErrorAction Stop
|
||||
Write-Host "DODA extracted to $installDir"
|
||||
} catch {
|
||||
Write-Host "ERROR: Extract failed - $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# MergeFiles.exe (cmm-utilities toolchain) reads C:\Apps\DODA\PreProcess\ as
|
||||
# its working dir. The DODA zip extracts flat without it, so create it here -
|
||||
# a missing PreProcess dir is the known cause of MergeFiles.GetDoDAFolder
|
||||
# throwing DirectoryNotFoundException (see cmm-utilities dotNET event.txt).
|
||||
$preProcess = Join-Path $installDir 'PreProcess'
|
||||
if (-not (Test-Path $preProcess)) {
|
||||
New-Item -Path $preProcess -ItemType Directory -Force | Out-Null
|
||||
Write-Host "Created $preProcess"
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $installDir 'DovetailAnalysis.exe')) {
|
||||
Write-Host "DovetailAnalysis.exe verified present"
|
||||
exit 0
|
||||
# --- 1. Python 2.7 ----------------------------------------------------------
|
||||
if (Test-Path $python27) {
|
||||
Log "Python 2.7 already present - skip"
|
||||
} else {
|
||||
Write-Host "ERROR: DovetailAnalysis.exe not found after extract"
|
||||
$msi = Find-One 'python-2.7*.msi'
|
||||
if ($msi) {
|
||||
Log "Installing Python: $($msi.Name)"
|
||||
$p = Start-Process msiexec.exe -Wait -PassThru -ArgumentList @('/i', "`"$($msi.FullName)`"", '/qn', '/norestart', 'ADDLOCAL=ALL')
|
||||
Log " Python msiexec exit $($p.ExitCode)"
|
||||
} else { Log "WARN: python-2.7*.msi not found in $stagingRoot" }
|
||||
}
|
||||
|
||||
# --- 2. OpenJDK 8 -----------------------------------------------------------
|
||||
$jdk = Get-ChildItem 'C:\Program Files\RedHat','C:\Program Files\Java' -Directory -EA SilentlyContinue |
|
||||
Where-Object Name -match 'openjdk|jdk|jre' | Select-Object -First 1
|
||||
if ($jdk) {
|
||||
Log "OpenJDK already present ($($jdk.Name)) - skip"
|
||||
} else {
|
||||
$msi = Find-One 'java-*openjdk*.msi'
|
||||
if ($msi) {
|
||||
Log "Installing OpenJDK: $($msi.Name)"
|
||||
$p = Start-Process msiexec.exe -Wait -PassThru -ArgumentList @('/i', "`"$($msi.FullName)`"", '/qn', '/norestart')
|
||||
Log " OpenJDK msiexec exit $($p.ExitCode)"
|
||||
$jdk = Get-ChildItem 'C:\Program Files\RedHat','C:\Program Files\Java' -Directory -EA SilentlyContinue |
|
||||
Where-Object Name -match 'openjdk|jdk|jre' | Select-Object -First 1
|
||||
} else { Log "WARN: java-*openjdk*.msi not found in $stagingRoot" }
|
||||
}
|
||||
|
||||
# --- 3. Ghostscript 9.27 ----------------------------------------------------
|
||||
if (Test-Path $gsExe) {
|
||||
Log "Ghostscript 9.27 already present - skip"
|
||||
} else {
|
||||
$gs = Find-One 'gs927w64.exe'
|
||||
if ($gs) {
|
||||
Log "Installing Ghostscript: $($gs.Name)"
|
||||
$p = Start-Process $gs.FullName -Wait -PassThru -ArgumentList '/S'
|
||||
Log " Ghostscript exit $($p.ExitCode)"
|
||||
} else { Log "WARN: gs927w64.exe not found in $stagingRoot" }
|
||||
}
|
||||
|
||||
# --- 4. VC++ redist (best-effort) ------------------------------------------
|
||||
$vc = Find-One 'vc_redist.x64.exe'
|
||||
if ($vc) {
|
||||
Log "Installing VC++ redist: $($vc.Name)"
|
||||
$p = Start-Process $vc.FullName -Wait -PassThru -ArgumentList @('/quiet', '/norestart')
|
||||
Log " VC++ exit $($p.ExitCode) (1638/3010 = already-present/ok)"
|
||||
} else { Log "VC++ redist not staged - skipping (usually already present)" }
|
||||
|
||||
# --- 5. DODA app files ------------------------------------------------------
|
||||
$zip = Find-One 'doda_build*.zip'
|
||||
if (-not $zip) {
|
||||
Log "ERROR: doda_build*.zip not found in $stagingRoot - cannot place DODA app"
|
||||
} else {
|
||||
New-Item -ItemType Directory -Path $installDir -Force -EA SilentlyContinue | Out-Null
|
||||
Log "Extracting $($zip.Name) -> $installDir"
|
||||
try { Expand-Archive -LiteralPath $zip.FullName -DestinationPath $installDir -Force -EA Stop }
|
||||
catch { Log "ERROR: extract failed - $_" }
|
||||
}
|
||||
|
||||
# --- 6. .settings (the NSIS installer wrote this; the zip does NOT contain it) ---
|
||||
$settings = Join-Path $installDir '.settings'
|
||||
$settingsBody = @'
|
||||
WORKING_DIR=$TMP
|
||||
PYTHONEXE=C:\Python27\python.exe
|
||||
OPTIONS_FILE="$WORKING_DIR/dvt_run_options.py"
|
||||
RPT_GUI=$PYTHONEXE $AFDA_INSTALL_DIR/dvtRptGui.py -file=$OPTIONS_FILE
|
||||
OUTPUT_PROCESSOR=$PYTHONEXE $AFDA_INSTALL_DIR/processOutput.py $OPTIONS_FILE
|
||||
PS2PDF="C:\Program Files\gs\gs9.27\bin\gswin64c.exe" -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$$out $$in
|
||||
'@
|
||||
if (Test-Path $installDir) {
|
||||
Set-Content -Path $settings -Value $settingsBody -Encoding ascii -Force
|
||||
Log "Wrote $settings"
|
||||
}
|
||||
|
||||
# --- 7. PreProcess dir (MergeFiles GetDoDAFolder reads it) ------------------
|
||||
$preProcess = Join-Path $installDir 'PreProcess'
|
||||
if (-not (Test-Path $preProcess)) { New-Item -ItemType Directory -Path $preProcess -Force | Out-Null; Log "Created $preProcess" }
|
||||
|
||||
# --- 8. Env: AFDA_INSTALL_DIR + Java on PATH (machine scope) ----------------
|
||||
[Environment]::SetEnvironmentVariable('AFDA_INSTALL_DIR', $installDir, 'Machine')
|
||||
Log "Set AFDA_INSTALL_DIR=$installDir (Machine)"
|
||||
if ($jdk) {
|
||||
$machPath = [Environment]::GetEnvironmentVariable('PATH','Machine')
|
||||
foreach ($add in @((Join-Path $jdk.FullName 'bin'), (Join-Path $jdk.FullName 'jre\bin'))) {
|
||||
if (Test-Path $add) {
|
||||
$parts = $machPath -split ';'
|
||||
if ($parts -notcontains $add) { $machPath = "$machPath;$add"; Log "PATH += $add" }
|
||||
}
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('PATH', $machPath, 'Machine')
|
||||
}
|
||||
|
||||
# --- 9. Precompile .py -> .pyc as admin (so the locked-down operator never
|
||||
# needs write access to compile bytecode at runtime) ------------------
|
||||
if ((Test-Path $python27) -and (Test-Path $installDir)) {
|
||||
Log "Precompiling DODA .py with $python27"
|
||||
$p = Start-Process $python27 -Wait -PassThru -ArgumentList @('-m','compileall', "`"$installDir`"") -WindowStyle Hidden
|
||||
Log " compileall exit $($p.ExitCode)"
|
||||
}
|
||||
|
||||
# --- 10. ACLs: Users + Authenticated Users Full on C:\Apps\DODA so the
|
||||
# locked-down operator can run DODA (writes .pyc/PreProcess/output) ---
|
||||
if (Test-Path $installDir) {
|
||||
foreach ($sid in '*S-1-5-32-545','*S-1-5-11') { # BUILTIN\Users, NT AUTHORITY\Authenticated Users
|
||||
& icacls $installDir /grant "${sid}:(OI)(CI)F" /T /C 2>&1 | Out-Null
|
||||
}
|
||||
Log "Granted Users + Authenticated Users Full on $installDir (recursive)"
|
||||
}
|
||||
|
||||
# --- Verify -----------------------------------------------------------------
|
||||
$ok = (Test-Path (Join-Path $installDir 'DovetailAnalysis.exe')) -and (Test-Path $python27) -and (Test-Path $gsExe) -and $jdk
|
||||
if ($ok) { Log "==== DODA install OK (DovetailAnalysis + Python27 + Ghostscript + OpenJDK present) ===="; exit 0 }
|
||||
else {
|
||||
Log "==== DODA install INCOMPLETE - DovetailAnalysis=$([bool](Test-Path (Join-Path $installDir 'DovetailAnalysis.exe'))) Python=$([bool](Test-Path $python27)) GS=$([bool](Test-Path $gsExe)) JDK=$([bool]$jdk) ===="
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Register-PCDMIS-COM.bat - register the PC-DMIS COM / automation server.
|
||||
REM
|
||||
REM WHY: goCMM connects to PC-DMIS over COM. If the COM server is not
|
||||
REM registered, goCMM throws "Object reference not set to an instance of an
|
||||
REM object" at startup (it calls Type.GetTypeFromProgID on the PC-DMIS ProgID,
|
||||
REM gets null, then Activator.CreateInstance(null) -> ArgumentNullException).
|
||||
REM
|
||||
REM IMPORTANT: PC-DMIS must be LICENSED FIRST. /regserver does NOT register the
|
||||
REM COM server until a valid license is present. Activate the license with
|
||||
REM clmadmin.exe, THEN run this. Safe to re-run any time goCMM stops connecting.
|
||||
REM
|
||||
REM Needs admin - this script self-elevates via UAC.
|
||||
REM ============================================================================
|
||||
|
||||
REM --- self-elevate if not already running as admin ---
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Requesting administrator rights...
|
||||
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
echo ================================================================
|
||||
echo Register PC-DMIS COM server (run AFTER licensing PC-DMIS)
|
||||
echo ================================================================
|
||||
echo.
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
set "FOUND="
|
||||
for %%D in (
|
||||
"C:\Program Files\Hexagon"
|
||||
"C:\Program Files (x86)\Hexagon"
|
||||
"C:\Program Files\WAI"
|
||||
"C:\Program Files (x86)\WAI"
|
||||
) do (
|
||||
if exist %%~D (
|
||||
for /f "delims=" %%F in ('dir /b /s "%%~D\Pcdlrn.exe" 2^>nul') do (
|
||||
set "FOUND=1"
|
||||
echo Registering: %%F
|
||||
"%%F" /regserver
|
||||
if !errorlevel! equ 0 (echo OK) else (echo regserver returned !errorlevel!)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
if not defined FOUND (
|
||||
echo ERROR: Pcdlrn.exe not found. Is PC-DMIS installed?
|
||||
) else (
|
||||
echo Done.
|
||||
echo If goCMM still errors, confirm PC-DMIS is LICENSED ^(clmadmin.exe^) and
|
||||
echo that you ran this AFTER activating the license, then re-run.
|
||||
)
|
||||
echo.
|
||||
pause
|
||||
@@ -2,12 +2,12 @@ cmm_id,pcdmis_version,doda,part_group
|
||||
CMM1,2019,no,S:\CMM\CMM1\HPTCMM1
|
||||
CMM2,2019,no,S:\CMM\CMM2\HPT
|
||||
CMM3,2019,no,S:\CMM\CMM3\VENTURE_CMM3
|
||||
CMM4,2016,no,S:\CMM\CMM4\Spool
|
||||
CMM4,2016,yes,S:\CMM\CMM4\Spool
|
||||
CMM5,2019,no,S:\CMM\CMM5\BLISKCMM5
|
||||
CMM6,2019,no,S:\CMM\CMM6\BLISKCMM6
|
||||
CMM7,2019,no,S:\CMM\CMM7\VENTURE_CMM7
|
||||
CMM8,2019,no,S:\CMM\CMM8\Venture CMM8
|
||||
CMM9,2019,no,S:\CMM\CMM9\BLISKCMM9
|
||||
CMM10,2016,no,S:\CMM\CMM10\Spool
|
||||
CMM11,2026,no,S:\CMM\CMM11\Spool
|
||||
CMM12,2026,no,S:\CMM\CMM12\Spool
|
||||
CMM10,2016,yes,S:\CMM\CMM10\Spool
|
||||
CMM11,2026,yes,S:\CMM\CMM11\Spool
|
||||
CMM12,2026,yes,S:\CMM\CMM12\Spool
|
||||
|
||||
|
@@ -57,19 +57,18 @@
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{a55fecde-0776-474e-a5b3-d57ea93d6a9f}"
|
||||
},
|
||||
{
|
||||
"_comment": "goCMM - Hexagon CMM job launcher. Install for all versions.",
|
||||
"Name": "goCMM",
|
||||
"Installer": "goCMM_1.1.6718.31289.exe",
|
||||
"_comment": "goCMM 2.12.3 - Hexagon/Renishaw CMM job launcher. NSIS installer; silent switch is /S (NOT /quiet - that was the WiX 1.1 bundle). Installs to C:\\Program Files\\goCMM and runs no-admin (asInvoker, VM-verified). This is the IMAGING manifest only, so NEW images get 2.12; the tsgwp00525 SFLD enforce manifest stays on goCMM 1.1 so already-deployed bays are NOT auto-upgraded. Install for all versions.",
|
||||
"Name": "goCMM 2.12.3",
|
||||
"Installer": "goCMM_2.12.3.0_Release.exe",
|
||||
"Type": "EXE",
|
||||
"InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\goCMM.log\"",
|
||||
"LogFile": "C:\\Logs\\CMM\\goCMM.log",
|
||||
"DetectionMethod": "Registry",
|
||||
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{94f02b85-bbca-422e-9b8b-0c16a769eced}"
|
||||
"InstallArgs": "/S",
|
||||
"DetectionMethod": "File",
|
||||
"DetectionPath": "C:\\Program Files\\goCMM\\goCMM.exe"
|
||||
},
|
||||
{
|
||||
"_comment": "DODA - Dovetail Digital Analysis. Deployed as a flat file extract to C:\\Apps\\DODA\\. Only installed when doda.txt=yes (pc-subtype.txt=doda gates this via PCTypes).",
|
||||
"_comment": "DODA - Dovetail Digital Analysis. Deployed as a flat file extract to C:\\Apps\\DODA\\. Only installed when doda.txt=yes -> startnet writes pc-subtype.txt=doda -> 09-Setup passes PCType=gea-shopfloor-cmm + PCSubType=doda -> matcher builds 'gea-shopfloor-cmm-doda'. PCTypes MUST be that exact Type-SubType string ('cmm-doda' never matched - the alias of gea-shopfloor-cmm is bare 'CMM', no subtype variant).",
|
||||
"Name": "DODA",
|
||||
"PCTypes": ["cmm-doda"],
|
||||
"PCTypes": ["gea-shopfloor-cmm-doda"],
|
||||
"Type": "PS1",
|
||||
"Script": "Install-DODA.ps1",
|
||||
"DetectionMethod": "File",
|
||||
|
||||
@@ -63,6 +63,30 @@ foreach ($s in 'Backup-goCMMSettings.ps1','Backup-PCDMISSettings.ps1') {
|
||||
catch { Log " ERROR in $s : $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# --- C:\Program Files\WAI (+ x86): whole vendor tree, captured complete.
|
||||
# Holds machine/controller content beyond what the per-version PC-DMIS backup
|
||||
# grabs. NOTE: can be multi-GB if WAI contains the full PC-DMIS 2016 install. ---
|
||||
$waiRoots = @("$env:ProgramFiles\WAI", "${env:ProgramFiles(x86)}\WAI")
|
||||
$waiPresent = @($waiRoots | Where-Object { Test-Path $_ })
|
||||
if ($waiPresent.Count -gt 0) {
|
||||
Log "---- capturing WAI tree (whole) ----"
|
||||
$waiStage = Join-Path $env:TEMP "wai-bk-$ts"
|
||||
New-Item -ItemType Directory -Path $waiStage -Force | Out-Null
|
||||
foreach ($wr in $waiPresent) {
|
||||
$label = if ($wr -like '*(x86)*') { 'WAI-x86' } else { 'WAI' }
|
||||
robocopy $wr (Join-Path $waiStage $label) /E /R:1 /W:1 /NFL /NDL /NJH /NJS | Out-Null
|
||||
Log " copied $wr -> $label"
|
||||
}
|
||||
$waiZip = Join-Path $dest "wai_backup_${env:COMPUTERNAME}_$ts.zip"
|
||||
if (Test-Path $waiZip) { Remove-Item $waiZip -Force }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::CreateFromDirectory($waiStage, $waiZip)
|
||||
Remove-Item $waiStage -Recurse -Force -EA SilentlyContinue
|
||||
Log " WAI backup: $waiZip ($([math]::Round((Get-Item $waiZip).Length/1MB)) MB)"
|
||||
} else {
|
||||
Log "No C:\Program Files\WAI (or x86) - skipping WAI backup"
|
||||
}
|
||||
|
||||
# index of what we captured
|
||||
$zips = Get-ChildItem $dest -Filter '*.zip' -File -ErrorAction SilentlyContinue
|
||||
[pscustomobject]@{
|
||||
@@ -71,6 +95,7 @@ $zips = Get-ChildItem $dest -Filter '*.zip' -File -ErrorAction SilentlyContinue
|
||||
Timestamp = (Get-Date -Format o)
|
||||
goCMM = @($zips | Where-Object { $_.Name -like 'gocmm_backup_*' } | Select-Object -Expand Name)
|
||||
PCDMIS = @($zips | Where-Object { $_.Name -like 'pcdmis_backup_*' } | Select-Object -Expand Name)
|
||||
WAI = @($zips | Where-Object { $_.Name -like 'wai_backup_*' } | Select-Object -Expand Name)
|
||||
} | ConvertTo-Json | Out-File (Join-Path $dest 'cmm-backup-index.json') -Encoding ascii
|
||||
|
||||
Log "================ DONE ================"
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<#
|
||||
Convert-goCMMSettings.ps1
|
||||
|
||||
Convert a legacy goCMM 1.1 ApplicationSettings.xml (C:\geaofi\) to the goCMM
|
||||
2.12.3 goCMMSettings.xml schema.
|
||||
|
||||
The two are different SettingsModel schemas (different filename too). This is a
|
||||
TEMPLATE-based converter: new-only fields get defaults, the per-bay values are
|
||||
carried over from the old XML (+ optional overrides). Decisions baked in
|
||||
(2026-06-19): access-control left blank, operator key normalized to "Operator",
|
||||
cal intervals/restart use template defaults (old values NOT carried), Slack/Andon
|
||||
/Quindos/Emx dropped.
|
||||
|
||||
Program paths default to the goCMM 2.12 convention (C:\geaofi\goCMM PC-DMIS
|
||||
Programs\) - override with -ResetProgram / -ProbeCalibrationProgram if the .PRG
|
||||
files live elsewhere. Those .PRG files MUST exist at the path written here.
|
||||
|
||||
Usage:
|
||||
.\Convert-goCMMSettings.ps1 -OldXml C:\geaofi\ApplicationSettings.xml `
|
||||
-OutFile C:\geaofi\goCMMSettings.xml -PcdmisVersion 2016 -CmmId CMM10
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$OldXml,
|
||||
[Parameter(Mandatory=$true)][string]$OutFile,
|
||||
[string]$PcdmisVersion, # 2016 | 2019 | 2026 (or full "PC-DMIS ... 64-bit")
|
||||
[string]$CmmId, # e.g. CMM10 - used for the cal-program name default
|
||||
[string]$PartGroup, # optional override (friendly S:\ or UNC); else from old XML
|
||||
[string]$ResetProgram, # optional override
|
||||
[string]$ProbeCalibrationProgram # optional override
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OldXml)) { throw "Old XML not found: $OldXml" }
|
||||
[xml]$old = Get-Content -LiteralPath $OldXml -Raw
|
||||
|
||||
# --- part-group share canonicalization (FQDN + \SHARED, matches goCMM 2.12) ---
|
||||
function Canon([string]$pg) {
|
||||
if (-not $pg) { return $pg }
|
||||
$pg = $pg -replace '(?i)^S:\\', '\\tsgwp00525.wjs.geaerospace.net\SHARED\'
|
||||
$pg = [regex]::Replace($pg, '(?i)\\\\tsgwp00525(?:\.[A-Za-z0-9.\-]+)?\\shared(?=\\|$)', '\\tsgwp00525.wjs.geaerospace.net\SHARED')
|
||||
return $pg
|
||||
}
|
||||
|
||||
# --- resolve per-bay values (override -> old XML -> default) ---
|
||||
# NOTE: read attributes with GetAttribute() - .Name collides with XmlElement.Name.
|
||||
$pg = $PartGroup
|
||||
if (-not $pg) {
|
||||
$pgNode = $old.SettingsModel.PartGroups.PartGroup
|
||||
if ($pgNode) { $pg = $pgNode.GetAttribute('FullName'); if (-not $pg) { $pg = $pgNode.FullName } }
|
||||
}
|
||||
$pg = Canon $pg
|
||||
|
||||
# NB: PowerShell vars are case-insensitive - do NOT name this $cmmid (collides with the $CmmId param).
|
||||
$machineCmmId = $old.SettingsModel.MachineDefinition.CMMID
|
||||
if (-not $machineCmmId -and $CmmId) { $machineCmmId = "WJRP/$CmmId" }
|
||||
|
||||
# SelectedVersionString from -PcdmisVersion
|
||||
$verMap = @{ '2016' = 'PC-DMIS 2016.0 64-bit'; '2019' = 'PC-DMIS 2019 R2 64-bit'; '2026' = 'PC-DMIS 2026.1 64-bit' }
|
||||
$selVer = ''
|
||||
if ($PcdmisVersion) { $selVer = if ($verMap.ContainsKey($PcdmisVersion)) { $verMap[$PcdmisVersion] } else { $PcdmisVersion } }
|
||||
|
||||
# bool carryovers (old flat flags -> new IsEnabled)
|
||||
function OldBool($node, $default='false') { $v = $node; if ($null -eq $v -or $v -eq '') { $default } else { ([string]$v).ToLower() } }
|
||||
$pcdmisEnabled = OldBool $old.SettingsModel.UsingPcDmis 'true'
|
||||
$modusEnabled = OldBool $old.SettingsModel.UsingModus 'false'
|
||||
$winState = if ($old.SettingsModel.DefaultWindowState) { $old.SettingsModel.DefaultWindowState } else { 'Maximized' }
|
||||
$allowSel = OldBool $old.SettingsModel.AllowOperationProgramSelection 'true'
|
||||
$visExec = OldBool $old.SettingsModel.PcDmisSettings.VisibleDuringExecution 'true'
|
||||
$cmmMode = if ($old.SettingsModel.PcDmisSettings.CMMMode) { $old.SettingsModel.PcDmisSettings.CMMMode } else { 'Online' }
|
||||
$sizeTol = if ($old.SettingsModel.MachineDefinition.ProbeCalSizeTol) { $old.SettingsModel.MachineDefinition.ProbeCalSizeTol } else { '0.0005' }
|
||||
$formTol = if ($old.SettingsModel.MachineDefinition.ProbeCalFormTol) { $old.SettingsModel.MachineDefinition.ProbeCalFormTol } else { '0.0005' }
|
||||
$hasRotary = OldBool $old.SettingsModel.MachineDefinition.MachineHasRotaryTable 'false'
|
||||
|
||||
# program paths (override -> goCMM 2.12 convention default)
|
||||
$progDir = 'C:\geaofi\goCMM PC-DMIS Programs'
|
||||
if (-not $ResetProgram) { $ResetProgram = "$progDir\MachineResetProgram.prg" }
|
||||
if (-not $ProbeCalibrationProgram) {
|
||||
$calName = if ($CmmId) { "${CmmId}_Cal_All_Probes_rev2.PRG" } else { 'Cal_All_Probes_rev2.PRG' }
|
||||
$ProbeCalibrationProgram = "$progDir\$calName"
|
||||
}
|
||||
|
||||
# --- operator inputs (old KeyLabel + NonTBIInputs -> new OperatorInput; OPER -> Operator) ---
|
||||
$inputs = @()
|
||||
$kl = $old.SettingsModel.OperatorInputs.KeyLabel
|
||||
if ($kl) { $inputs += [pscustomobject]@{ Key = $kl.GetAttribute('Name'); Display = $kl.GetAttribute('Display') } }
|
||||
foreach ($oi in $old.SettingsModel.OperatorInputs.NonTBIInputs.OperatorInput) {
|
||||
if ($oi -is [System.Xml.XmlElement]) { $inputs += [pscustomobject]@{ Key = $oi.GetAttribute('Name'); Display = $oi.GetAttribute('Display') } }
|
||||
}
|
||||
if (-not $inputs) {
|
||||
$inputs = @([pscustomobject]@{Key='SERNO';Display='Serial Number'}, [pscustomobject]@{Key='Operator';Display='Operator'})
|
||||
}
|
||||
$opXml = ($inputs | ForEach-Object {
|
||||
$k = if ($_.Key -ieq 'OPER') { 'Operator' } else { $_.Key } # decision #4
|
||||
$d = if ($_.Key -ieq 'OPER') { 'Operator' } else { $_.Display }
|
||||
" <OperatorInput ClearInput=`"true`">`n <Key>$k</Key>`n <DisplayName>$d</DisplayName>`n <ValidationRules />`n </OperatorInput>"
|
||||
}) -join "`n"
|
||||
|
||||
function X([string]$s){ [System.Security.SecurityElement]::Escape($s) }
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<SettingsModel xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" LockInterface="false" AdminGroupName="" PowerUsersGroupName="" PasswordHash="" PasswordSalt="" LockMachineReset="false">
|
||||
<MaintainFolderStructureWhenCopying>true</MaintainFolderStructureWhenCopying>
|
||||
<DefaultSoftware>PCDMIS</DefaultSoftware>
|
||||
<PartGroups>
|
||||
<PartGroup>
|
||||
<FullName>$(X $pg)</FullName>
|
||||
<Children />
|
||||
</PartGroup>
|
||||
</PartGroups>
|
||||
<OperatorInputs>
|
||||
$opXml
|
||||
</OperatorInputs>
|
||||
<UseBladeGrid>false</UseBladeGrid>
|
||||
<DefaultWindowState>$(X $winState)</DefaultWindowState>
|
||||
<AllowOperationProgramSelection>$allowSel</AllowOperationProgramSelection>
|
||||
<BladesDatFile />
|
||||
<PcDmisSettings>
|
||||
<IsEnabled>$pcdmisEnabled</IsEnabled>
|
||||
<VisibleDuringExecution>$visExec</VisibleDuringExecution>
|
||||
<ResetProgram>$(X $ResetProgram)</ResetProgram>
|
||||
<RotaryTableCalibrationProgram />
|
||||
<TrackProbeCalibration>false</TrackProbeCalibration>
|
||||
<TrackRotaryTableCalbiration>false</TrackRotaryTableCalbiration>
|
||||
<ProbeCalibrationProgram>$(X $ProbeCalibrationProgram)</ProbeCalibrationProgram>
|
||||
<RotaryTableCalibration />
|
||||
<IPPServerExe />
|
||||
<IPPArguments />
|
||||
<SaveAndClose>false</SaveAndClose>
|
||||
<DelayMeasurementRoutines>false</DelayMeasurementRoutines>
|
||||
<MeasurementRoutineDelay>1000</MeasurementRoutineDelay>
|
||||
<Restart>true</Restart>
|
||||
<RestartMode>ProgramCount</RestartMode>
|
||||
<UseIPPServer>false</UseIPPServer>
|
||||
<SelectedVersionString>$(X $selVer)</SelectedVersionString>
|
||||
<NumberOfProgramsBeforeRestart>5</NumberOfProgramsBeforeRestart>
|
||||
<RestartDelay>5</RestartDelay>
|
||||
<StartupDelay>0</StartupDelay>
|
||||
<UseOtherSoftwareForRotaryCal>false</UseOtherSoftwareForRotaryCal>
|
||||
<UseOtherSoftwareForProbeCalibration>false</UseOtherSoftwareForProbeCalibration>
|
||||
<RotaryCalSoftware />
|
||||
<ProbeCalSoftware />
|
||||
</PcDmisSettings>
|
||||
<ModusSettings>
|
||||
<IsEnabled>$modusEnabled</IsEnabled>
|
||||
<VisibleDuringExecution>true</VisibleDuringExecution>
|
||||
<ResetProgram />
|
||||
<RotaryTableCalibrationProgram />
|
||||
<TrackProbeCalibration>false</TrackProbeCalibration>
|
||||
<TrackRotaryTableCalbiration>false</TrackRotaryTableCalbiration>
|
||||
<ProbeCalibrationProgram />
|
||||
<RotaryTableCalibration />
|
||||
<IPPServerExe />
|
||||
<IPPArguments />
|
||||
<ModusArguments>-Organiser</ModusArguments>
|
||||
</ModusSettings>
|
||||
<MachineDefinition>
|
||||
<CMMID>$(X $machineCmmId)</CMMID>
|
||||
<MachineType />
|
||||
<HeadType />
|
||||
<ForceProbeCalibration>false</ForceProbeCalibration>
|
||||
<ProbeCalSizeTol>$sizeTol</ProbeCalSizeTol>
|
||||
<ProbeCalFormTolerance>$formTol</ProbeCalFormTolerance>
|
||||
<ProbeCalibrationInterval>
|
||||
<Hours>72</Hours>
|
||||
<Minutes>0</Minutes>
|
||||
</ProbeCalibrationInterval>
|
||||
<RotaryTableCalibrationInterval>
|
||||
<Hours>24</Hours>
|
||||
<Minutes>0</Minutes>
|
||||
</RotaryTableCalibrationInterval>
|
||||
<MachineHasRotary>$hasRotary</MachineHasRotary>
|
||||
<ForceRotaryCalibration>false</ForceRotaryCalibration>
|
||||
<CMMMode>$(X $cmmMode)</CMMMode>
|
||||
</MachineDefinition>
|
||||
<Notifications UseAndon="false">
|
||||
<Notifications />
|
||||
</Notifications>
|
||||
<ProgramConstants />
|
||||
<CommonFiles />
|
||||
</SettingsModel>
|
||||
"@
|
||||
|
||||
$dir = Split-Path -Parent $OutFile
|
||||
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
# validate well-formed before writing
|
||||
[void][xml]$xml
|
||||
Set-Content -LiteralPath $OutFile -Value $xml -Encoding UTF8
|
||||
Write-Host "Wrote $OutFile"
|
||||
Write-Host " PartGroup = $pg"
|
||||
Write-Host " CMMID = $machineCmmId"
|
||||
Write-Host " SelectedVersion = $selVer"
|
||||
Write-Host " ResetProgram = $ResetProgram"
|
||||
Write-Host " ProbeCalProgram = $ProbeCalibrationProgram"
|
||||
Write-Host "NOTE: confirm those .PRG files exist at the paths above (-ResetProgram / -ProbeCalibrationProgram to override)."
|
||||
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
REM Ensure-PCDMISFrontEnd.bat - launcher for Ensure-PCDMISFrontEnd.ps1
|
||||
REM
|
||||
REM Creates C:\GE PC-DMIS FRONT END + grants the operator write access, so
|
||||
REM PCDToIGES.exe can write its error log there instead of crashing with
|
||||
REM "Could not find a part of the path 'C:\GE PC-DMIS FRONT END\PCDToIGES.ERR'".
|
||||
REM
|
||||
REM Needs admin - self-elevates.
|
||||
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Requesting administrator rights...
|
||||
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
set "HERE=%~dp0"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%Ensure-PCDMISFrontEnd.ps1"
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,47 @@
|
||||
# Ensure-PCDMISFrontEnd.ps1 - create C:\GE PC-DMIS FRONT END + grant operator write.
|
||||
#
|
||||
# WHY: PCDToIGES.exe (geaofi\Scripts) writes its error log to
|
||||
# C:\GE PC-DMIS FRONT END\PCDToIGES.ERR in its catch block. If that dir does
|
||||
# not exist, ANY PCDToIGES failure becomes an unhandled DirectoryNotFoundException
|
||||
# that CRASHES the export and MASKS the real error (observed on freshly imaged CMM
|
||||
# bays - the legacy "GE PC-DMIS FRONT END" front-end setup is not part of imaging,
|
||||
# so the dir is absent). Creating it + making it operator-writable lets the real
|
||||
# error log instead of crashing, and gives the front-end a working dir.
|
||||
#
|
||||
# Run as administrator. Idempotent - safe to re-run; re-asserts the ACL.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$dir = 'C:\GE PC-DMIS FRONT END'
|
||||
|
||||
$logDir = 'C:\Logs\CMM'
|
||||
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
|
||||
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$log = Join-Path $logDir "pcdmis-frontend-$ts.log"
|
||||
function Log($m){ $line = "[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m; Write-Host $line; Add-Content -Path $log -Value $line -EA SilentlyContinue }
|
||||
|
||||
Log "==== Ensure-PCDMISFrontEnd on $env:COMPUTERNAME ===="
|
||||
|
||||
if (Test-Path -LiteralPath $dir) {
|
||||
Log "Dir already exists: $dir"
|
||||
} else {
|
||||
try { New-Item -ItemType Directory -Path $dir -Force -EA Stop | Out-Null; Log "Created $dir" }
|
||||
catch { Log "ERROR: could not create $dir - $($_.Exception.Message)"; exit 1 }
|
||||
}
|
||||
|
||||
# Operator (locked-down, non-admin) must be able to write PCDToIGES.ERR here.
|
||||
# Modify = read/write/create/delete. SIDs, not names, to stay locale-independent.
|
||||
foreach ($sid in '*S-1-5-32-545','*S-1-5-11') { # BUILTIN\Users, NT AUTHORITY\Authenticated Users
|
||||
& icacls "$dir" /grant "${sid}:(OI)(CI)M" /T /C 2>&1 | Out-Null
|
||||
}
|
||||
Log "Granted Users + Authenticated Users Modify on $dir"
|
||||
|
||||
# Verify an operator-style write works
|
||||
$probe = Join-Path $dir '.write-test'
|
||||
try { Set-Content -LiteralPath $probe -Value 'ok' -EA Stop; Remove-Item -LiteralPath $probe -EA SilentlyContinue; Log "Write-test OK" }
|
||||
catch { Log "WARN: write-test failed - $($_.Exception.Message)" }
|
||||
|
||||
Log "==== DONE ===="
|
||||
Write-Host ""
|
||||
Write-Host "C:\GE PC-DMIS FRONT END ready. Re-run the measurement; if PCDToIGES still" -ForegroundColor Green
|
||||
Write-Host "errors, the real cause now logs to $dir\PCDToIGES.ERR" -ForegroundColor Green
|
||||
exit 0
|
||||
@@ -11,8 +11,10 @@ resolve-cmm-bay-config.ps1 at the WinPE picker):
|
||||
version it ever had (2016.0 + 2019 R2); we restore ONLY the
|
||||
one the bay-config pins, matched by substring (2019 -> the
|
||||
'2019 R2' zip, 2016 -> the '2016.0' zip).
|
||||
doda.txt - 'no' to proceed; anything else SKIPS restore (DODA bays are
|
||||
handled separately and must not get a settings overlay).
|
||||
doda.txt - read for logging only. DODA bays now restore like any other:
|
||||
once they are properly backed up + staged, the config-version
|
||||
zip is restored the same way. (The old skip-on-doda policy was
|
||||
dropped 2026-06-17; DODA bays had no backups when it was added.)
|
||||
partgroup.txt - optional goCMM Selected Part Group (friendly S:\ form), used
|
||||
as the authoritative per-bay override even if the backup is stale.
|
||||
|
||||
@@ -50,7 +52,9 @@ $doda = (ReadTxt 'doda.txt').ToLower()
|
||||
$pgRaw = ReadTxt 'partgroup.txt'
|
||||
|
||||
if (-not $cmmid) { Log "no cmmid.txt (manual CMM id, or not a bay-config bay) - nothing to restore. Skipping."; exit 0 }
|
||||
if ($doda -eq 'yes') { Log "DODA bay ($cmmid) - skipping settings restore by policy."; exit 0 }
|
||||
# DODA bays restore like any other now (skip-on-doda dropped 2026-06-17). DODA
|
||||
# itself installs separately via the manifest to C:\Apps\DODA - independent of
|
||||
# the goCMM (C:\geaofi) + PC-DMIS settings this restore lays back.
|
||||
|
||||
$bdir = Join-Path $BackupRoot $cmmid
|
||||
if (-not (Test-Path -LiteralPath $bdir)) { Log "no staged backup at $bdir - skipping (stage it with sync-cmm-backups.sh)."; exit 0 }
|
||||
|
||||
@@ -4,9 +4,17 @@ REM
|
||||
REM *** RUN THIS AS THE OPERATOR (the locked-down shop-floor user). ***
|
||||
REM *** DO NOT right-click "Run as administrator" - that hides the bug. ***
|
||||
REM
|
||||
REM Reproduces the goCMM "Requested registry access is not allowed" error and
|
||||
REM dumps the goCMM registry key's ACL so we can confirm the lockdown stripped
|
||||
REM the BUILTIN\Users write grant. Output lands in C:\Logs\CMM\.
|
||||
REM Diagnoses goCMM startup failures:
|
||||
REM 1. "Requested registry access is not allowed" - dumps the goCMM key ACL
|
||||
REM to confirm lockdown stripped the BUILTIN\Users write grant (PROBE 1-2).
|
||||
REM 2. "Object reference not set to an instance of an object" at start:
|
||||
REM - PROBE 3: part-group match (reg vs ApplicationSettings.xml, case-sensitive).
|
||||
REM - PROBE 4: pulls the actual .NET crash stack from the Application log.
|
||||
REM - PROBE 5: part-group UNC reachability + PC-DMIS COM registration.
|
||||
REM Confirmed root cause to date: PC-DMIS COM server NOT registered ->
|
||||
REM goCMM's Activator.CreateInstance gets a null type -> NRE. Fix: run
|
||||
REM Pcdlrn.exe once elevated (/regserver). PROBE 5 reports this directly.
|
||||
REM Output lands in C:\Logs\CMM\.
|
||||
|
||||
setlocal
|
||||
set "HERE=%~dp0"
|
||||
|
||||
@@ -93,14 +93,155 @@ W " exported -> $regOut"
|
||||
W ""
|
||||
|
||||
W "================ goCMM version + install ================"
|
||||
# The goCMM MSI installs goCMM.exe (1.1.6718.x) to this dir and points the Start
|
||||
# Menu shortcut at it - goCMM.exe IS the launcher. (An earlier version of this
|
||||
# script checked for GEAOperatorFriendlyInterface.exe, which this product does
|
||||
# NOT install - that produced a false MISSING. Check the real exe.)
|
||||
foreach ($p in @(
|
||||
'C:\Program Files (x86)\General Electric\goCMM\GEAOperatorFriendlyInterface.exe',
|
||||
'C:\Program Files (x86)\General Electric\goCMM\goCMM.exe',
|
||||
'C:\Program Files (x86)\General Electric\goCMM\GEA_OFI_Common.dll')) {
|
||||
if (Test-Path $p) { $vi = (Get-Item $p).VersionInfo; W (" {0} FileVer={1} ProductVer={2}" -f (Split-Path $p -Leaf), $vi.FileVersion, $vi.ProductVersion) }
|
||||
else { W " MISSING: $p" }
|
||||
}
|
||||
W ""
|
||||
|
||||
W "================ PROBE 3: Selected Part Group vs ApplicationSettings.xml (the startup NRE) ================"
|
||||
# goCMM at start matches the registry 'Selected Part Group' against the
|
||||
# <PartGroup FullName> entries in ApplicationSettings.xml with a CASE-SENSITIVE
|
||||
# (Ordinal) compare. No match -> SelectedPartGroup is null -> a startup deref
|
||||
# throws "Object reference not set to an instance of an object". This is a
|
||||
# DIFFERENT failure from the registry SecurityException probed above.
|
||||
# Mismatch forms seen: host (bare vs FQDN) and share segment (\shared vs \SHARED).
|
||||
try {
|
||||
$base32 = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry32')
|
||||
$kpg = $base32.OpenSubKey($key32native, $false)
|
||||
$regPg = $null; $sharedDir = 'C:\geaofi'
|
||||
if ($kpg) {
|
||||
$regPg = [string]$kpg.GetValue('Selected Part Group','')
|
||||
$sd = [string]$kpg.GetValue('Shared Data Directory','')
|
||||
if ($sd) { $sharedDir = $sd.TrimEnd('\') }
|
||||
$kpg.Close()
|
||||
}
|
||||
W (" reg 'Selected Part Group' = [{0}]" -f $regPg)
|
||||
W (" Shared Data Directory = {0}" -f $sharedDir)
|
||||
|
||||
$xml = Join-Path $sharedDir 'ApplicationSettings.xml'
|
||||
if (-not (Test-Path $xml)) {
|
||||
W " *** ApplicationSettings.xml NOT FOUND at $xml - no part groups to match -> null SelectedPartGroup -> NRE likely. <<"
|
||||
} else {
|
||||
$txt = [System.IO.File]::ReadAllText($xml)
|
||||
# Capture FullName in both attribute (FullName="...") and element (<FullName>...</FullName>) forms.
|
||||
$names = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($m in [regex]::Matches($txt, 'FullName\s*=\s*"([^"]*)"')) { $names.Add($m.Groups[1].Value) }
|
||||
foreach ($m in [regex]::Matches($txt, '<FullName>([^<]*)</FullName>')) { $names.Add($m.Groups[1].Value) }
|
||||
$names = $names | Select-Object -Unique
|
||||
W (" ApplicationSettings.xml part-group FullName entries ({0}):" -f @($names).Count)
|
||||
foreach ($n in $names) { W (" [{0}]" -f $n) }
|
||||
|
||||
if (-not $regPg) {
|
||||
W " >> reg 'Selected Part Group' is EMPTY - goCMM has no part group pinned -> null -> NRE likely. <<"
|
||||
} else {
|
||||
$exact = $false; $caseOnly = $false
|
||||
foreach ($n in $names) {
|
||||
if ([string]::Equals($n, $regPg, [StringComparison]::Ordinal)) { $exact = $true; break }
|
||||
if ([string]::Equals($n, $regPg, [StringComparison]::OrdinalIgnoreCase)) { $caseOnly = $true }
|
||||
}
|
||||
if ($exact) {
|
||||
W " >> MATCH: reg value matches an XML FullName exactly (case-sensitive). Part group is NOT the NRE cause. <<"
|
||||
} elseif ($caseOnly) {
|
||||
W " >> *** CASE-ONLY MATCH *** reg value matches an XML entry only when case is ignored."
|
||||
W " >> goCMM's compare is case-sensitive -> Find returns null -> SelectedPartGroup null -> NRE. <<"
|
||||
W " >> FIX: re-run Install-goCMMSettings.ps1 (>= commit d441abd canonicalizes host + \SHARED case). <<"
|
||||
} else {
|
||||
W " >> *** NO MATCH *** reg 'Selected Part Group' is absent from ApplicationSettings.xml (any case)."
|
||||
W " >> Find returns null -> SelectedPartGroup null -> NRE. Wrong per-bay override, or XML from a different bay. <<"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { W " ERROR in part-group probe: $($_.Exception.Message)" }
|
||||
W ""
|
||||
|
||||
W "================ PROBE 4: goCMM crash events (Application log) - the actual stack ================"
|
||||
# The NRE reproduces even for admin, so it is NOT a rights problem. The faulting
|
||||
# stack names the null object. .NET pushes it to the Application log under
|
||||
# '.NET Runtime' / 'Application Error' / 'Windows Error Reporting'.
|
||||
try {
|
||||
$since = (Get-Date).AddDays(-14)
|
||||
$ev = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$since } -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.ProviderName -match '\.NET Runtime|Application Error|Windows Error Reporting|Application Hang' -and "$($_.Message)" -match 'goCMM' }
|
||||
if (-not $ev) {
|
||||
W " (no goCMM-related crash events in the last 14 days - launch goCMM once, then re-run this)"
|
||||
} else {
|
||||
foreach ($e in ($ev | Select-Object -First 5)) {
|
||||
W (" ---- {0} [{1}] EventId={2} ----" -f $e.TimeCreated, $e.ProviderName, $e.Id)
|
||||
foreach ($line in ("$($e.Message)" -split "`r?`n")) { W (" " + $line) }
|
||||
W ""
|
||||
}
|
||||
W " >> The topmost goCMM / GEA_OFI frame in the stack above is where the null is dereferenced. <<"
|
||||
}
|
||||
} catch { W " ERROR reading event log: $($_.Exception.Message)" }
|
||||
W ""
|
||||
|
||||
W "================ PROBE 5: part-group UNC reachability + PC-DMIS install ================"
|
||||
# (a) Can THIS bay open the Selected Part Group share? An unreachable / unauthenticated
|
||||
# UNC -> goCMM enumerates null -> startup NRE, regardless of local rights (admin too).
|
||||
try {
|
||||
$base32 = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry32')
|
||||
$kpg = $base32.OpenSubKey($key32native, $false)
|
||||
$pg = if ($kpg) { [string]$kpg.GetValue('Selected Part Group','') } else { '' }
|
||||
if ($kpg) { $kpg.Close() }
|
||||
if ($pg) {
|
||||
W (" part group UNC: $pg")
|
||||
if (Test-Path -LiteralPath $pg -ErrorAction SilentlyContinue) {
|
||||
$n = @(Get-ChildItem -LiteralPath $pg -ErrorAction SilentlyContinue).Count
|
||||
W (" >> REACHABLE - $n item(s) under it. (not the NRE cause) <<")
|
||||
} else {
|
||||
W " >> *** UNREACHABLE *** bay cannot open the part-group UNC (share offline, not on the"
|
||||
W " >> production net yet, or no credential to \\tsgwp00525\SHARED). goCMM enumerating this"
|
||||
W " >> path returns null -> startup NRE even for admin. Check net connectivity + share auth. <<"
|
||||
}
|
||||
} else { W " (no Selected Part Group in registry)" }
|
||||
} catch { W " ERROR testing part-group UNC: $($_.Exception.Message)" }
|
||||
W ""
|
||||
# (b) PC-DMIS COM automation - THE confirmed root cause (see debug.log). goCMM
|
||||
# connects via Activator.CreateInstance on the PC-DMIS COM type. If the
|
||||
# automation server is not registered, Type.GetTypeFromProgID returns null
|
||||
# -> CreateInstance(null) -> "ArgumentNullException: ... Parameter name: type"
|
||||
# -> ConnectToSoftware derefs null -> NRE. PC-DMIS can be RUNNING and still
|
||||
# not be COM-registered. Stack: GEAE.Common.CMM.CMMInterfaces.PCDMIS.ConnectToSoftware.
|
||||
$comOk = $false
|
||||
foreach ($pgid in 'PCDLRN.Application','PCDLRN.Automation','Pcdlrn.Application','PCDLRN.Object') {
|
||||
foreach ($cls in "HKLM:\SOFTWARE\Classes\$pgid","HKLM:\SOFTWARE\Classes\WOW6432Node\$pgid") {
|
||||
if (Test-Path $cls) {
|
||||
$clsid = (Get-ItemProperty -Path "$cls\CLSID" -ErrorAction SilentlyContinue).'(default)'
|
||||
$comOk = $true
|
||||
$srv = $null
|
||||
if ($clsid) {
|
||||
foreach ($cb in "HKLM:\SOFTWARE\Classes\CLSID\$clsid\LocalServer32","HKLM:\SOFTWARE\Classes\WOW6432Node\CLSID\$clsid\LocalServer32") {
|
||||
if (Test-Path $cb) { $srv = (Get-ItemProperty -Path $cb -EA SilentlyContinue).'(default)' }
|
||||
}
|
||||
}
|
||||
W (" COM ProgID registered: $pgid CLSID=$clsid server=$srv")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $comOk) {
|
||||
W " >> *** PC-DMIS COM SERVER NOT REGISTERED *** no PCDLRN.* ProgID in HKCR. This is exactly the"
|
||||
W " >> null behind 'ArgumentNullException: ... Parameter name: type' in debug.log -> goCMM startup NRE."
|
||||
W " >> FIX: run the installed Pcdlrn.exe once ELEVATED (self-registers), or 'Pcdlrn.exe /regserver'."
|
||||
W " >> Then confirm the registered build matches the goCMM-expected PC-DMIS version (the version gate). <<"
|
||||
}
|
||||
# install presence (context for the COM verdict)
|
||||
$pcdlrn = $null
|
||||
foreach ($d in 'C:\Program Files\Hexagon','C:\Program Files (x86)\Hexagon','C:\Program Files\WAI','C:\Program Files (x86)\WAI') {
|
||||
if (Test-Path $d) {
|
||||
$hit = Get-ChildItem $d -Filter 'Pcdlrn.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($hit) { $pcdlrn = $hit.FullName; W (" Pcdlrn.exe: $($hit.FullName) v$((Get-Item $hit.FullName).VersionInfo.FileVersion)"); break }
|
||||
}
|
||||
}
|
||||
if (-not $pcdlrn) { W " Pcdlrn.exe NOT found in standard dirs - PC-DMIS not installed where expected." }
|
||||
W ""
|
||||
|
||||
W "================ UAC / registry virtualization ================"
|
||||
(reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" /v EnableLUA 2>&1 | Out-String) | W
|
||||
(reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" /v EnableVirtualization 2>&1 | Out-String) | W
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
REM ==========================================================================
|
||||
REM Diagnose-eDNCDetection.bat - diagnose the eDNC 6.4.5 enforce loop (MSI
|
||||
REM 1603 "same or newer already installed" every 5 minutes).
|
||||
REM
|
||||
REM Usage (run on the problem PC):
|
||||
REM Diagnose-eDNCDetection.bat auto-find manifest (W: or UNC)
|
||||
REM Diagnose-eDNCDetection.bat /manifest <path> explicit manifest.json path
|
||||
REM
|
||||
REM Read-only: no installs, no share writes.
|
||||
REM ==========================================================================
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
REM --- self-elevate ---
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Requesting administrator elevation...
|
||||
powershell -NoProfile -Command "Start-Process -Verb RunAs -FilePath '%~f0' -ArgumentList '%*'"
|
||||
exit /b
|
||||
)
|
||||
|
||||
set "PS=%~dp0Diagnose-eDNCDetection.ps1"
|
||||
set "ARGS="
|
||||
|
||||
:parse
|
||||
if "%~1"=="" goto run
|
||||
if /I "%~1"=="/manifest" set "ARGS=!ARGS! -ManifestPath '%~2'" & shift & shift & goto parse
|
||||
shift
|
||||
goto parse
|
||||
|
||||
:run
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS%" !ARGS!
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
@@ -0,0 +1,232 @@
|
||||
<#
|
||||
Diagnose-eDNCDetection.ps1
|
||||
|
||||
One-shot diagnosis of the eDNC 6.4.5 enforce loop: GE-Enforce re-runs
|
||||
eDNC_6-4-5.msi every 5 minutes, the MSI aborts 1603 with "The same or newer
|
||||
version of this software is already installed", and InUseCheck kills DncMain
|
||||
in the process. Root cause is the manifest entry's Detection* fields not
|
||||
matching what is actually installed.
|
||||
|
||||
Runs the whole triage and prints a verdict:
|
||||
|
||||
1. Installed state - DncMain.exe / NTLARS.exe file versions, x86+x64
|
||||
uninstall registry entries for eDNC (DisplayVersion,
|
||||
ProductCode).
|
||||
2. Manifest entry - the live "eDNC (bundles NTLARS)" entry from the SFLD
|
||||
share manifest (uses W: if enforce has it mounted,
|
||||
else direct UNC, else -ManifestPath).
|
||||
3. Comparison - replays Install-FromManifest's Test-AppInstalled
|
||||
logic against the entry and says WHY it misses.
|
||||
4. Verdict - suggested corrected manifest JSON.
|
||||
|
||||
Read-only: no installs, no share writes. Run as administrator on the
|
||||
problem PC (registry + share read).
|
||||
|
||||
Params:
|
||||
-ManifestPath explicit path to the collections manifest.json (skips
|
||||
W:/UNC auto-discovery)
|
||||
#>
|
||||
param(
|
||||
[string]$ManifestPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Section($t){ Write-Host ''; Write-Host ("==== {0} ====" -f $t) -ForegroundColor Cyan }
|
||||
function KV($k,$v){ Write-Host (" {0,-24}: {1}" -f $k, $v) }
|
||||
|
||||
Write-Host '########################################################'
|
||||
Write-Host '# eDNC Detection Loop Diagnosis'
|
||||
Write-Host ("# {0} host {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), [System.Environment]::MachineName)
|
||||
Write-Host '########################################################'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Installed state
|
||||
# ---------------------------------------------------------------------------
|
||||
Section '1. Installed eDNC state on this PC'
|
||||
|
||||
$dncMain = 'C:\Program Files (x86)\Dnc\bin\DncMain.exe'
|
||||
$ntlars = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe'
|
||||
$dncMainVer = ''
|
||||
$ntlarsVer = ''
|
||||
|
||||
if (Test-Path $dncMain) {
|
||||
$dncMainVer = (Get-Item $dncMain).VersionInfo.FileVersion
|
||||
KV 'DncMain.exe' ("present, FileVersion {0}" -f $dncMainVer)
|
||||
} else {
|
||||
KV 'DncMain.exe' 'MISSING'
|
||||
}
|
||||
if (Test-Path $ntlars) {
|
||||
$ntlarsVer = (Get-Item $ntlars).VersionInfo.FileVersion
|
||||
KV 'NTLARS.exe' ("present, FileVersion {0}" -f $ntlarsVer)
|
||||
} else {
|
||||
KV 'NTLARS.exe' 'MISSING'
|
||||
}
|
||||
|
||||
# Uninstall entries - both registry views. Win32_Product deliberately avoided
|
||||
# (it triggers MSI self-repair on enumeration).
|
||||
$uninstallRoots = @(
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
$edncProducts = @()
|
||||
foreach ($root in $uninstallRoots) {
|
||||
$edncProducts += Get-ItemProperty -Path $root -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -like '*eDNC*' -or $_.DisplayName -like '*DNC*Client*' }
|
||||
}
|
||||
if ($edncProducts) {
|
||||
foreach ($p in $edncProducts) {
|
||||
Write-Host ''
|
||||
KV 'DisplayName' $p.DisplayName
|
||||
KV 'DisplayVersion' $p.DisplayVersion
|
||||
KV 'ProductCode' $p.PSChildName
|
||||
KV 'InstallDate' $p.InstallDate
|
||||
KV 'RegistryView' ($(if ($p.PSPath -like '*WOW6432Node*') { 'x86 (WOW6432Node)' } else { 'x64' }))
|
||||
}
|
||||
} else {
|
||||
Write-Host ' No eDNC uninstall entry found in either registry view.' -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Manifest entry from the share
|
||||
# ---------------------------------------------------------------------------
|
||||
Section '2. Manifest entry (share)'
|
||||
|
||||
$candidates = @()
|
||||
if ($ManifestPath) { $candidates += $ManifestPath }
|
||||
$candidates += 'W:\gea-shopfloor-collections\manifest.json'
|
||||
$candidates += '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\gea-shopfloor-collections\manifest.json'
|
||||
|
||||
$manifestFile = $null
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path $c) { $manifestFile = $c; break }
|
||||
}
|
||||
|
||||
$entry = $null
|
||||
if ($manifestFile) {
|
||||
KV 'Manifest' $manifestFile
|
||||
try {
|
||||
$manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json
|
||||
$entry = $manifest.Applications | Where-Object { $_.Name -like 'eDNC*' } | Select-Object -First 1
|
||||
} catch {
|
||||
Write-Host (" Failed to parse manifest: {0}" -f $_) -ForegroundColor Red
|
||||
}
|
||||
if ($entry) {
|
||||
KV 'Name' $entry.Name
|
||||
KV 'Installer' $entry.Installer
|
||||
KV 'Type' $entry.Type
|
||||
KV 'InstallArgs' $entry.InstallArgs
|
||||
KV 'DetectionMethod' $entry.DetectionMethod
|
||||
KV 'DetectionPath' $entry.DetectionPath
|
||||
KV 'DetectionName' $entry.DetectionName
|
||||
KV 'DetectionValue' $entry.DetectionValue
|
||||
} else {
|
||||
Write-Host ' No eDNC* entry found in manifest Applications.' -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host ' Manifest not reachable (no W: mount, UNC denied).' -ForegroundColor Yellow
|
||||
Write-Host ' Re-run with -ManifestPath, or run while GE-Enforce has W: mounted.'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Replay Test-AppInstalled against the entry
|
||||
# ---------------------------------------------------------------------------
|
||||
Section '3. Detection replay'
|
||||
|
||||
$detected = $null
|
||||
$missReason = ''
|
||||
if ($entry) {
|
||||
switch ($entry.DetectionMethod) {
|
||||
'File' {
|
||||
$detected = Test-Path $entry.DetectionPath
|
||||
if (-not $detected) { $missReason = "DetectionPath does not exist: $($entry.DetectionPath)" }
|
||||
}
|
||||
'FileVersion' {
|
||||
if (-not (Test-Path $entry.DetectionPath)) {
|
||||
$detected = $false
|
||||
$missReason = "DetectionPath does not exist: $($entry.DetectionPath)"
|
||||
} elseif (-not $entry.DetectionValue) {
|
||||
$detected = $false
|
||||
$missReason = 'FileVersion detection with no DetectionValue always misses'
|
||||
} else {
|
||||
$actual = (Get-Item $entry.DetectionPath -ErrorAction SilentlyContinue).VersionInfo.FileVersion
|
||||
$detected = ($actual -eq $entry.DetectionValue)
|
||||
if (-not $detected) {
|
||||
$missReason = "exact-match compare failed: actual '$actual' vs DetectionValue '$($entry.DetectionValue)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
'Registry' {
|
||||
if (-not (Test-Path $entry.DetectionPath)) {
|
||||
$detected = $false
|
||||
$missReason = "registry key does not exist: $($entry.DetectionPath) (eDNC uninstall key path varies across 6.x releases)"
|
||||
} elseif ($entry.DetectionName) {
|
||||
$v = Get-ItemProperty -Path $entry.DetectionPath -Name $entry.DetectionName -ErrorAction SilentlyContinue
|
||||
if (-not $v) {
|
||||
$detected = $false
|
||||
$missReason = "value '$($entry.DetectionName)' missing under $($entry.DetectionPath)"
|
||||
} elseif ($entry.DetectionValue) {
|
||||
$actual = $v.$($entry.DetectionName)
|
||||
$detected = ("$actual" -eq "$($entry.DetectionValue)")
|
||||
if (-not $detected) {
|
||||
$missReason = "exact-match compare failed: actual '$actual' vs DetectionValue '$($entry.DetectionValue)'"
|
||||
}
|
||||
} else {
|
||||
$detected = $true
|
||||
}
|
||||
} else {
|
||||
$detected = $true
|
||||
}
|
||||
}
|
||||
default {
|
||||
$missReason = "detection method '$($entry.DetectionMethod)' not replayed by this script"
|
||||
}
|
||||
}
|
||||
if ($null -ne $detected) {
|
||||
KV 'Test-AppInstalled' ($(if ($detected) { 'TRUE (would skip install)' } else { 'FALSE (re-runs MSI every cycle)' }))
|
||||
if ($missReason) { KV 'Miss reason' $missReason }
|
||||
}
|
||||
} else {
|
||||
Write-Host ' Skipped - no manifest entry available to replay.'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Verdict
|
||||
# ---------------------------------------------------------------------------
|
||||
Section '4. Verdict'
|
||||
|
||||
$edncInstalled = ($edncProducts.Count -gt 0) -or (Test-Path $dncMain)
|
||||
|
||||
if (-not $edncInstalled) {
|
||||
Write-Host ' eDNC does NOT appear installed. The 1603 "same or newer" abort'
|
||||
Write-Host ' then points at a stale MSI registration; check the upgrade-code'
|
||||
Write-Host ' products under HKLM:\SOFTWARE\Classes\Installer\UpgradeCodes.'
|
||||
} elseif ($detected -eq $true) {
|
||||
Write-Host ' Detection passes now. If enforce still loops, the manifest on the'
|
||||
Write-Host ' share differs from the one inspected here - confirm the path.'
|
||||
} else {
|
||||
Write-Host ' eDNC IS installed but the manifest entry does not detect it, so'
|
||||
Write-Host ' GE-Enforce re-runs the MSI every cycle and the MSI aborts 1603'
|
||||
Write-Host ' ("same or newer already installed"). Fix the manifest entry on'
|
||||
Write-Host ' the share. Suggested replacement (File detection on NTLARS.exe,'
|
||||
Write-Host ' same approach as the working 6.4.3 machineapps entry):'
|
||||
Write-Host ''
|
||||
$suggested = [ordered]@{
|
||||
Name = 'eDNC (bundles NTLARS)'
|
||||
Installer = 'apps\eDNC_6-4-5.msi'
|
||||
Type = 'MSI'
|
||||
InstallArgs = '/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress SITESELECTED="West Jefferson"'
|
||||
DetectionMethod = 'File'
|
||||
DetectionPath = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe'
|
||||
} | ConvertTo-Json
|
||||
Write-Host $suggested
|
||||
Write-Host ''
|
||||
if ($edncProducts) {
|
||||
$dv = ($edncProducts | Select-Object -First 1).DisplayVersion
|
||||
Write-Host (" Alternative: keep Registry detection but set DetectionValue to the")
|
||||
Write-Host (" actual installed DisplayVersion: '{0}'" -f $dv)
|
||||
}
|
||||
Write-Host ' Until the manifest is fixed, every enforce cycle also risks killing'
|
||||
Write-Host ' a live DncMain session (InUseCheck force-kill before the doomed MSI).'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
@@ -1,21 +1,28 @@
|
||||
# Report-AssetToShopDB.ps1
|
||||
#
|
||||
# Reports a collections bay's identity to ShopDB so the machines record stays
|
||||
# current with whatever the bay actually is right now: hostname, BIOS serial,
|
||||
# DNC machine number (2001, 2002, ...) and its corp/AESFMA IPv4 address.
|
||||
# Reports a PC's identity to ShopDB so the machines record stays current with
|
||||
# whatever the PC actually is right now: hostname, BIOS serial, pc-type, logged-
|
||||
# in user, DNC machine number (2001, 2002, ... when present) and its corp/AESFMA
|
||||
# IPv4 address.
|
||||
#
|
||||
# Deployed in common\ (runs on EVERY shopfloor pc-type: collections, cmm,
|
||||
# keyence, waxtrace, genspect, heattreat, partmarker, nocollections, ...), not
|
||||
# collections-only. Non-DNC bays (cmm/keyence/waxtrace) simply report identity
|
||||
# with no machineNo, so no PC-to-machine relationship is built - by design.
|
||||
#
|
||||
# Runs every GE-Enforce cycle as a Type=PS1 manifest entry (DetectionMethod
|
||||
# Always) under the SYSTEM scheduled task. Idempotent on the server side:
|
||||
# ShopDB api.asp action=updateCompleteAsset upserts the machines row keyed by
|
||||
# hostname, clears+reinserts the interface rows, and (re)creates the
|
||||
# PC-to-machine relationship from machineNo. Safe to fire repeatedly.
|
||||
# hostname (patch-style: only the fields posted here are updated, so it never
|
||||
# clobbers model/VNC/WinRM), clears+reinserts the interface rows, and (re)creates
|
||||
# the PC-to-machine relationship from machineNo. Safe to fire repeatedly.
|
||||
#
|
||||
# WHY collections-only and corp-NIC-only:
|
||||
# Collections (controller-NIC) bays carry two NICs - a private controller
|
||||
# NIC (e.g. 192.168.x / 10.x stray) and the routable corp/AESFMA NIC. Only
|
||||
# the corp NIC belongs in ShopDB, so we filter to the WJ corp ranges and
|
||||
# drop everything else. Mirrors the allowed-range gate in
|
||||
# Invoke-FilteredReportIP.ps1.
|
||||
# WHY corp-NIC-only:
|
||||
# Some bays (collections/controller) carry two NICs - a private controller NIC
|
||||
# (e.g. 192.168.x / 10.x stray) and the routable corp/AESFMA NIC. Only the corp
|
||||
# NIC belongs in ShopDB, so we filter to the WJ corp ranges and drop the rest.
|
||||
# Single-NIC PCs just pass their one corp IP through the same gate. Mirrors the
|
||||
# allowed-range gate in Invoke-FilteredReportIP.ps1.
|
||||
#
|
||||
# Always exits 0 so the GE-Enforce "last run result" stays clean; failures are
|
||||
# logged, never thrown.
|
||||
@@ -83,15 +90,17 @@ if (-not $serialNumber) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# DNC machine number (2001, 2002, ...). optional - sent only if found.
|
||||
# Resolution order mirrors the GE-Enforce lib Get-CurrentMachineNumber so the
|
||||
# reporter and manifest gating agree:
|
||||
# 1. eDNC registry (WOW6432Node, then native) - follows bay reassignment, which
|
||||
# Set-MachineNumber rewrites here.
|
||||
# 2. C:\Enrollment\machine-number.txt - the imaging-time value written once by
|
||||
# startnet.cmd. Used when eDNC has not populated the registry yet (fresh
|
||||
# image, or a bay where eDNC has not run), so the PC still reports its number
|
||||
# and api.asp can build the relationship.
|
||||
# Machine identifier - optional, sent only if found. api.asp matches it against
|
||||
# the equipment machinenumber OR alias column, so one value covers every type:
|
||||
# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
|
||||
# Follows bay reassignment, which Set-MachineNumber rewrites here.
|
||||
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3), written by
|
||||
# select-cmm-bay.ps1 at imaging. Matches the equipment machinenumber.
|
||||
# 3. C:\Enrollment\machine-number.txt - imaging value. For wax&trace this is the
|
||||
# asset tag from select-waxtrace-asset.ps1 (matches the equipment alias);
|
||||
# for DNC bays it is the digit number. 9999 is the placeholder = skip.
|
||||
# keyence / genspect / part-marker have no per-bay id on the PC -> no machineNo
|
||||
# is sent and the link is assigned manually in ShopDB.
|
||||
$machineNo = ''
|
||||
foreach ($regPath in @(
|
||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
|
||||
@@ -101,43 +110,115 @@ foreach ($regPath in @(
|
||||
try {
|
||||
if (Test-Path $regPath) {
|
||||
$v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
|
||||
if ($v) { $machineNo = $v.Trim() }
|
||||
if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
|
||||
}
|
||||
} catch {
|
||||
Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
if (-not $machineNo) {
|
||||
$cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
|
||||
if (Test-Path -LiteralPath $cmmFile) {
|
||||
try {
|
||||
$v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
|
||||
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
|
||||
} catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
if (-not $machineNo) {
|
||||
$mnFile = 'C:\Enrollment\machine-number.txt'
|
||||
if (Test-Path -LiteralPath $mnFile) {
|
||||
try {
|
||||
$v = Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop
|
||||
if ($v) { $machineNo = ([string]$v).Trim(); Log "machineNo from $mnFile (eDNC registry empty): $machineNo" }
|
||||
$v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
|
||||
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
|
||||
} catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
# OS caption for the operatingsystems lookup.
|
||||
# OS for the operatingsystems lookup: caption + feature-update (e.g. 23H2, read
|
||||
# from the registry since WMI does not expose it) + major build number. Yields a
|
||||
# string like "Microsoft Windows 11 Enterprise 23H2 (build 22631)" so the fleet
|
||||
# OS-version breakdown is visible everywhere osid is shown - no schema change,
|
||||
# api.asp upserts each distinct string into operatingsystems.
|
||||
$osVersion = ''
|
||||
# last boot time - api.asp stores it as machines.lastboottime; uptime is derived
|
||||
# server-side (DATEDIFF(NOW(), lastboottime)). MySQL datetime format.
|
||||
$lastBootTime = ''
|
||||
try {
|
||||
$osVersion = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).Caption
|
||||
if ($osVersion) { $osVersion = $osVersion.Trim() }
|
||||
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
||||
$osVersion = "$($os.Caption)".Trim()
|
||||
$displayVersion = ''
|
||||
try {
|
||||
$displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
|
||||
} catch {}
|
||||
if ($displayVersion) { $osVersion += " $displayVersion" }
|
||||
if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" }
|
||||
$osVersion = $osVersion.Trim()
|
||||
try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss') } catch {}
|
||||
} catch {}
|
||||
|
||||
# gather corp NICs only. one networkInterfaces entry per allowed IPv4.
|
||||
# interactive console user (DOMAIN\user). this script runs as SYSTEM so we
|
||||
# cannot use $env:USERNAME; Win32_ComputerSystem.UserName is the console
|
||||
# session owner and works from SYSTEM context. empty when nobody is logged on,
|
||||
# in which case we omit it from the post so an unattended bay does not blank
|
||||
# the last-known user on the server.
|
||||
$loggedInUser = ''
|
||||
try {
|
||||
$loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
|
||||
# Win32 returns COMPUTERNAME\user (local account) or DOMAIN\user. Keep only
|
||||
# the username part: matches the legacy bare-username convention and avoids
|
||||
# the backslash, which the inline api.asp SQL does not escape so MySQL eats
|
||||
# it (FB90238\ShopFloor was being stored as FB90238ShopFloor).
|
||||
if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
|
||||
} catch {
|
||||
Log "WARN could not read logged-in user: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# imaging pc-type (gea-shopfloor-*), read from the enrollment file written at
|
||||
# image time. api.asp maps both the gea-shopfloor-* values and the legacy
|
||||
# display strings to the right pctypeid. Sent only when present; when absent the
|
||||
# server's patch-style update leaves the existing pctype untouched (so a bare
|
||||
# report never re-types a PC). This is what lets the reporter run fleet-wide
|
||||
# from common\ instead of collections-only.
|
||||
$pcType = ''
|
||||
$ptFile = 'C:\Enrollment\pc-type.txt'
|
||||
if (Test-Path -LiteralPath $ptFile) {
|
||||
try {
|
||||
$pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
|
||||
} catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# PC make/model from WMI (e.g. "Dell Inc." / "OptiPlex 7090"). api.asp resolves
|
||||
# or creates the vendor + model rows and links modelnumberid. Sent only when
|
||||
# present so a WMI read failure does not blank the model on the row.
|
||||
$manufacturer = ''
|
||||
$model = ''
|
||||
try {
|
||||
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
||||
$manufacturer = "$($cs.Manufacturer)".Trim()
|
||||
$model = "$($cs.Model)".Trim()
|
||||
} catch {
|
||||
Log "WARN could not read make/model: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# gather IPv4 NICs - BOTH the corp/AESFMA NIC and the controller/machine LAN NIC,
|
||||
# each with its MAC. Physical adapters only (drop Hyper-V/VPN/WSL/virtual plus
|
||||
# link-local 169.254 and loopback). Each NIC is tagged IsMachineNetwork: true for
|
||||
# the controller LAN (any IP outside the corp ranges), false for the corp NIC.
|
||||
$interfaces = @()
|
||||
try {
|
||||
$ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
|
||||
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
|
||||
foreach ($ipo in $ipObjs) {
|
||||
if (-not (Test-InAllowedRange $ipo.IPAddress)) { continue }
|
||||
$adapter = $null
|
||||
try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
|
||||
# physical + connected only; skip virtual adapters (Hyper-V/VPN/WSL/etc)
|
||||
if (-not $adapter) { continue }
|
||||
if (-not $adapter.HardwareInterface) { continue }
|
||||
if ($adapter.Status -ne 'Up') { continue }
|
||||
|
||||
$mac = ''
|
||||
$mac = $adapter.MacAddress
|
||||
$gw = ''
|
||||
try {
|
||||
$adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop
|
||||
$mac = $adapter.MacAddress
|
||||
} catch {}
|
||||
try {
|
||||
$gw = (Get-NetRoute -InterfaceIndex $ipo.InterfaceIndex -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
|
||||
Select-Object -First 1).NextHop
|
||||
@@ -149,13 +230,15 @@ try {
|
||||
[Array]::Reverse($maskBytes)
|
||||
$subnetMask = ($maskBytes | ForEach-Object { $_ }) -join '.'
|
||||
|
||||
$isCorp = Test-InAllowedRange $ipo.IPAddress
|
||||
|
||||
$interfaces += [pscustomobject]@{
|
||||
IPAddress = $ipo.IPAddress
|
||||
MACAddress = $mac
|
||||
SubnetMask = $subnetMask
|
||||
DefaultGateway = $gw
|
||||
InterfaceName = $ipo.InterfaceAlias
|
||||
IsMachineNetwork = $false # corp NIC, not the controller LAN
|
||||
IsMachineNetwork = (-not $isCorp) # controller/machine LAN = true; corp = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -163,7 +246,7 @@ try {
|
||||
}
|
||||
|
||||
if ($interfaces.Count -eq 0) {
|
||||
Log 'WARN no corp-range IPv4 found; posting identity without interfaces.'
|
||||
Log 'WARN no physical IPv4 NIC found; posting identity without interfaces.'
|
||||
}
|
||||
|
||||
$networkInterfacesJson = if ($interfaces.Count -gt 0) { $interfaces | ConvertTo-Json -Compress -Depth 4 } else { '' }
|
||||
@@ -174,14 +257,18 @@ $body = @{
|
||||
action = 'updateCompleteAsset'
|
||||
hostname = $hostname
|
||||
serialNumber = $serialNumber
|
||||
pcType = 'Shopfloor'
|
||||
osVersion = $osVersion
|
||||
networkInterfaces = $networkInterfacesJson
|
||||
}
|
||||
if ($machineNo) { $body['machineNo'] = $machineNo }
|
||||
if ($machineNo) { $body['machineNo'] = $machineNo }
|
||||
if ($loggedInUser) { $body['loggedInUser'] = $loggedInUser }
|
||||
if ($pcType) { $body['pcType'] = $pcType }
|
||||
if ($manufacturer) { $body['manufacturer'] = $manufacturer }
|
||||
if ($model) { $body['model'] = $model }
|
||||
if ($lastBootTime) { $body['lastBootTime'] = $lastBootTime }
|
||||
|
||||
Log ("POST {0} host={1} serial={2} machineNo={3} ips={4}" -f `
|
||||
$ApiUrl, $hostname, $serialNumber, $machineNo, (($interfaces | ForEach-Object { $_.IPAddress }) -join ','))
|
||||
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ips={10}" -f `
|
||||
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, (($interfaces | ForEach-Object { $_.IPAddress }) -join ','))
|
||||
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $body -TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
|
||||
@@ -147,7 +147,7 @@ if (-not $mn -or $mn -eq '9999' -or $mn -notmatch '^\d+$') {
|
||||
function Mount-SFLDShare {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$SharePath,
|
||||
[string]$DriveLetter = 'V:'
|
||||
[string]$DriveLetter = 'R:'
|
||||
)
|
||||
$server = ($SharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1
|
||||
$basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
|
||||
@@ -178,11 +178,18 @@ function Mount-SFLDShare {
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
Log "Mounting share with SFLD creds: $BackupShareRoot -> W:"
|
||||
# R:, not W: and not V:. GE-Enforce owns W: for the whole enforce cycle
|
||||
# (GE-Enforce.ps1 $driveLetter). This script runs as a manifest PS1 entry
|
||||
# inside that cycle, so remapping/deleting W: here kills the share for every
|
||||
# later manifest entry - Join-Path on a dead W: returns null and the next
|
||||
# entry dies with "Cannot bind argument to parameter 'LiteralPath' because it
|
||||
# is null". V: is no good either: Update-MachineNumber.ps1 / Restore-EDncReg.ps1
|
||||
# mount V: and run as SYSTEM in the same drive namespace. R: is unclaimed.
|
||||
Log "Mounting share with SFLD creds: $BackupShareRoot -> R:"
|
||||
$shareMounted = $false
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($sw.Elapsed.TotalSeconds -lt $ShareTimeoutSec) {
|
||||
if (Mount-SFLDShare -SharePath $BackupShareRoot -DriveLetter 'W:') {
|
||||
if (Mount-SFLDShare -SharePath $BackupShareRoot -DriveLetter 'R:') {
|
||||
$shareMounted = $true
|
||||
break
|
||||
}
|
||||
@@ -190,16 +197,16 @@ while ($sw.Elapsed.TotalSeconds -lt $ShareTimeoutSec) {
|
||||
}
|
||||
$sw.Stop()
|
||||
if ($shareMounted) {
|
||||
Log ("Share mounted as W: after {0:N1} s" -f $sw.Elapsed.TotalSeconds)
|
||||
Log ("Share mounted as R: after {0:N1} s" -f $sw.Elapsed.TotalSeconds)
|
||||
} else {
|
||||
Log "Mount-SFLDShare failed after $ShareTimeoutSec s. SFLD creds may be missing in HKLM:\SOFTWARE\GE\SFLD\Credentials, or the share is unreachable. Exiting non-zero so the dispatcher logs a failure." 'ERROR'
|
||||
Log 'Exit 1.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
# All bay-level paths now go through W: (authenticated as SFLD user) so
|
||||
# All bay-level paths now go through R: (authenticated as SFLD user) so
|
||||
# Test-Path returns the truth, not access-denied-False.
|
||||
$bayDir = Join-Path 'W:\' $mn
|
||||
$bayDir = Join-Path 'R:\' $mn
|
||||
$srcCur = Join-Path $bayDir 'CurrentData.json'
|
||||
$srcArc = Join-Path $bayDir 'ArchivedData'
|
||||
Log "Probing backup paths for bay $mn"
|
||||
@@ -213,7 +220,7 @@ Log " ArchivedData/ src: $(if ($srcArcExists) { 'present' } else { 'absent'
|
||||
|
||||
if (-not $srcCurExists -and -not $srcArcExists) {
|
||||
Log "No backup waiting for bay $mn (neither CurrentData.json nor ArchivedData\ at bay root) - no work to do this cycle."
|
||||
& net use W: /delete /y 2>$null | Out-Null
|
||||
& net use R: /delete /y 2>$null | Out-Null
|
||||
Log 'Exit 0.'
|
||||
exit 0
|
||||
}
|
||||
@@ -251,7 +258,7 @@ if (-not (Test-Path -LiteralPath $UdcDataDir)) {
|
||||
} catch {
|
||||
Log "Failed to create $UdcDataDir - cannot continue" 'ERROR'
|
||||
LogErr $_
|
||||
& net use W: /delete /y 2>$null | Out-Null
|
||||
& net use R: /delete /y 2>$null | Out-Null
|
||||
Log 'Exit 1.'
|
||||
exit 1
|
||||
}
|
||||
@@ -376,7 +383,7 @@ if ((Test-Path -LiteralPath $UdcExePath) -and ($copiedCur -or $copiedArc)) {
|
||||
}
|
||||
|
||||
# Unmount the SFLD-creds-mounted drive so we don't leave a stale net-use entry
|
||||
& net use W: /delete /y 2>$null | Out-Null
|
||||
& net use R: /delete /y 2>$null | Out-Null
|
||||
|
||||
Log 'Exit 0.'
|
||||
Log '==============================================='
|
||||
|
||||
@@ -52,6 +52,40 @@ Write-Host " Edge RelaunchNotificationPeriod=1h"
|
||||
Write-Host " Edge RelaunchHeadsUpPeriod=1m"
|
||||
Write-Host " Edge RelaunchWindow=02:00-04:00"
|
||||
|
||||
# --- Arm the ShopDB kiosk bootstrap -------------------------------------
|
||||
# A PXE-imaged display ends up with no GE-Enforce client: the installer pulls
|
||||
# itself from {BaseUrl}/installers/kiosk over HTTPS, and ShopDB is unreachable
|
||||
# until the bay joins the AESFMA wifi SSID. So it cannot run now.
|
||||
#
|
||||
# Register a SYSTEM task that does nothing until ShopDB answers, then runs the
|
||||
# vendor installer once and unregisters itself. At boot and every 15 minutes,
|
||||
# so a bay cabled up days later still self-configures.
|
||||
Write-Host '=== Arming ShopDB kiosk bootstrap ==='
|
||||
$bootstrap = Join-Path $PSScriptRoot 'Install-ShopdbKiosk-WhenOnline.ps1'
|
||||
$taskName = 'ShopDB Kiosk Bootstrap'
|
||||
|
||||
if (-not (Test-Path $bootstrap)) {
|
||||
Write-Warning " $bootstrap not found - kiosk will NOT self-configure. Tell the PXE admin."
|
||||
} else {
|
||||
try {
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||||
-Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -TaskName "{1}"' -f $bootstrap, $taskName)
|
||||
$trigBoot = New-ScheduledTaskTrigger -AtStartup
|
||||
$trigRep = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes 15)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30)
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action `
|
||||
-Trigger @($trigBoot, $trigRep) -Principal $principal -Settings $settings `
|
||||
-Force -ErrorAction Stop | Out-Null
|
||||
Write-Host " Registered '$taskName' (at boot + every 15 min, SYSTEM)"
|
||||
Write-Host " It waits for ShopDB, installs the enforce client, then removes itself."
|
||||
} catch {
|
||||
Write-Warning " Failed to register '$taskName': $_"
|
||||
}
|
||||
}
|
||||
|
||||
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
|
||||
Send-PxeStatus -Stage '09-Setup-Display: complete' -StageIndex 4 -StageTotal 8
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run the ShopDB kiosk installer as soon as ShopDB becomes reachable.
|
||||
|
||||
.DESCRIPTION
|
||||
A PXE-imaged display currently ends up with NO GE-Enforce client at all.
|
||||
Confirmed on 579C144, 2026-08-06:
|
||||
|
||||
is the client installed? NOT FOUND
|
||||
scheduled tasks that would run it? NONE
|
||||
|
||||
The client is delivered by Install-ShopdbKiosk.ps1, which downloads itself
|
||||
from {BaseUrl}/installers/kiosk over HTTPS - and ShopDB is only reachable
|
||||
once the bay has joined the AESFMA wifi SSID. So the installer cannot run
|
||||
during imaging, and nothing was arranged to run it afterwards.
|
||||
|
||||
This closes that gap. Imaging stages the installer and the key; this waiter
|
||||
runs as SYSTEM on a schedule, does nothing until ShopDB answers, then runs
|
||||
the installer once and removes itself.
|
||||
|
||||
Deliberately does NOT reimplement the installer. It waits, invokes, verifies
|
||||
and cleans up - the vendor script keeps doing the actual work, so it can be
|
||||
replaced wholesale without touching this.
|
||||
|
||||
.PARAMETER EnrollmentRoot
|
||||
Where imaging staged things. Expects:
|
||||
<root>\display-type.txt Dashboard | Lobby | 3DPrintRoom
|
||||
and, beside this script (see -KioskRoot):
|
||||
kiosk\Install-ShopdbKiosk.ps1
|
||||
kiosk\shopdb-key.txt (optional) labelled: collector=..., fetch=...
|
||||
|
||||
.PARAMETER TaskName
|
||||
The scheduled task to remove once installation succeeds.
|
||||
|
||||
.NOTES
|
||||
The key file is deleted after a successful install. The installer writes the
|
||||
secrets into HKLM:\SOFTWARE\GE\ShopDB, which is ACLed to SYSTEM and
|
||||
Administrators - a better home than a file readable by any local user until
|
||||
lockdown. Bounding that exposure to the imaging window is the point.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$EnrollmentRoot = 'C:\Enrollment',
|
||||
|
||||
# Where the staged installer and key live. Defaults to kiosk\ NEXT TO THIS
|
||||
# SCRIPT, not under EnrollmentRoot: startnet copies the whole type-specific
|
||||
# tree to
|
||||
# C:\Enrollment\shopfloor-setup\gea-shopfloor-display\
|
||||
# so the payload arrives beside this file, not at C:\Enrollment\kiosk.
|
||||
# Getting this wrong is silent - the bootstrap just logs "not staged" every
|
||||
# cycle and never installs anything.
|
||||
[string]$KioskRoot = (Join-Path $PSScriptRoot 'kiosk'),
|
||||
[string]$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb',
|
||||
[string]$TaskName = 'ShopDB Kiosk Bootstrap',
|
||||
[int]$TimeoutSeconds = 30
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$logDir = 'C:\Logs\ShopDB'
|
||||
$log = Join-Path $logDir 'kiosk-bootstrap.log'
|
||||
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
|
||||
|
||||
function Log { param([string]$m)
|
||||
$line = "{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m
|
||||
Write-Host $line
|
||||
Add-Content -Path $log -Value $line -EA SilentlyContinue
|
||||
}
|
||||
|
||||
function Remove-Self {
|
||||
try {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -EA Stop
|
||||
Log "Unregistered '$TaskName' - nothing further to do."
|
||||
} catch { Log "Could not unregister '$TaskName': $_" }
|
||||
}
|
||||
|
||||
Log "=== kiosk bootstrap ==="
|
||||
|
||||
# --- already done? ------------------------------------------------------
|
||||
# Both conditions, not just the registry key: BaseUrl alone can be present from
|
||||
# a partial run, and the enforce task is what actually proves an install.
|
||||
$reg = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
$haveCfg = (Test-Path $reg) -and ((Get-ItemProperty $reg -EA SilentlyContinue).BaseUrl)
|
||||
$haveTask = [bool](Get-ScheduledTask -EA SilentlyContinue |
|
||||
Where-Object { $_.TaskName -match '(?i)shopdb.*enforce' })
|
||||
if ($haveCfg -and $haveTask) {
|
||||
Log "Already installed (BaseUrl set, enforce task present)."
|
||||
Remove-Self
|
||||
return
|
||||
}
|
||||
|
||||
# --- what kind of display is this? --------------------------------------
|
||||
$dtFile = Join-Path $EnrollmentRoot 'display-type.txt'
|
||||
if (-not (Test-Path $dtFile)) {
|
||||
Log "No $dtFile - cannot choose a DisplayType. Leaving the task armed."
|
||||
return
|
||||
}
|
||||
$displayType = (Get-Content $dtFile -First 1).Trim()
|
||||
if ($displayType -notin @('Dashboard','Lobby','3DPrintRoom')) {
|
||||
Log "display-type.txt says '$displayType', which the installer will reject. Leaving armed."
|
||||
return
|
||||
}
|
||||
Log "DisplayType: $displayType"
|
||||
|
||||
$installer = Join-Path $KioskRoot 'Install-ShopdbKiosk.ps1'
|
||||
if (-not (Test-Path $installer)) {
|
||||
Log "Installer not staged at $installer. Leaving armed."
|
||||
return
|
||||
}
|
||||
|
||||
# --- is ShopDB reachable yet? -------------------------------------------
|
||||
# Expected to fail until the bay joins AESFMA. That is the whole reason this
|
||||
# script exists, so a failure here is logged quietly and retried, not raised.
|
||||
$probe = "$($BaseUrl.TrimEnd('/'))/api/docs"
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $probe -UseBasicParsing -TimeoutSec $TimeoutSeconds -EA Stop
|
||||
Log "ShopDB reachable (HTTP $($r.StatusCode))."
|
||||
} catch {
|
||||
$code = try { $_.Exception.Response.StatusCode.value__ } catch { $null }
|
||||
if ($code) {
|
||||
# Answered at all = reachable. 401/403 just means no token on this probe.
|
||||
Log "ShopDB reachable (HTTP $code)."
|
||||
} else {
|
||||
Log "Not reachable yet - waiting for AESFMA. ($($_.Exception.Message))"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# --- keys ----------------------------------------------------------------
|
||||
$keyFile = Join-Path $KioskRoot 'shopdb-key.txt'
|
||||
$collectorKey = ''
|
||||
$fetchToken = ''
|
||||
if (Test-Path $keyFile) {
|
||||
# LABELLED format, because the two tokens are not interchangeable and a
|
||||
# positional mix-up is silent: a fetch token in the collector slot leaves
|
||||
# asset reporting broken while everything looks configured.
|
||||
#
|
||||
# collector=<collector.ingest PAT> always needed for asset reporting
|
||||
# fetch=<geenforce.fetch PAT> optional when the subnet is allowlisted
|
||||
#
|
||||
# A bare single line is REJECTED rather than guessed at.
|
||||
foreach ($line in (Get-Content $keyFile -EA SilentlyContinue)) {
|
||||
$t = $line.Trim()
|
||||
if (-not $t -or $t.StartsWith('#')) { continue }
|
||||
if ($t -match '^(?i)collector\s*=\s*(.+)$') { $collectorKey = $Matches[1].Trim() }
|
||||
elseif ($t -match '^(?i)fetch\s*=\s*(.+)$') { $fetchToken = $Matches[1].Trim() }
|
||||
else {
|
||||
Log "Key file line is not labelled 'collector=' or 'fetch=' - ignoring it rather than guessing which token it is."
|
||||
}
|
||||
}
|
||||
Log ("Key file present (collector={0}, fetch={1})" -f `
|
||||
$(if ($collectorKey) { 'yes' } else { 'no' }), $(if ($fetchToken) { 'yes' } else { 'no' }))
|
||||
} else {
|
||||
# Not fatal. The fetch token is unnecessary when the kiosk subnet is
|
||||
# IP-allowlisted; only the asset-report task needs the collector key, and
|
||||
# the installer skips that task rather than failing.
|
||||
Log "No key file - installing without tokens (allowlisted subnets still work; no asset reporting)."
|
||||
}
|
||||
|
||||
# --- run the vendor installer -------------------------------------------
|
||||
$args = @{ DisplayType = $displayType; BaseUrl = $BaseUrl }
|
||||
if ($collectorKey) { $args.CollectorKey = $collectorKey }
|
||||
if ($fetchToken) { $args.ShopdbToken = $fetchToken }
|
||||
|
||||
Log "Running $installer ..."
|
||||
try {
|
||||
& $installer @args 2>&1 | ForEach-Object { Log " $_" }
|
||||
} catch {
|
||||
Log "Installer threw: $_"
|
||||
return
|
||||
}
|
||||
|
||||
# --- verify, then clean up ----------------------------------------------
|
||||
$haveCfg = (Test-Path $reg) -and ((Get-ItemProperty $reg -EA SilentlyContinue).BaseUrl)
|
||||
$haveTask = [bool](Get-ScheduledTask -EA SilentlyContinue |
|
||||
Where-Object { $_.TaskName -match '(?i)shopdb.*enforce' })
|
||||
if ($haveCfg -and $haveTask) {
|
||||
Log "Install verified: BaseUrl set and enforce task registered."
|
||||
if (Test-Path $keyFile) {
|
||||
# The installer has moved the secrets into HKLM (SYSTEM/Admins ACL).
|
||||
# The staged copy is readable by any local user until lockdown, so it
|
||||
# goes now rather than lingering.
|
||||
Remove-Item $keyFile -Force -EA SilentlyContinue
|
||||
Log "Removed staged key file."
|
||||
}
|
||||
Remove-Self
|
||||
} else {
|
||||
Log "Installer ran but verification FAILED (BaseUrl=$haveCfg, enforceTask=$haveTask). Leaving armed to retry."
|
||||
}
|
||||
15
playbook/shopfloor-setup/menu.json
Normal file
15
playbook/shopfloor-setup/menu.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "Data-driven WinPE shopfloor PC-type menu. Read at boot by select-shopfloor-type.ps1 (mirrors the CMM bay picker) and edited by the PXE webapp. 'key' is the PCTYPE value - it MUST match a shopfloor-setup/gea-shopfloor-<x> handler dir. Order = display order. Set enabled=false to hide without deleting. Deployed to the enrollment share; startnet.cmd falls back to its baked-in menu if this file or the picker is unavailable.",
|
||||
"shopfloor": [
|
||||
{ "key": "gea-shopfloor-collections", "label": "Machine with Collections", "hint": "eDNC + UDC + Plant Apps", "enabled": true },
|
||||
{ "key": "gea-shopfloor-nocollections", "label": "Machine without Collections", "hint": "eDNC + Plant Apps, no UDC", "enabled": true },
|
||||
{ "key": "gea-shopfloor-common", "label": "Common", "hint": "Timeclock, Lab; WJ Shopfloor only", "enabled": true },
|
||||
{ "key": "gea-shopfloor-keyence", "label": "Keyence", "hint": "VR-3000 / VR-5000 / VR-6000 microscope","enabled": true },
|
||||
{ "key": "gea-shopfloor-cmm", "label": "CMM", "hint": "Hexagon PC-DMIS + Protect Viewer", "enabled": true },
|
||||
{ "key": "gea-shopfloor-genspect", "label": "Genspect", "hint": "", "enabled": true },
|
||||
{ "key": "gea-shopfloor-heattreat", "label": "Heattreat", "hint": "eDNC + HeatTreat app", "enabled": true },
|
||||
{ "key": "gea-shopfloor-waxtrace", "label": "Wax and Trace", "hint": "", "enabled": true },
|
||||
{ "key": "gea-shopfloor-display", "label": "Display", "hint": "kiosk dashboard", "enabled": true },
|
||||
{ "key": "gea-shopfloor-partmarker", "label": "Part Marker", "hint": "eDNC + Telesis Mark", "enabled": true }
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,18 @@ function Log {
|
||||
|
||||
Log "=== GE Aerospace GCCH Enrollment ==="
|
||||
|
||||
# --- Run once ------------------------------------------------------------
|
||||
# This script has been observed running twice on one build (579C144,
|
||||
# 2026-08-06: 13:31:55 and 13:36:45). The second provtool call returned
|
||||
# 0x800700B7 ERROR_ALREADY_EXISTS and achieved nothing except re-applying a
|
||||
# pending computer rename over the one the package had just set. The rename is
|
||||
# gone now, but a second full pass is still pointless work on an 8 GB package.
|
||||
$appliedMarker = 'C:\Enrollment\.ppkg-applied'
|
||||
if (Test-Path $appliedMarker) {
|
||||
Log "Provisioning package already applied on $(Get-Content $appliedMarker -First 1) - skipping."
|
||||
return
|
||||
}
|
||||
|
||||
# --- Find the .ppkg ---
|
||||
$ppkgFile = Get-ChildItem "C:\Enrollment\*.ppkg" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $ppkgFile) {
|
||||
@@ -27,11 +39,22 @@ if (-not $ppkgFile) {
|
||||
}
|
||||
Log "Package: $($ppkgFile.Name)"
|
||||
|
||||
# --- Set computer name to E<serial> ---
|
||||
$serial = (Get-CimInstance Win32_BIOS).SerialNumber
|
||||
$newName = "E$serial"
|
||||
Log "Setting computer name to $newName"
|
||||
Rename-Computer -NewName $newName -Force -ErrorAction SilentlyContinue
|
||||
# --- Computer name: the PPKG owns it, do NOT rename here ---
|
||||
# The package declares <DNSComputerName>F%SERIAL%</DNSComputerName>, so a bay is
|
||||
# meant to come up as F<serial>. This script used to run
|
||||
# Rename-Computer -NewName "E$serial"
|
||||
# which is a second pending rename racing the package's. Observed on 579C144
|
||||
# 2026-08-06:
|
||||
#
|
||||
# 13:31:55 run-enrollment Rename-Computer -> E579C144 (pending)
|
||||
# 13:32:17 ppkg Reboot/DeviceName -> F%SERIAL% (pending)
|
||||
# "Reboot successfully scheduled. Device name set successfully."
|
||||
# 13:36:45 run-enrollment runs AGAIN -> E579C144 (overwrites F)
|
||||
# ~13:37 reboot, E579C144 wins
|
||||
#
|
||||
# Last writer wins at reboot, so the hardcoded E prefix silently beat the
|
||||
# package's F. Nothing in this repo ever produced an F name; the convention only
|
||||
# works if we leave naming to the package.
|
||||
|
||||
# --- Install provisioning package ---
|
||||
# IMPORTANT: The PPKG must be installed BEFORE OOBEComplete is set. Bulk
|
||||
@@ -83,9 +106,20 @@ Log "PPKG diagnostic logs -> $ppkgLogDir (provtool writes them automatically)"
|
||||
try {
|
||||
$p = Start-Process -FilePath $provtool -ArgumentList $provArgs -Wait -PassThru -NoNewWindow -ErrorAction Stop
|
||||
Log "provtool.exe exit code: $($p.ExitCode)"
|
||||
if ($p.ExitCode -ne 0) {
|
||||
if ($p.ExitCode -eq 0) {
|
||||
Set-Content -Path $appliedMarker -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -ErrorAction SilentlyContinue
|
||||
Log "Wrote applied-marker $appliedMarker"
|
||||
} else {
|
||||
$hex = '0x{0:X8}' -f $p.ExitCode
|
||||
Log "WARNING: provtool.exe returned non-zero exit code ($hex). Check $ppkgLogDir for diagnostic bundle."
|
||||
# 0x800700B7 ERROR_ALREADY_EXISTS means the package is already applied.
|
||||
# That is a success for our purposes, and it is what a second run of
|
||||
# this script produced on 579C144 (2026-08-06) - so mark it applied and
|
||||
# let the next run skip instead of repeating the work.
|
||||
if ($p.ExitCode -eq -2147024713) {
|
||||
Set-Content -Path $appliedMarker -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -ErrorAction SilentlyContinue
|
||||
Log "Package was already applied (ERROR_ALREADY_EXISTS) - marker written."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log "ERROR: Failed to launch provtool.exe: $_"
|
||||
|
||||
63
playbook/shopfloor-setup/select-shopfloor-type.ps1
Normal file
63
playbook/shopfloor-setup/select-shopfloor-type.ps1
Normal file
@@ -0,0 +1,63 @@
|
||||
<#
|
||||
select-shopfloor-type.ps1 - data-driven GEA Shopfloor PC-type picker for WinPE.
|
||||
|
||||
Reads the shopfloor menu from menu.json (edited by the PXE webapp), renders a
|
||||
numbered menu, and writes the chosen PCTYPE key (e.g. gea-shopfloor-cmm) to
|
||||
-OutFile. startnet.cmd runs this instead of a hardcoded menu, and falls back
|
||||
to its baked-in menu if this script or menu.json is missing. Mirrors the CMM
|
||||
bay picker (select-cmm-bay.ps1).
|
||||
|
||||
Usage (from startnet.cmd):
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File select-shopfloor-type.ps1 `
|
||||
-MenuJson Y:\menu.json -OutFile X:\pctype.txt
|
||||
Exit 0 + a written key on success; non-zero (and no file) on any failure so
|
||||
the batch fallback kicks in.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$MenuJson,
|
||||
[Parameter(Mandatory = $true)][string]$OutFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Remove-Item -LiteralPath $OutFile -ErrorAction SilentlyContinue
|
||||
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath $MenuJson)) { Write-Host "menu.json not found: $MenuJson"; exit 2 }
|
||||
$data = Get-Content -LiteralPath $MenuJson -Raw | ConvertFrom-Json
|
||||
$items = @($data.shopfloor | Where-Object { $_.key -and ($_.enabled -ne $false) })
|
||||
if ($items.Count -eq 0) { Write-Host "menu.json has no enabled shopfloor items"; exit 3 }
|
||||
|
||||
while ($true) {
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host "========================================"
|
||||
Write-Host " GEA Shopfloor PC Sub-Type"
|
||||
Write-Host "========================================"
|
||||
Write-Host ""
|
||||
for ($i = 0; $i -lt $items.Count; $i++) {
|
||||
$n = $i + 1
|
||||
$lbl = $items[$i].label
|
||||
$hint = $items[$i].hint
|
||||
$line = "{0,3}. {1}" -f $n, $lbl
|
||||
if ($hint) { $line = "{0,-45} ({1})" -f $line, $hint }
|
||||
Write-Host $line
|
||||
}
|
||||
Write-Host ""
|
||||
$sel = Read-Host ("Enter your choice (1-{0})" -f $items.Count)
|
||||
$num = 0
|
||||
if ([int]::TryParse($sel, [ref]$num) -and $num -ge 1 -and $num -le $items.Count) {
|
||||
$key = $items[$num - 1].key
|
||||
# OutFile is on X: (WinPE scratch); write the bare PCTYPE key, no BOM/newline surprises
|
||||
[System.IO.File]::WriteAllText($OutFile, $key)
|
||||
Write-Host ""
|
||||
Write-Host ("Selected: {0} ({1})" -f $items[$num - 1].label, $key)
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Invalid choice - try again."
|
||||
Start-Sleep -Milliseconds 800
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "shopfloor picker error: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"urls": {
|
||||
"plantApps": "https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications",
|
||||
"shopFloorHomepage": "https://tsgwp00525.wjs.geaerospace.net",
|
||||
"shopfloorDashboard": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/",
|
||||
"shopfloorDashboard": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor",
|
||||
"webmail": "https://outlook.office365.us/mail"
|
||||
},
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
{ "name": "Defect_Tracker", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\Defect_Tracker.lnk" },
|
||||
{ "name": "PC-DMIS 2016", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\PC-DMIS 2016.lnk" },
|
||||
{ "name": "PC-DMIS 2019 R2", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\PC-DMIS 2019 R2.lnk" },
|
||||
{ "name": "PC-DMIS 2026.1", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\PC-DMIS 2026.1.lnk" },
|
||||
{ "name": "CLM Admin", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\CLM Admin.lnk" },
|
||||
{ "name": "goCMM", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\goCMM.lnk" }
|
||||
],
|
||||
@@ -198,6 +199,7 @@
|
||||
{ "name": "Defect_Tracker", "kind": "existing", "sourceName": "Defect_Tracker.lnk" },
|
||||
{ "name": "PC-DMIS 2016", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\PC-DMIS 2016.0 64-bit\\PCDLRN.exe" },
|
||||
{ "name": "PC-DMIS 2019 R2", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\PC-DMIS 2019 R2 64-bit\\PCDLRN.exe" },
|
||||
{ "name": "PC-DMIS 2026.1", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\PC-DMIS 2026.1 64-bit\\PCDLRN.exe" },
|
||||
{ "name": "CLM Admin", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\CLM Admin 1.8 64-bit\\clmadmin.exe" },
|
||||
{ "name": "goCMM", "kind": "exe", "exePath": "C:\\Program Files (x86)\\General Electric\\goCMM\\goCMM.exe" }
|
||||
],
|
||||
@@ -303,7 +305,7 @@
|
||||
{ "name": "Microsoft Edge", "lnkPath": "%ALLUSERSPROFILE%\\Microsoft\\Windows\\Start Menu\\Programs\\Microsoft Edge.lnk" }
|
||||
],
|
||||
"desktopApps": [],
|
||||
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/",
|
||||
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/tv",
|
||||
"edgeStartupTabs": [
|
||||
{ "baseName": "Shopfloor Dashboard", "fallbackUrlKey": "shopfloorDashboard" }
|
||||
]
|
||||
@@ -316,7 +318,7 @@
|
||||
{ "name": "Microsoft Edge", "lnkPath": "%ALLUSERSPROFILE%\\Microsoft\\Windows\\Start Menu\\Programs\\Microsoft Edge.lnk" }
|
||||
],
|
||||
"desktopApps": [],
|
||||
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/",
|
||||
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor",
|
||||
"edgeStartupTabs": [
|
||||
{ "baseName": "Shopfloor Dashboard", "fallbackUrlKey": "shopfloorDashboard" }
|
||||
]
|
||||
|
||||
@@ -3,10 +3,21 @@ echo Please wait while 'WinPE' is being processed. This may take a few seconds.
|
||||
wpeinit
|
||||
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
|
||||
|
||||
REM --- Site values -----------------------------------------------------
|
||||
REM The PXE server address and share credentials appeared 14 and 13 times
|
||||
REM respectively, so moving this server or rotating the account meant editing
|
||||
REM every net use in the file and rebuilding boot.wim. Set them once here.
|
||||
REM These are also written to C:\Enrollment\fetch-source.txt, which
|
||||
REM Fetch-StagingPayload.ps1 and Verify-And-Heal-Staging.ps1 read at first
|
||||
REM logon - so the post-boot self-heal follows this value automatically.
|
||||
set PXESERVER=172.16.9.1
|
||||
set SHAREUSER=pxe-upload
|
||||
set SHAREPASS=pxe
|
||||
|
||||
REM --- Wait for network (DHCP may take a moment after wpeinit) ---
|
||||
echo Waiting for network...
|
||||
:wait_net
|
||||
ping -n 2 172.16.9.1 >NUL 2>&1
|
||||
ping -n 2 %PXESERVER% >NUL 2>&1
|
||||
if errorlevel 1 goto wait_net
|
||||
echo Network ready.
|
||||
|
||||
@@ -17,7 +28,7 @@ REM CALLed scripts inside parens does not propagate BIOS_STATUS back to
|
||||
REM this script reliably. Use goto-flow instead so the CALL runs at the
|
||||
REM top scope and BIOS_STATUS persists.
|
||||
set BIOS_STATUS=No BIOS check (share unavailable)
|
||||
net use B: \\172.16.9.1\winpeapps_bios /user:pxe-upload pxe /persistent:no 2>NUL
|
||||
net use B: \\%PXESERVER%\winpeapps_bios /user:%SHAREUSER% %SHAREPASS% /persistent:no 2>NUL
|
||||
if not exist B:\check-bios.cmd goto :bios_check_done
|
||||
echo.
|
||||
echo Checking for BIOS updates...
|
||||
@@ -38,26 +49,85 @@ echo.
|
||||
echo 1. GEA Standard
|
||||
echo 2. GEA Engineer
|
||||
echo 3. GEA Shopfloor (sub-menu)
|
||||
echo 4. GE Standard
|
||||
echo 5. GE Engineer
|
||||
echo 6. GE Shopfloor Lockdown
|
||||
echo 7. GE Shopfloor MCE
|
||||
echo.
|
||||
echo ========================================
|
||||
echo.
|
||||
set /p choice=Enter your choice (1-7):
|
||||
set /p choice=Enter your choice (1-3):
|
||||
|
||||
REM --- Shopfloor images (3,6,7) need GCCH enrollment + (for 3) PC-type sub-menu.
|
||||
REM Choice 3 = GEA Shopfloor: drill into sub-menu first to pick the gea-shopfloor-*
|
||||
REM sub-category, THEN the office menu, THEN machine number for collections+nocollections.
|
||||
set PPKG=
|
||||
set PCCONFIG=
|
||||
if "%choice%"=="3" goto gea_shopfloor_submenu
|
||||
if "%choice%"=="6" goto enroll_menu
|
||||
if "%choice%"=="7" goto enroll_menu
|
||||
REM Standard and engineer enrol through the vendor orchestrator, which asks for
|
||||
REM tenant + PC purpose with a GUI at first logon. Ask here instead, while the
|
||||
REM tech who knows what the machine is for is standing at it, and pre-seed the
|
||||
REM answer so the dialog never appears. See :gea_purpose_submenu.
|
||||
if "%choice%"=="1" goto gea_purpose_submenu
|
||||
if "%choice%"=="2" goto gea_purpose_submenu
|
||||
goto enroll_staged
|
||||
|
||||
:gea_purpose_submenu
|
||||
cls
|
||||
echo.
|
||||
echo ========================================
|
||||
echo PC Purpose - Entra enrollment
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 1. Single-User PC (one assigned person)
|
||||
echo 2. Shared PC (multiple users)
|
||||
echo 3. Loaner PC (temporary assignment)
|
||||
echo.
|
||||
echo 4. Ask at first logon (show the vendor dialog instead)
|
||||
echo.
|
||||
set PCPURPOSE=
|
||||
set /p purp_choice=Enter your choice (1-4):
|
||||
if "%purp_choice%"=="1" set PCPURPOSE=PERS
|
||||
if "%purp_choice%"=="2" set PCPURPOSE=SH
|
||||
if "%purp_choice%"=="3" set PCPURPOSE=LOAN
|
||||
if "%purp_choice%"=="4" goto enroll_staged
|
||||
if "%PCPURPOSE%"=="" goto gea_purpose_submenu
|
||||
|
||||
:gea_tenant_submenu
|
||||
cls
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Entra Tenant
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 1. GCC High (GCCH - US)
|
||||
echo 2. Rest of World (RoW)
|
||||
echo.
|
||||
REM The tenant is not cosmetic: Insert-BPRTToPPKG.ps1 matches the bulk token by
|
||||
REM Tenant AND Purpose, so this picks which token gets injected.
|
||||
set PCTENANT=
|
||||
set /p ten_choice=Enter your choice (1-2):
|
||||
if "%ten_choice%"=="1" set PCTENANT=GCCH
|
||||
if "%ten_choice%"=="2" set PCTENANT=RoW
|
||||
if "%PCTENANT%"=="" goto gea_tenant_submenu
|
||||
set PCCONFIG=%PCTENANT%_%PCPURPOSE%
|
||||
echo.
|
||||
echo PC config: %PCCONFIG%
|
||||
goto enroll_staged
|
||||
|
||||
:gea_shopfloor_submenu
|
||||
cls
|
||||
set PCTYPE=
|
||||
REM Data-driven PC-type menu: the PXE webapp maintains menu.json on the
|
||||
REM enrollment share; select-shopfloor-type.ps1 renders it and writes the
|
||||
REM chosen PCTYPE to X:\pctype.txt. Falls back to the baked-in menu below if
|
||||
REM the share or the picker is unavailable (mirrors the CMM bay picker).
|
||||
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>NUL
|
||||
del X:\pctype.txt 2>NUL
|
||||
if exist "Y:\shopfloor-setup\select-shopfloor-type.ps1" powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\shopfloor-setup\select-shopfloor-type.ps1" -MenuJson "Y:\shopfloor-setup\menu.json" -OutFile "X:\pctype.txt"
|
||||
if exist X:\pctype.txt set /p PCTYPE=<X:\pctype.txt
|
||||
if not "%PCTYPE%"=="" goto gea_shopfloor_have_type
|
||||
|
||||
:gea_shopfloor_submenu_fallback
|
||||
cls
|
||||
echo.
|
||||
echo ========================================
|
||||
echo GEA Shopfloor PC Sub-Type
|
||||
@@ -74,7 +144,6 @@ echo 8. Wax and Trace
|
||||
echo 9. Display (kiosk dashboard)
|
||||
echo 10. Part Marker (eDNC + Telesis Mark)
|
||||
echo.
|
||||
set PCTYPE=
|
||||
set /p ges_choice=Enter your choice (1-10):
|
||||
if "%ges_choice%"=="1" set PCTYPE=gea-shopfloor-collections
|
||||
if "%ges_choice%"=="2" set PCTYPE=gea-shopfloor-nocollections
|
||||
@@ -86,7 +155,9 @@ if "%ges_choice%"=="7" set PCTYPE=gea-shopfloor-heattreat
|
||||
if "%ges_choice%"=="8" set PCTYPE=gea-shopfloor-waxtrace
|
||||
if "%ges_choice%"=="9" set PCTYPE=gea-shopfloor-display
|
||||
if "%ges_choice%"=="10" set PCTYPE=gea-shopfloor-partmarker
|
||||
if "%PCTYPE%"=="" goto gea_shopfloor_submenu
|
||||
if "%PCTYPE%"=="" goto gea_shopfloor_submenu_fallback
|
||||
|
||||
:gea_shopfloor_have_type
|
||||
if "%PCTYPE%"=="gea-shopfloor-display" goto display_submenu
|
||||
if "%PCTYPE%"=="gea-shopfloor-keyence" goto keyence_submenu
|
||||
if "%PCTYPE%"=="gea-shopfloor-cmm" goto cmm_submenu
|
||||
@@ -121,7 +192,7 @@ echo ========================================
|
||||
echo.
|
||||
echo Loading CMM bay list from PXE share...
|
||||
REM Mount enrollment share early so the picker can read the CSV.
|
||||
net use Y: \\172.16.9.1\enrollment /user:pxe-upload pxe /persistent:no >NUL 2>NUL
|
||||
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>NUL
|
||||
del X:\cmm-bay.txt 2>NUL
|
||||
set CMMID=
|
||||
set CMMVARIANT=standard
|
||||
@@ -139,8 +210,8 @@ if "%CMMID%"=="" (
|
||||
goto enroll_menu
|
||||
)
|
||||
echo CMM bay: %CMMID%
|
||||
REM Resolve bay config (writes version.txt + doda.txt to W:\Enrollment\cmm\
|
||||
REM after W: appears). Resolution deferred to after imaging starts - store
|
||||
REM Resolve bay config (writes version.txt + doda.txt to %OSDRIVE%\Enrollment\cmm\
|
||||
REM after %OSDRIVE% appears). Resolution deferred to after imaging starts - store
|
||||
REM the CMMID for now and resolve in the post-imaging copy block below.
|
||||
goto enroll_menu
|
||||
|
||||
@@ -153,11 +224,21 @@ echo ========================================
|
||||
echo.
|
||||
echo 1. Dashboard (shop floor metrics dashboard)
|
||||
echo 2. Lobby Display (lobby information screen)
|
||||
echo 3. 3D Parts Kiosk (printed-parts kiosk)
|
||||
echo.
|
||||
REM These values are KEYS into the GE-Enforce display scope
|
||||
REM (shopdb-flask plugins/geenforce/seed_display_scope.py):
|
||||
REM Dashboard -> /shopdb/shopfloor, Lobby -> /shopdb/tv,
|
||||
REM 3DPrintRoom -> /shopdb/parts-kiosk
|
||||
REM They are written to C:\Enrollment\display-type.txt and the dispatcher reads
|
||||
REM that as its fallback when the server has no IP-based role for the device, so
|
||||
REM the spelling must match the map exactly. 3DPrintRoom was in the map but had
|
||||
REM no menu entry, so a parts kiosk could only be set by hand after imaging.
|
||||
set DISPLAYTYPE=
|
||||
set /p disp_choice=Enter your choice (1-2):
|
||||
set /p disp_choice=Enter your choice (1-3):
|
||||
if "%disp_choice%"=="1" set DISPLAYTYPE=Dashboard
|
||||
if "%disp_choice%"=="2" set DISPLAYTYPE=Lobby
|
||||
if "%disp_choice%"=="3" set DISPLAYTYPE=3DPrintRoom
|
||||
if "%DISPLAYTYPE%"=="" goto display_submenu
|
||||
goto enroll_menu
|
||||
|
||||
@@ -180,10 +261,25 @@ set /p enroll=Enter your choice (1-6):
|
||||
REM --- PPKG configuration (constructed at menu time, see docs) ---
|
||||
REM Vendor ships one source PPKG; we construct the BPRT-tagged filename
|
||||
REM by filling in Office, Region, Expiry, Version on the target copy.
|
||||
REM Update SOURCE_PPKG + PPKG_VER when a new PPKG is released.
|
||||
set SOURCE_PPKG=GCCH_Prod_SFLD_v4.16.ppkg
|
||||
set PPKG_VER=v4.16
|
||||
set PPKG_EXP=20260831
|
||||
REM The ACTIVE ppkg is read from \\172.16.9.1\enrollment\ppkg.conf, which the
|
||||
REM PXE webapp writes on upload - so a new ppkg goes live with no startnet edit.
|
||||
REM The baked-in values below are only a fallback if ppkg.conf is missing.
|
||||
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >nul 2>&1
|
||||
set SOURCE_PPKG=
|
||||
set TARGET_PPKG=
|
||||
set PPKG_VER=
|
||||
set PPKG_EXP=
|
||||
if exist "Y:\ppkg.conf" (
|
||||
for /f "usebackq tokens=1,* delims==" %%A in ("Y:\ppkg.conf") do (
|
||||
if /i "%%A"=="SOURCE_PPKG" set "SOURCE_PPKG=%%B"
|
||||
if /i "%%A"=="PPKG_VER" set "PPKG_VER=%%B"
|
||||
if /i "%%A"=="PPKG_EXP" set "PPKG_EXP=%%B"
|
||||
if /i "%%A"=="TARGET_PPKG" set "TARGET_PPKG=%%B"
|
||||
)
|
||||
)
|
||||
if "%SOURCE_PPKG%"=="" set SOURCE_PPKG=GCCH_Prod_SFLD_v4.16.ppkg
|
||||
if "%PPKG_VER%"=="" set PPKG_VER=v4.16
|
||||
if "%PPKG_EXP%"=="" set PPKG_EXP=20260831
|
||||
set REGION=US
|
||||
|
||||
set OFFICE=
|
||||
@@ -196,7 +292,32 @@ if "%enroll%"=="6" set OFFICE=
|
||||
if "%enroll%"=="" goto enroll_menu
|
||||
|
||||
set PPKG=
|
||||
if not "%OFFICE%"=="" set PPKG=GCCH_Prod_SFLD_%OFFICE%_%REGION%_Exp_%PPKG_EXP%_%PPKG_VER%.ppkg
|
||||
REM Target filename for the staged package.
|
||||
REM
|
||||
REM The SFLD-era scheme encoded an Office variant in the name
|
||||
REM (GCCH_Prod_SFLD_<Office>_<Region>_Exp_<date>_<ver>.ppkg) because one source
|
||||
REM package was renamed per Office choice. MCL packages do not work that way -
|
||||
REM Office is installed by Install-Office.ps1 inside the package - and renaming
|
||||
REM an MCL package into the SFLD scheme just produces a misleading filename.
|
||||
REM
|
||||
REM So: honour TARGET_PPKG from ppkg.conf when present, otherwise keep the
|
||||
REM source filename unchanged. The SFLD construction remains only as a fallback
|
||||
REM for a legacy SFLD source package.
|
||||
if not "%TARGET_PPKG%"=="" (
|
||||
set PPKG=%TARGET_PPKG%
|
||||
) else (
|
||||
REM find, NOT findstr: findstr.exe is not in this WinPE image (find.exe is,
|
||||
REM and startnet already uses it for the PESetup wait). findstr fails with
|
||||
REM "not recognized", leaves errorlevel 9009, and "if errorlevel 1" then
|
||||
REM takes the NON-SFLD branch for an SFLD package - so the staged ppkg keeps
|
||||
REM its source name and never gets the BPRT-tagged one.
|
||||
echo %SOURCE_PPKG% | find /I "_SFLD_" >NUL
|
||||
if errorlevel 1 (
|
||||
set PPKG=%SOURCE_PPKG%
|
||||
) else (
|
||||
if not "%OFFICE%"=="" set PPKG=GCCH_Prod_SFLD_%OFFICE%_%REGION%_Exp_%PPKG_EXP%_%PPKG_VER%.ppkg
|
||||
)
|
||||
)
|
||||
|
||||
REM --- 2026-05-04 rename reorg: PCTYPE is set by gea_shopfloor_submenu above
|
||||
REM (single string, e.g. gea-shopfloor-collections). pc-subtype.txt is no
|
||||
@@ -229,7 +350,7 @@ echo.
|
||||
echo Loading Wax/Trace bay list from PXE share...
|
||||
REM Mount enrollment share early so the picker can read INDEX.csv. Later
|
||||
REM net use Y: will be a no-op if Y: is already mapped.
|
||||
net use Y: \\172.16.9.1\enrollment /user:pxe-upload pxe /persistent:no >NUL 2>NUL
|
||||
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>NUL
|
||||
del X:\waxtrace-asset.txt 2>NUL
|
||||
if not exist "Y:\installers-post\waxtrace\select-waxtrace-asset.ps1" goto waxtrace_picker_skip
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\waxtrace\select-waxtrace-asset.ps1" -IndexPath "Y:\installers-post\waxtrace\bay-config.csv" -OutFile "X:\waxtrace-asset.txt"
|
||||
@@ -257,7 +378,7 @@ if not "%PPKG%"=="" set NEED_ENROLL=1
|
||||
if not "%PCTYPE%"=="" set NEED_ENROLL=1
|
||||
if "%NEED_ENROLL%"=="0" goto enroll_staged
|
||||
if exist Y:\ goto enroll_already_mapped
|
||||
net use Y: \\172.16.9.1\enrollment /user:pxe-upload pxe /persistent:no
|
||||
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no
|
||||
:enroll_already_mapped
|
||||
if "%PPKG%"=="" goto enroll_staged
|
||||
if not exist "Y:\ppkgs\%SOURCE_PPKG%" (
|
||||
@@ -270,10 +391,6 @@ echo. > X:\Boot.tag
|
||||
if "%choice%"=="1" goto gea-standard
|
||||
if "%choice%"=="2" goto gea-engineer
|
||||
if "%choice%"=="3" goto gea-shopfloor
|
||||
if "%choice%"=="4" goto ge-standard
|
||||
if "%choice%"=="5" goto ge-engineer
|
||||
if "%choice%"=="6" goto ge-shopfloor-lockdown
|
||||
if "%choice%"=="7" goto ge-shopfloor-mce
|
||||
echo Invalid choice. Please try again.
|
||||
pause
|
||||
goto menu
|
||||
@@ -283,7 +400,7 @@ echo.
|
||||
echo Starting GEA Standard setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\gea-standard /user:pxe-upload pxe /persistent:no
|
||||
net use Z: \\%PXESERVER%\winpeapps\gea-standard /user:%SHAREUSER% %SHAREPASS% /persistent:no
|
||||
goto end
|
||||
|
||||
:gea-engineer
|
||||
@@ -291,7 +408,7 @@ echo.
|
||||
echo Starting GEA Engineer setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\gea-engineer /user:pxe-upload pxe /persistent:no
|
||||
net use Z: \\%PXESERVER%\winpeapps\gea-engineer /user:%SHAREUSER% %SHAREPASS% /persistent:no
|
||||
goto end
|
||||
|
||||
:gea-shopfloor
|
||||
@@ -299,39 +416,34 @@ echo.
|
||||
echo Starting GEA Shopfloor setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\gea-shopfloor /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:ge-standard
|
||||
REM --- Per-PCTYPE media view -----------------------------------------------
|
||||
REM PESetup's CopyPackages copies the WHOLE of <media>\Deploy\Applications to
|
||||
REM the target, recursively, with no filtering and no manifest (see
|
||||
REM docs/PESETUP-INTERNALS.md). One shared shopfloor media therefore hands
|
||||
REM every shopfloor payload to every bay whatever its type.
|
||||
REM _media\<PCTYPE> is that same media rebuilt out of symlinks by
|
||||
REM scripts/build-pctype-media.py, carrying only this type's payload. It is
|
||||
REM built once, server-side, and never mutated per session: bays image
|
||||
REM concurrently and CopyPackages is fail-fast, so changing a path mid-copy
|
||||
REM would fail some OTHER bay's imaging.
|
||||
if "%PCTYPE%"=="" goto gea_shopfloor_media_shared
|
||||
REM Free Z: first. A leftover mapping makes net use fail with "device already
|
||||
REM in use" and the probe below would then pass against the OLD media.
|
||||
net use Z: /delete /y >NUL 2>&1
|
||||
net use Z: \\%PXESERVER%\winpeapps\_media\%PCTYPE% /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>&1
|
||||
REM Probe a file that only resolves when the view exists AND its symlinks are
|
||||
REM intact. A dangling view must not image silently off a half-mapped drive.
|
||||
if exist "Z:\Deploy\Control\HardwareDriver.json" goto end
|
||||
echo.
|
||||
echo Starting GE Standard setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-standard /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:ge-engineer
|
||||
echo WARNING: no usable per-type media at _media\%PCTYPE%.
|
||||
echo Falling back to the shared shopfloor media, so EVERY shopfloor
|
||||
echo payload will be staged on this bay, not just this type's.
|
||||
echo Imaging is otherwise unaffected. Tell the PXE admin.
|
||||
echo.
|
||||
echo Starting GE Engineer setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-engineer /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
net use Z: /delete /y >NUL 2>&1
|
||||
|
||||
:ge-shopfloor-lockdown
|
||||
echo.
|
||||
echo Starting GE Shopfloor Lockdown setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-shopfloor-lockdown /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:ge-shopfloor-mce
|
||||
echo.
|
||||
echo Starting GE Shopfloor MCE setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-shopfloor-mce /user:pxe-upload pxe /persistent:no
|
||||
:gea_shopfloor_media_shared
|
||||
net use Z: \\%PXESERVER%\winpeapps\gea-shopfloor /user:%SHAREUSER% %SHAREPASS% /persistent:no
|
||||
goto end
|
||||
|
||||
:end
|
||||
@@ -345,6 +457,22 @@ if exist "Y:\scripts\winpe-status-push.ps1" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "Y:\scripts\winpe-status-push.ps1"
|
||||
)
|
||||
|
||||
REM --- Pre-imaging checks ---------------------------------------------------
|
||||
REM Checks the things PESetup fails on, before it fails on them: secure boot
|
||||
REM (hard failure at GatherData), disk >= 120 GB, a driver pack matching THIS
|
||||
REM model, and media age. The driver check is the one that earns its keep - a
|
||||
REM miss is only a WARNING to PESetup, so the bay images with no NIC and the
|
||||
REM symptom shows up much later as a DNS failure during enrollment.
|
||||
REM Advisory: it reports and pauses on a blocker, it does not abort. The tech
|
||||
REM decides. Lives on the share so it can be fixed without a boot.wim rebuild.
|
||||
if exist "Y:\scripts\preflight.ps1" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "Y:\scripts\preflight.ps1" -MediaDrive Z:
|
||||
if errorlevel 1 (
|
||||
echo Press any key to image anyway, or power off the bay to stop.
|
||||
pause >NUL
|
||||
)
|
||||
)
|
||||
|
||||
echo Waiting for PESetup.exe to start...
|
||||
:wait_start
|
||||
ping -n 3 127.0.0.1 >NUL
|
||||
@@ -353,27 +481,99 @@ if errorlevel 1 goto wait_start
|
||||
echo PESetup.exe is running. Waiting for imaging to complete...
|
||||
|
||||
REM --- Copy enrollment package and shopfloor setup as soon as Windows partition appears ---
|
||||
if "%PPKG%"=="" if "%PCTYPE%"=="" goto wait_finish
|
||||
echo Waiting for Windows partition at W: ...
|
||||
REM PCCONFIG is here so standard/engineer builds still reach the volume wait:
|
||||
REM they select no ppkg and no PCTYPE, but they do need pc-config.txt written.
|
||||
if "%PPKG%"=="" if "%PCTYPE%"=="" if "%PCCONFIG%"=="" goto wait_finish
|
||||
REM --- Wait for the volume PESetup applies Windows to -----------------------
|
||||
REM PESetup hardcodes W: in nine places - every copy destination, both DISM
|
||||
REM offline sessions, bcdboot and reagentc - and CREATES it during its own disk
|
||||
REM preparation. See docs/PESETUP-INTERNALS.md. There is nothing to search for.
|
||||
REM
|
||||
REM Do NOT try to locate the volume earlier by scanning for a Windows install.
|
||||
REM Proven on 579C144, 2026-08-06, PESetup 4.0.0.20:
|
||||
REM 11:30:07 PESetup logs the OLD install still mounted at C:
|
||||
REM 11:30:18 operator presses Next
|
||||
REM 11:30:20 PESetup runs DISKPART /S X:\diskpartEFI0.txt - the wipe
|
||||
REM 11:30:24 a volume scan had returned that C:, startnet assigned it W: and
|
||||
REM staged into the partition PESetup was mid-way through erasing.
|
||||
REM Every robocopy died, "The system cannot find the path
|
||||
REM specified", and NOTHING was staged.
|
||||
REM 11:30:30 PESetup creates its own W: and starts copying to it
|
||||
REM The SYSTEM hive is the signal that distinguishes the applied image from the
|
||||
REM one being wiped: it exists only once the WIM apply has written it. Do not
|
||||
REM weaken this test to "the volume exists" or "Windows is present".
|
||||
set OSDRIVE=
|
||||
echo Waiting for the applied Windows volume at W: ...
|
||||
set OSWAIT=0
|
||||
:wait_enroll
|
||||
if exist W:\Windows\System32\config\system (
|
||||
set OSDRIVE=W:
|
||||
goto found_os
|
||||
)
|
||||
set /a OSWAIT+=1
|
||||
REM 270 polls at ~10s = 45 minutes. Was 120 (20 min), which is fine on NVMe but
|
||||
REM tight on slow media - a WIM apply can run 25+ minutes there, and the failure
|
||||
REM mode is the scary os_not_found banner plus nothing staged. Waiting longer
|
||||
REM costs nothing: the loop exits the moment the SYSTEM hive appears.
|
||||
if %OSWAIT% GEQ 270 goto os_not_found
|
||||
ping -n 11 127.0.0.1 >NUL
|
||||
if not exist W:\Windows\System32\config\system goto wait_enroll
|
||||
echo Found Windows at W:
|
||||
mkdir W:\Enrollment 2>NUL
|
||||
goto wait_enroll
|
||||
|
||||
:os_not_found
|
||||
echo.
|
||||
echo ******************************************************************
|
||||
echo ERROR: no applied Windows volume found.
|
||||
echo NOTHING was staged: pc-type.txt, the enrollment package and
|
||||
echo shopfloor-setup are all missing. Imaging will finish but the
|
||||
echo post-install setup will NOT run. Tell the PXE admin.
|
||||
echo ******************************************************************
|
||||
echo.
|
||||
goto cleanup_enroll
|
||||
|
||||
:found_os
|
||||
echo Found Windows at %OSDRIVE%
|
||||
|
||||
REM No drive-letter reassignment here. PESetup owns W: and runs its own DISKPART
|
||||
REM against the same disk; a second diskpart racing it destroys the volume that
|
||||
REM staging is writing to. The unattend's hardcoded W:\Drivers path is PESetup's
|
||||
REM own destination (CopyDrivers unzips there), so it lines up by construction.
|
||||
mkdir %OSDRIVE%\Enrollment 2>NUL
|
||||
REM Log all copy operations to a file that persists on the target disk.
|
||||
REM Tech can check C:\Enrollment\winpe-staging.log post-imaging.
|
||||
REM Each echo in the copy block below also appends to this file via
|
||||
REM the >> redirect after the screen echo. robocopy /LOG+ appends its
|
||||
REM own output directly.
|
||||
set STAGELOG=W:\Enrollment\winpe-staging.log
|
||||
set STAGELOG=%OSDRIVE%\Enrollment\winpe-staging.log
|
||||
echo [%DATE% %TIME%] WinPE staging started >> "%STAGELOG%"
|
||||
echo PCTYPE=%PCTYPE% PPKG=%PPKG% MACHINENUM=%MACHINENUM% CMMID=%CMMID% >> "%STAGELOG%"
|
||||
echo PCTYPE=%PCTYPE% PPKG=%PPKG% MACHINENUM=%MACHINENUM% CMMID=%CMMID% PCCONFIG=%PCCONFIG% >> "%STAGELOG%"
|
||||
|
||||
REM --- Pre-seed the tenant + PC purpose for the enrollment orchestrator -----
|
||||
REM Start-BulkEnrollOrchestrator.ps1 asks for this with a GUI at first logon
|
||||
REM (Select-PCConfig.ps1) and uses the answer twice: to pick which MCL package
|
||||
REM to apply, and to select which bulk token Insert-BPRTToPPKG.ps1 injects -
|
||||
REM it matches on Tenant AND Purpose. Writing the answer here lets the shim
|
||||
REM return it non-interactively, so an unattended build never stops on a dialog.
|
||||
REM Format is exactly what the vendor dialog returns: {Tenant}_{Purpose},
|
||||
REM e.g. GCCH_PERS, GCCH_SH, RoW_LOAN.
|
||||
REM Only the CHOICE is pre-seeded. Token injection still happens at first logon
|
||||
REM because it fetches an encrypted token table from mcl.dwcdn.geaerospace.com,
|
||||
REM which is unreachable from this isolated imaging LAN.
|
||||
if not "%PCCONFIG%"=="" (
|
||||
echo %PCCONFIG%> %OSDRIVE%\Enrollment\pc-config.txt
|
||||
echo Pre-seeded PC config %PCCONFIG% for the enrollment orchestrator.
|
||||
echo [%TIME%] Wrote pc-config.txt=%PCCONFIG% >> "%STAGELOG%"
|
||||
)
|
||||
|
||||
REM --- Copy site config (drives site-specific values in all setup scripts) ---
|
||||
if exist "Y:\config\site-config.json" (
|
||||
robocopy "Y:\config" "W:\Enrollment" "site-config.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
echo Copied site-config.json.
|
||||
echo [%TIME%] Copied site-config.json >> "%STAGELOG%"
|
||||
robocopy "Y:\config" "%OSDRIVE%\Enrollment" "site-config.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
if errorlevel 8 (
|
||||
echo WARNING: site-config.json robocopy FAILED - not staged.
|
||||
echo [%TIME%] FAILED: site-config.json robocopy >> "%STAGELOG%"
|
||||
) else (
|
||||
echo Copied site-config.json.
|
||||
echo [%TIME%] Copied site-config.json >> "%STAGELOG%"
|
||||
)
|
||||
) else (
|
||||
echo WARNING: site-config.json not found on enrollment share.
|
||||
echo [%TIME%] WARNING: site-config.json not found >> "%STAGELOG%"
|
||||
@@ -381,7 +581,7 @@ if exist "Y:\config\site-config.json" (
|
||||
|
||||
REM --- Copy PPKG if selected (renames from SOURCE to BPRT-tagged filename) ---
|
||||
if "%PPKG%"=="" goto copy_pctype
|
||||
robocopy "Y:\ppkgs" "W:\Enrollment" "%SOURCE_PPKG%" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
robocopy "Y:\ppkgs" "%OSDRIVE%\Enrollment" "%SOURCE_PPKG%" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
REM robocopy exit codes: 0-7 = success, 8+ = failure (inverse of copy's 0/1).
|
||||
if errorlevel 8 (
|
||||
echo WARNING: Failed to copy enrollment package.
|
||||
@@ -389,106 +589,126 @@ if errorlevel 8 (
|
||||
goto copy_pctype
|
||||
)
|
||||
REM robocopy keeps the source name; rename to the BPRT-tagged target name.
|
||||
if not "%SOURCE_PPKG%"=="%PPKG%" ren "W:\Enrollment\%SOURCE_PPKG%" "%PPKG%"
|
||||
if not "%SOURCE_PPKG%"=="%PPKG%" ren "%OSDRIVE%\Enrollment\%SOURCE_PPKG%" "%PPKG%"
|
||||
echo [%TIME%] Copied PPKG %SOURCE_PPKG% as %PPKG% >> "%STAGELOG%"
|
||||
robocopy "Y:\scripts" "W:\Enrollment" "run-enrollment.ps1" "wait-for-internet.ps1" "migrate-to-wifi.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
robocopy "Y:\scripts" "%OSDRIVE%\Enrollment" "run-enrollment.ps1" "wait-for-internet.ps1" "migrate-to-wifi.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
echo [%TIME%] Copied enrollment scripts >> "%STAGELOG%"
|
||||
|
||||
REM --- Create enroll.cmd at drive root as manual fallback ---
|
||||
> W:\enroll.cmd (
|
||||
> %OSDRIVE%\enroll.cmd (
|
||||
echo @echo off
|
||||
echo echo Waiting for network...
|
||||
echo :waitnet
|
||||
echo ping -n 2 8.8.8.8 ^>NUL 2^>^&1
|
||||
echo if errorlevel 1 goto waitnet
|
||||
echo echo Network connected. Running enrollment...
|
||||
echo powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"
|
||||
echo powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\run-enrollment.ps1"
|
||||
)
|
||||
echo Manual fallback created at W:\enroll.cmd
|
||||
echo Manual fallback created at %OSDRIVE%\enroll.cmd
|
||||
|
||||
:copy_pctype
|
||||
REM --- Copy shopfloor PC type setup scripts ---
|
||||
if "%PCTYPE%"=="" goto cleanup_enroll
|
||||
echo %PCTYPE%> W:\Enrollment\pc-type.txt
|
||||
echo %PCTYPE%> %OSDRIVE%\Enrollment\pc-type.txt
|
||||
REM 2026-05-04 rename reorg: pc-subtype.txt no longer written.
|
||||
REM display-type.txt IS still written for gea-shopfloor-display because
|
||||
REM Install-KioskApp.cmd reads it to pick Lobby vs Dashboard installer
|
||||
REM and Get-PCProfile.ps1 reads it to build the Display-{type} profile key.
|
||||
if not "%DISPLAYTYPE%"=="" echo %DISPLAYTYPE%> W:\Enrollment\display-type.txt
|
||||
if not "%MACHINENUM%"=="" echo %MACHINENUM%> W:\Enrollment\machine-number.txt
|
||||
if not "%DISPLAYTYPE%"=="" echo %DISPLAYTYPE%> %OSDRIVE%\Enrollment\display-type.txt
|
||||
if not "%MACHINENUM%"=="" echo %MACHINENUM%> %OSDRIVE%\Enrollment\machine-number.txt
|
||||
REM Keyence model goes to BOTH keyence-model.txt (read by 09-Setup-Keyence) AND
|
||||
REM pc-subtype.txt (read by GE-Enforce for per-model dispatch via existing
|
||||
REM PCSubType wiring: looks for gea-shopfloor-keyence-<model>\manifest.json on share).
|
||||
if not "%KEYENCEMODEL%"=="" (
|
||||
echo %KEYENCEMODEL%> W:\Enrollment\keyence-model.txt
|
||||
echo %KEYENCEMODEL%> W:\Enrollment\pc-subtype.txt
|
||||
echo %KEYENCEMODEL%> %OSDRIVE%\Enrollment\keyence-model.txt
|
||||
echo %KEYENCEMODEL%> %OSDRIVE%\Enrollment\pc-subtype.txt
|
||||
)
|
||||
REM CMM bay ID + resolved config. CMMID comes from the bay picker (e.g. CMM4).
|
||||
REM Written to machine-number.txt (same field collections/waxtrace use) so
|
||||
REM TargetMachineNumbers on the SFLD share manifest gates per-bay entries.
|
||||
REM resolve-cmm-bay-config.ps1 reads the CSV and writes version.txt + doda.txt
|
||||
REM to W:\Enrollment\cmm\ for 09-Setup-CMM.ps1 to consume.
|
||||
REM to %OSDRIVE%\Enrollment\cmm\ for 09-Setup-CMM.ps1 to consume.
|
||||
if "%CMMID%"=="" goto cmm_id_done
|
||||
echo %CMMID%> W:\Enrollment\machine-number.txt
|
||||
mkdir W:\Enrollment\cmm 2>NUL
|
||||
echo %CMMID%> %OSDRIVE%\Enrollment\machine-number.txt
|
||||
mkdir %OSDRIVE%\Enrollment\cmm 2>NUL
|
||||
if exist "Y:\installers-post\cmm\resolve-cmm-bay-config.ps1" (
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\cmm\resolve-cmm-bay-config.ps1" -ConfigPath "Y:\installers-post\cmm\cmm-bay-config.csv" -CmmId "%CMMID%" -OutDir "W:\Enrollment\cmm"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\cmm\resolve-cmm-bay-config.ps1" -ConfigPath "Y:\installers-post\cmm\cmm-bay-config.csv" -CmmId "%CMMID%" -OutDir "%OSDRIVE%\Enrollment\cmm"
|
||||
)
|
||||
REM Read doda flag to set pc-subtype.txt for GE-Enforce manifest gating.
|
||||
REM goto-flow avoids CMD variable scoping issues inside parens blocks.
|
||||
if not exist W:\Enrollment\cmm\doda.txt goto cmm_id_done
|
||||
set /p CMMDODA=<W:\Enrollment\cmm\doda.txt
|
||||
if /i "%CMMDODA%"=="yes" echo doda> W:\Enrollment\pc-subtype.txt
|
||||
if not exist %OSDRIVE%\Enrollment\cmm\doda.txt goto cmm_id_done
|
||||
set /p CMMDODA=<%OSDRIVE%\Enrollment\cmm\doda.txt
|
||||
if /i "%CMMDODA%"=="yes" echo doda> %OSDRIVE%\Enrollment\pc-subtype.txt
|
||||
:cmm_id_done
|
||||
robocopy "Y:\shopfloor-setup" "W:\Enrollment" "Run-ShopfloorSetup.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Run-ShopfloorSetup.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
REM Post-boot bulk re-fetch. WinPE staging below is best-effort (it can fail if
|
||||
REM the Y: mount went idle-dead during the WIM apply); Fetch-StagingPayload.ps1
|
||||
REM re-pulls the shopfloor-setup tree + preinstall bundle at first logon on a
|
||||
REM FRESH mount. The unattend FirstLogonCommands runs it before the PowerShell 7
|
||||
REM MSI + Run-ShopfloorSetup. Stage the script + its source coords here.
|
||||
robocopy "Y:\shopfloor-setup" "W:\Enrollment" "Fetch-StagingPayload.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Fetch-StagingPayload.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
REM Stage the staging self-heal directly too, so the unattend Order 5 heal step
|
||||
REM runs even if Fetch-StagingPayload itself did not land. Small, reliable copy.
|
||||
robocopy "Y:\shopfloor-setup" "W:\Enrollment" "Verify-And-Heal-Staging.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
> W:\Enrollment\fetch-source.txt (
|
||||
echo \\172.16.9.1\enrollment
|
||||
echo pxe-upload
|
||||
echo pxe
|
||||
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Verify-And-Heal-Staging.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
> %OSDRIVE%\Enrollment\fetch-source.txt (
|
||||
echo \\%PXESERVER%\enrollment
|
||||
echo %SHAREUSER%
|
||||
echo %SHAREPASS%
|
||||
)
|
||||
REM --- Always copy Shopfloor baseline scripts ---
|
||||
mkdir W:\Enrollment\shopfloor-setup 2>NUL
|
||||
robocopy "Y:\shopfloor-setup" "W:\Enrollment\shopfloor-setup" "backup_lockdown.bat" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
mkdir %OSDRIVE%\Enrollment\shopfloor-setup 2>NUL
|
||||
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment\shopfloor-setup" "backup_lockdown.bat" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
if exist "Y:\shopfloor-setup\Shopfloor" (
|
||||
mkdir W:\Enrollment\shopfloor-setup\Shopfloor 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\Shopfloor" "W:\Enrollment\shopfloor-setup\Shopfloor" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: shopfloor-setup\Shopfloor robocopy exit %ERRORLEVEL%
|
||||
echo Copied Shopfloor baseline setup files.
|
||||
echo [%TIME%] Copied Shopfloor baseline >> "%STAGELOG%"
|
||||
mkdir %OSDRIVE%\Enrollment\shopfloor-setup\Shopfloor 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\Shopfloor" "%OSDRIVE%\Enrollment\shopfloor-setup\Shopfloor" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
REM Report what actually happened. These echoes used to run unconditionally
|
||||
REM and printed "Copied ..." for six copies that moved zero bytes.
|
||||
REM %ERRORLEVEL% is not printed: inside a parenthesised block it expands at
|
||||
REM PARSE time, so it shows a stale value from before the robocopy ran.
|
||||
if errorlevel 8 (
|
||||
echo WARNING: shopfloor-setup\Shopfloor robocopy FAILED - nothing copied.
|
||||
echo [%TIME%] FAILED: Shopfloor baseline robocopy >> "%STAGELOG%"
|
||||
) else (
|
||||
echo Copied Shopfloor baseline setup files.
|
||||
echo [%TIME%] Copied Shopfloor baseline >> "%STAGELOG%"
|
||||
)
|
||||
)
|
||||
REM --- Always copy common/ (cross-PC-type GE-Enforce dispatcher + lib live here post-v2) ---
|
||||
if exist "Y:\shopfloor-setup\common" (
|
||||
mkdir W:\Enrollment\shopfloor-setup\common 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\common" "W:\Enrollment\shopfloor-setup\common" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: shopfloor-setup\common robocopy exit %ERRORLEVEL%
|
||||
echo Copied common setup files.
|
||||
echo [%TIME%] Copied common >> "%STAGELOG%"
|
||||
mkdir %OSDRIVE%\Enrollment\shopfloor-setup\common 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\common" "%OSDRIVE%\Enrollment\shopfloor-setup\common" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: shopfloor-setup\common robocopy FAILED - nothing copied.
|
||||
echo [%TIME%] FAILED: common robocopy >> "%STAGELOG%"
|
||||
) else (
|
||||
echo Copied common setup files.
|
||||
echo [%TIME%] Copied common >> "%STAGELOG%"
|
||||
)
|
||||
)
|
||||
REM --- Copy _ntlars-backups (147 per-bay .reg files restored by gea-shopfloor-{collections,nocollections}\03-RestoreEDncConfig.ps1) ---
|
||||
REM Same root level as common/, referenced by 03-RestoreEDncConfig.ps1 via Join-Path $PSScriptRoot '..\_ntlars-backups'.
|
||||
if exist "Y:\shopfloor-setup\_ntlars-backups" (
|
||||
mkdir W:\Enrollment\shopfloor-setup\_ntlars-backups 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\_ntlars-backups" "W:\Enrollment\shopfloor-setup\_ntlars-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: _ntlars-backups robocopy exit %ERRORLEVEL%
|
||||
echo Copied _ntlars-backups.
|
||||
echo [%TIME%] Copied _ntlars-backups >> "%STAGELOG%"
|
||||
mkdir %OSDRIVE%\Enrollment\shopfloor-setup\_ntlars-backups 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\_ntlars-backups" "%OSDRIVE%\Enrollment\shopfloor-setup\_ntlars-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: _ntlars-backups robocopy FAILED - nothing copied.
|
||||
echo [%TIME%] FAILED: _ntlars-backups robocopy >> "%STAGELOG%"
|
||||
) else (
|
||||
echo Copied _ntlars-backups.
|
||||
echo [%TIME%] Copied _ntlars-backups >> "%STAGELOG%"
|
||||
)
|
||||
)
|
||||
REM --- Copy type-specific scripts on top of baseline ---
|
||||
if exist "Y:\shopfloor-setup\%PCTYPE%" (
|
||||
mkdir "W:\Enrollment\shopfloor-setup\%PCTYPE%" 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\%PCTYPE%" "W:\Enrollment\shopfloor-setup\%PCTYPE%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: shopfloor-setup\%PCTYPE% robocopy exit %ERRORLEVEL%
|
||||
echo Copied %PCTYPE% setup files.
|
||||
echo [%TIME%] Copied %PCTYPE% type-specific >> "%STAGELOG%"
|
||||
mkdir "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" 2>NUL
|
||||
robocopy "Y:\shopfloor-setup\%PCTYPE%" "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: shopfloor-setup\%PCTYPE% robocopy FAILED - nothing copied.
|
||||
echo [%TIME%] FAILED: %PCTYPE% type-specific robocopy >> "%STAGELOG%"
|
||||
) else (
|
||||
echo Copied %PCTYPE% setup files.
|
||||
echo [%TIME%] Copied %PCTYPE% type-specific >> "%STAGELOG%"
|
||||
)
|
||||
) else (
|
||||
echo WARNING: No setup files found for PC type %PCTYPE%.
|
||||
echo [%TIME%] WARNING: No setup files for %PCTYPE% >> "%STAGELOG%"
|
||||
@@ -496,21 +716,27 @@ if exist "Y:\shopfloor-setup\%PCTYPE%" (
|
||||
|
||||
REM --- Stage preinstall bundle (apps installed locally to save Azure bandwidth) ---
|
||||
if exist "Y:\pre-install\preinstall.json" (
|
||||
mkdir W:\PreInstall 2>NUL
|
||||
mkdir W:\PreInstall\installers 2>NUL
|
||||
robocopy "Y:\pre-install" "W:\PreInstall" "preinstall.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
mkdir %OSDRIVE%\PreInstall 2>NUL
|
||||
mkdir %OSDRIVE%\PreInstall\installers 2>NUL
|
||||
robocopy "Y:\pre-install" "%OSDRIVE%\PreInstall" "preinstall.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
|
||||
if exist "Y:\pre-install\installers" (
|
||||
robocopy "Y:\pre-install\installers" "W:\PreInstall\installers" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: pre-install\installers robocopy exit %ERRORLEVEL%
|
||||
echo Staged preinstall bundle to W:\PreInstall.
|
||||
robocopy "Y:\pre-install\installers" "%OSDRIVE%\PreInstall\installers" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: pre-install\installers robocopy FAILED - nothing staged.
|
||||
) else (
|
||||
echo Staged preinstall bundle to %OSDRIVE%\PreInstall.
|
||||
)
|
||||
echo [%TIME%] Staged preinstall bundle >> "%STAGELOG%"
|
||||
) else (
|
||||
echo WARNING: Y:\pre-install\installers not found - preinstall.json staged without installers.
|
||||
)
|
||||
if exist "Y:\pre-install\udc-backups" (
|
||||
robocopy "Y:\pre-install\udc-backups" "W:\PreInstall\udc-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: pre-install\udc-backups robocopy exit %ERRORLEVEL%
|
||||
echo Staged UDC settings backups to W:\PreInstall\udc-backups.
|
||||
robocopy "Y:\pre-install\udc-backups" "%OSDRIVE%\PreInstall\udc-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: pre-install\udc-backups robocopy FAILED - nothing staged.
|
||||
) else (
|
||||
echo Staged UDC settings backups to %OSDRIVE%\PreInstall\udc-backups.
|
||||
)
|
||||
echo [%TIME%] Staged UDC backups >> "%STAGELOG%"
|
||||
)
|
||||
) else (
|
||||
@@ -526,24 +752,32 @@ REM bootstrap exists to get the first-install through. Post-imaging, the
|
||||
REM unified GE-Enforce dispatcher takes over from the share for ongoing updates.
|
||||
if /i not "%PCTYPE%"=="gea-shopfloor-cmm" goto skip_cmm_stage
|
||||
if exist "Y:\installers-post\cmm\cmm-manifest.json" (
|
||||
mkdir W:\CMM-Install 2>NUL
|
||||
mkdir %OSDRIVE%\CMM-Install 2>NUL
|
||||
REM /XD the backups tree: it holds EVERY bay's settings backup (some 240 MB
|
||||
REM each). Copying all of it to every imaged CMM would waste GBs. The bulk
|
||||
REM copy excludes it; the selected bay's backup is staged separately below.
|
||||
robocopy "Y:\installers-post\cmm" "W:\CMM-Install" /E /XD "Y:\installers-post\cmm\backups" /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: cmm robocopy exit %ERRORLEVEL%
|
||||
echo Staged CMM bootstrap to W:\CMM-Install.
|
||||
robocopy "Y:\installers-post\cmm" "%OSDRIVE%\CMM-Install" /E /XD "Y:\installers-post\cmm\backups" /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: cmm robocopy FAILED - CMM bootstrap NOT staged.
|
||||
) else (
|
||||
echo Staged CMM bootstrap to %OSDRIVE%\CMM-Install.
|
||||
)
|
||||
echo [%TIME%] Staged CMM bootstrap >> "%STAGELOG%"
|
||||
) else (
|
||||
echo WARNING: Y:\cmm-installers not found - CMM PC cannot install Hexagon apps at imaging time.
|
||||
)
|
||||
REM Stage ONLY the selected bay's settings backup. 09-Setup-CMM's Restore-CMM
|
||||
REM reads W:\CMM-Install\backups\%CMMID%\ to restore PC-DMIS + goCMM settings.
|
||||
REM reads %OSDRIVE%\CMM-Install\backups\%CMMID%\ to restore PC-DMIS + goCMM settings.
|
||||
if not defined CMMID goto skip_cmm_stage
|
||||
if exist "Y:\installers-post\cmm\backups\%CMMID%" (
|
||||
robocopy "Y:\installers-post\cmm\backups\%CMMID%" "W:\CMM-Install\backups\%CMMID%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
echo Staged %CMMID% settings backup for restore.
|
||||
echo [%TIME%] Staged %CMMID% backup >> "%STAGELOG%"
|
||||
robocopy "Y:\installers-post\cmm\backups\%CMMID%" "%OSDRIVE%\CMM-Install\backups\%CMMID%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: %CMMID% backup robocopy FAILED - settings will NOT restore.
|
||||
echo [%TIME%] FAILED: %CMMID% backup robocopy >> "%STAGELOG%"
|
||||
) else (
|
||||
echo Staged %CMMID% settings backup for restore.
|
||||
echo [%TIME%] Staged %CMMID% backup >> "%STAGELOG%"
|
||||
)
|
||||
)
|
||||
:skip_cmm_stage
|
||||
|
||||
@@ -558,11 +792,14 @@ REM 09-Setup-Keyence.ps1 reads C:\Enrollment\keyence-model.txt to know which.
|
||||
if /i not "%PCTYPE%"=="gea-shopfloor-keyence" goto skip_keyence_stage
|
||||
if "%KEYENCEMODEL%"=="" goto skip_keyence_stage
|
||||
if exist "Y:\installers-post\keyence\%KEYENCEMODEL%\manifest.json" (
|
||||
mkdir W:\KeyenceInstall 2>NUL
|
||||
mkdir W:\KeyenceInstall\%KEYENCEMODEL% 2>NUL
|
||||
robocopy "Y:\installers-post\keyence\%KEYENCEMODEL%" "W:\KeyenceInstall\%KEYENCEMODEL%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 echo WARNING: keyence\%KEYENCEMODEL% robocopy exit %ERRORLEVEL%
|
||||
echo Staged Keyence %KEYENCEMODEL% bootstrap to W:\KeyenceInstall\%KEYENCEMODEL%\.
|
||||
mkdir %OSDRIVE%\KeyenceInstall 2>NUL
|
||||
mkdir %OSDRIVE%\KeyenceInstall\%KEYENCEMODEL% 2>NUL
|
||||
robocopy "Y:\installers-post\keyence\%KEYENCEMODEL%" "%OSDRIVE%\KeyenceInstall\%KEYENCEMODEL%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
|
||||
if errorlevel 8 (
|
||||
echo WARNING: keyence\%KEYENCEMODEL% robocopy FAILED - nothing staged.
|
||||
) else (
|
||||
echo Staged Keyence %KEYENCEMODEL% bootstrap to %OSDRIVE%\KeyenceInstall\%KEYENCEMODEL%\.
|
||||
)
|
||||
echo [%TIME%] Staged Keyence %KEYENCEMODEL% >> "%STAGELOG%"
|
||||
) else (
|
||||
echo WARNING: Y:\installers-post\keyence\%KEYENCEMODEL% not found - Keyence %KEYENCEMODEL% cannot install at imaging time.
|
||||
@@ -571,44 +808,46 @@ if exist "Y:\installers-post\keyence\%KEYENCEMODEL%\manifest.json" (
|
||||
|
||||
REM --- Stage WaxTrace bootstrap bundle (wax/trace gea-shopfloor-waxtrace only) ---
|
||||
REM Three-step process:
|
||||
REM 1. robocopy installers-post\waxtrace -> W:\WaxTrace-Install (everything
|
||||
REM 1. robocopy installers-post\waxtrace -> %OSDRIVE%\WaxTrace-Install (everything
|
||||
REM EXCEPT the formtracepak\ subdir, via /XD - that subdir carries all
|
||||
REM 7 vendor ISOs and we only need one of them)
|
||||
REM 2. resolve-bay-config.ps1: reads bay-config.csv + asset_tag, writes
|
||||
REM W:\Enrollment\waxtrace\{version,model,userid}.txt for 09-Setup to read
|
||||
REM %OSDRIVE%\Enrollment\waxtrace\{version,model,userid}.txt for 09-Setup to read
|
||||
REM 3. robocopy ONLY the bay's matching FORMTRACEPAK-V<ver>.iso from
|
||||
REM Y:\installers-post\waxtrace\formtracepak\ onto the target disk
|
||||
REM (so 09-Setup-WaxAndTrace.ps1 mounts the right version per bay)
|
||||
if /i not "%PCTYPE%"=="gea-shopfloor-waxtrace" goto skip_waxtrace_stage
|
||||
if not exist "Y:\installers-post\waxtrace\waxtrace-manifest.json" goto skip_waxtrace_missing
|
||||
mkdir W:\WaxTrace-Install 2>NUL
|
||||
mkdir %OSDRIVE%\WaxTrace-Install 2>NUL
|
||||
REM robocopy /XD excludes the formtracepak\ dir up front so we don't waste
|
||||
REM disk + time copying 12+ GB of vendor ISOs only to delete them. The
|
||||
REM matched FTPak ISO gets cherry-picked below into the same target dir.
|
||||
REM /NFL/NDL keep the listing manageable; output left visible (no >NUL) so
|
||||
REM any failure is debuggable from the install log. robocopy exit codes
|
||||
REM 0-7 are "OK" (per Microsoft); 8+ is real failure.
|
||||
robocopy "Y:\installers-post\waxtrace" "W:\WaxTrace-Install" /E /XD formtracepak /MT:16 /R:1 /W:1 /NFL /NDL
|
||||
if errorlevel 8 echo WARNING: robocopy exit %ERRORLEVEL% - some files may not have copied.
|
||||
mkdir W:\WaxTrace-Install\formtracepak 2>NUL
|
||||
echo Staged WaxTrace bootstrap minus formtracepak\ to W:\WaxTrace-Install.
|
||||
REM Resolve bay-config: writes W:\Enrollment\waxtrace\{version,model,userid}.txt
|
||||
mkdir W:\Enrollment\waxtrace 2>NUL
|
||||
robocopy "Y:\installers-post\waxtrace" "%OSDRIVE%\WaxTrace-Install" /E /XD formtracepak /MT:16 /R:1 /W:1 /NFL /NDL
|
||||
REM Both tests read the robocopy result, so they must sit before any command
|
||||
REM that sets its own errorlevel - mkdir does, which is why it moved below.
|
||||
if errorlevel 8 echo WARNING: WaxTrace bootstrap robocopy FAILED - nothing staged.
|
||||
if not errorlevel 8 echo Staged WaxTrace bootstrap minus formtracepak\ to %OSDRIVE%\WaxTrace-Install.
|
||||
mkdir %OSDRIVE%\WaxTrace-Install\formtracepak 2>NUL
|
||||
REM Resolve bay-config: writes %OSDRIVE%\Enrollment\waxtrace\{version,model,userid}.txt
|
||||
mkdir %OSDRIVE%\Enrollment\waxtrace 2>NUL
|
||||
if not "%MACHINENUM%"=="" (
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\waxtrace\resolve-bay-config.ps1" -Asset "%MACHINENUM%" -OutDir "W:\Enrollment\waxtrace"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\waxtrace\resolve-bay-config.ps1" -Asset "%MACHINENUM%" -OutDir "%OSDRIVE%\Enrollment\waxtrace"
|
||||
) else (
|
||||
echo WARNING: no MACHINENUM set - skipping bay-config resolve. 09-Setup-WaxAndTrace will abort cleanly with no version.
|
||||
)
|
||||
REM Read the resolved version + robocopy ONLY that FTPak ISO (avoids dumping
|
||||
REM 12 GB of ISOs on the bay disk; only the matched ~2 GB ISO lands locally).
|
||||
set WTVER=
|
||||
if exist W:\Enrollment\waxtrace\version.txt set /p WTVER=<W:\Enrollment\waxtrace\version.txt
|
||||
if exist %OSDRIVE%\Enrollment\waxtrace\version.txt set /p WTVER=<%OSDRIVE%\Enrollment\waxtrace\version.txt
|
||||
if not "%WTVER%"=="" (
|
||||
if exist "Y:\installers-post\waxtrace\formtracepak\FORMTRACEPAK-V%WTVER%.iso" (
|
||||
REM robocopy /J = unbuffered I/O, meaningfully faster than xcopy on
|
||||
REM 2 GB FTPak ISOs over SMB. Single-file syntax: source dir, dest
|
||||
REM dir, file pattern.
|
||||
robocopy "Y:\installers-post\waxtrace\formtracepak" "W:\WaxTrace-Install\formtracepak" "FORMTRACEPAK-V%WTVER%.iso" /J /R:1 /W:1 /NFL /NDL
|
||||
robocopy "Y:\installers-post\waxtrace\formtracepak" "%OSDRIVE%\WaxTrace-Install\formtracepak" "FORMTRACEPAK-V%WTVER%.iso" /J /R:1 /W:1 /NFL /NDL
|
||||
echo Staged FormTracePak V%WTVER%.iso for %MACHINENUM%.
|
||||
) else (
|
||||
echo WARNING: No FORMTRACEPAK-V%WTVER%.iso on share for %MACHINENUM% - 09-Setup-WaxAndTrace will abort cleanly with the version-mismatch error.
|
||||
@@ -616,17 +855,17 @@ if not "%WTVER%"=="" (
|
||||
) else (
|
||||
echo WARNING: bay-config did not resolve a FTPak version for %MACHINENUM%.
|
||||
)
|
||||
REM Per-asset backup ZIPs land at W:\WaxTrace-Install\backups\<asset>.zip
|
||||
REM Per-asset backup ZIPs land at %OSDRIVE%\WaxTrace-Install\backups\<asset>.zip
|
||||
REM as part of the robocopy bundle above (backups\ is not in the /XD list).
|
||||
REM 09-Setup-WaxAndTrace.ps1 Step 3b reads C:\WaxTrace-Install\backups\
|
||||
REM <asset>.zip post-vendor-MSI and runs Install-FormtracepakSettings.ps1
|
||||
REM -RestoreData -RestoreConfig. Total ~17 MB on disk for the whole set, no
|
||||
REM cherry-pick needed.
|
||||
if not "%MACHINENUM%"=="" (
|
||||
if exist "W:\WaxTrace-Install\backups\%MACHINENUM%.zip" (
|
||||
if exist "%OSDRIVE%\WaxTrace-Install\backups\%MACHINENUM%.zip" (
|
||||
echo Backup ZIP %MACHINENUM%.zip is in the bundle - 09-Setup Step 3b will restore.
|
||||
) else (
|
||||
echo INFO: No backup ZIP for %MACHINENUM% in W:\WaxTrace-Install\backups - 09-Setup will skip the restore step.
|
||||
echo INFO: No backup ZIP for %MACHINENUM% in %OSDRIVE%\WaxTrace-Install\backups - 09-Setup will skip the restore step.
|
||||
)
|
||||
)
|
||||
goto skip_waxtrace_stage
|
||||
@@ -636,7 +875,64 @@ echo WARNING: Y:\installers-post\waxtrace not found - WaxTrace PC cannot install
|
||||
:pctype_done
|
||||
if defined STAGELOG echo [%TIME%] WinPE staging complete >> "%STAGELOG%"
|
||||
|
||||
REM --- BIOS update sub-stage push (fires AFTER W: copies complete) ---
|
||||
REM --- Verify what we just staged ------------------------------------------
|
||||
REM Check the payload BEFORE anything depends on it, while a tech is still
|
||||
REM standing at the machine. A short copy found here costs 30 seconds; the same
|
||||
REM copy found at first logon costs 20 minutes, and found never costs a rebuild.
|
||||
REM Four Display bays sat at "imaging stage 2, green" for weeks because nothing
|
||||
REM checked. Retry once for the small trees; the ppkg is 8 GB so report it
|
||||
REM rather than blindly re-pulling it.
|
||||
if "%PCTYPE%"=="" goto verify_done
|
||||
set STAGEBAD=
|
||||
if not exist "%OSDRIVE%\Enrollment\pc-type.txt" set STAGEBAD=%STAGEBAD% pc-type.txt
|
||||
if not exist "%OSDRIVE%\Enrollment\Run-ShopfloorSetup.ps1" set STAGEBAD=%STAGEBAD% Run-ShopfloorSetup.ps1
|
||||
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\common" set STAGEBAD=%STAGEBAD% shopfloor-setup\common
|
||||
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" set STAGEBAD=%STAGEBAD% shopfloor-setup\%PCTYPE%
|
||||
if "%STAGEBAD%"=="" goto verify_ppkg
|
||||
|
||||
echo.
|
||||
echo Staging verify: missing%STAGEBAD% - retrying once...
|
||||
echo [%TIME%] VERIFY: missing%STAGEBAD% - retry >> "%STAGELOG%"
|
||||
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Run-ShopfloorSetup.ps1" /R:2 /W:2 /NFL /NDL /NJH /NJS
|
||||
robocopy "Y:\shopfloor-setup\common" "%OSDRIVE%\Enrollment\shopfloor-setup\common" /E /MT:16 /R:2 /W:2 /NFL /NDL /NJH /NJS
|
||||
robocopy "Y:\shopfloor-setup\%PCTYPE%" "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" /E /MT:16 /R:2 /W:2 /NFL /NDL /NJH /NJS
|
||||
if not exist "%OSDRIVE%\Enrollment\pc-type.txt" echo %PCTYPE%> %OSDRIVE%\Enrollment\pc-type.txt
|
||||
|
||||
set STAGEBAD=
|
||||
if not exist "%OSDRIVE%\Enrollment\pc-type.txt" set STAGEBAD=%STAGEBAD% pc-type.txt
|
||||
if not exist "%OSDRIVE%\Enrollment\Run-ShopfloorSetup.ps1" set STAGEBAD=%STAGEBAD% Run-ShopfloorSetup.ps1
|
||||
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\common" set STAGEBAD=%STAGEBAD% shopfloor-setup\common
|
||||
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" set STAGEBAD=%STAGEBAD% shopfloor-setup\%PCTYPE%
|
||||
if "%STAGEBAD%"=="" (
|
||||
echo Staging verify: retry fixed it.
|
||||
echo [%TIME%] VERIFY: retry succeeded >> "%STAGELOG%"
|
||||
goto verify_ppkg
|
||||
)
|
||||
echo.
|
||||
echo ******************************************************************
|
||||
echo STAGING INCOMPLETE after retry:%STAGEBAD%
|
||||
echo This bay will image but the post-install setup will NOT run
|
||||
echo properly. Fix the share and re-image. Tell the PXE admin.
|
||||
echo ******************************************************************
|
||||
echo.
|
||||
echo [%TIME%] VERIFY FAILED after retry:%STAGEBAD% >> "%STAGELOG%"
|
||||
|
||||
:verify_ppkg
|
||||
REM The enrollment package is the expensive one - 8 GB. Report, do not re-pull.
|
||||
if "%PPKG%"=="" goto verify_done
|
||||
if exist "%OSDRIVE%\Enrollment\%PPKG%" (
|
||||
echo Staging verify: enrollment package present.
|
||||
echo [%TIME%] VERIFY: %PPKG% present >> "%STAGELOG%"
|
||||
) else (
|
||||
echo.
|
||||
echo WARNING: enrollment package %PPKG% is MISSING from %OSDRIVE%\Enrollment.
|
||||
echo Enrollment will not run. Re-image this bay.
|
||||
echo.
|
||||
echo [%TIME%] VERIFY FAILED: %PPKG% missing >> "%STAGELOG%"
|
||||
)
|
||||
:verify_done
|
||||
|
||||
REM --- BIOS update sub-stage push (fires AFTER %OSDRIVE% copies complete) ---
|
||||
REM check-bios.cmd drops X:\bios-fired.flag iff it actually flashed or
|
||||
REM staged a firmware update. Reading the flag file is more reliable than
|
||||
REM scanning BIOS_STATUS for substrings (caret escaping inside quoted SET
|
||||
@@ -655,6 +951,53 @@ net use Y: /delete 2>NUL
|
||||
ping -n 11 127.0.0.1 >NUL
|
||||
wmic process where "name='PESetup.exe'" get name 2>NUL | find /I "PESetup" >NUL
|
||||
if not errorlevel 1 goto wait_finish
|
||||
|
||||
REM --- Harvest the imaging logs to the share --------------------------------
|
||||
REM Every diagnosis of a failed build so far has meant walking to the bay and
|
||||
REM copying files off by hand. PESetup preserves its log and the generated
|
||||
REM unattend, DISM writes its apply logs, and our staging log sits on the target
|
||||
REM - all of it is thrown away at reboot unless collected now. Runs after
|
||||
REM PESetup exits so the logs are final, which means re-mapping Y: (cleanup
|
||||
REM already dropped it).
|
||||
REM Best-effort throughout: a bay must never fail to reboot because a log copy
|
||||
REM did not work.
|
||||
set SERIAL=
|
||||
for /f "skip=1 tokens=*" %%S in ('wmic bios get serialnumber 2^>NUL') do if not defined SERIAL set SERIAL=%%S
|
||||
for /f "tokens=*" %%a in ("%SERIAL%") do set SERIAL=%%a
|
||||
if "%SERIAL%"=="" set SERIAL=unknown-serial
|
||||
|
||||
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>&1
|
||||
if not exist "Y:\" goto harvest_done
|
||||
set LOGDEST=Y:\imaging-logs\%SERIAL%
|
||||
mkdir "Y:\imaging-logs" 2>NUL
|
||||
mkdir "%LOGDEST%" 2>NUL
|
||||
echo Collecting imaging logs to %LOGDEST% ...
|
||||
|
||||
REM WinPE side: DISM apply logs, the unattend PESetup generated, BIOS flash log.
|
||||
robocopy X:\ "%LOGDEST%" "*.log" "Unattend.xml" /R:1 /W:1 /NFL /NDL /NJH /NJS >NUL 2>&1
|
||||
REM Target side: PESetup's own log lands in Panther, and our staging log.
|
||||
if defined OSDRIVE (
|
||||
robocopy "%OSDRIVE%\Windows\Panther" "%LOGDEST%" "PESetup*.log" /R:1 /W:1 /NFL /NDL /NJH /NJS >NUL 2>&1
|
||||
robocopy "%OSDRIVE%\Enrollment" "%LOGDEST%" "winpe-staging.log" /R:1 /W:1 /NFL /NDL /NJH /NJS >NUL 2>&1
|
||||
)
|
||||
REM A breadcrumb of what this build actually was, so the logs are readable
|
||||
REM without cross-referencing the webapp.
|
||||
> "%LOGDEST%\build-context.txt" (
|
||||
echo Serial=%SERIAL%
|
||||
echo PCTYPE=%PCTYPE%
|
||||
echo PPKG=%PPKG%
|
||||
echo MACHINENUM=%MACHINENUM%
|
||||
echo CMMID=%CMMID%
|
||||
echo KEYENCEMODEL=%KEYENCEMODEL%
|
||||
echo DISPLAYTYPE=%DISPLAYTYPE%
|
||||
echo OSDRIVE=%OSDRIVE%
|
||||
echo Media=%MEDIAPATH%
|
||||
echo Date=%DATE% %TIME%
|
||||
)
|
||||
echo Imaging logs collected.
|
||||
net use Y: /delete /y >NUL 2>&1
|
||||
:harvest_done
|
||||
|
||||
echo.
|
||||
echo Imaging complete. Rebooting in 15 seconds...
|
||||
echo Press Ctrl+C to cancel.
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
# Pushes the NEWEST backup set per cmm_id to:
|
||||
# /srv/samba/enrollment/installers-post/cmm/backups/<cmm_id>/
|
||||
#
|
||||
# DODA bays: do NOT back them up (don't put them under cmm-bk/). 09-Setup-CMM
|
||||
# also gates restore on cmm-bay-config doda=no as a second guard.
|
||||
# DODA bays ARE backed up + restored like any other now (the old skip-on-doda
|
||||
# policy in Restore-CMM was dropped 2026-06-17). Stage them here the same way.
|
||||
#
|
||||
# Usage: ./playbook/sync-cmm-backups.sh (all cmm_id folders)
|
||||
# CMM_ID=CMM3 ./playbook/sync-cmm-backups.sh (just one)
|
||||
|
||||
271
scripts/build-pctype-media.py
Executable file
271
scripts/build-pctype-media.py
Executable file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
build-pctype-media.py - Build a per-PCTYPE PESetup media view out of symlinks.
|
||||
|
||||
PESetup's CopyPackages copies the WHOLE of <media>\\Deploy\\Applications recursively
|
||||
to the target (see docs/PESETUP-INTERNALS.md). It does not filter, so every shopfloor
|
||||
bay built from the shared gea-shopfloor media receives every shopfloor payload
|
||||
regardless of its PC type.
|
||||
|
||||
Rather than mutate the shared media per session - which races, because bays image
|
||||
concurrently and CopyPackages is fail-fast, so pulling a path mid-copy fails the
|
||||
OTHER bay's imaging - give each PC type its own media directory built entirely from
|
||||
symlinks. Samba resolves them server-side (follow symlinks + wide links are already
|
||||
on, and every image root already uses this pattern for Sources, Operating Systems,
|
||||
Out-of-box Drivers and Packages), so WinPE sees ordinary directories.
|
||||
|
||||
Layout produced, per type:
|
||||
|
||||
_media/<pctype>/
|
||||
Sources -> base Sources
|
||||
Tools -> base Tools
|
||||
Deploy/Control -> base Deploy/Control
|
||||
Deploy/FlatUnattendW10.xml -> base Deploy/FlatUnattendW10.xml
|
||||
Deploy/Operating Systems -> base Deploy/Operating Systems
|
||||
Deploy/Out-of-box Drivers -> base Deploy/Out-of-box Drivers
|
||||
Deploy/Packages -> base Deploy/Packages
|
||||
Deploy/Tools -> base Deploy/Tools
|
||||
Deploy/Applications/ real directory holding:
|
||||
<every entry of base Deploy/Applications> -> base entry
|
||||
<every entry of _shared/Applications/pctype/<pctype>> -> that entry
|
||||
|
||||
Everything is a link, so a type costs inodes rather than gigabytes, and the shared
|
||||
media stays untouched and readable while bays image.
|
||||
|
||||
The type list comes from the enrollment share's menu.json, the same file the WinPE
|
||||
picker renders, so the media set cannot drift from the boot menu.
|
||||
|
||||
Usage:
|
||||
./build-pctype-media.py # show the plan, change nothing
|
||||
./build-pctype-media.py --apply # build or refresh the media dirs
|
||||
./build-pctype-media.py --verify # resolve every link, report breaks
|
||||
./build-pctype-media.py --apply --prune # also remove types no longer listed
|
||||
./build-pctype-media.py --base gea-standard --prefix _media-std --apply
|
||||
|
||||
Requires: sshpass (remote mode only)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import posixpath
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PXE_HOST = "172.16.9.1"
|
||||
PXE_USER = "pxe"
|
||||
PXE_PASS = "pxe"
|
||||
IMAGE_BASE = "/srv/samba/winpeapps"
|
||||
SHARED = IMAGE_BASE + "/_shared"
|
||||
MENU_JSON = "/srv/samba/enrollment/shopfloor-setup/menu.json"
|
||||
|
||||
# Underscore prefix keeps these out of the webapp's image-type listing, the same way
|
||||
# _shared is skipped. They are media views, not image types.
|
||||
MEDIA_PREFIX = "_media"
|
||||
DEFAULT_BASE = "gea-shopfloor"
|
||||
|
||||
# Per-type payload lives here. Absent dir just means "nothing type-specific yet".
|
||||
PCTYPE_OVERLAY = SHARED + "/Applications/pctype"
|
||||
|
||||
# Deploy entries linked straight through from the base image. Anything else in the
|
||||
# base Deploy (the FlatUnattendW10.xml.pre-* backups, stray logs) is deliberately
|
||||
# NOT mirrored - the media view is the clean set PESetup actually reads.
|
||||
DEPLOY_LINKS = [
|
||||
"Control",
|
||||
"FlatUnattendW10.xml",
|
||||
"Operating Systems",
|
||||
"Out-of-box Drivers",
|
||||
"Packages",
|
||||
"Tools",
|
||||
]
|
||||
ROOT_LINKS = ["Sources", "Tools"]
|
||||
|
||||
# PESetup will not get far without these, so they are what --verify resolves.
|
||||
CRITICAL_PATHS = [
|
||||
"Sources/PESetup.exe",
|
||||
"Deploy/Control/HardwareDriver.json",
|
||||
"Deploy/Control/OperatingSystem.json",
|
||||
"Deploy/FlatUnattendW10.xml",
|
||||
"Deploy/Operating Systems",
|
||||
"Deploy/Out-of-box Drivers",
|
||||
]
|
||||
|
||||
|
||||
def ssh_cmd(host, cmd, stdin=None):
|
||||
return subprocess.run(
|
||||
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "LogLevel=ERROR", f"{PXE_USER}@{host}", cmd],
|
||||
capture_output=True, text=True, input=stdin)
|
||||
|
||||
|
||||
def sh_quote(s):
|
||||
return "'" + str(s).replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def run_root(host, script, apply_it):
|
||||
"""Server-side work needs root: the share is root-owned.
|
||||
|
||||
The script travels inside the command as a heredoc, not on stdin. "echo pxe |
|
||||
sudo -S bash -s" hands the password pipe to bash as well, so bash reads EOF
|
||||
immediately, runs nothing, and still exits 0 - a silent no-op."""
|
||||
if not apply_it:
|
||||
return None
|
||||
remote = "/tmp/build-pctype-media.$$.sh"
|
||||
wrapped = (
|
||||
"cat > %s <<'PCTYPEMEDIAEOF'\n%s\nPCTYPEMEDIAEOF\n"
|
||||
"echo pxe | sudo -S -p '' bash %s; rc=$?; rm -f %s; exit $rc"
|
||||
% (remote, script, remote, remote))
|
||||
r = ssh_cmd(host, wrapped)
|
||||
if r.returncode != 0:
|
||||
sys.exit("ERROR: server-side step failed:\n%s\n%s" % (r.stdout, r.stderr))
|
||||
return r.stdout
|
||||
|
||||
|
||||
def read_types(host, menu_path):
|
||||
r = ssh_cmd(host, "cat %s" % sh_quote(menu_path))
|
||||
if r.returncode != 0:
|
||||
sys.exit("ERROR: cannot read %s (%s)" % (menu_path, r.stderr.strip()))
|
||||
try:
|
||||
menu = json.loads(r.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
sys.exit("ERROR: %s does not parse: %s" % (menu_path, e))
|
||||
types = [e["key"] for e in menu.get("shopfloor", [])
|
||||
if e.get("enabled", True) and e.get("key")]
|
||||
if not types:
|
||||
sys.exit("ERROR: no enabled types in %s" % menu_path)
|
||||
return types
|
||||
|
||||
|
||||
def list_dir(host, path):
|
||||
r = ssh_cmd(host, "ls -1 %s 2>/dev/null" % sh_quote(path))
|
||||
return [n for n in r.stdout.splitlines() if n] if r.returncode == 0 else []
|
||||
|
||||
|
||||
def plan_for_type(base_dir, media_dir, pctype, base_apps, overlay_entries):
|
||||
"""Every link this type needs, as (link_path, target) pairs."""
|
||||
links = []
|
||||
for name in ROOT_LINKS:
|
||||
links.append((posixpath.join(media_dir, name),
|
||||
posixpath.join(base_dir, name)))
|
||||
for name in DEPLOY_LINKS:
|
||||
links.append((posixpath.join(media_dir, "Deploy", name),
|
||||
posixpath.join(base_dir, "Deploy", name)))
|
||||
apps_dir = posixpath.join(media_dir, "Deploy", "Applications")
|
||||
for name in base_apps:
|
||||
links.append((posixpath.join(apps_dir, name),
|
||||
posixpath.join(base_dir, "Deploy", "Applications", name)))
|
||||
# Overlay wins: a type-specific entry replaces the base entry of the same name.
|
||||
overlay_dir = posixpath.join(PCTYPE_OVERLAY, pctype)
|
||||
for name in overlay_entries:
|
||||
links.append((posixpath.join(apps_dir, name),
|
||||
posixpath.join(overlay_dir, name)))
|
||||
return links
|
||||
|
||||
|
||||
def build_script(media_dir, links):
|
||||
"""Rebuild the type's dir from scratch. Idempotent, and drops stale links.
|
||||
|
||||
Builds into a scratch dir and swaps, so a bay that maps this path mid-refresh
|
||||
sees either the old tree or the new one, never a half-built one."""
|
||||
tmp = media_dir + ".new"
|
||||
out = ["set -e", "rm -rf %s" % sh_quote(tmp)]
|
||||
dirs = {posixpath.dirname(link) for link, _ in links}
|
||||
for d in sorted(dirs):
|
||||
out.append("mkdir -p %s" % sh_quote(d.replace(media_dir, tmp, 1)))
|
||||
for link, target in links:
|
||||
out.append("ln -s %s %s" % (sh_quote(target),
|
||||
sh_quote(link.replace(media_dir, tmp, 1))))
|
||||
out.append("rm -rf %s" % sh_quote(media_dir + ".old"))
|
||||
out.append("if [ -e %s ]; then mv %s %s; fi"
|
||||
% (sh_quote(media_dir), sh_quote(media_dir), sh_quote(media_dir + ".old")))
|
||||
out.append("mv %s %s" % (sh_quote(tmp), sh_quote(media_dir)))
|
||||
out.append("rm -rf %s" % sh_quote(media_dir + ".old"))
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def verify(host, media_root, types):
|
||||
"""Resolve the paths PESetup reads. A dangling link here is a dead media view."""
|
||||
checks = []
|
||||
for pctype in types:
|
||||
for rel in CRITICAL_PATHS:
|
||||
checks.append(posixpath.join(media_root, pctype, rel))
|
||||
script = "\n".join("if [ -e %s ]; then echo \"OK %s\"; else echo \"DEAD %s\"; fi"
|
||||
% (sh_quote(p), p, p) for p in checks)
|
||||
r = ssh_cmd(host, "bash -s", stdin=script)
|
||||
dead = [line for line in r.stdout.splitlines() if line.startswith("DEAD")]
|
||||
for line in r.stdout.splitlines():
|
||||
if line.startswith("DEAD"):
|
||||
print(" " + line)
|
||||
print(" %d paths checked, %d dead" % (len(checks), len(dead)))
|
||||
return len(dead)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build per-PCTYPE PESetup media views out of symlinks.")
|
||||
parser.add_argument("--server", default=PXE_HOST, help="PXE server (default: %s)" % PXE_HOST)
|
||||
parser.add_argument("--base", default=DEFAULT_BASE,
|
||||
help="image type the view is built from (default: %s)" % DEFAULT_BASE)
|
||||
parser.add_argument("--prefix", default=MEDIA_PREFIX,
|
||||
help="directory under winpeapps to hold the views (default: %s)"
|
||||
% MEDIA_PREFIX)
|
||||
parser.add_argument("--menu", default=MENU_JSON, help="menu.json path on the server")
|
||||
parser.add_argument("--types", nargs="+", help="explicit type list, bypassing menu.json")
|
||||
parser.add_argument("--apply", action="store_true", help="write; default is a dry run")
|
||||
parser.add_argument("--prune", action="store_true",
|
||||
help="remove media views whose type is no longer listed")
|
||||
parser.add_argument("--verify", action="store_true",
|
||||
help="resolve the critical paths in each view and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_dir = posixpath.join(IMAGE_BASE, args.base)
|
||||
media_root = posixpath.join(IMAGE_BASE, args.prefix)
|
||||
types = args.types or read_types(args.server, args.menu)
|
||||
|
||||
if args.verify:
|
||||
print("Verifying %d media views under %s" % (len(types), media_root))
|
||||
return 1 if verify(args.server, media_root, types) else 0
|
||||
|
||||
base_apps = list_dir(args.server, posixpath.join(base_dir, "Deploy", "Applications"))
|
||||
if not base_apps:
|
||||
sys.exit("ERROR: %s/Deploy/Applications is empty or unreadable" % base_dir)
|
||||
|
||||
print("base image : %s" % base_dir)
|
||||
print("media root : %s" % media_root)
|
||||
print("types : %d from %s" % (len(types), "--types" if args.types else args.menu))
|
||||
print("base apps : %s" % ", ".join(base_apps))
|
||||
print()
|
||||
|
||||
total_links = 0
|
||||
for pctype in types:
|
||||
media_dir = posixpath.join(media_root, pctype)
|
||||
overlay_entries = list_dir(args.server, posixpath.join(PCTYPE_OVERLAY, pctype))
|
||||
links = plan_for_type(base_dir, media_dir, pctype, base_apps, overlay_entries)
|
||||
total_links += len(links)
|
||||
print("%-32s %2d links%s" % (
|
||||
pctype, len(links),
|
||||
" overlay: " + ", ".join(overlay_entries) if overlay_entries else ""))
|
||||
run_root(args.server, build_script(media_dir, links), args.apply)
|
||||
|
||||
print()
|
||||
if args.prune:
|
||||
existing = list_dir(args.server, media_root)
|
||||
stale = [n for n in existing if n not in types and not n.endswith((".new", ".old"))]
|
||||
for name in stale:
|
||||
print("prune %s" % posixpath.join(media_root, name))
|
||||
run_root(args.server, "rm -rf %s\n" % sh_quote(posixpath.join(media_root, name)),
|
||||
args.apply)
|
||||
if not stale:
|
||||
print("prune: nothing stale")
|
||||
print()
|
||||
|
||||
if not args.apply:
|
||||
print("DRY RUN: %d links across %d types. Re-run with --apply to build."
|
||||
% (total_links, len(types)))
|
||||
return 0
|
||||
|
||||
print("Built %d links across %d types. Verifying." % (total_links, len(types)))
|
||||
return 1 if verify(args.server, media_root, types) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -68,6 +68,53 @@ def ssh_cmd(host, cmd):
|
||||
|
||||
|
||||
|
||||
# Remote path resolver that is case-insensitive about EXISTING directories.
|
||||
#
|
||||
# Dell/GE manifests spell the same folder inconsistently ("OptiPlex" vs
|
||||
# "Optiplex"). Windows does not care; the Samba share is backed by a
|
||||
# case-sensitive Linux filesystem, so a blind "mkdir -p" creates a SECOND tree
|
||||
# and the drivers split between them. That is exactly how the OptiPlex Micro
|
||||
# 7020 package went missing: the manifest asked for OptiPlex/D13MLK while the
|
||||
# 3.2 GB zip sat in Optiplex/D13MLK, so imaging installed no drivers at all -
|
||||
# no NIC, no WiFi, and bulk enrollment could not reach the CDN.
|
||||
#
|
||||
# Walk the path one component at a time and reuse whatever is already there,
|
||||
# whatever its case. Only genuinely new components get created.
|
||||
CI_RESOLVE_SH = r"""
|
||||
set -e
|
||||
p="$1"; shift
|
||||
for c in "$@"; do
|
||||
[ -z "$c" ] && continue
|
||||
if [ -d "$p/$c" ]; then
|
||||
p="$p/$c"
|
||||
else
|
||||
m=$(ls -1 "$p" 2>/dev/null | awk -v c="$c" 'tolower($0)==tolower(c){print; exit}')
|
||||
if [ -n "$m" ]; then p="$p/$m"; else mkdir -p "$p/$c"; p="$p/$c"; fi
|
||||
fi
|
||||
done
|
||||
printf '%s' "$p"
|
||||
"""
|
||||
|
||||
|
||||
def mkdir_ci(host, target_dir):
|
||||
"""mkdir -p that reuses existing dirs differing only in case.
|
||||
|
||||
Returns the path that actually exists on the server, which may differ in
|
||||
case from target_dir. Callers must use the RETURNED path."""
|
||||
parts = [c for c in target_dir.split("/") if c]
|
||||
if not parts:
|
||||
return target_dir
|
||||
base = "/" if target_dir.startswith("/") else "."
|
||||
r = ssh_cmd(host, "bash -s -- '%s' %s <<'CIEOF'\n%s\nCIEOF" % (
|
||||
base, " ".join("'%s'" % c.replace("'", "'\''") for c in parts), CI_RESOLVE_SH))
|
||||
out = (r.stdout or "").strip()
|
||||
if r.returncode != 0 or not out:
|
||||
# Fall back to the old behaviour rather than skipping the download.
|
||||
ssh_cmd(host, "mkdir -p '%s'" % target_dir)
|
||||
return target_dir
|
||||
return out
|
||||
|
||||
|
||||
def verify_sha256(filepath, expected):
|
||||
sha = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
@@ -155,6 +202,14 @@ def parse_driver_catalog(xml_path, os_filter=None):
|
||||
return packs
|
||||
|
||||
|
||||
def _ver_tuple(v):
|
||||
"""Version string -> tuple of ints for numeric compare ('1.20.1' -> (1,20,1))."""
|
||||
try:
|
||||
return tuple(int(x) for x in str(v).split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return (0,)
|
||||
|
||||
|
||||
def parse_bios_catalog(xml_path, model_names):
|
||||
"""Parse DellSDPCatalogPC.xml → list of latest BIOS update dicts for given models."""
|
||||
tree = ET.parse(xml_path)
|
||||
@@ -199,9 +254,11 @@ def parse_bios_catalog(xml_path, model_names):
|
||||
"model": matched_model,
|
||||
}
|
||||
|
||||
# Keep latest version per model
|
||||
# Keep latest version per model. Compare numerically (tuple of ints),
|
||||
# not as strings - "1.20.1" > "1.9.0" is False as a string compare,
|
||||
# which would wrongly keep the older 1.9.0 BIOS.
|
||||
key = matched_model
|
||||
if key not in bios or version > bios[key]["version"]:
|
||||
if key not in bios or _ver_tuple(version) > _ver_tuple(bios[key]["version"]):
|
||||
bios[key] = entry
|
||||
|
||||
return list(bios.values())
|
||||
@@ -314,7 +371,9 @@ def process_download(args, url, filename, sha256, size, target_dir, label, tmpdi
|
||||
|
||||
# Push zip to PXE server
|
||||
print(f" [{label}] Pushing to {target_dir}/{zip_name}...")
|
||||
ssh_cmd(args.server, f"mkdir -p '{target_dir}'")
|
||||
# Reuse an existing directory that differs only in case, or the drivers
|
||||
# split across two trees and imaging silently installs none of them.
|
||||
target_dir = mkdir_ci(args.server, target_dir)
|
||||
r = subprocess.run([
|
||||
"rsync", "-a",
|
||||
"-e", f"sshpass -p {PXE_PASS} ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR",
|
||||
|
||||
435
scripts/lint-driver-catalogue.py
Executable file
435
scripts/lint-driver-catalogue.py
Executable file
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
lint-driver-catalogue.py - Validate HardwareDriver.json against PESetup's real matcher.
|
||||
|
||||
PESetup selects a driver pack with GetDriverByModel (see docs/PESETUP-INTERNALS.md)
|
||||
and a miss is only a WARNING: imaging finishes, the machine comes up with no NIC and
|
||||
no WiFi, DNS fails at first boot, and bulk enrollment cannot reach the CDN. The
|
||||
symptom appears far from the cause, which is how the OptiPlex Micro 7020 driver pack
|
||||
went unnoticed and how the Display MicroPC failures were misattributed to a drive
|
||||
letter.
|
||||
|
||||
This script reimplements the matcher exactly and reports the ways a catalogue can be
|
||||
silently wrong:
|
||||
|
||||
- a "virtual platform" entry, which fails every physical machine
|
||||
- an empty token (trailing comma), which matches every model string
|
||||
- tokens carrying whitespace, which the C# Split(',') never trims
|
||||
- a referenced zip that is absent, or present under a different case
|
||||
- an entry that can never win because an earlier entry shadows it
|
||||
- a family field that contradicts the entry's own tokens
|
||||
- two entries covering one model for different OS versions (first wins, not the
|
||||
OS-appropriate one)
|
||||
|
||||
Usage:
|
||||
./lint-driver-catalogue.py # lint every image type on the PXE server
|
||||
./lint-driver-catalogue.py --image gea-shopfloor # one image type
|
||||
./lint-driver-catalogue.py --local /srv/samba/winpeapps # run on the server itself
|
||||
./lint-driver-catalogue.py --models fleet.txt # also resolve real WMI model strings
|
||||
./lint-driver-catalogue.py --quiet # findings only, no per-entry detail
|
||||
|
||||
Exits non-zero when any ERROR or CRITICAL is found, so it can gate a deploy.
|
||||
|
||||
Requires: sshpass (remote mode only)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
PXE_HOST = "172.16.9.1"
|
||||
PXE_USER = "pxe"
|
||||
PXE_PASS = "pxe"
|
||||
IMAGE_BASE = "/srv/samba/winpeapps"
|
||||
# Where destinationDir points, relative to the image root. A symlink into _shared.
|
||||
DRIVERS_SUBDIR = "Deploy/Out-of-box Drivers"
|
||||
|
||||
# PESetup only knows these three Dell lines. Anything else skips the family filter
|
||||
# and rides entirely on modelswminame substrings.
|
||||
KNOWN_FAMILIES = ("Latitude", "Optiplex", "Precision")
|
||||
|
||||
CRITICAL, ERROR, WARN, INFO = "CRITICAL", "ERROR", "WARN", "INFO"
|
||||
FAIL_LEVELS = (CRITICAL, ERROR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ssh_cmd(host, cmd):
|
||||
return subprocess.run(
|
||||
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "LogLevel=ERROR", f"{PXE_USER}@{host}", cmd],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def read_remote(host, path):
|
||||
r = ssh_cmd(host, "cat '%s'" % path)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return r.stdout
|
||||
|
||||
|
||||
def list_remote(host, path):
|
||||
"""Every file under path, relative and POSIX. Empty list if path is gone.
|
||||
|
||||
-L is required: Deploy/Out-of-box Drivers is a symlink into _shared, and plain
|
||||
find reports the symlink itself and descends nothing, which silently turns every
|
||||
zip-existence check into a no-op."""
|
||||
r = ssh_cmd(host, "find -L '%s' -type f -printf '%%P\\n' 2>/dev/null" % path)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
return [line for line in r.stdout.splitlines() if line]
|
||||
|
||||
|
||||
def read_local(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def list_local(path):
|
||||
# os.walk with followlinks, not rglob: the drivers dir is a symlink into
|
||||
# _shared and rglob will not descend it.
|
||||
import os
|
||||
if not os.path.isdir(path):
|
||||
return []
|
||||
out = []
|
||||
for root, _dirs, names in os.walk(path, followlinks=True):
|
||||
rel = os.path.relpath(root, path)
|
||||
for n in names:
|
||||
out.append(n if rel == "." else os.path.join(rel, n))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PESetup's matcher, reimplemented
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def model_family(model):
|
||||
"""PESetup: three Contains() tests, last one 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 either."""
|
||||
return str(entry.get("modelswminame") or "").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
|
||||
for token in entry_tokens(entry):
|
||||
# C#: model.ToLower().Contains(token.ToLower()). "" is contained by
|
||||
# every string, so a trailing comma matches all models.
|
||||
if token.lower() in model.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def select_driver(entries, model):
|
||||
"""First match wins, exactly like GetDriverByModel. None when nothing matches."""
|
||||
for i, entry in enumerate(entries):
|
||||
if entry_matches(entry, model):
|
||||
return i, entry
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_dest_dir(dest):
|
||||
"""*destinationdir*\\Deploy\\... -> Deploy/... (matches download-drivers.py)."""
|
||||
return (str(dest or "")
|
||||
.replace("*destinationdir*\\", "")
|
||||
.replace("*destinationdir*", "")
|
||||
.replace("\\", "/")
|
||||
.strip("/"))
|
||||
|
||||
|
||||
def entry_zip_relpath(entry):
|
||||
dest = resolve_dest_dir(entry.get("destinationDir") or entry.get("DestinationDir"))
|
||||
name = str(entry.get("fileName") or entry.get("FileName") or "")
|
||||
if not name:
|
||||
return None
|
||||
return str(PurePosixPath(dest) / name) if dest else name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Findings:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def add(self, level, check, message, entry_index=None):
|
||||
self.items.append((level, check, message, entry_index))
|
||||
|
||||
def worst(self):
|
||||
for level in (CRITICAL, ERROR, WARN, INFO):
|
||||
if any(i[0] == level for i in self.items):
|
||||
return level
|
||||
return None
|
||||
|
||||
def count(self, level):
|
||||
return sum(1 for i in self.items if i[0] == level)
|
||||
|
||||
|
||||
def label(entry, index):
|
||||
name = entry.get("modelsfriendlyname") or entry.get("modelswminame") or "?"
|
||||
return "[%02d] %s" % (index, name)
|
||||
|
||||
|
||||
def check_catalogue(entries, files, findings):
|
||||
"""Structural checks: one pass over the entries, no share lookups."""
|
||||
# A file listing is only meaningful when the drivers tree was readable.
|
||||
files_lower = {f.lower(): f for f in files}
|
||||
files_set = set(files)
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
tag = label(entry, i)
|
||||
|
||||
# 1. Virtual-platform hijack. One entry anywhere flips the whole tool
|
||||
# into virtual-only mode and every physical machine hard-fails.
|
||||
manufacturer = str(entry.get("manufacturer") or "")
|
||||
if "virtual platform" in manufacturer.lower():
|
||||
findings.add(CRITICAL, "virtual-platform",
|
||||
"%s manufacturer is %r. PESetup takes list[0] as the driver and "
|
||||
"then REQUIRES the machine to look virtual, so every physical "
|
||||
"machine fails GatherDataSelection." % (tag, manufacturer), i)
|
||||
|
||||
# 2. Empty token. C# Contains("") is true for every string, so this entry
|
||||
# swallows every model whose family passes the filter.
|
||||
tokens = entry_tokens(entry)
|
||||
if any(t == "" for t in tokens):
|
||||
findings.add(CRITICAL, "empty-token",
|
||||
"%s modelswminame %r yields an EMPTY token (trailing or doubled "
|
||||
"comma). An empty token matches every model string, so this entry "
|
||||
"hijacks the catalogue." % (tag, entry.get("modelswminame")), i)
|
||||
|
||||
# 3. Untrimmed whitespace. Split(',') keeps the space, so the model string
|
||||
# must literally contain " OptiPlex 7020" to match.
|
||||
for token in tokens:
|
||||
if token and token != token.strip():
|
||||
findings.add(ERROR, "token-whitespace",
|
||||
"%s token %r carries whitespace. PESetup does not trim, so a "
|
||||
"model must contain the space too - this token will usually "
|
||||
"miss." % (tag, token), i)
|
||||
|
||||
if not str(entry.get("modelswminame") or "").strip():
|
||||
findings.add(ERROR, "no-tokens",
|
||||
"%s has an empty modelswminame, so no model can ever select it."
|
||||
% tag, i)
|
||||
|
||||
# 4. Family filter contradicting the entry's own tokens. PESetup derives the
|
||||
# family from the MODEL, then requires entry.family to contain it.
|
||||
family_field = str(entry.get("family") or "")
|
||||
for token in tokens:
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
implied = model_family(token)
|
||||
if implied and implied.lower() not in family_field.lower():
|
||||
findings.add(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)
|
||||
|
||||
# 5. Referenced zip present, case-exact. The share is a case-sensitive Linux
|
||||
# filesystem; Windows-side tooling that writes "Optiplex" instead of
|
||||
# "OptiPlex" splits the tree and the pack is never found.
|
||||
rel = entry_zip_relpath(entry)
|
||||
if not rel:
|
||||
findings.add(ERROR, "no-filename",
|
||||
"%s has no fileName, so CopyDrivers has nothing to unzip." % tag, i)
|
||||
elif files:
|
||||
if rel not in files_set:
|
||||
alt = files_lower.get(rel.lower())
|
||||
if alt:
|
||||
findings.add(ERROR, "case-mismatch",
|
||||
"%s references %r but the share holds %r. Case-sensitive "
|
||||
"filesystem: PESetup will not find it." % (tag, rel, alt), i)
|
||||
else:
|
||||
findings.add(ERROR, "missing-zip",
|
||||
"%s references %r, which is not on the share. Driver miss "
|
||||
"is a WARNING only, so imaging finishes with no drivers."
|
||||
% (tag, rel), i)
|
||||
|
||||
# 6. Shadowing. Replay each entry's own model names through the real matcher;
|
||||
# if a different entry wins, this one is dead weight or an outright trap.
|
||||
for i, entry in enumerate(entries):
|
||||
for token in entry_tokens(entry):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
winner_i, _ = select_driver(entries, token)
|
||||
if winner_i is not None and winner_i != i:
|
||||
findings.add(WARN, "shadowed",
|
||||
"%s token %r is answered by %s instead (first match wins)."
|
||||
% (label(entry, i), token, label(entries[winner_i], winner_i)), i)
|
||||
|
||||
# 7. One model, several OS builds. GetDriverByModel does not look at osId, so
|
||||
# the win10 pack can land on a win11 build purely by ordering.
|
||||
by_token = {}
|
||||
for i, entry in enumerate(entries):
|
||||
for token in entry_tokens(entry):
|
||||
token = token.strip().lower()
|
||||
if token:
|
||||
by_token.setdefault(token, []).append(i)
|
||||
for token, idxs in sorted(by_token.items()):
|
||||
if len(idxs) > 1:
|
||||
detail = ", ".join("%s osId=%s" % (label(entries[i], i), entries[i].get("osId"))
|
||||
for i in idxs)
|
||||
findings.add(WARN, "duplicate-token",
|
||||
"token %r is claimed by %d entries (%s). osId is NOT part of the "
|
||||
"match, so the first one wins regardless of the OS being applied."
|
||||
% (token, len(idxs), detail))
|
||||
|
||||
|
||||
def check_fleet(entries, models, findings):
|
||||
"""Resolve real WMI model strings. A miss here is a machine with no drivers."""
|
||||
for model in models:
|
||||
idx, entry = select_driver(entries, model)
|
||||
if entry is None:
|
||||
findings.add(ERROR, "fleet-miss",
|
||||
"model %r matches NO entry. PESetup logs 'driver for [%s] not "
|
||||
"found' as a warning and images the machine with no drivers."
|
||||
% (model, model))
|
||||
else:
|
||||
findings.add(INFO, "fleet-match",
|
||||
"model %r -> %s (%s)" % (model, label(entry, idx),
|
||||
entry.get("fileName")))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lint_image(name, catalogue_text, files, models, quiet):
|
||||
print("=" * 72)
|
||||
print("IMAGE: %s" % name)
|
||||
print("=" * 72)
|
||||
|
||||
if catalogue_text is None:
|
||||
print(" SKIP: no HardwareDriver.json")
|
||||
return None
|
||||
|
||||
try:
|
||||
entries = json.loads(catalogue_text)
|
||||
except json.JSONDecodeError as e:
|
||||
print(" CRITICAL: HardwareDriver.json does not parse: %s" % e)
|
||||
return CRITICAL
|
||||
|
||||
if not isinstance(entries, list):
|
||||
print(" CRITICAL: HardwareDriver.json is %s, expected a list" % type(entries).__name__)
|
||||
return CRITICAL
|
||||
|
||||
findings = Findings()
|
||||
check_catalogue(entries, files, findings)
|
||||
if models:
|
||||
check_fleet(entries, models, findings)
|
||||
|
||||
print(" %d entries, %d driver files visible on the share" % (len(entries), len(files)))
|
||||
|
||||
if not quiet:
|
||||
for i, entry in enumerate(entries):
|
||||
print(" %-28s family=%-28s %s" % (
|
||||
label(entry, i),
|
||||
(entry.get("family") or "")[:28],
|
||||
entry.get("fileName") or "(no fileName)"))
|
||||
|
||||
print()
|
||||
order = {CRITICAL: 0, ERROR: 1, WARN: 2, INFO: 3}
|
||||
for level, check, message, _ in sorted(findings.items, key=lambda f: order[f[0]]):
|
||||
if quiet and level == INFO:
|
||||
continue
|
||||
print(" %-8s %-18s %s" % (level, check, message))
|
||||
|
||||
if not findings.items:
|
||||
print(" clean")
|
||||
print()
|
||||
print(" %d critical, %d error, %d warn" % (
|
||||
findings.count(CRITICAL), findings.count(ERROR), findings.count(WARN)))
|
||||
print()
|
||||
return findings.worst()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Lint HardwareDriver.json against PESetup's real driver matcher.")
|
||||
parser.add_argument("--image", help="single image type (default: all on the server)")
|
||||
parser.add_argument("--server", default=PXE_HOST,
|
||||
help="PXE server IP (default: %s)" % PXE_HOST)
|
||||
parser.add_argument("--local", metavar="PATH",
|
||||
help="lint a local winpeapps tree instead of going over SSH")
|
||||
parser.add_argument("--models", metavar="FILE",
|
||||
help="file of WMI model strings, one per line, to resolve")
|
||||
parser.add_argument("--quiet", action="store_true",
|
||||
help="findings only, no per-entry listing")
|
||||
args = parser.parse_args()
|
||||
|
||||
models = []
|
||||
if args.models:
|
||||
with open(args.models) as f:
|
||||
models = [line.strip() for line in f
|
||||
if line.strip() and not line.startswith("#")]
|
||||
|
||||
if args.local:
|
||||
base = args.local.rstrip("/")
|
||||
reader, lister = read_local, list_local
|
||||
else:
|
||||
base = IMAGE_BASE
|
||||
reader = lambda p: read_remote(args.server, p)
|
||||
lister = lambda p: list_remote(args.server, p)
|
||||
|
||||
if args.image:
|
||||
images = [args.image]
|
||||
elif args.local:
|
||||
from pathlib import Path
|
||||
images = sorted(d.name for d in Path(base).iterdir()
|
||||
if d.is_dir() and not d.name.startswith("_"))
|
||||
else:
|
||||
r = ssh_cmd(args.server,
|
||||
"find '%s' -maxdepth 1 -mindepth 1 -type d -printf '%%f\\n'" % base)
|
||||
if r.returncode != 0:
|
||||
sys.exit("ERROR: cannot list %s on %s: %s" % (base, args.server, r.stderr.strip()))
|
||||
images = sorted(n for n in r.stdout.split() if not n.startswith("_"))
|
||||
|
||||
worst = None
|
||||
order = {CRITICAL: 0, ERROR: 1, WARN: 2, INFO: 3, None: 4}
|
||||
for image in images:
|
||||
catalogue = reader("%s/%s/Deploy/Control/HardwareDriver.json" % (base, image))
|
||||
# Drivers live under the shared tree that Deploy/Out-of-box Drivers points at.
|
||||
# Listing through the image path follows the symlink, so per-image case
|
||||
# differences still show up. The listing comes back relative to the drivers
|
||||
# dir; destinationDir is relative to the image root, so re-anchor it.
|
||||
files = ["%s/%s" % (DRIVERS_SUBDIR, f)
|
||||
for f in lister("%s/%s/%s" % (base, image, DRIVERS_SUBDIR))]
|
||||
result = lint_image(image, catalogue, files, models, args.quiet)
|
||||
if order[result] < order[worst]:
|
||||
worst = result
|
||||
|
||||
if worst in FAIL_LEVELS:
|
||||
print("FAILED: fix the findings above before imaging.")
|
||||
return 1
|
||||
print("PASSED: no blocking findings.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
211
scripts/lint-unattend.py
Executable file
211
scripts/lint-unattend.py
Executable file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
lint-unattend.py - Validate unattend answer files before they reach a bay.
|
||||
|
||||
Well-formed XML is not enough. Windows Setup validates against a schema, and ONE
|
||||
bad value invalidates the WHOLE answer file for that pass - the machine stops at
|
||||
"Windows could not parse or process unattend answer file ... The answer file is
|
||||
invalid" with nothing configured, and the only clue is a line in
|
||||
C:\\Windows\\Panther\\setupact.log naming an XPath.
|
||||
|
||||
That is not hypothetical. On 2026-08-06 every shopfloor, standard and engineer
|
||||
build was failing this way:
|
||||
|
||||
/settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
|
||||
Description = Value is invalid. hrResult = 0x80220005 pass = specialize
|
||||
|
||||
The cause was an inlined "powershell.exe -Command ..." that had grown to 676
|
||||
characters in a field capped at 259. It had been broken since the previous
|
||||
evening. Every check in this script is mechanical and would have caught it before
|
||||
a single bay was booted.
|
||||
|
||||
Checks:
|
||||
path-too-long RunSynchronousCommand/Path > 259 chars
|
||||
cmdline-too-long SynchronousCommand/CommandLine > 1024 chars
|
||||
description-too-long Description > 256 chars
|
||||
duplicate-element an element that may appear once appears twice
|
||||
not-well-formed XML does not parse
|
||||
bom UTF-8 BOM present (the live files carry none; adding one
|
||||
is an unintended diff, and python's utf-8-sig ADDS one)
|
||||
unknown-token %token% left in the file that PESetup will not substitute
|
||||
(it only replaces %serialnumber% and *arch*)
|
||||
|
||||
Usage:
|
||||
./lint-unattend.py # every image type on the PXE server
|
||||
./lint-unattend.py --image gea-shopfloor
|
||||
./lint-unattend.py --local playbook/FlatUnattendW10.xml ...
|
||||
./lint-unattend.py --quiet
|
||||
|
||||
Exits non-zero when any ERROR is found, so it can gate a deploy.
|
||||
|
||||
Requires: sshpass (remote mode only)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.dom.minidom
|
||||
|
||||
PXE_HOST = "172.16.9.1"
|
||||
PXE_USER = "pxe"
|
||||
PXE_PASS = "pxe"
|
||||
IMAGE_BASE = "/srv/samba/winpeapps"
|
||||
UNATTEND_REL = "Deploy/FlatUnattendW10.xml"
|
||||
|
||||
# Schema limits. Exceeding any of these invalidates the entire answer file for
|
||||
# the pass the element sits in, not just the one command.
|
||||
MAX_PATH = 259 # Microsoft-Windows-Deployment RunSynchronousCommand/Path
|
||||
MAX_COMMANDLINE = 1024 # Microsoft-Windows-Shell-Setup SynchronousCommand/CommandLine
|
||||
MAX_DESCRIPTION = 256 # Description on either of the above
|
||||
|
||||
# PESetup substitutes exactly these two and nothing else (docs/PESETUP-INTERNALS.md).
|
||||
KNOWN_TOKENS = {"%serialnumber%"}
|
||||
|
||||
# Windows expands these itself at run time, so they are not PESetup's problem and
|
||||
# must not be reported. Without this the check cries wolf on every %WINDIR%.
|
||||
SHELL_VARS = {"%windir%", "%systemroot%", "%systemdrive%", "%programfiles%",
|
||||
"%programdata%", "%temp%", "%tmp%", "%userprofile%", "%appdata%",
|
||||
"%localappdata%", "%computername%", "%username%", "%public%",
|
||||
"%allusersprofile%", "%path%"}
|
||||
|
||||
# Elements that may appear at most once inside a command block.
|
||||
SINGLE_OCCURRENCE = ("Order", "Description", "Path", "CommandLine", "RequiresUserInput", "WillReboot")
|
||||
|
||||
ERROR, WARN = "ERROR", "WARN"
|
||||
|
||||
|
||||
def ssh_cmd(host, cmd):
|
||||
return subprocess.run(
|
||||
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "LogLevel=ERROR", f"{PXE_USER}@{host}", cmd],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def read_remote_bytes(host, path):
|
||||
"""Base64 in transit so a BOM or CRLF survives the hop intact."""
|
||||
r = ssh_cmd(host, "base64 -w0 '%s' 2>/dev/null" % path)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
return None
|
||||
import base64
|
||||
return base64.b64decode(r.stdout.strip())
|
||||
|
||||
|
||||
def read_local_bytes(path):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def blocks(raw, tag):
|
||||
return re.findall(r"<%s\b.*?</%s>" % (tag, tag), raw, re.S)
|
||||
|
||||
|
||||
def order_of(block):
|
||||
m = re.search(r"<Order>(\d+)</Order>", block)
|
||||
return m.group(1) if m else "?"
|
||||
|
||||
|
||||
def check(name, data, findings):
|
||||
if data is None:
|
||||
findings.append((ERROR, "missing", f"{name}: cannot read the file"))
|
||||
return
|
||||
|
||||
if data.startswith(b"\xef\xbb\xbf"):
|
||||
findings.append((WARN, "bom",
|
||||
f"{name}: file carries a UTF-8 BOM. The live files have none - "
|
||||
"this is usually an accidental diff from a tool that wrote "
|
||||
"utf-8-sig."))
|
||||
raw = data.decode("utf-8-sig")
|
||||
else:
|
||||
raw = data.decode("utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
xml.dom.minidom.parseString(raw.encode("utf-8"))
|
||||
except Exception as e:
|
||||
findings.append((ERROR, "not-well-formed", f"{name}: XML does not parse: {e}"))
|
||||
return
|
||||
|
||||
for tag, field, limit in (("RunSynchronousCommand", "Path", MAX_PATH),
|
||||
("SynchronousCommand", "CommandLine", MAX_COMMANDLINE)):
|
||||
for b in blocks(raw, tag):
|
||||
m = re.search(r"<%s>(.*?)</%s>" % (field, field), b, re.S)
|
||||
if m and len(m.group(1)) > limit:
|
||||
findings.append((ERROR, "%s-too-long" % field.lower(),
|
||||
f"{name}: {tag} Order {order_of(b)} has {field} of "
|
||||
f"{len(m.group(1))} chars, limit {limit}. This invalidates "
|
||||
f"the WHOLE answer file for its pass. Move the body into a "
|
||||
f"script under Deploy\\Applications and call it by path."))
|
||||
|
||||
for tag in ("RunSynchronousCommand", "SynchronousCommand"):
|
||||
for b in blocks(raw, tag):
|
||||
for d in re.findall(r"<Description>(.*?)</Description>", b, re.S):
|
||||
if len(d) > MAX_DESCRIPTION:
|
||||
findings.append((ERROR, "description-too-long",
|
||||
f"{name}: {tag} Order {order_of(b)} has a Description of "
|
||||
f"{len(d)} chars, limit {MAX_DESCRIPTION}. Put the "
|
||||
f"rationale in an XML comment instead."))
|
||||
for el in SINGLE_OCCURRENCE:
|
||||
n = len(re.findall(r"<%s>" % el, b))
|
||||
if n > 1:
|
||||
findings.append((ERROR, "duplicate-element",
|
||||
f"{name}: {tag} Order {order_of(b)} has {n} <{el}> "
|
||||
f"elements; at most one is allowed."))
|
||||
|
||||
for tok in sorted(set(re.findall(r"%[a-zA-Z_]+%", raw))):
|
||||
if tok.lower() not in KNOWN_TOKENS and tok.lower() not in SHELL_VARS:
|
||||
findings.append((WARN, "unknown-token",
|
||||
f"{name}: {tok} is not substituted by PESetup (it replaces only "
|
||||
f"%serialnumber% and *arch*), so it reaches Windows literally."))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Validate unattend answer files.")
|
||||
parser.add_argument("--server", default=PXE_HOST, help=f"PXE server (default: {PXE_HOST})")
|
||||
parser.add_argument("--image", help="single image type instead of all")
|
||||
parser.add_argument("--local", nargs="+", metavar="FILE", help="lint local files instead")
|
||||
parser.add_argument("--quiet", action="store_true", help="suppress WARN lines")
|
||||
args = parser.parse_args()
|
||||
|
||||
findings = []
|
||||
targets = []
|
||||
|
||||
if args.local:
|
||||
for f in args.local:
|
||||
targets.append((f, read_local_bytes(f)))
|
||||
else:
|
||||
if args.image:
|
||||
images = [args.image]
|
||||
else:
|
||||
r = ssh_cmd(args.server,
|
||||
"find '%s' -maxdepth 1 -mindepth 1 -type d -printf '%%f\\n'" % IMAGE_BASE)
|
||||
if r.returncode != 0:
|
||||
sys.exit("ERROR: cannot list %s: %s" % (IMAGE_BASE, r.stderr.strip()))
|
||||
images = sorted(n for n in r.stdout.split() if not n.startswith("_"))
|
||||
for img in images:
|
||||
path = f"{IMAGE_BASE}/{img}/{UNATTEND_REL}"
|
||||
targets.append((img, read_remote_bytes(args.server, path)))
|
||||
|
||||
for name, data in targets:
|
||||
check(name, data, findings)
|
||||
|
||||
print("Linted %d unattend file(s)\n" % len(targets))
|
||||
shown = [f for f in findings if not (args.quiet and f[0] == WARN)]
|
||||
for level, kind, msg in sorted(shown, key=lambda f: 0 if f[0] == ERROR else 1):
|
||||
print(" %-6s %-22s %s" % (level, kind, msg))
|
||||
if not shown:
|
||||
print(" clean")
|
||||
|
||||
errors = sum(1 for f in findings if f[0] == ERROR)
|
||||
print("\n%d error, %d warn" % (errors, sum(1 for f in findings if f[0] == WARN)))
|
||||
if errors:
|
||||
print("FAILED: do not deploy this answer file.")
|
||||
return 1
|
||||
print("PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
139
scripts/preflight.py
Executable file
139
scripts/preflight.py
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
preflight.py - run every server-side check before a build day.
|
||||
|
||||
The individual linters each catch one class of silent failure. This runs them
|
||||
together so "is the PXE server fit to image from?" is one command and one exit
|
||||
code, instead of four things somebody has to remember.
|
||||
|
||||
What it runs:
|
||||
|
||||
lint-driver-catalogue.py HardwareDriver.json vs PESetup's real matcher -
|
||||
missing/case-mismatched packs, empty tokens,
|
||||
shadowed entries. A driver miss is only a WARNING
|
||||
to PESetup, so the bay images with no NIC.
|
||||
lint-unattend.py answer files vs the schema limits. One over-length
|
||||
value invalidates the whole file for its pass and
|
||||
stops the bay at a dialog with nothing configured.
|
||||
build-pctype-media.py resolves the critical paths in every per-PCTYPE
|
||||
--verify media view; a dangling symlink is a dead view.
|
||||
firmware coverage models in the driver catalogues that have no entry
|
||||
in the BIOS models.txt, so their firmware is never
|
||||
offered. This one is advisory - the manifest is a
|
||||
curated set and most misses are out-of-fleet.
|
||||
|
||||
Usage:
|
||||
./preflight.py # everything
|
||||
./preflight.py --quick # skip the media-view verify (the slow one)
|
||||
|
||||
Exit code is non-zero if any blocking check fails.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HERE)
|
||||
|
||||
PXE_HOST = "172.16.9.1"
|
||||
PXE_USER = "pxe"
|
||||
PXE_PASS = "pxe"
|
||||
MODELS_TXT = "/srv/samba/winpeapps/_shared/BIOS/models.txt"
|
||||
IMAGE_BASE = "/srv/samba/winpeapps"
|
||||
|
||||
|
||||
def run(label, argv, blocking=True):
|
||||
print("=" * 72)
|
||||
print(label)
|
||||
print("=" * 72)
|
||||
try:
|
||||
r = subprocess.run(argv, cwd=REPO)
|
||||
rc = r.returncode
|
||||
except OSError as e:
|
||||
print(" could not run: %s" % e)
|
||||
rc = 1
|
||||
status = "PASS" if rc == 0 else ("FAIL" if blocking else "WARN")
|
||||
print("-> %s (exit %d)\n" % (status, rc))
|
||||
return rc if blocking else 0
|
||||
|
||||
|
||||
def ssh(cmd):
|
||||
return subprocess.run(
|
||||
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "LogLevel=ERROR", "%s@%s" % (PXE_USER, PXE_HOST), cmd],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def firmware_coverage():
|
||||
"""Models in the driver catalogues with no BIOS manifest entry.
|
||||
|
||||
Advisory only. models.txt is deliberately curated and most uncovered models
|
||||
are legacy or not in this fleet - the value is spotting a model you DO image
|
||||
whose firmware silently never updates, which is how the OptiPlex 7020 family
|
||||
sat uncovered until 2026-08-06."""
|
||||
print("=" * 72)
|
||||
print("firmware coverage (advisory)")
|
||||
print("=" * 72)
|
||||
|
||||
r = ssh("cat '%s'" % MODELS_TXT)
|
||||
if r.returncode != 0:
|
||||
print(" could not read %s\n-> WARN\n" % MODELS_TXT)
|
||||
return 0
|
||||
tokens = [ln.split("|")[0] for ln in r.stdout.splitlines()
|
||||
if ln.strip() and not ln.startswith("#")]
|
||||
|
||||
r = ssh("cat %s/*/Deploy/Control/HardwareDriver.json" % IMAGE_BASE)
|
||||
if r.returncode != 0:
|
||||
print(" could not read the driver catalogues\n-> WARN\n")
|
||||
return 0
|
||||
|
||||
import json, re
|
||||
models = set()
|
||||
for blob in re.findall(r"\[.*?\n\]", r.stdout, re.S):
|
||||
try:
|
||||
for e in json.loads(blob):
|
||||
for t in str(e.get("modelswminame") or "").split(","):
|
||||
if t.strip():
|
||||
models.add(t.strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
missing = [m for m in sorted(models)
|
||||
if not any(tok.lower() in m.lower() for tok in tokens)]
|
||||
print(" %d catalogued models, %d covered, %d with no firmware entry"
|
||||
% (len(models), len(models) - len(missing), len(missing)))
|
||||
if missing:
|
||||
print(" uncovered (check any of these you actually image):")
|
||||
for m in missing[:15]:
|
||||
print(" %s" % m)
|
||||
if len(missing) > 15:
|
||||
print(" ... and %d more" % (len(missing) - 15))
|
||||
print("-> WARN (advisory)\n" if missing else "-> PASS\n")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Run every pre-build check.")
|
||||
ap.add_argument("--quick", action="store_true",
|
||||
help="skip the media-view verify (the slow one)")
|
||||
args = ap.parse_args()
|
||||
|
||||
rc = 0
|
||||
rc |= run("driver catalogue", [sys.executable, "scripts/lint-driver-catalogue.py", "--quiet"])
|
||||
rc |= run("unattend answer files", [sys.executable, "scripts/lint-unattend.py", "--quiet"])
|
||||
if not args.quick:
|
||||
rc |= run("per-PCTYPE media views", [sys.executable, "scripts/build-pctype-media.py", "--verify"])
|
||||
rc |= firmware_coverage()
|
||||
|
||||
print("=" * 72)
|
||||
if rc:
|
||||
print("PREFLIGHT FAILED - fix the blocking findings above before imaging.")
|
||||
return 1
|
||||
print("PREFLIGHT PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
153
scripts/share-drift.py
Executable file
153
scripts/share-drift.py
Executable file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
share-drift.py - report where the repo and the live share disagree.
|
||||
|
||||
WHY
|
||||
The share is production. The repo is meant to describe it. On 2026-08-06 an
|
||||
unattend edit made directly on the share broke every shopfloor, standard and
|
||||
engineer build for a day - while the repo copy was fine the whole time. Nobody
|
||||
could have known, because nothing compared them.
|
||||
|
||||
Worse, drift runs both directions. Some live files are AHEAD of the repo
|
||||
(hand-edits nobody committed) and some repo files are ahead of live (fixes never
|
||||
deployed). Blindly pushing the repo over the share would have overwritten a
|
||||
working 17 KB unattend with a stale 12 KB one.
|
||||
|
||||
So this tool REPORTS by default and never writes. Each pair is classified:
|
||||
|
||||
git-owned the repo is the source of truth. Safe to push.
|
||||
unreconciled the two have diverged and nobody has decided which wins. NOT safe
|
||||
to push. Reconcile by hand, then move the pair to git-owned.
|
||||
|
||||
Usage:
|
||||
./share-drift.py # status table
|
||||
./share-drift.py --diff # show the actual differences
|
||||
./share-drift.py --only unattend # filter by substring
|
||||
|
||||
Exit non-zero if any git-owned pair differs, so it can gate a deploy. An
|
||||
unreconciled pair that differs is reported but does not fail - that is a known
|
||||
state, not a regression.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import difflib
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PXE_HOST = "172.16.9.1"
|
||||
PXE_USER = "pxe"
|
||||
PXE_PASS = "pxe"
|
||||
|
||||
GIT_OWNED, UNRECONCILED = "git-owned", "unreconciled"
|
||||
|
||||
# (repo path, live path, ownership)
|
||||
PAIRS = [
|
||||
("playbook/shopfloor-setup/run-enrollment.ps1",
|
||||
"/srv/samba/enrollment/scripts/run-enrollment.ps1", GIT_OWNED),
|
||||
("playbook/scripts/preflight.ps1",
|
||||
"/srv/samba/enrollment/scripts/preflight.ps1", GIT_OWNED),
|
||||
("playbook/shopfloor-setup/Run-ShopfloorSetup.ps1",
|
||||
"/srv/samba/enrollment/shopfloor-setup/Run-ShopfloorSetup.ps1", GIT_OWNED),
|
||||
("playbook/shopfloor-setup/Verify-And-Heal-Staging.ps1",
|
||||
"/srv/samba/enrollment/shopfloor-setup/Verify-And-Heal-Staging.ps1", GIT_OWNED),
|
||||
("playbook/shopfloor-setup/Fetch-StagingPayload.ps1",
|
||||
"/srv/samba/enrollment/shopfloor-setup/Fetch-StagingPayload.ps1", GIT_OWNED),
|
||||
("playbook/shopfloor-setup/site-config.json",
|
||||
"/srv/samba/enrollment/shopfloor-setup/site-config.json", GIT_OWNED),
|
||||
("playbook/shopfloor-setup/BIOS/models.txt",
|
||||
"/srv/samba/winpeapps/_shared/BIOS/models.txt", GIT_OWNED),
|
||||
("playbook/shopfloor-setup/BIOS/check-bios.cmd",
|
||||
"/srv/samba/winpeapps/_shared/BIOS/check-bios.cmd", GIT_OWNED),
|
||||
|
||||
# The config/ copy is what startnet stages to C:\Enrollment\site-config.json
|
||||
# and it has its own edit history - it carried the dead tsgwp00524 host that
|
||||
# the repo copy never had. Same filename, different lineage.
|
||||
("playbook/shopfloor-setup/site-config.json",
|
||||
"/srv/samba/enrollment/config/site-config.json", UNRECONCILED),
|
||||
|
||||
# The live unattends are the ones that boot machines and they are FAR ahead
|
||||
# of the repo copies (17 KB vs 12 KB). Pushing the repo over them would
|
||||
# regress production. Reconcile before promoting to git-owned.
|
||||
("playbook/FlatUnattendW10-shopfloor.xml",
|
||||
"/srv/samba/winpeapps/gea-shopfloor/Deploy/FlatUnattendW10.xml", UNRECONCILED),
|
||||
("playbook/FlatUnattendW10.xml",
|
||||
"/srv/samba/winpeapps/gea-standard/Deploy/FlatUnattendW10.xml", UNRECONCILED),
|
||||
("playbook/FlatUnattendW10.xml",
|
||||
"/srv/samba/winpeapps/gea-engineer/Deploy/FlatUnattendW10.xml", UNRECONCILED),
|
||||
]
|
||||
|
||||
|
||||
def ssh(cmd):
|
||||
return subprocess.run(
|
||||
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "LogLevel=ERROR", f"{PXE_USER}@{PXE_HOST}", cmd],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def live_bytes(path):
|
||||
"""base64 in transit so BOM and CRLF survive the hop unchanged."""
|
||||
r = ssh("base64 -w0 '%s' 2>/dev/null" % path)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
return None
|
||||
return base64.b64decode(r.stdout.strip())
|
||||
|
||||
|
||||
def repo_bytes(path):
|
||||
try:
|
||||
with open(os.path.join(REPO, path), "rb") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def short(b):
|
||||
return hashlib.md5(b).hexdigest()[:10] if b is not None else "MISSING"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Report repo vs live share drift.")
|
||||
ap.add_argument("--diff", action="store_true", help="show the actual differences")
|
||||
ap.add_argument("--only", help="only pairs whose paths contain this substring")
|
||||
args = ap.parse_args()
|
||||
|
||||
blocking = 0
|
||||
known = 0
|
||||
print("%-13s %-44s %-11s %-11s" % ("OWNERSHIP", "LIVE PATH", "REPO", "LIVE"))
|
||||
print("-" * 84)
|
||||
|
||||
for repo_path, live_path, owner in PAIRS:
|
||||
if args.only and args.only not in repo_path and args.only not in live_path:
|
||||
continue
|
||||
rb, lb = repo_bytes(repo_path), live_bytes(live_path)
|
||||
same = rb is not None and lb is not None and rb == lb
|
||||
tag = "same" if same else "DIFFERS"
|
||||
if not same:
|
||||
if owner == GIT_OWNED:
|
||||
blocking += 1
|
||||
else:
|
||||
known += 1
|
||||
print("%-13s %-44s %-11s %-11s %s"
|
||||
% (owner, live_path.replace("/srv/samba/", ""), short(rb), short(lb), tag))
|
||||
|
||||
if args.diff and not same and rb is not None and lb is not None:
|
||||
a = rb.decode("utf-8", "replace").replace("\r\n", "\n").splitlines()
|
||||
b = lb.decode("utf-8", "replace").replace("\r\n", "\n").splitlines()
|
||||
for line in list(difflib.unified_diff(a, b, "repo", "live", lineterm="", n=1))[:40]:
|
||||
print(" " + line)
|
||||
print()
|
||||
|
||||
print("-" * 84)
|
||||
print("%d git-owned pair(s) out of sync, %d known-unreconciled" % (blocking, known))
|
||||
if blocking:
|
||||
print("FAILED: a git-owned file differs from the share. Deploy it or commit the live version.")
|
||||
return 1
|
||||
print("PASSED: everything git-owned matches the share.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -38,14 +38,10 @@ echo.
|
||||
echo 1. GEA Standard
|
||||
echo 2. GEA Engineer
|
||||
echo 3. GEA Shopfloor
|
||||
echo 4. GE Standard
|
||||
echo 5. GE Engineer
|
||||
echo 6. GE Shopfloor Lockdown
|
||||
echo 7. GE Shopfloor MCE
|
||||
echo.
|
||||
echo ========================================
|
||||
echo.
|
||||
set /p choice=Enter your choice (1-7):
|
||||
set /p choice=Enter your choice (1-3):
|
||||
|
||||
REM --- Only shopfloor images (3,6,7) need GCCH enrollment ---
|
||||
set PPKG=
|
||||
@@ -186,10 +182,6 @@ echo. > X:\Boot.tag
|
||||
if "%choice%"=="1" goto gea-standard
|
||||
if "%choice%"=="2" goto gea-engineer
|
||||
if "%choice%"=="3" goto gea-shopfloor
|
||||
if "%choice%"=="4" goto ge-standard
|
||||
if "%choice%"=="5" goto ge-engineer
|
||||
if "%choice%"=="6" goto ge-shopfloor-lockdown
|
||||
if "%choice%"=="7" goto ge-shopfloor-mce
|
||||
echo Invalid choice. Please try again.
|
||||
pause
|
||||
goto menu
|
||||
@@ -218,38 +210,6 @@ for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\gea-shopfloor /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:ge-standard
|
||||
echo.
|
||||
echo Starting GE Standard setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-standard /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:ge-engineer
|
||||
echo.
|
||||
echo Starting GE Engineer setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-engineer /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:ge-shopfloor-lockdown
|
||||
echo.
|
||||
echo Starting GE Shopfloor Lockdown setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-shopfloor-lockdown /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:ge-shopfloor-mce
|
||||
echo.
|
||||
echo Starting GE Shopfloor MCE setup...
|
||||
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
|
||||
for /l %%i in (1,1,2000000) do rem
|
||||
net use Z: \\172.16.9.1\winpeapps\ge-shopfloor-mce /user:pxe-upload pxe /persistent:no
|
||||
goto end
|
||||
|
||||
:end
|
||||
echo.
|
||||
echo Waiting for PESetup.exe to start...
|
||||
|
||||
379
webapp/app.py
379
webapp/app.py
@@ -15,6 +15,7 @@ This file is the route surface; most logic lives in ``services/``:
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
@@ -113,9 +114,9 @@ def images_import():
|
||||
src_items = os.listdir(source)
|
||||
|
||||
# Move files from network upload to save disk space; copy from USB.
|
||||
# deploy.sync_tree uses rsync --checksum: unchanged files are
|
||||
# skipped, only new/changed files are written (no full replace).
|
||||
use_move = source == config.UPLOAD_DIR or source.startswith(config.UPLOAD_DIR + "/")
|
||||
_transfer = shutil.move if use_move else shutil.copy2
|
||||
_transfer_tree = shutil.move if use_move else shutil.copytree
|
||||
|
||||
top_dirs = {d for d in src_items if os.path.isdir(os.path.join(source, d))}
|
||||
full_layout = "Deploy" in top_dirs
|
||||
@@ -134,8 +135,7 @@ def images_import():
|
||||
elif os.path.isdir(src_item) and item in shared_root:
|
||||
prefix_key = target.split("-")[0] + "-"
|
||||
shared_dest = os.path.join(config.SHARED_DIR, f"{prefix_key}{item}")
|
||||
os.makedirs(shared_dest, exist_ok=True)
|
||||
deploy._merge_tree(src_item, shared_dest, move=use_move)
|
||||
deploy.sync_tree(src_item, shared_dest, move=use_move)
|
||||
dst_item = os.path.join(root, item)
|
||||
if os.path.islink(dst_item):
|
||||
os.remove(dst_item)
|
||||
@@ -143,12 +143,9 @@ def images_import():
|
||||
shutil.rmtree(dst_item)
|
||||
os.symlink(shared_dest, dst_item)
|
||||
elif os.path.isdir(src_item):
|
||||
dst_item = os.path.join(root, item)
|
||||
if os.path.exists(dst_item):
|
||||
shutil.rmtree(dst_item)
|
||||
_transfer_tree(src_item, dst_item)
|
||||
deploy.sync_tree(src_item, os.path.join(root, item), move=use_move)
|
||||
else:
|
||||
_transfer(src_item, os.path.join(root, item))
|
||||
deploy.sync_tree(src_item, os.path.join(root, item), move=use_move)
|
||||
else:
|
||||
deploy.import_deploy(source, dest, target, move=use_move)
|
||||
|
||||
@@ -818,6 +815,37 @@ def imaging_set_laps(serial):
|
||||
# Routes - Enrollment Packages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PPKG_CONF = os.path.join(config.ENROLLMENT_SHARE, "ppkg.conf")
|
||||
|
||||
|
||||
def read_ppkg_conf():
|
||||
"""Read the active-PPKG config (SOURCE_PPKG/PPKG_VER/PPKG_EXP) that
|
||||
startnet.cmd sources at boot. Returns a dict (empty if unset)."""
|
||||
data = {}
|
||||
if os.path.isfile(PPKG_CONF):
|
||||
try:
|
||||
with open(PPKG_CONF, "r", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if "=" in line:
|
||||
k, _, v = line.strip().partition("=")
|
||||
data[k.strip()] = v.strip()
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def write_ppkg_conf(source_ppkg, ppkg_ver, ppkg_exp):
|
||||
"""Write ppkg.conf (CRLF) so startnet.cmd picks up the active ppkg at
|
||||
boot with no boot.wim edit."""
|
||||
lines = [
|
||||
f"SOURCE_PPKG={source_ppkg}",
|
||||
f"PPKG_VER={ppkg_ver}",
|
||||
f"PPKG_EXP={ppkg_exp}",
|
||||
]
|
||||
with open(PPKG_CONF, "w", newline="\r\n") as fh:
|
||||
fh.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
@app.route("/enrollment")
|
||||
def enrollment():
|
||||
packages = []
|
||||
@@ -834,6 +862,7 @@ def enrollment():
|
||||
return render_template(
|
||||
"enrollment.html",
|
||||
packages=packages,
|
||||
active_ppkg=read_ppkg_conf(),
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
)
|
||||
@@ -860,6 +889,21 @@ def enrollment_upload():
|
||||
f.save(dest)
|
||||
audit("ENROLLMENT_UPLOAD", filename)
|
||||
flash(f"Uploaded {filename} successfully.", "success")
|
||||
|
||||
# 2b: set this ppkg active for startnet.cmd by writing ppkg.conf on the
|
||||
# share (no boot.wim edit). Only for the standard GCCH_..._v<ver>.ppkg name.
|
||||
m = re.search(r"_(v\d+(?:\.\d+)*)\.ppkg$", filename, re.IGNORECASE)
|
||||
if m:
|
||||
ver = m.group(1)
|
||||
expiry = (request.form.get("expiry") or "").strip()
|
||||
if not expiry:
|
||||
expiry = read_ppkg_conf().get("PPKG_EXP", "")
|
||||
try:
|
||||
write_ppkg_conf(filename, ver, expiry)
|
||||
audit("PPKG_ACTIVATE", f"{filename} ver={ver} exp={expiry}")
|
||||
flash(f"Set {filename} as the active PPKG - startnet.cmd will use it.", "success")
|
||||
except OSError as exc:
|
||||
flash(f"Uploaded, but could not update ppkg.conf: {exc}", "warning")
|
||||
return redirect(url_for("enrollment"))
|
||||
|
||||
|
||||
@@ -888,29 +932,89 @@ def enrollment_delete(filename):
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes - startnet.cmd Editor (boot.wim)
|
||||
#
|
||||
# The raw full-text editor (POST /startnet/save) is the source of truth and the
|
||||
# always-available fallback. The Settings and Boot-menu tabs are conveniences
|
||||
# that parse the CURRENT extracted content to prefill, then round-trip an
|
||||
# apply_* result back through the SAME wim.update_startnet save path. Every save
|
||||
# path snapshots the prior startnet.cmd first (feature 4) so a bad edit can be
|
||||
# rolled back from the Snapshots tab.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _startnet_wiminfo():
|
||||
"""Parse `wiminfo boot.wim` into a flat dict (empty on any failure)."""
|
||||
import subprocess
|
||||
info = {}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wiminfo", config.BOOT_WIM],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.splitlines():
|
||||
if ":" in line:
|
||||
key, _, val = line.partition(":")
|
||||
info[key.strip()] = val.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def _startnet_current():
|
||||
"""Extracted startnet.cmd text from boot.wim, or '' if unavailable."""
|
||||
if not os.path.isfile(config.BOOT_WIM):
|
||||
return ""
|
||||
return wim.extract_startnet(config.BOOT_WIM) or ""
|
||||
|
||||
|
||||
def _startnet_save_content(new_content, prior, action, detail):
|
||||
"""Snapshot `prior` then write `new_content` to boot.wim via wimtools.
|
||||
|
||||
Central save path shared by the raw editor, the settings tab, the menu tab,
|
||||
and snapshot-restore so EVERY write snapshots first. Flashes success/failure
|
||||
and returns True on success. Lint warnings are surfaced but never block.
|
||||
"""
|
||||
try:
|
||||
wim.save_snapshot(prior, note=action)
|
||||
except Exception as exc:
|
||||
# A snapshot failure should not silently drop the safety net; warn but
|
||||
# still let the operator save (the raw editor is the source of truth).
|
||||
flash(f"Warning: could not snapshot prior startnet.cmd: {exc}", "warning")
|
||||
|
||||
ok, err = wim.update_startnet(config.BOOT_WIM, new_content)
|
||||
if not ok:
|
||||
flash(f"Failed to update boot.wim: {err}", "danger")
|
||||
return False
|
||||
|
||||
lint = wim.lint_startnet(new_content)
|
||||
for e in lint.get("errors", []):
|
||||
loc = f"line {e['line']}: " if e.get("line") else ""
|
||||
flash(f"Lint error - {loc}{e['message']}", "warning")
|
||||
for w in lint.get("warnings", []):
|
||||
loc = f"line {w['line']}: " if w.get("line") else ""
|
||||
flash(f"Lint warning - {loc}{w['message']}", "warning")
|
||||
|
||||
audit(action, detail)
|
||||
return True
|
||||
|
||||
|
||||
@app.route("/startnet")
|
||||
def startnet_editor():
|
||||
import subprocess
|
||||
wim_exists = os.path.isfile(config.BOOT_WIM)
|
||||
content = ""
|
||||
wim_info = {}
|
||||
settings = {"server_ip": None, "menu_timeout": None, "menu_default": None}
|
||||
boot_menu = []
|
||||
lint = {"errors": [], "warnings": []}
|
||||
|
||||
if wim_exists:
|
||||
content = wim.extract_startnet(config.BOOT_WIM) or ""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wiminfo", config.BOOT_WIM],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.splitlines():
|
||||
if ":" in line:
|
||||
key, _, val = line.partition(":")
|
||||
wim_info[key.strip()] = val.strip()
|
||||
except Exception:
|
||||
pass
|
||||
content = _startnet_current()
|
||||
wim_info = _startnet_wiminfo()
|
||||
settings = wim.parse_settings(content)
|
||||
boot_menu = wim.parse_boot_menu(content)
|
||||
lint = wim.lint_startnet(content)
|
||||
|
||||
snapshots = wim.list_snapshots()
|
||||
|
||||
return render_template(
|
||||
"startnet_editor.html",
|
||||
@@ -918,6 +1022,12 @@ def startnet_editor():
|
||||
wim_path=config.BOOT_WIM,
|
||||
content=content,
|
||||
wim_info=wim_info,
|
||||
settings=settings,
|
||||
boot_menu=boot_menu,
|
||||
lint=lint,
|
||||
snapshots=snapshots,
|
||||
shopfloor_items=read_shopfloor_menu(),
|
||||
shopfloor_available=available_pctypes(),
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
)
|
||||
@@ -930,15 +1040,228 @@ def startnet_save():
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
content = request.form.get("content", "")
|
||||
ok, err = wim.update_startnet(config.BOOT_WIM, content)
|
||||
if ok:
|
||||
audit("STARTNET_SAVE", "boot.wim updated")
|
||||
prior = _startnet_current()
|
||||
if _startnet_save_content(content, prior, "STARTNET_SAVE", "boot.wim updated (raw)"):
|
||||
flash("startnet.cmd updated successfully in boot.wim.", "success")
|
||||
else:
|
||||
flash(f"Failed to update boot.wim: {err}", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
|
||||
@app.route("/startnet/settings", methods=["POST"])
|
||||
def startnet_save_settings():
|
||||
"""Apply the Settings tab into the CURRENT extracted content, then save.
|
||||
|
||||
Only server_ip / menu_timeout / menu_default are touched, via targeted
|
||||
replacement (apply_settings never rebuilds the file). Blank fields are left
|
||||
alone. The raw text stays the source of truth: we apply into what is on
|
||||
boot.wim right now, not a rebuilt file.
|
||||
"""
|
||||
if not os.path.isfile(config.BOOT_WIM):
|
||||
flash("boot.wim not found.", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
prior = _startnet_current()
|
||||
settings = {}
|
||||
|
||||
ip = (request.form.get("server_ip") or "").strip()
|
||||
if ip:
|
||||
settings["server_ip"] = ip
|
||||
|
||||
to = (request.form.get("menu_timeout") or "").strip()
|
||||
if to:
|
||||
try:
|
||||
settings["menu_timeout"] = int(to)
|
||||
except ValueError:
|
||||
flash("Menu timeout must be a whole number of seconds.", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
md = (request.form.get("menu_default") or "").strip()
|
||||
if md:
|
||||
settings["menu_default"] = md
|
||||
|
||||
new_content = wim.apply_settings(prior, settings)
|
||||
if new_content == prior:
|
||||
flash("No settings changed.", "info")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
if _startnet_save_content(new_content, prior, "STARTNET_SETTINGS",
|
||||
f"settings applied: {settings}"):
|
||||
flash("Startnet settings applied and saved to boot.wim.", "success")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
|
||||
@app.route("/startnet/menu", methods=["POST"])
|
||||
def startnet_save_menu():
|
||||
"""Apply the Boot-menu tab into the CURRENT extracted content, then save.
|
||||
|
||||
The tab posts an `items` JSON array of {label, target, target_image}; list
|
||||
ORDER defines the new 1..N numbering (any 'num' is ignored). apply_boot_menu
|
||||
regenerates only the echo lines, choice count, dispatch, and action blocks;
|
||||
existing block bodies are byte-for-byte preserved. It RAISES ValueError when
|
||||
the structure is ambiguous or a renumber would desync the enrollment
|
||||
%choice% router (e.g. reorder/remove of a cross-referenced image); we catch
|
||||
it, flash the reason, and leave the raw text untouched.
|
||||
"""
|
||||
if not os.path.isfile(config.BOOT_WIM):
|
||||
flash("boot.wim not found.", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
payload = request.form.get("items", "[]")
|
||||
try:
|
||||
items = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
flash("Invalid boot-menu payload.", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
if not isinstance(items, list):
|
||||
flash("Boot-menu payload must be a list of items.", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
for it in items:
|
||||
if not isinstance(it, dict) or not (it.get("target") or "").strip():
|
||||
flash("Every boot-menu entry needs a non-empty Target.", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
prior = _startnet_current()
|
||||
try:
|
||||
new_content = wim.apply_boot_menu(prior, items)
|
||||
except ValueError as exc:
|
||||
flash(f"Boot menu not rewritten (kept raw text): {exc}", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
if new_content == prior:
|
||||
flash("No boot-menu changes to save.", "info")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
if _startnet_save_content(new_content, prior, "STARTNET_MENU",
|
||||
f"boot menu rebuilt ({len(items)} item(s))"):
|
||||
flash("Boot menu rebuilt and saved to boot.wim.", "success")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
|
||||
@app.route("/startnet/restore", methods=["POST"])
|
||||
def startnet_restore_snapshot():
|
||||
"""Restore a snapshot back through the save path.
|
||||
|
||||
Reads the snapshot (path-traversal safe), snapshots the ABOUT-TO-BE-REPLACED
|
||||
current startnet first, then writes the restored bytes to boot.wim.
|
||||
"""
|
||||
if not os.path.isfile(config.BOOT_WIM):
|
||||
flash("boot.wim not found.", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
snapshot_id = (request.form.get("snapshot_id") or "").strip()
|
||||
restored = wim.read_snapshot(snapshot_id)
|
||||
if restored is None:
|
||||
flash(f"Snapshot not found: {snapshot_id}", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
prior = _startnet_current()
|
||||
if _startnet_save_content(restored, prior, "STARTNET_RESTORE",
|
||||
f"restored snapshot {snapshot_id}"):
|
||||
flash(f"Restored startnet.cmd from {snapshot_id}.", "success")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
|
||||
@app.route("/startnet/diff", methods=["POST"])
|
||||
def startnet_diff():
|
||||
"""Return a unified diff as JSON. Two modes (body may be form or JSON):
|
||||
|
||||
- snapshot_id -> diff that snapshot (fromfile) vs current boot.wim (tofile)
|
||||
- content -> diff current boot.wim (fromfile) vs pending edit (tofile)
|
||||
|
||||
JSON callers must send the CSRF token in the X-CSRF-Token header.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
snapshot_id = (request.form.get("snapshot_id") or body.get("snapshot_id") or "").strip()
|
||||
pending = request.form.get("content")
|
||||
if pending is None:
|
||||
pending = body.get("content")
|
||||
|
||||
current = _startnet_current()
|
||||
|
||||
if snapshot_id:
|
||||
snap = wim.read_snapshot(snapshot_id)
|
||||
if snap is None:
|
||||
return jsonify({"error": f"snapshot not found: {snapshot_id}"}), 404
|
||||
diff = wim.unified_diff(snap, current, fromfile=snapshot_id, tofile="current")
|
||||
return jsonify({"diff": diff, "mode": "snapshot", "snapshot_id": snapshot_id})
|
||||
|
||||
if pending is not None:
|
||||
diff = wim.unified_diff(current, pending, fromfile="current", tofile="pending")
|
||||
return jsonify({"diff": diff, "mode": "pending"})
|
||||
|
||||
return jsonify({"error": "provide snapshot_id or content"}), 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes - Shopfloor boot-menu (data-driven menu.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SHOPFLOOR_SETUP_DIR = os.path.join(config.ENROLLMENT_SHARE, "shopfloor-setup")
|
||||
SHOPFLOOR_MENU_JSON = os.path.join(SHOPFLOOR_SETUP_DIR, "menu.json")
|
||||
|
||||
|
||||
def read_shopfloor_menu():
|
||||
"""The shopfloor PC-type menu items from menu.json (list; [] if absent)."""
|
||||
try:
|
||||
with open(SHOPFLOOR_MENU_JSON) as fh:
|
||||
return json.load(fh).get("shopfloor", [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def available_pctypes():
|
||||
"""gea-shopfloor-* handler dirs on the share - the only valid menu keys."""
|
||||
try:
|
||||
return sorted(
|
||||
d for d in os.listdir(SHOPFLOOR_SETUP_DIR)
|
||||
if d.startswith("gea-shopfloor-")
|
||||
and os.path.isdir(os.path.join(SHOPFLOOR_SETUP_DIR, d))
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
@app.route("/shopfloor-menu", methods=["GET", "POST"])
|
||||
def shopfloor_menu():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
items = json.loads(request.form.get("items", "[]"))
|
||||
except json.JSONDecodeError:
|
||||
flash("Invalid menu payload.", "danger")
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
valid = set(available_pctypes())
|
||||
clean = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
key = (it.get("key") or "").strip()
|
||||
label = (it.get("label") or "").strip()
|
||||
if not key or not label:
|
||||
continue
|
||||
if key not in valid:
|
||||
flash(f"Skipped '{label}': {key} has no shopfloor-setup handler.", "warning")
|
||||
continue
|
||||
clean.append({
|
||||
"key": key,
|
||||
"label": label,
|
||||
"hint": (it.get("hint") or "").strip(),
|
||||
"enabled": bool(it.get("enabled", True)),
|
||||
})
|
||||
if not clean:
|
||||
flash("Refused to save an empty menu.", "danger")
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
try:
|
||||
os.makedirs(SHOPFLOOR_SETUP_DIR, exist_ok=True)
|
||||
with open(SHOPFLOOR_MENU_JSON, "w") as fh:
|
||||
json.dump({"shopfloor": clean}, fh, indent=2)
|
||||
audit("SHOPFLOOR_MENU_SAVE", f"{len(clean)} items")
|
||||
flash("Shopfloor boot menu saved - WinPE reads it at next boot.", "success")
|
||||
except OSError as exc:
|
||||
flash(f"Could not write menu.json: {exc}", "danger")
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes - Audit Log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -56,20 +56,12 @@ IMAGE_TYPES = [
|
||||
"gea-standard",
|
||||
"gea-engineer",
|
||||
"gea-shopfloor",
|
||||
"ge-standard",
|
||||
"ge-engineer",
|
||||
"ge-shopfloor-lockdown",
|
||||
"ge-shopfloor-mce",
|
||||
]
|
||||
|
||||
FRIENDLY_NAMES = {
|
||||
"gea-standard": "GE Aerospace Standard",
|
||||
"gea-engineer": "GE Aerospace Engineer",
|
||||
"gea-shopfloor": "GE Aerospace Shop Floor",
|
||||
"ge-standard": "GE Legacy Standard",
|
||||
"ge-engineer": "GE Legacy Engineer",
|
||||
"ge-shopfloor-lockdown": "GE Legacy Shop Floor Lockdown",
|
||||
"ge-shopfloor-mce": "GE Legacy Shop Floor MCE",
|
||||
}
|
||||
|
||||
# --- Unattend XML namespaces -------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ re-use the same multi-GB driver tree without doubling disk usage.
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import config
|
||||
from services.system import find_usb_mounts
|
||||
@@ -21,27 +22,43 @@ def _replace_with_symlink(link_path, target_path):
|
||||
os.symlink(target_path, link_path)
|
||||
|
||||
|
||||
def _merge_tree(src, dst, move=False):
|
||||
"""Recursively merge src tree into dst, overwriting existing files.
|
||||
def sync_tree(src, dst, move=False, checksum=True):
|
||||
"""Copy src -> dst with rsync, SKIPPING files whose content already
|
||||
matches and transferring only new or changed files.
|
||||
|
||||
When move=True, files are moved instead of copied (saves disk space
|
||||
on imports from the local upload-dir).
|
||||
"""
|
||||
_transfer = shutil.move if move else shutil.copy2
|
||||
_transfer_tree = shutil.move if move else shutil.copytree
|
||||
for item in os.listdir(src):
|
||||
s = os.path.join(src, item)
|
||||
d = os.path.join(dst, item)
|
||||
if os.path.isdir(s):
|
||||
if os.path.isdir(d):
|
||||
_merge_tree(s, d, move=move)
|
||||
else:
|
||||
if os.path.exists(d):
|
||||
os.remove(d)
|
||||
_transfer_tree(s, d)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(d), exist_ok=True)
|
||||
_transfer(s, d)
|
||||
src may be a file or a directory (its contents merge into dst). With
|
||||
checksum=True rsync compares by hash (--checksum), so a re-import only
|
||||
rewrites files that actually differ instead of replacing everything;
|
||||
set checksum=False for the faster size+mtime comparison. move=True
|
||||
removes source files after a successful transfer (frees the upload dir).
|
||||
Existing dst files not present in src are left untouched (merge, not
|
||||
mirror - no --delete)."""
|
||||
flags = ["-a"] # recurse, preserve perms/times/symlinks
|
||||
if checksum:
|
||||
flags.append("--checksum") # compare by content hash, not size+mtime
|
||||
if move:
|
||||
flags.append("--remove-source-files")
|
||||
if os.path.isdir(src):
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
src_arg = src.rstrip("/") + "/" # trailing slash = merge CONTENTS into dst
|
||||
else:
|
||||
parent = os.path.dirname(dst)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
src_arg = src
|
||||
subprocess.run(["rsync", *flags, src_arg, dst], check=True)
|
||||
if move and os.path.isdir(src):
|
||||
# --remove-source-files empties files but leaves the dir skeleton; prune it
|
||||
for root, _dirs, _files in os.walk(src, topdown=False):
|
||||
try:
|
||||
os.rmdir(root)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _merge_tree(src, dst, move=False):
|
||||
"""Backwards-compatible alias: checksum-aware merge of src into dst."""
|
||||
sync_tree(src, dst, move=move)
|
||||
|
||||
|
||||
def import_deploy(src_deploy, dst_deploy, target="", move=False):
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir).
|
||||
"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir)
|
||||
plus pure-Python helpers for the startnet.cmd editor.
|
||||
|
||||
Used by the startnet.cmd editor to extract + update the boot script
|
||||
that runs when WinPE boots from PXE.
|
||||
Two layers live here:
|
||||
|
||||
1. wimtools wrappers (extract_startnet / update_startnet / list_files) that
|
||||
read + write startnet.cmd inside boot.wim. update_startnet writes CRLF.
|
||||
|
||||
2. Framework-free text helpers used by the rebuilt startnet.cmd editor:
|
||||
settings-as-forms (parse_settings / apply_settings), the boot-menu builder
|
||||
(parse_boot_menu / apply_boot_menu), lint_startnet, timestamped snapshots
|
||||
(save_snapshot / list_snapshots / read_snapshot) and unified_diff.
|
||||
|
||||
The helpers hold NO Flask imports on purpose; the routes stage wires them up.
|
||||
startnet.cmd is a WinPE BATCH script with CRLF endings. Every regen helper
|
||||
preserves the file's existing line ending and only rewrites the exact lines it
|
||||
targets, leaving everything else byte-for-byte. When a structure cannot be
|
||||
located unambiguously the regen helpers raise ValueError so the caller can fall
|
||||
back to the raw full-text editor (the source of truth).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import difflib
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
|
||||
# Root-writable dir for timestamped startnet.cmd snapshots. Overridable by env
|
||||
# so tests / alt deploys can redirect it. save_snapshot() creates it on demand.
|
||||
BACKUPS_DIR = os.environ.get(
|
||||
"STARTNET_BACKUPS_DIR", "/var/lib/pxe-webapp/startnet-backups"
|
||||
)
|
||||
|
||||
|
||||
def extract_startnet(wim_path):
|
||||
@@ -22,7 +48,11 @@ def extract_startnet(wim_path):
|
||||
)
|
||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||
if result.returncode == 0 and os.path.isfile(startnet_path):
|
||||
with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
# newline="" keeps the file's CRLF bytes verbatim; default universal
|
||||
# newlines would fold CRLF to LF, breaking the lint CRLF check and
|
||||
# storing LF-only snapshots.
|
||||
with open(startnet_path, "r", encoding="utf-8", errors="replace",
|
||||
newline="") as fh:
|
||||
return fh.read()
|
||||
return None
|
||||
except Exception:
|
||||
@@ -38,6 +68,10 @@ def update_startnet(wim_path, content):
|
||||
"""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
# Normalize to LF first: newline="\r\n" translates each bare \n, so
|
||||
# CRLF input (browser textareas post CRLF) would otherwise become
|
||||
# \r\r\n. After normalizing, output is always clean CRLF.
|
||||
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||
with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh:
|
||||
fh.write(content)
|
||||
@@ -68,3 +102,628 @@ def list_files(wim_path, path="/"):
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Pure text helpers for the startnet.cmd editor (no Flask, no wimtools).
|
||||
# All operate on the startnet.cmd text. Regen helpers preserve the file's
|
||||
# existing line ending and only touch their target lines.
|
||||
# ===========================================================================
|
||||
|
||||
def _detect_eol(content):
|
||||
"""Return the dominant line ending, defaulting to CRLF for WinPE batch."""
|
||||
if "\r\n" in content:
|
||||
return "\r\n"
|
||||
if "\n" in content:
|
||||
return "\n"
|
||||
return "\r\n"
|
||||
|
||||
|
||||
def _split_lines(content):
|
||||
"""Split into lines WITHOUT terminators, tolerant of CRLF/LF/CR.
|
||||
|
||||
Rejoining with eol.join(...) reproduces a trailing newline because a
|
||||
trailing terminator yields a final empty element.
|
||||
"""
|
||||
# Collapse any run of CRs before a newline (handles CRLF and a doubled
|
||||
# \r\r\n that a bad CRLF-conversion can leave behind), then lone CR.
|
||||
return re.sub(r"\r+\n", "\n", content).replace("\r", "\n").split("\n")
|
||||
|
||||
|
||||
# --- Shared regexes ---------------------------------------------------------
|
||||
_LABEL_RE = re.compile(r"^\s*:([A-Za-z0-9_][A-Za-z0-9_\-]*)\s*$")
|
||||
_NETUSE_IMG_RE = re.compile(
|
||||
r"net use\s+\w+:\s+\\\\[0-9.]+\\winpeapps\\([A-Za-z0-9_\-]+)", re.IGNORECASE
|
||||
)
|
||||
_GOTO_END_RE = re.compile(r"^\s*goto\s+end\s*$", re.IGNORECASE)
|
||||
_DISPATCH_RE = re.compile(
|
||||
r'^(\s*)if\s+"%choice%"=="(\d+)"\s+goto\s+([A-Za-z0-9_\-]+)\s*$', re.IGNORECASE
|
||||
)
|
||||
_ECHO_NUM_RE = re.compile(r"^(\s*)echo\s+(\d+)\.\s?(.*?)\s*$", re.IGNORECASE)
|
||||
_CHOICE_PROMPT_RE = re.compile(
|
||||
r"^(\s*set\s+/p\s+choice=.*\(1-)(\d+)(\).*)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 1: settings as forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _detect_server_ip(content):
|
||||
"""Best-effort PXE server IP: the IP most used in \\\\IP\\ UNC paths.
|
||||
|
||||
Falls back to a `ping <ip>` target. Returns the dotted-quad or None.
|
||||
"""
|
||||
ips = re.findall(r"\\\\(\d{1,3}(?:\.\d{1,3}){3})\\", content)
|
||||
if not ips:
|
||||
ips = re.findall(
|
||||
r"ping\b[^\r\n]*?\b(\d{1,3}(?:\.\d{1,3}){3})\b", content, re.IGNORECASE
|
||||
)
|
||||
if not ips:
|
||||
return None
|
||||
return Counter(ips).most_common(1)[0][0]
|
||||
|
||||
|
||||
def _replace_server_ip(content, old, new):
|
||||
"""Replace old IP with new everywhere it stands as a whole IPv4 token.
|
||||
|
||||
The negative look-around on [\\d.] keeps it from matching a fragment of a
|
||||
longer dotted number, and only the detected server IP is ever passed in, so
|
||||
unrelated addresses (e.g. 8.8.8.8 in the enroll.cmd here-doc) are untouched.
|
||||
This covers UNC (\\\\IP\\...), the ping wait-for-network line, and any URL.
|
||||
"""
|
||||
pat = re.compile(r"(?<![\d.])" + re.escape(old) + r"(?![\d.])")
|
||||
return pat.sub(new, content)
|
||||
|
||||
|
||||
def _detect_menu_timeout_default(content):
|
||||
"""Pull timeout seconds + default key off a `choice /t N /d X` line.
|
||||
|
||||
startnet uses `set /p` (no timeout) today, so this is usually (None, None).
|
||||
Only a real `choice` command is recognised; nothing is invented.
|
||||
"""
|
||||
timeout = None
|
||||
default = None
|
||||
for line in _split_lines(content):
|
||||
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
|
||||
mt = re.search(r"/t\s*:?\s*(\d+)", line, re.IGNORECASE)
|
||||
md = re.search(r"/d\s*:?\s*([A-Za-z0-9]+)", line, re.IGNORECASE)
|
||||
if mt:
|
||||
timeout = int(mt.group(1))
|
||||
if md:
|
||||
default = md.group(1)
|
||||
break
|
||||
return timeout, default
|
||||
|
||||
|
||||
def parse_settings(content):
|
||||
"""content -> settings dict of safe, common knobs.
|
||||
|
||||
Keys:
|
||||
server_ip str|None PXE server IP used in UNC/ping paths
|
||||
menu_timeout int|None seconds, from a `choice /t` construct if present
|
||||
menu_default str|None default choice key, from `choice /d` if present
|
||||
"""
|
||||
ip = _detect_server_ip(content)
|
||||
timeout, default = _detect_menu_timeout_default(content)
|
||||
return {"server_ip": ip, "menu_timeout": timeout, "menu_default": default}
|
||||
|
||||
|
||||
def apply_settings(content, settings):
|
||||
"""settings dict -> content, via targeted replacement only.
|
||||
|
||||
- server_ip: if given and different from the current detected IP, rewrites
|
||||
that IP everywhere it appears as a whole IPv4 token (UNC, ping, URL).
|
||||
- menu_timeout / menu_default: updated ONLY if a `choice /t../d..` construct
|
||||
already exists; never injected (conservative - avoids risky rewrites).
|
||||
Unknown/None keys are left alone. Line endings are preserved.
|
||||
"""
|
||||
out = content
|
||||
new_ip = settings.get("server_ip")
|
||||
if new_ip:
|
||||
cur = _detect_server_ip(content)
|
||||
if cur and cur != new_ip:
|
||||
out = _replace_server_ip(out, cur, new_ip)
|
||||
|
||||
if settings.get("menu_timeout") is not None or settings.get("menu_default") is not None:
|
||||
out = _apply_menu_timeout_default(
|
||||
out, settings.get("menu_timeout"), settings.get("menu_default")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _apply_menu_timeout_default(content, timeout, default):
|
||||
"""Rewrite /t and /d on an existing `choice` line only. No-op otherwise."""
|
||||
eol = _detect_eol(content)
|
||||
lines = _split_lines(content)
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
|
||||
if timeout is not None:
|
||||
line = re.sub(
|
||||
r"(/t\s*:?\s*)\d+", r"\g<1>" + str(timeout), line, flags=re.IGNORECASE
|
||||
)
|
||||
if default is not None:
|
||||
line = re.sub(
|
||||
r"(/d\s*:?\s*)[A-Za-z0-9]+",
|
||||
r"\g<1>" + str(default),
|
||||
line,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
lines[i] = line
|
||||
break
|
||||
return eol.join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 2: boot-menu builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_label_line(lines, name):
|
||||
name = name.lower()
|
||||
for i, line in enumerate(lines):
|
||||
m = _LABEL_RE.match(line)
|
||||
if m and m.group(1).lower() == name:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _find_image_blocks(lines):
|
||||
"""Find per-image action blocks: a ':<target>' label whose body maps
|
||||
Z: to \\\\IP\\winpeapps\\<image> and ends with 'goto end'.
|
||||
|
||||
Returns list of dicts: {target, image, start, end} where start is the label
|
||||
line index and end is the 'goto end' line index (inclusive block span).
|
||||
"""
|
||||
label_idxs = [i for i, l in enumerate(lines) if _LABEL_RE.match(l)]
|
||||
label_set = set(label_idxs)
|
||||
blocks = []
|
||||
for i in label_idxs:
|
||||
target = _LABEL_RE.match(lines[i]).group(1)
|
||||
# body runs to the next label or EOF
|
||||
end_bound = len(lines)
|
||||
for j in range(i + 1, len(lines)):
|
||||
if j in label_set:
|
||||
end_bound = j
|
||||
break
|
||||
image = None
|
||||
goto_end_idx = None
|
||||
for j in range(i + 1, end_bound):
|
||||
if image is None:
|
||||
mi = _NETUSE_IMG_RE.search(lines[j])
|
||||
if mi:
|
||||
image = mi.group(1)
|
||||
if _GOTO_END_RE.match(lines[j]):
|
||||
goto_end_idx = j
|
||||
if image is not None and goto_end_idx is not None:
|
||||
blocks.append(
|
||||
{"target": target, "image": image, "start": i, "end": goto_end_idx}
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def _find_dispatch_lines(lines):
|
||||
"""All `if "%choice%"=="N" goto TARGET` lines as dicts {idx,num,target,indent}."""
|
||||
out = []
|
||||
for i, line in enumerate(lines):
|
||||
m = _DISPATCH_RE.match(line)
|
||||
if m:
|
||||
out.append(
|
||||
{
|
||||
"idx": i,
|
||||
"indent": m.group(1),
|
||||
"num": int(m.group(2)),
|
||||
"target": m.group(3),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _find_choice_prompt(lines, start):
|
||||
for i in range(start, len(lines)):
|
||||
if _CHOICE_PROMPT_RE.match(lines[i]):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _find_numbered_echo_run(lines, lo, hi):
|
||||
"""Contiguous run of numbered 'echo N. Label' lines within [lo, hi).
|
||||
|
||||
Returns (indices, indent, labels_by_num) or raises ValueError if the numbered
|
||||
echo lines are not on consecutive source lines (ambiguous - refuse to edit).
|
||||
"""
|
||||
matched = []
|
||||
for i in range(lo, hi):
|
||||
m = _ECHO_NUM_RE.match(lines[i])
|
||||
if m:
|
||||
matched.append((i, m))
|
||||
if not matched:
|
||||
raise ValueError("no numbered 'echo N. ...' menu lines found")
|
||||
idxs = [i for i, _ in matched]
|
||||
if idxs != list(range(idxs[0], idxs[0] + len(idxs))):
|
||||
raise ValueError("menu echo lines are not contiguous")
|
||||
indent = matched[0][1].group(1)
|
||||
labels = {int(m.group(2)): m.group(3).rstrip() for _, m in matched}
|
||||
return idxs, indent, labels
|
||||
|
||||
|
||||
def _analyze_boot_menu(lines):
|
||||
"""Locate every editable region of the image menu. Raises ValueError if the
|
||||
clearly-delimited structure is missing/ambiguous. Returns a dict of parts."""
|
||||
menu_idx = _find_label_line(lines, "menu")
|
||||
if menu_idx is None:
|
||||
raise ValueError("no :menu label found")
|
||||
choice_idx = _find_choice_prompt(lines, menu_idx)
|
||||
if choice_idx is None:
|
||||
raise ValueError("no 'set /p choice=' prompt found after :menu")
|
||||
|
||||
echo_idxs, echo_indent, echo_labels = _find_numbered_echo_run(
|
||||
lines, menu_idx, choice_idx
|
||||
)
|
||||
|
||||
image_blocks = _find_image_blocks(lines)
|
||||
if not image_blocks:
|
||||
raise ValueError("no image action blocks (net use winpeapps ... goto end)")
|
||||
img_targets = {b["target"] for b in image_blocks}
|
||||
|
||||
all_dispatch = _find_dispatch_lines(lines)
|
||||
image_dispatch = [d for d in all_dispatch if d["target"] in img_targets]
|
||||
if not image_dispatch:
|
||||
raise ValueError("no image %choice% dispatch lines found")
|
||||
d_idxs = [d["idx"] for d in image_dispatch]
|
||||
if d_idxs != list(range(d_idxs[0], d_idxs[-1] + 1)):
|
||||
raise ValueError("image %choice% dispatch lines are not contiguous")
|
||||
|
||||
blocks_sorted = sorted(image_blocks, key=lambda b: b["start"])
|
||||
region_start = blocks_sorted[0]["start"]
|
||||
region_end = blocks_sorted[-1]["end"]
|
||||
# every line inside the block region must belong to a block span or be blank
|
||||
covered = set()
|
||||
for b in blocks_sorted:
|
||||
covered.update(range(b["start"], b["end"] + 1))
|
||||
for j in range(region_start, region_end + 1):
|
||||
if j not in covered and lines[j].strip() != "":
|
||||
raise ValueError(
|
||||
"image action blocks are not contiguous (foreign content at "
|
||||
"line %d)" % (j + 1)
|
||||
)
|
||||
|
||||
return {
|
||||
"menu_idx": menu_idx,
|
||||
"choice_idx": choice_idx,
|
||||
"echo_idxs": echo_idxs,
|
||||
"echo_indent": echo_indent,
|
||||
"echo_labels": echo_labels,
|
||||
"image_blocks": blocks_sorted,
|
||||
"image_dispatch": image_dispatch,
|
||||
"all_dispatch": all_dispatch,
|
||||
"region_start": region_start,
|
||||
"region_end": region_end,
|
||||
"dispatch_start": d_idxs[0],
|
||||
"dispatch_end": d_idxs[-1],
|
||||
}
|
||||
|
||||
|
||||
def parse_boot_menu(content):
|
||||
"""content -> list of image-menu items, ordered by menu number.
|
||||
|
||||
Each item dict:
|
||||
num int choice number that routes to this image
|
||||
label str echo text after 'N. ' (kept verbatim, trailing ws only stripped)
|
||||
target str goto label of the action block (the ':<target>')
|
||||
target_image str winpeapps\\<image> the block maps Z: to
|
||||
|
||||
Returns [] if the delimited menu region cannot be found (caller uses raw text).
|
||||
"""
|
||||
lines = _split_lines(content)
|
||||
try:
|
||||
a = _analyze_boot_menu(lines)
|
||||
except ValueError:
|
||||
return []
|
||||
by_target = {b["target"]: b for b in a["image_blocks"]}
|
||||
items = []
|
||||
for d in a["image_dispatch"]:
|
||||
blk = by_target[d["target"]]
|
||||
items.append(
|
||||
{
|
||||
"num": d["num"],
|
||||
"label": a["echo_labels"].get(d["num"], ""),
|
||||
"target": d["target"],
|
||||
"target_image": blk["image"],
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda it: it["num"])
|
||||
return items
|
||||
|
||||
|
||||
def _synth_block(item, ip, eol_indent=""):
|
||||
"""Build a fresh per-image action block for a newly added menu item."""
|
||||
target = item["target"]
|
||||
image = item.get("target_image") or target
|
||||
label = item.get("label") or target
|
||||
return [
|
||||
":%s" % target,
|
||||
"echo.",
|
||||
"echo Starting %s setup..." % label,
|
||||
r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe',
|
||||
r"for /l %%i in (1,1,2000000) do rem",
|
||||
r"net use Z: \\%s\winpeapps\%s /user:pxe-upload pxe /persistent:no"
|
||||
% (ip, image),
|
||||
"goto end",
|
||||
]
|
||||
|
||||
|
||||
def apply_boot_menu(content, items):
|
||||
"""items -> content: regenerate ONLY the menu echo lines, the choice prompt
|
||||
count, the image %choice% dispatch (renumbered 1..N), and reorder/add/remove
|
||||
the per-image action blocks. Existing block BODIES are kept byte-for-byte;
|
||||
only new blocks are synthesised. Everything else is untouched.
|
||||
|
||||
items: list of {label, target, target_image} (a 'num' key, if present, is
|
||||
ignored - order in the list defines the new numbering).
|
||||
|
||||
Raises ValueError when the structure is ambiguous OR when renumbering would
|
||||
desync a secondary `%choice%` router (e.g. the enrollment routing that also
|
||||
keys off choice numbers). The caller then falls back to the raw editor.
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("boot menu must have at least one item")
|
||||
|
||||
eol = _detect_eol(content)
|
||||
lines = _split_lines(content)
|
||||
ip = _detect_server_ip(content) or "172.16.9.1"
|
||||
a = _analyze_boot_menu(lines)
|
||||
|
||||
old_num_by_target = {d["target"]: d["num"] for d in a["image_dispatch"]}
|
||||
old_target_by_num = {d["num"]: d["target"] for d in a["image_dispatch"]}
|
||||
new_num_by_target = {it["target"]: i + 1 for i, it in enumerate(items)}
|
||||
|
||||
# Safety: a secondary %choice% router (not one of the image blocks) that
|
||||
# references an image number whose meaning is about to change is a desync.
|
||||
image_dispatch_idxs = {d["idx"] for d in a["image_dispatch"]}
|
||||
for d in a["all_dispatch"]:
|
||||
if d["idx"] in image_dispatch_idxs:
|
||||
continue
|
||||
n = d["num"]
|
||||
if n in old_target_by_num: # this number currently selects an image
|
||||
tgt = old_target_by_num[n]
|
||||
if tgt not in new_num_by_target or new_num_by_target[tgt] != n:
|
||||
raise ValueError(
|
||||
"refusing to renumber: secondary %%choice%% routing at line "
|
||||
"%d references image choice %d whose number would change"
|
||||
% (d["idx"] + 1, n)
|
||||
)
|
||||
|
||||
blocks_by_target = {
|
||||
b["target"]: lines[b["start"]:b["end"] + 1] for b in a["image_blocks"]
|
||||
}
|
||||
|
||||
# --- build replacement lines for each region ---
|
||||
new_echo = [
|
||||
"%secho %d. %s" % (a["echo_indent"], i + 1, it.get("label") or it["target"])
|
||||
for i, it in enumerate(items)
|
||||
]
|
||||
disp_indent = a["image_dispatch"][0]["indent"]
|
||||
new_dispatch = [
|
||||
'%sif "%%choice%%"=="%d" goto %s' % (disp_indent, i + 1, it["target"])
|
||||
for i, it in enumerate(items)
|
||||
]
|
||||
cm = _CHOICE_PROMPT_RE.match(lines[a["choice_idx"]])
|
||||
new_choice = "%s%d%s" % (cm.group(1), len(items), cm.group(3))
|
||||
|
||||
new_region = []
|
||||
for i, it in enumerate(items):
|
||||
if i > 0:
|
||||
new_region.append("") # blank line between blocks, matching style
|
||||
if it["target"] in blocks_by_target:
|
||||
new_region.extend(blocks_by_target[it["target"]])
|
||||
else:
|
||||
new_region.extend(_synth_block(it, ip))
|
||||
|
||||
# Apply splices high-index-first so earlier indices stay valid.
|
||||
splices = [
|
||||
(a["region_start"], a["region_end"], new_region),
|
||||
(a["dispatch_start"], a["dispatch_end"], new_dispatch),
|
||||
(a["choice_idx"], a["choice_idx"], [new_choice]),
|
||||
(a["echo_idxs"][0], a["echo_idxs"][-1], new_echo),
|
||||
]
|
||||
for start, end, repl in sorted(splices, key=lambda s: s[0], reverse=True):
|
||||
lines[start:end + 1] = repl
|
||||
|
||||
return eol.join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 3: lint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _strip_for_parens(line):
|
||||
"""Neutralise a line for paren counting: drop comments, quoted spans, and
|
||||
caret-escaped chars (so `^>NUL`, `echo ... ^(` etc. do not count)."""
|
||||
s = line.lstrip()
|
||||
low = s.lower()
|
||||
if low.startswith("rem ") or low == "rem" or s.startswith("::"):
|
||||
return ""
|
||||
# remove escaped pairs first, then any caret-escaped char
|
||||
s = s.replace("^^", "")
|
||||
s = re.sub(r"\^.", "", s)
|
||||
# drop double-quoted spans
|
||||
s = re.sub(r'"[^"]*"', "", s)
|
||||
return s
|
||||
|
||||
|
||||
def lint_startnet(content):
|
||||
"""Static checks over startnet.cmd. Advisory - never blocks a save.
|
||||
|
||||
Returns {"errors": [...], "warnings": [...]} where each entry is
|
||||
{"line": int|None, "message": str}. Errors: goto with no matching label.
|
||||
Warnings: lone-LF (non-CRLF) lines, unbalanced parentheses.
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
lines = _split_lines(content)
|
||||
|
||||
labels = set()
|
||||
for line in lines:
|
||||
m = _LABEL_RE.match(line)
|
||||
if m:
|
||||
labels.add(m.group(1).lower())
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# A `goto` inside an `echo` line is emitted text (e.g. the enroll.cmd
|
||||
# here-doc), not a jump - skip it to avoid false positives.
|
||||
if line.lstrip().lower().startswith("echo"):
|
||||
continue
|
||||
for m in re.finditer(
|
||||
r"goto\s+:?([A-Za-z0-9_][A-Za-z0-9_\-]*)", line, re.IGNORECASE
|
||||
):
|
||||
target = m.group(1).lower()
|
||||
if target == "eof":
|
||||
continue # goto :eof is a cmd.exe builtin
|
||||
if target not in labels:
|
||||
errors.append(
|
||||
{
|
||||
"line": i + 1,
|
||||
"message": "goto %s has no matching :%s label"
|
||||
% (m.group(1), m.group(1)),
|
||||
}
|
||||
)
|
||||
|
||||
# CRLF check: count lone LFs (a \n not preceded by \r)
|
||||
crlf = content.count("\r\n")
|
||||
lf = content.count("\n")
|
||||
lone = lf - crlf
|
||||
if lone > 0:
|
||||
first = None
|
||||
idx = content.find("\n")
|
||||
ln = 1
|
||||
while idx != -1:
|
||||
if idx == 0 or content[idx - 1] != "\r":
|
||||
first = ln
|
||||
break
|
||||
idx = content.find("\n", idx + 1)
|
||||
ln += 1
|
||||
warnings.append(
|
||||
{
|
||||
"line": first,
|
||||
"message": "%d line(s) are not CRLF-terminated; WinPE batch "
|
||||
"expects CRLF" % lone,
|
||||
}
|
||||
)
|
||||
|
||||
# Parenthesis balance (best-effort, comments/quotes/carets removed)
|
||||
delta = 0
|
||||
for line in lines:
|
||||
cleaned = _strip_for_parens(line)
|
||||
delta += cleaned.count("(") - cleaned.count(")")
|
||||
if delta != 0:
|
||||
warnings.append(
|
||||
{
|
||||
"line": None,
|
||||
"message": "parentheses look unbalanced (delta %+d) - check "
|
||||
"if(...) blocks" % delta,
|
||||
}
|
||||
)
|
||||
|
||||
return {"errors": errors, "warnings": warnings}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 4: snapshots + diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_backups_dir():
|
||||
os.makedirs(BACKUPS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _snapshot_meta(path):
|
||||
st = os.stat(path)
|
||||
base = os.path.basename(path)
|
||||
note = ""
|
||||
note_path = path + ".note"
|
||||
if os.path.isfile(note_path):
|
||||
try:
|
||||
with open(note_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
note = fh.read().strip()
|
||||
except Exception:
|
||||
note = ""
|
||||
return {
|
||||
"id": base,
|
||||
"path": path,
|
||||
"mtime": st.st_mtime,
|
||||
"timestamp": datetime.datetime.fromtimestamp(st.st_mtime).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
"size": st.st_size,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
|
||||
def save_snapshot(content, note=""):
|
||||
"""Write content verbatim to a timestamped file under BACKUPS_DIR.
|
||||
|
||||
Snapshots the bytes exactly (no line-ending translation) so a restore
|
||||
round-trips faithfully. Returns the snapshot metadata dict (see
|
||||
_snapshot_meta). Call this with the PRIOR startnet before overwriting it.
|
||||
"""
|
||||
_ensure_backups_dir()
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
base = "startnet-%s.cmd" % ts
|
||||
path = os.path.join(BACKUPS_DIR, base)
|
||||
n = 1
|
||||
while os.path.exists(path):
|
||||
base = "startnet-%s-%d.cmd" % (ts, n)
|
||||
path = os.path.join(BACKUPS_DIR, base)
|
||||
n += 1
|
||||
with open(path, "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write(content)
|
||||
if note:
|
||||
with open(path + ".note", "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write(note)
|
||||
return _snapshot_meta(path)
|
||||
|
||||
|
||||
def list_snapshots():
|
||||
"""Return snapshot metadata dicts, newest first."""
|
||||
_ensure_backups_dir()
|
||||
metas = []
|
||||
for path in glob.glob(os.path.join(BACKUPS_DIR, "startnet-*.cmd")):
|
||||
if path.endswith(".note"):
|
||||
continue
|
||||
try:
|
||||
metas.append(_snapshot_meta(path))
|
||||
except OSError:
|
||||
continue
|
||||
metas.sort(key=lambda m: m["mtime"], reverse=True)
|
||||
return metas
|
||||
|
||||
|
||||
def read_snapshot(snapshot_id):
|
||||
"""Return a snapshot's text by id (its filename), or None if not found.
|
||||
|
||||
Rejects path traversal: the id must be a bare basename under BACKUPS_DIR.
|
||||
"""
|
||||
if not snapshot_id or os.path.basename(snapshot_id) != snapshot_id:
|
||||
return None
|
||||
if ".." in snapshot_id or "/" in snapshot_id or "\\" in snapshot_id:
|
||||
return None
|
||||
path = os.path.join(BACKUPS_DIR, snapshot_id)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
# newline="" so the stored CRLF bytes come back verbatim (faithful restore).
|
||||
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def unified_diff(a, b, fromfile="current", tofile="new"):
|
||||
"""Unified diff between two startnet texts, as a single string ('' if equal).
|
||||
|
||||
Line endings are normalised for comparison so a pure CRLF/LF difference does
|
||||
not produce a wall of noise; content differences are what surface.
|
||||
"""
|
||||
a_lines = _split_lines(a)
|
||||
b_lines = _split_lines(b)
|
||||
return "\n".join(
|
||||
difflib.unified_diff(
|
||||
a_lines, b_lines, fromfile=fromfile, tofile=tofile, lineterm=""
|
||||
)
|
||||
)
|
||||
|
||||
928
webapp/static/pxe-theme.css
Normal file
928
webapp/static/pxe-theme.css
Normal file
@@ -0,0 +1,928 @@
|
||||
/* ==========================================================================
|
||||
pxe-theme.css - GE Aerospace shopdb-flask design port for the PXE webapp
|
||||
Load AFTER bootstrap.min.css so these token+component rules win.
|
||||
Hand-written, dependency-free, air-gap safe (no CDN, no external fonts).
|
||||
========================================================================== */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Tokens - LIGHT is the default on :root. Dark overrides live in the
|
||||
[data-theme="dark"] block. Brand tokens are theme-invariant.
|
||||
-------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* GE brand (theme-invariant) */
|
||||
--ge-atmosphere-blue: #00003d;
|
||||
--ge-sky-blue: #4181ff;
|
||||
--ge-avionics-green: #0ad64f;
|
||||
--ge-tungsten: #eaeaea;
|
||||
|
||||
/* accents (same in both themes unless noted) */
|
||||
--primary: #4181ff;
|
||||
--primary-dark: #2d6ce0;
|
||||
--secondary: #6c757d;
|
||||
--secondary-dark: #82503f;
|
||||
--success: #0ad64f;
|
||||
--success-dark: #019e4c;
|
||||
--warning: #ff9500;
|
||||
--warning-dark: #e67c02;
|
||||
--danger: #dc3545;
|
||||
--danger-dark: #e62c51;
|
||||
--info: #4181ff;
|
||||
--info-dark: #039ce0;
|
||||
--purple: #9c27b0;
|
||||
--link: #4181ff;
|
||||
|
||||
/* surfaces + text (LIGHT) */
|
||||
--bg: #f5f5f5;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-solid: #ffffff;
|
||||
--bg-popover: #111111;
|
||||
--text: #1a1a2e;
|
||||
--text-light: #6c757d;
|
||||
--border: #dee2e6;
|
||||
|
||||
/* sidebar (same in both themes) */
|
||||
--sidebar-bg: #00003d;
|
||||
--sidebar-text: #ffffff;
|
||||
--sidebar-width: 250px;
|
||||
|
||||
/* custom caret for selects (light) */
|
||||
--select-caret: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--danger: #f5365c;
|
||||
--bg: #0a0a1a;
|
||||
--bg-card: rgba(0, 0, 61, 0.4);
|
||||
--bg-card-solid: #0d0d2b;
|
||||
--text: rgba(255, 255, 255, 0.9);
|
||||
--text-light: rgba(255, 255, 255, 0.6);
|
||||
--border: rgba(65, 129, 255, 0.2);
|
||||
--select-caret: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Belt-and-suspenders: honour OS dark before JS stamps the attribute,
|
||||
to avoid an unstyled flash on dark-OS machines. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
--danger: #f5365c;
|
||||
--bg: #0a0a1a;
|
||||
--bg-card: rgba(0, 0, 61, 0.4);
|
||||
--bg-card-solid: #0d0d2b;
|
||||
--text: rgba(255, 255, 255, 0.9);
|
||||
--text-light: rgba(255, 255, 255, 0.6);
|
||||
--border: rgba(65, 129, 255, 0.2);
|
||||
--select-caret: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Base / reset
|
||||
-------------------------------------------------------------------------- */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.5px;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: var(--link); }
|
||||
|
||||
.mono,
|
||||
.mac, .ip, .id-cell {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Bootstrap .text-light is near-white always - remap to muted token so it
|
||||
reads in BOTH themes (templates use it as "muted text") */
|
||||
.text-light { color: var(--text-light) !important; }
|
||||
|
||||
/* Signature type treatment helper */
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Layout: sidebar rail + main content
|
||||
-------------------------------------------------------------------------- */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 20px 10px 70px 10px;
|
||||
}
|
||||
|
||||
/* Sidebar header ---------------------------------------------------------- */
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.sidebar-logo {
|
||||
width: 180px;
|
||||
max-width: 100%;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.sidebar-header .brand-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 0.5rem 0 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Sidebar search ---------------------------------------------------------- */
|
||||
.sidebar-search {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
.sidebar-search input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sidebar-search input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 13px;
|
||||
}
|
||||
.sidebar-search input:focus {
|
||||
outline: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Sidebar nav ------------------------------------------------------------- */
|
||||
.sidebar-nav { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
.sidebar-nav .nav-link,
|
||||
.sidebar .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0;
|
||||
}
|
||||
.sidebar-nav .nav-link i,
|
||||
.sidebar .nav-link i {
|
||||
font-size: 16px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.sidebar-nav .nav-link:hover,
|
||||
.sidebar-nav .nav-link.active,
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
.sidebar-nav .nav-link.active,
|
||||
.sidebar .nav-link.active {
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
/* sub-link (Configuration under an image) */
|
||||
.sidebar-nav .nav-sublink {
|
||||
padding-left: 2.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.nav-section,
|
||||
.sidebar-heading {
|
||||
padding: 0.75rem 1.25rem 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link.external::after {
|
||||
content: '\2197';
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Sidebar footer ---------------------------------------------------------- */
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
.user-menu {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.user-menu .avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-menu .username {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.user-menu .user-sub {
|
||||
font-size: 0.72rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.user-menu .icon-btn {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.user-menu .icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Page header
|
||||
-------------------------------------------------------------------------- */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-header h1,
|
||||
.page-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Card (also overrides Bootstrap .card)
|
||||
-------------------------------------------------------------------------- */
|
||||
.pxe-card,
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
padding: 0;
|
||||
margin-bottom: 25px;
|
||||
color: var(--text);
|
||||
}
|
||||
/* pxe-card with padded body baked in */
|
||||
.pxe-card { padding: 1.25rem; }
|
||||
|
||||
[data-theme="light"] .pxe-card,
|
||||
[data-theme="light"] .card { border: 1px solid var(--border); }
|
||||
[data-theme="dark"] .pxe-card,
|
||||
[data-theme="dark"] .card { border: none; }
|
||||
|
||||
.pxe-card-header,
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
.pxe-card-header h3,
|
||||
.card-header h3 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
|
||||
.pxe-card-body,
|
||||
.card-body { padding: 1.25rem; }
|
||||
|
||||
.pxe-card-footer,
|
||||
.card-footer {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: transparent;
|
||||
}
|
||||
.card-title,
|
||||
.pxe-card-title { font-size: 16px; font-weight: 600; margin-bottom: 0.75rem; }
|
||||
|
||||
/* Section-card variant */
|
||||
.section-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
[data-theme="light"] .section-card { border: 1px solid var(--border); }
|
||||
[data-theme="dark"] .section-card { border: none; }
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Buttons (overrides Bootstrap .btn + variants)
|
||||
-------------------------------------------------------------------------- */
|
||||
.pxe-btn,
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 9px 19px;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pxe-btn:focus,
|
||||
.btn:focus { box-shadow: none; outline: none; }
|
||||
|
||||
.pxe-btn-primary, .btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.pxe-btn-primary:hover, .btn-primary:hover { background: var(--primary-dark); color: #fff; }
|
||||
.pxe-btn-secondary, .btn-secondary { background: var(--secondary); color: #fff; border-color: var(--secondary); }
|
||||
.pxe-btn-secondary:hover, .btn-secondary:hover { background: var(--secondary-dark); color: #fff; }
|
||||
.pxe-btn-success, .btn-success { background: var(--success); color: #00003d; border-color: var(--success); }
|
||||
.pxe-btn-success:hover, .btn-success:hover { background: var(--success-dark); color: #00003d; }
|
||||
.pxe-btn-info, .btn-info { background: var(--info); color: #fff; border-color: var(--info); }
|
||||
.pxe-btn-info:hover, .btn-info:hover { background: var(--info-dark); color: #fff; }
|
||||
.pxe-btn-warning, .btn-warning { background: var(--warning); color: #00003d; border-color: var(--warning); }
|
||||
.pxe-btn-warning:hover, .btn-warning:hover { background: var(--warning-dark); color: #00003d; }
|
||||
.pxe-btn-danger, .btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.pxe-btn-danger:hover, .btn-danger:hover { background: var(--danger-dark); color: #fff; }
|
||||
|
||||
/* Ghost / outline buttons keep the brand shape but read as secondary chrome */
|
||||
.pxe-btn-ghost,
|
||||
.btn-outline-primary,
|
||||
.btn-outline-secondary,
|
||||
.btn-outline-danger,
|
||||
.btn-outline-success,
|
||||
.btn-outline-info {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.pxe-btn-ghost:hover { background: var(--bg); color: var(--text); }
|
||||
.btn-outline-primary { color: var(--primary); border-color: var(--primary); }
|
||||
.btn-outline-primary:hover { background: var(--primary); color: #fff; }
|
||||
.btn-outline-danger { color: var(--danger); border-color: var(--danger); }
|
||||
.btn-outline-danger:hover { background: var(--danger); color: #fff; }
|
||||
.btn-outline-secondary:hover { background: var(--secondary); color: #fff; }
|
||||
.btn-outline-success { color: var(--success); border-color: var(--success); }
|
||||
.btn-outline-success:hover { background: var(--success); color: #00003d; }
|
||||
.btn-outline-info { color: var(--info); border-color: var(--info); }
|
||||
.btn-outline-info:hover { background: var(--info); color: #fff; }
|
||||
|
||||
.pxe-btn-link,
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--info);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.pxe-btn-sm, .btn-sm { font-size: 10px; padding: 6px 15px; }
|
||||
.pxe-btn-lg, .btn-lg { font-size: 14px; padding: 12px 38px; }
|
||||
|
||||
/* small icon-only row action buttons in tables keep a compact footprint */
|
||||
.btn-row-action { padding: 6px 10px; font-size: 10px; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Tables (overrides Bootstrap .table)
|
||||
-------------------------------------------------------------------------- */
|
||||
.table-container { overflow-x: auto; white-space: nowrap; }
|
||||
|
||||
.data-table,
|
||||
table.table,
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: transparent;
|
||||
}
|
||||
.data-table th,
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-top: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.data-table td,
|
||||
.table td {
|
||||
padding: 0.75rem;
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.data-table tbody tr:hover,
|
||||
.table tbody tr:hover { background: rgba(65, 129, 255, 0.1); }
|
||||
|
||||
/* sortable header */
|
||||
.data-table th.sortable,
|
||||
.table th.sortable { cursor: pointer; }
|
||||
.data-table th.sortable:hover,
|
||||
.table th.sortable:hover { color: var(--primary); }
|
||||
.sort-arrow { font-size: 10px; }
|
||||
|
||||
/* truncating cell */
|
||||
.cell-truncate td {
|
||||
max-width: 32rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* action column: keep it a real table cell, space the buttons */
|
||||
td.actions { display: table-cell; }
|
||||
td.actions .btn + .btn,
|
||||
.actions .pxe-btn + .pxe-btn { margin-left: 0.25rem; }
|
||||
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:hover td { background: var(--bg); }
|
||||
|
||||
/* drag handle used by app.js command tables */
|
||||
.drag-handle { cursor: grab; color: var(--text-light); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Forms (overrides Bootstrap .form-control / .form-select)
|
||||
-------------------------------------------------------------------------- */
|
||||
.form-field,
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
|
||||
.form-field label,
|
||||
.form-group label,
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
|
||||
}
|
||||
.form-control::placeholder { color: var(--text-light); opacity: 0.7; }
|
||||
|
||||
.form-control-sm { padding: 0.375rem 0.6rem; font-size: 0.85rem; }
|
||||
|
||||
[data-theme="dark"] .form-control {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
[data-theme="dark"] .form-control:focus {
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
/* select */
|
||||
select.form-control,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
padding-right: 2.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
background-image: var(--select-caret);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
}
|
||||
select.form-control:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
|
||||
}
|
||||
[data-theme="dark"] select.form-control,
|
||||
[data-theme="dark"] .form-select {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
select.form-control option,
|
||||
.form-select option { background: var(--bg-card); }
|
||||
[data-theme="dark"] select.form-control option,
|
||||
[data-theme="dark"] .form-select option { background: var(--bg-card-solid); }
|
||||
|
||||
/* textarea */
|
||||
textarea.form-control {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* checkboxes / radios */
|
||||
.form-check-input,
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Badges (overrides Bootstrap .badge + bg-* utilities)
|
||||
-------------------------------------------------------------------------- */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
color: #fff;
|
||||
}
|
||||
.badge-success, .badge.bg-success { background: var(--success); color: #00003d; }
|
||||
.badge-warning, .badge.bg-warning { background: var(--warning); color: #00003d; }
|
||||
.badge-danger, .badge.bg-danger { background: var(--danger); color: #fff; }
|
||||
.badge-info, .badge.bg-info { background: var(--info); color: #fff; }
|
||||
.badge-primary, .badge.bg-primary { background: var(--primary); color: #fff; }
|
||||
.badge-secondary,.badge.bg-secondary { background: var(--secondary); color: #fff; }
|
||||
.badge-printer, .badge.bg-purple { background: var(--purple); color: #fff; }
|
||||
.badge-lg { padding: 0.35rem 0.75rem; font-size: 12px; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Status pill / dot / toggle
|
||||
-------------------------------------------------------------------------- */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--secondary);
|
||||
}
|
||||
.status-dot.inactive { background: var(--secondary); }
|
||||
.status-dot.warning,
|
||||
.status-dot.unknown { background: var(--warning); }
|
||||
.status-dot.pending { background: var(--primary); }
|
||||
.status-dot.success,
|
||||
.status-dot.active { background: var(--success); }
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
background: var(--secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-btn.active { background: var(--success); }
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle-btn.active .toggle-slider { transform: translateX(24px); }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Alerts (overrides Bootstrap .alert) + notification banners
|
||||
-------------------------------------------------------------------------- */
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.error-message,
|
||||
.alert-danger,
|
||||
.alert-error {
|
||||
background: rgba(245, 54, 92, 0.2);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(245, 54, 92, 0.3);
|
||||
}
|
||||
.settings-success,
|
||||
.alert-success {
|
||||
background: var(--success);
|
||||
color: #00003d;
|
||||
border-color: var(--success);
|
||||
}
|
||||
.alert-warning {
|
||||
background: rgba(255, 136, 0, 0.15);
|
||||
color: var(--warning-dark);
|
||||
border: 1px solid rgba(255, 136, 0, 0.3);
|
||||
}
|
||||
.alert-info,
|
||||
.alert-primary {
|
||||
background: rgba(65, 129, 255, 0.12);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(65, 129, 255, 0.3);
|
||||
}
|
||||
/* Flask flashes commonly use "message" category */
|
||||
.alert-message {
|
||||
background: rgba(65, 129, 255, 0.12);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(65, 129, 255, 0.3);
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.notification-item.type-incident { border-left-color: var(--danger); background: rgba(245, 54, 92, 0.06); }
|
||||
.notification-item.type-change { border-left-color: var(--warning); background: rgba(255, 136, 0, 0.06); }
|
||||
.notification-item.type-awareness { border-left-color: var(--success); background: rgba(4, 185, 98, 0.06); }
|
||||
[data-theme="dark"] .notification-item.type-incident { background: rgba(245, 54, 92, 0.1); }
|
||||
[data-theme="dark"] .notification-item.type-change { background: rgba(255, 136, 0, 0.1); }
|
||||
[data-theme="dark"] .notification-item.type-awareness { background: rgba(4, 185, 98, 0.1); }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Toasts
|
||||
-------------------------------------------------------------------------- */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: 1.25rem;
|
||||
right: 1.25rem;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
max-width: min(420px, 100vw - 2.5rem);
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-left: 4px solid var(--secondary);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.toast-success { border-left-color: var(--success); }
|
||||
.toast-error { border-left-color: var(--danger); }
|
||||
.toast-info { border-left-color: var(--primary); }
|
||||
.toast .toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-light);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Modal (overrides Bootstrap-manual markup; use .modal-overlay wrapper)
|
||||
-------------------------------------------------------------------------- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1050;
|
||||
}
|
||||
.modal-overlay .modal,
|
||||
.pxe-modal {
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 0.25rem;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 16px 38px -12px rgba(0, 0, 0, 0.56),
|
||||
0 4px 25px rgba(0, 0, 0, 0.12),
|
||||
0 8px 10px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.modal-body { padding: 1.25rem; }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Pagination
|
||||
-------------------------------------------------------------------------- */
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.pagination { display: flex; gap: 0.25rem; list-style: none; margin: 0; padding: 0; }
|
||||
.pagination button,
|
||||
.pagination .page-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled),
|
||||
.pagination .page-btn:hover:not(.disabled) {
|
||||
background: var(--bg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.pagination .active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.pagination button:disabled,
|
||||
.pagination .disabled { opacity: 0.4; cursor: default; }
|
||||
.perpage-select { padding: 0.375rem 0.5rem; font-size: 13px; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Stat cards / dashboard grid
|
||||
-------------------------------------------------------------------------- */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 0.25rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
[data-theme="light"] .stat-card { border: 1px solid var(--border); }
|
||||
[data-theme="dark"] .stat-card { border: none; }
|
||||
.stat-card .label { font-size: 14px; color: var(--text-light); margin-bottom: 0.5rem; }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 600; color: var(--text); }
|
||||
.stat-card.success .value { color: var(--success); }
|
||||
.stat-card.warning .value { color: var(--warning); }
|
||||
.stat-card.danger .value { color: var(--danger); }
|
||||
.stat-card.info .value { color: var(--info); }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Tabs (editor-tabs used by app.js; overrides Bootstrap .nav-tabs)
|
||||
-------------------------------------------------------------------------- */
|
||||
.editor-tabs.nav-tabs,
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.editor-tabs .nav-link,
|
||||
.nav-tabs .nav-link {
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-light);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
.editor-tabs .nav-link:hover,
|
||||
.nav-tabs .nav-link:hover { color: var(--text); }
|
||||
.editor-tabs .nav-link.active,
|
||||
.nav-tabs .nav-link.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Content two-column multicol detail flow
|
||||
-------------------------------------------------------------------------- */
|
||||
.content-grid { column-count: 2; column-gap: 25px; }
|
||||
.content-grid .section-card { break-inside: avoid; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Responsive: collapse the fixed rail under 768px, keep colors/spacing
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.main-content { margin-left: 0; }
|
||||
.content-grid { column-count: 1; }
|
||||
}
|
||||
@@ -58,7 +58,8 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %}
|
||||
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card{% if is_inferred %} imaging-inferred{% endif %}"
|
||||
<details class="card mb-2 imaging-card{% if is_inferred %} imaging-inferred{% endif %}"
|
||||
style="border-left:4px solid var(--{{ border }});"
|
||||
data-serial="{{ s.serial or '' }}"
|
||||
data-key="{{ card_key }}"
|
||||
data-mac="{{ s.mac or '' }}"
|
||||
@@ -72,8 +73,8 @@
|
||||
style="line-height:0; flex-shrink:0;"
|
||||
title="Intune Device ID: {{ s.intune_device_id }}"></div>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light text-muted small"
|
||||
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;">
|
||||
<div class="d-flex align-items-center justify-content-center eyebrow"
|
||||
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem; background:var(--bg); border:1px solid var(--border);">
|
||||
{% if is_inferred %}log-only{% else %}no DeviceId{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -86,27 +87,27 @@
|
||||
{% elif s.mac %}{{ s.mac }}
|
||||
{% else %}{{ s.ip or '(unknown bay)' }}{% endif %}
|
||||
</strong>
|
||||
{% if s.hostname_target and s.serial %}<code class="text-muted small">{{ s.hostname_target }}</code>{% endif %}
|
||||
{% if s.ip %}<code class="text-muted small">{{ s.ip }}</code>{% endif %}
|
||||
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %}
|
||||
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %}
|
||||
{% if is_inferred %}<span class="badge bg-warning text-dark">log-inferred</span>{% endif %}
|
||||
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
|
||||
{% if s.hostname_target and s.serial %}<code class="mono" style="color:var(--text-light);">{{ s.hostname_target }}</code>{% endif %}
|
||||
{% if s.ip %}<code class="mono" style="color:var(--text-light);">{{ s.ip }}</code>{% endif %}
|
||||
{% if s.pctype %}<span class="badge badge-info">{{ s.pctype }}</span>{% endif %}
|
||||
{% if s.machinenumber %}<span class="badge badge-secondary">#{{ s.machinenumber }}</span>{% endif %}
|
||||
{% if is_inferred %}<span class="badge badge-warning">log-inferred</span>{% endif %}
|
||||
<span class="badge badge-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-1">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<strong>{{ friendly[0] }}</strong>
|
||||
{% if not is_inferred %}
|
||||
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
|
||||
<span class="badge badge-secondary">{{ stage_idx }}/{{ stage_total or '?' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not is_inferred %}<span class="text-muted small">{{ pct }}%</span>{% endif %}
|
||||
{% if not is_inferred %}<span class="eyebrow">{{ pct }}%</span>{% endif %}
|
||||
</div>
|
||||
{% if not is_inferred %}
|
||||
<div class="progress mt-1" style="height:0.7rem;">
|
||||
<div class="progress mt-1" style="height:0.7rem; background:var(--bg); border-radius:0.25rem;">
|
||||
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
|
||||
role="progressbar" style="width: {{ pct }}%;"
|
||||
role="progressbar" style="width: {{ pct }}%; background-color:var(--{{ border }});"
|
||||
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -114,46 +115,44 @@
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="card-body pt-0 pb-3 border-top">
|
||||
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %}
|
||||
<div class="card-body pt-0 pb-3" style="border-top:1px solid var(--border);">
|
||||
{% if friendly[1] %}<div class="mt-2" style="color:var(--text-light); font-size:0.85rem;">{{ friendly[1] }}</div>{% endif %}
|
||||
|
||||
{% if s.intune_device_id %}
|
||||
<div class="small mt-2" style="font-size:0.75rem;">
|
||||
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 copy-btn"
|
||||
style="font-size:0.65rem; line-height:1; transition: all 0.2s;"
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-3">
|
||||
<span class="eyebrow">Intune</span>
|
||||
<code class="mono">{{ s.intune_device_id }}</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn"
|
||||
data-copy-text="{{ s.intune_device_id }}">copy</button>
|
||||
<a class="btn btn-sm btn-outline-primary py-0 px-1"
|
||||
style="font-size:0.65rem; line-height:1;"
|
||||
<a class="btn btn-sm btn-outline-primary btn-row-action"
|
||||
target="_blank" rel="noopener"
|
||||
href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a>
|
||||
<a class="btn btn-sm btn-outline-warning py-0 px-1"
|
||||
style="font-size:0.65rem; line-height:1;"
|
||||
<a class="btn btn-sm btn-outline-warning btn-row-action"
|
||||
target="_blank" rel="noopener"
|
||||
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-muted mt-1" style="font-size:0.7rem;">
|
||||
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span>
|
||||
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span>
|
||||
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span>
|
||||
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
|
||||
<div class="mt-2 d-flex flex-wrap gap-3" style="color:var(--text-light); font-size:0.75rem;">
|
||||
<span>started <code class="mono">{{ s.started_at or '-' }}</code></span>
|
||||
<span>last <code class="mono">{{ s.last_updated or '-' }}</code></span>
|
||||
<span>MAC <code class="mono">{{ s.mac or '-' }}</code></span>
|
||||
{% if s.current_stage %}<span class="mono">{{ s.current_stage }}</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if s.error %}
|
||||
<div class="alert alert-danger small py-2 mb-2 mt-3">
|
||||
<div class="alert alert-danger py-2 mb-2 mt-3" style="font-size:0.85rem;">
|
||||
<strong>Error:</strong> {{ s.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not is_inferred %}
|
||||
<div class="mt-3 laps-card" data-serial="{{ s.serial }}">
|
||||
<div class="text-muted small mb-1">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="eyebrow mb-2">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm laps-input"
|
||||
style="font-family: monospace; max-width: 22rem;"
|
||||
class="form-control form-control-sm laps-input mono"
|
||||
style="max-width: 22rem;"
|
||||
placeholder="paste LAPS password from Intune portal here"
|
||||
autocomplete="off"
|
||||
value="{{ s.laps_password or '' }}">
|
||||
@@ -166,8 +165,8 @@
|
||||
|
||||
{% if s.log_tail %}
|
||||
<details class="mt-3">
|
||||
<summary class="text-muted small">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
|
||||
<pre class="bg-light p-2 mt-2 small mb-0" style="max-height: 12rem; overflow-y: auto;">{% for line in s.log_tail %}{{ line }}
|
||||
<summary class="eyebrow" style="cursor:pointer;">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
|
||||
<pre class="mono p-2 mt-2 mb-0" style="max-height: 12rem; overflow-y: auto; background:var(--bg); border:1px solid var(--border); border-radius:0.25rem; font-size:0.78rem;">{% for line in s.log_tail %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,61 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Audit Log</h2>
|
||||
<span class="badge bg-secondary fs-6">{{ entries|length }} entries</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
Activity History
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if entries %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 180px;">Timestamp</th>
|
||||
<th style="width: 130px;">Source</th>
|
||||
<th style="width: 180px;">Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
{% set parts = entry.split(' ', 1) %}
|
||||
{% if parts|length == 2 %}
|
||||
{% set meta = parts[1].split('] ', 1) %}
|
||||
<td><small class="text-muted">{{ parts[0] }}</small></td>
|
||||
{% if meta|length == 2 %}
|
||||
<td><code>{{ meta[0].lstrip('[') }}</code></td>
|
||||
{% set action_detail = meta[1].split(': ', 1) %}
|
||||
{% if action_detail|length == 2 %}
|
||||
<td><span class="badge bg-primary">{{ action_detail[0] }}</span></td>
|
||||
<td>{{ action_detail[1] }}</td>
|
||||
{% else %}
|
||||
<td colspan="2">{{ meta[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="3">{{ parts[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="4">{{ entry }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No audit log entries yet.</p>
|
||||
<p class="small">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Audit Log</h1>
|
||||
<div class="header-actions">
|
||||
<span class="badge badge-secondary badge-lg">{{ entries|length }} entries</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Activity History</div>
|
||||
{% if entries %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 180px;">Timestamp</th>
|
||||
<th style="width: 130px;">Source</th>
|
||||
<th style="width: 180px;">Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
{% set parts = entry.split(' ', 1) %}
|
||||
{% if parts|length == 2 %}
|
||||
{% set meta = parts[1].split('] ', 1) %}
|
||||
<td class="mono">{{ parts[0] }}</td>
|
||||
{% if meta|length == 2 %}
|
||||
<td class="mono">{{ meta[0].lstrip('[') }}</td>
|
||||
{% set action_detail = meta[1].split(': ', 1) %}
|
||||
{% if action_detail|length == 2 %}
|
||||
<td><span class="badge badge-primary">{{ action_detail[0] }}</span></td>
|
||||
<td>{{ action_detail[1] }}</td>
|
||||
{% else %}
|
||||
<td colspan="2">{{ meta[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="3">{{ parts[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="4">{{ entry }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-list-check" style="font-size: 2rem; opacity: 0.4;"></i>
|
||||
<p class="mt-2 mb-1">No audit log entries yet.</p>
|
||||
<p class="small mb-0">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,132 +1,140 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Clonezilla Backups</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
Upload Backup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
Machine Backups
|
||||
<span class="badge bg-secondary ms-2">{{ backups|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if backups %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><strong>{{ b.machine }}</strong></td>
|
||||
<td><code>{{ b.filename }}</code></td>
|
||||
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
|
||||
<td>{{ b.modified | timestamp_fmt }}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Download">
|
||||
Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No backups found. Upload a Clonezilla backup .zip to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Backup Naming Convention</h6>
|
||||
<p class="card-text mb-0">
|
||||
Name backup files with the machine number (e.g., <code>6501.zip</code>).
|
||||
The Samba share <code>\\pxe-server\clonezilla</code> is also available on the network for direct Clonezilla save/restore operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Backup</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="backupFile" class="form-label">Backup File (.zip)</label>
|
||||
<input type="file" class="form-control" id="backupFile" name="backup_file"
|
||||
accept=".zip" required>
|
||||
<div class="form-text">
|
||||
Name the file with the machine number (e.g., <code>6501.zip</code>).
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
var machine = btn.getAttribute('data-machine');
|
||||
document.getElementById('deleteMachine').textContent = machine;
|
||||
document.getElementById('deleteForm').action = '/backups/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Clonezilla Backups</h1>
|
||||
<div class="header-actions">
|
||||
<button class="pxe-btn pxe-btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-upload"></i> Upload Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<i class="bi bi-hdd-stack me-2"></i>
|
||||
Machine Backups
|
||||
<span class="badge badge-secondary ms-2">{{ backups|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if backups %}
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><strong>{{ b.machine }}</strong></td>
|
||||
<td><span class="mono">{{ b.filename }}</span></td>
|
||||
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
|
||||
<td>{{ b.modified | timestamp_fmt }}</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
|
||||
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-hdd-stack" style="font-size: 2rem; opacity: 0.4;"></i>
|
||||
<p class="mt-2 mb-0">No backups found. Upload a Clonezilla backup .zip to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card mt-3">
|
||||
<div class="section-title">Backup Naming Convention</div>
|
||||
<p class="mb-0">
|
||||
Name backup files with the machine number (e.g., <span class="mono">6501.zip</span>).
|
||||
The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also available on the network for direct Clonezilla save/restore operations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Backup</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field mb-0">
|
||||
<label for="backupFile" class="form-label">Backup File (.zip)</label>
|
||||
<input type="file" class="form-control" id="backupFile" name="backup_file"
|
||||
accept=".zip" required>
|
||||
<div class="form-text">
|
||||
Name the file with the machine number (e.g., <span class="mono">6501.zip</span>).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-upload"></i> Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-danger">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
var machine = btn.getAttribute('data-machine');
|
||||
document.getElementById('deleteMachine').textContent = machine;
|
||||
document.getElementById('deleteForm').action = "{{ url_for('clonezilla_delete', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,228 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
background-color: #1a1d21;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar .nav-link {
|
||||
color: #adb5bd;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
.sidebar-heading {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #6c757d;
|
||||
padding: 1rem 1.25rem 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar .brand {
|
||||
padding: 1.2rem 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 2rem;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.active {
|
||||
background-color: #198754;
|
||||
}
|
||||
.status-dot.inactive {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.status-dot.unknown {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-row-action {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #6c757d;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.nav-section-divider {
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="brand">
|
||||
<img src="{{ url_for('static', filename='ge-aerospace-logo.svg') }}" alt="GE Aerospace" style="height: 28px; filter: brightness(0) invert(1);">
|
||||
PXE Manager
|
||||
</div>
|
||||
<ul class="nav flex-column mt-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('dashboard') }}">
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}"
|
||||
href="{{ url_for('images_import') }}">
|
||||
Image Import
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('imaging_dashboard') }}">
|
||||
Imaging Progress
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">Tools</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'startnet_editor' %}active{% endif %}"
|
||||
href="{{ url_for('startnet_editor') }}">
|
||||
startnet.cmd
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
|
||||
href="{{ url_for('clonezilla_backups') }}">
|
||||
Clonezilla Backups
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'blancco_reports' %}active{% endif %}"
|
||||
href="{{ url_for('blancco_reports') }}">
|
||||
Blancco Reports
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'enrollment' %}active{% endif %}"
|
||||
href="{{ url_for('enrollment') }}">
|
||||
Enrollment
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
|
||||
href="{{ url_for('audit_log') }}">
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">GE Aerospace Images</div>
|
||||
<ul class="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) }}">
|
||||
{{ all_friendly_names[it] }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('image_config', image_type=it) }}" style="padding-left: 2.5rem; font-size: 0.82rem;">
|
||||
Configuration
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">GE Legacy Images</div>
|
||||
<ul class="nav flex-column">
|
||||
{% for it in all_image_types if it.startswith('ge-') and not 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) }}">
|
||||
{{ all_friendly_names[it] }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('image_config', image_type=it) }}" style="padding-left: 2.5rem; font-size: 0.82rem;">
|
||||
Configuration
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="main-content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qr-render.js') }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='bootstrap-icons.min.css') }}" rel="stylesheet">
|
||||
<!-- pxe-theme.css loads AFTER bootstrap so its token+component rules win -->
|
||||
<link href="{{ url_for('static', filename='pxe-theme.css') }}" rel="stylesheet">
|
||||
<script>
|
||||
// Set the theme before first paint to avoid an unstyled flash.
|
||||
(function () {
|
||||
var KEY = 'pxe-theme';
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem(KEY); } catch (e) {}
|
||||
var theme = (stored === 'light' || stored === 'dark')
|
||||
? stored
|
||||
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="sidebar-header">
|
||||
<img class="sidebar-logo" src="{{ url_for('static', filename='ge-aerospace-logo.svg') }}" alt="GE Aerospace">
|
||||
<div class="brand-title">PXE Manager</div>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-nav nav flex-column mt-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('dashboard') }}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}"
|
||||
href="{{ url_for('images_import') }}">
|
||||
<i class="bi bi-box-arrow-in-down"></i> Image Import
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('imaging_dashboard') }}">
|
||||
<i class="bi bi-activity"></i> Imaging Progress
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section">Tools</div>
|
||||
<ul class="sidebar-nav nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint in ['startnet_editor', 'shopfloor_menu'] %}active{% endif %}"
|
||||
href="{{ url_for('startnet_editor') }}">
|
||||
<i class="bi bi-list-ol"></i> Boot Menu
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
|
||||
href="{{ url_for('clonezilla_backups') }}">
|
||||
<i class="bi bi-hdd-stack"></i> Clonezilla Backups
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'blancco_reports' %}active{% endif %}"
|
||||
href="{{ url_for('blancco_reports') }}">
|
||||
<i class="bi bi-shield-check"></i> Blancco Reports
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'enrollment' %}active{% endif %}"
|
||||
href="{{ url_for('enrollment') }}">
|
||||
<i class="bi bi-pc-display"></i> Enrollment
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
|
||||
href="{{ url_for('audit_log') }}">
|
||||
<i class="bi bi-list-check"></i> Audit Log
|
||||
</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-') %}
|
||||
<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>
|
||||
</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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<!-- Footer pinned to bottom -->
|
||||
<div class="sidebar-footer mt-auto">
|
||||
<button type="button" id="themeToggle" class="theme-toggle">
|
||||
<i class="bi bi-moon-stars" id="themeToggleIcon"></i>
|
||||
<span id="themeToggleLabel">Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="main-content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme toggle: flips data-theme (and data-bs-theme) and persists to localStorage.
|
||||
(function () {
|
||||
var KEY = 'pxe-theme';
|
||||
var btn = document.getElementById('themeToggle');
|
||||
var icon = document.getElementById('themeToggleIcon');
|
||||
var label = document.getElementById('themeToggleLabel');
|
||||
|
||||
function paintToggle(theme) {
|
||||
// Label/icon advertise the theme you would switch TO.
|
||||
if (theme === 'dark') {
|
||||
if (icon) icon.className = 'bi bi-sun';
|
||||
if (label) label.textContent = 'Light';
|
||||
} else {
|
||||
if (icon) icon.className = 'bi bi-moon-stars';
|
||||
if (label) label.textContent = 'Dark';
|
||||
}
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
try { localStorage.setItem(KEY, theme); } catch (e) {}
|
||||
paintToggle(theme);
|
||||
}
|
||||
|
||||
paintToggle(document.documentElement.getAttribute('data-theme') || 'light');
|
||||
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
apply(current === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qr-render.js') }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,241 +1,278 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Dashboard</h2>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
PXE Services
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Status</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for svc in services %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ svc.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
|
||||
{{ "Running" if svc.active else "Stopped" }}
|
||||
</td>
|
||||
<td><code>{{ svc.state }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Deployment Images</span>
|
||||
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
|
||||
+ New image type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Deploy Content</th>
|
||||
<th>unattend.xml</th>
|
||||
<th>Path</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ img.friendly_name }}</strong><br>
|
||||
<small class="text-muted">{{ img.image_type }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge bg-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_unattend %}
|
||||
<span class="badge bg-success">Exists</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Missing</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><code class="small">{{ img.deploy_path }}</code></td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('image_config', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-secondary me-1">
|
||||
Config
|
||||
</a>
|
||||
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-primary me-1">
|
||||
Unattend
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
|
||||
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
Clone
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New image type modal -->
|
||||
<div class="modal fade" id="newImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{{ url_for('images_new') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Key</label>
|
||||
<input type="text" class="form-control font-monospace" name="key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required
|
||||
placeholder="gea-shopfloor-newtype">
|
||||
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Friendly name</label>
|
||||
<input type="text" class="form-control" name="friendly_name" required
|
||||
placeholder="GE Aerospace Shop Floor (newtype)">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Empty image type. Populate via Image Import or Clone afterwards.
|
||||
</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-success">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone image modal -->
|
||||
<div class="modal fade" id="cloneImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="cloneForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Clone image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Source: <strong id="cloneSrcFriendly"></strong> <code id="cloneSrcKey"></code></p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New key</label>
|
||||
<input type="text" class="form-control font-monospace" name="dst_key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Friendly name (optional)</label>
|
||||
<input type="text" class="form-control" name="friendly_name"
|
||||
placeholder="leave blank for ‘<src> (copy)’">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Copies Deploy/ tree (Control + Tools + unattend) and preserves
|
||||
symlinks to shared dirs (Out-of-box Drivers, Operating Systems,
|
||||
Packages). Disk usage stays low because shared content is not
|
||||
duplicated.
|
||||
</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-success">Clone</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete image modal -->
|
||||
<div class="modal fade" id="deleteImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="deleteForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Remove <strong id="deleteSrcFriendly"></strong> <code id="deleteSrcKey"></code> from the registry.</p>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck">
|
||||
<label class="form-check-label" for="deleteContentCheck">
|
||||
Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed)
|
||||
</label>
|
||||
</div>
|
||||
<div class="alert alert-warning small">
|
||||
Removing from registry hides the image from the UI and Ansible
|
||||
playbook list. Existing PXE-imaged clients are unaffected. Wiping
|
||||
content is irreversible.
|
||||
</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-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('show.bs.modal', function(e) {
|
||||
var btn = e.relatedTarget;
|
||||
if (!btn) return;
|
||||
var srcKey = btn.getAttribute('data-src-key') || '';
|
||||
var srcFriendly = btn.getAttribute('data-src-friendly') || '';
|
||||
if (e.target.id === 'cloneImageModal') {
|
||||
e.target.querySelector('#cloneSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#cloneSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('cloneForm').action = '/images/' + encodeURIComponent(srcKey) + '/clone';
|
||||
} else if (e.target.id === 'deleteImageModal') {
|
||||
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('deleteForm').action = '/images/' + encodeURIComponent(srcKey) + '/delete';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('images_import') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-box-arrow-in-down"></i> Image Import
|
||||
</a>
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#newImageModal">
|
||||
<i class="bi bi-plus-lg"></i> New image type
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- At-a-glance stats -->
|
||||
<div class="dashboard-grid mb-4">
|
||||
{% set services_up = services | selectattr('active') | list | length %}
|
||||
{% set services_total = services | list | length %}
|
||||
<div class="stat-card {{ 'success' if services_up == services_total else 'danger' }}">
|
||||
<div class="label"><i class="bi bi-hdd-network"></i> Services running</div>
|
||||
<div class="value">{{ services_up }} / {{ services_total }}</div>
|
||||
</div>
|
||||
{% set images_total = images | list | length %}
|
||||
<div class="stat-card info">
|
||||
<div class="label"><i class="bi bi-hdd-stack"></i> Image types</div>
|
||||
<div class="value">{{ images_total }}</div>
|
||||
</div>
|
||||
{% set images_ready = images | selectattr('has_content') | list | length %}
|
||||
<div class="stat-card success">
|
||||
<div class="label"><i class="bi bi-check2-circle"></i> Images with content</div>
|
||||
<div class="value">{{ images_ready }}</div>
|
||||
</div>
|
||||
{% set unattend_missing = images | rejectattr('has_unattend') | list | length %}
|
||||
<div class="stat-card {{ 'warning' if unattend_missing else 'success' }}">
|
||||
<div class="label"><i class="bi bi-file-earmark-code"></i> Missing unattend.xml</div>
|
||||
<div class="value">{{ unattend_missing }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-hdd-network"></i> PXE Services</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-container">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Status</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for svc in services %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ svc.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
|
||||
{{ "Running" if svc.active else "Stopped" }}
|
||||
</td>
|
||||
<td><span class="mono">{{ svc.state }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-hdd-stack"></i> Deployment Images</span>
|
||||
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
|
||||
<i class="bi bi-plus-lg"></i> New image type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-container">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Deploy Content</th>
|
||||
<th>unattend.xml</th>
|
||||
<th>Path</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ img.friendly_name }}</strong><br>
|
||||
<small class="text-muted mono">{{ img.image_type }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge bg-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_unattend %}
|
||||
<span class="badge bg-success">Exists</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Missing</span>
|
||||
{% endif %}
|
||||
</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) }}"
|
||||
class="btn btn-sm btn-outline-secondary btn-row-action">
|
||||
<i class="bi bi-gear"></i> Config
|
||||
</a>
|
||||
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-primary btn-row-action">
|
||||
<i class="bi bi-file-earmark-code"></i> Unattend
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
<i class="bi bi-files"></i> Clone
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New image type modal -->
|
||||
<div class="modal fade" id="newImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{{ url_for('images_new') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Key</label>
|
||||
<input type="text" class="form-control mono" name="key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required
|
||||
placeholder="gea-shopfloor-newtype">
|
||||
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Friendly name</label>
|
||||
<input type="text" class="form-control" name="friendly_name" required
|
||||
placeholder="GE Aerospace Shop Floor (newtype)">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Empty image type. Populate via Image Import or Clone afterwards.
|
||||
</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-success">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone image modal -->
|
||||
<div class="modal fade" id="cloneImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="cloneForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Clone image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Source: <strong id="cloneSrcFriendly"></strong> <span class="mono" id="cloneSrcKey"></span></p>
|
||||
<div class="form-field">
|
||||
<label class="form-label">New key</label>
|
||||
<input type="text" class="form-control mono" name="dst_key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Friendly name (optional)</label>
|
||||
<input type="text" class="form-control" name="friendly_name"
|
||||
placeholder="leave blank for ‘<src> (copy)’">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Copies Deploy/ tree (Control + Tools + unattend) and preserves
|
||||
symlinks to shared dirs (Out-of-box Drivers, Operating Systems,
|
||||
Packages). Disk usage stays low because shared content is not
|
||||
duplicated.
|
||||
</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-success">Clone</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete image modal -->
|
||||
<div class="modal fade" id="deleteImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="deleteForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Remove <strong id="deleteSrcFriendly"></strong> <span class="mono" id="deleteSrcKey"></span> from the registry.</p>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck">
|
||||
<label class="form-check-label" for="deleteContentCheck">
|
||||
Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed)
|
||||
</label>
|
||||
</div>
|
||||
<div class="alert alert-warning small">
|
||||
Removing from registry hides the image from the UI and Ansible
|
||||
playbook list. Existing PXE-imaged clients are unaffected. Wiping
|
||||
content is irreversible.
|
||||
</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-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('show.bs.modal', function(e) {
|
||||
var btn = e.relatedTarget;
|
||||
if (!btn) return;
|
||||
var srcKey = btn.getAttribute('data-src-key') || '';
|
||||
var srcFriendly = btn.getAttribute('data-src-friendly') || '';
|
||||
if (e.target.id === 'cloneImageModal') {
|
||||
e.target.querySelector('#cloneSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#cloneSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('cloneForm').action = "{{ url_for('images_clone', image_type='__K__') }}".replace('__K__', encodeURIComponent(srcKey));
|
||||
} else if (e.target.id === 'deleteImageModal') {
|
||||
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('deleteForm').action = "{{ url_for('images_delete', image_type='__K__') }}".replace('__K__', encodeURIComponent(srcKey));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,135 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Enrollment Packages</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
Upload Package
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
GCCH Provisioning Packages
|
||||
<span class="badge bg-secondary ms-2">{{ packages|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if packages %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
<tr>
|
||||
<td><code>{{ p.filename }}</code></td>
|
||||
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
|
||||
<td>{{ p.modified | timestamp_fmt }}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{{ url_for('enrollment_download', filename=p.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Download">
|
||||
Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ p.filename }}" title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No enrollment packages found. Upload a <code>.ppkg</code> file to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">About Enrollment Packages</h6>
|
||||
<p class="card-text">
|
||||
GCCH enrollment <code>.ppkg</code> provisioning packages are copied to
|
||||
<code>C:\Enrollment\</code> on the target machine after imaging.
|
||||
At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>,
|
||||
then browse to <code>C:\Enrollment\</code> and select the package. No USB stick needed.
|
||||
</p>
|
||||
<p class="card-text mb-0">
|
||||
<strong>Naming convention:</strong> Use <code>with-office.ppkg</code> and
|
||||
<code>without-office.ppkg</code> to match the WinPE enrollment menu options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Enrollment Package</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label>
|
||||
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
|
||||
accept=".ppkg" required>
|
||||
<div class="form-text">
|
||||
Use <code>with-office.ppkg</code> or <code>without-office.ppkg</code> to match the WinPE boot menu.
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = '/enrollment/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-pc-display"></i> Enrollment Packages</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-upload"></i> Upload Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if active_ppkg and active_ppkg.SOURCE_PPKG %}
|
||||
<div class="alert alert-info">
|
||||
<strong>Active PPKG:</strong> <span class="mono">{{ active_ppkg.SOURCE_PPKG }}</span>
|
||||
<span class="badge badge-info">{{ active_ppkg.PPKG_VER }}</span>
|
||||
{% if active_ppkg.PPKG_EXP %}<span class="badge badge-secondary">exp {{ active_ppkg.PPKG_EXP }}</span>{% endif %}
|
||||
<span class="form-text">- startnet.cmd boots with this (from <span class="mono">ppkg.conf</span>).</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-box-seam"></i> GCCH Provisioning Packages</span>
|
||||
<span class="badge badge-secondary">{{ packages|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if packages %}
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
<tr>
|
||||
<td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span></td>
|
||||
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
|
||||
<td>{{ p.modified | timestamp_fmt }}</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
<a href="{{ url_for('enrollment_download', filename=p.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ p.filename }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 2.5rem; opacity: 0.5;"></i>
|
||||
<p class="mt-2 mb-0">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pxe-card">
|
||||
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> About Enrollment Packages</h3>
|
||||
<p>
|
||||
GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to
|
||||
<span class="mono">C:\Enrollment\</span> on the target machine after imaging.
|
||||
At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>,
|
||||
then browse to <span class="mono">C:\Enrollment\</span> and select the package. No USB stick needed.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Naming convention:</strong> Use <span class="mono">with-office.ppkg</span> and
|
||||
<span class="mono">without-office.ppkg</span> to match the WinPE enrollment menu options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Enrollment Package</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label>
|
||||
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
|
||||
accept=".ppkg" required>
|
||||
<div class="form-text">
|
||||
A standard <span class="mono">GCCH_Prod_SFLD_v<ver>.ppkg</span> upload is set as the active PPKG automatically
|
||||
(written to <span class="mono">ppkg.conf</span> on the share; startnet.cmd reads it at boot - no boot.wim edit).
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="ppkgExpiry" class="form-label">Expiry (optional)</label>
|
||||
<input type="text" class="form-control mono" id="ppkgExpiry" name="expiry"
|
||||
placeholder="YYYYMMDD" pattern="\d{8}">
|
||||
<div class="form-text">Blank keeps the current expiry.</div>
|
||||
</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"><i class="bi bi-upload"></i> Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename" class="mono"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = "{{ url_for('enrollment_delete', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,458 +1,492 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ friendly_name }} - Configuration{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.section-card { margin-bottom: 1.5rem; }
|
||||
.section-card .card-header { padding: 0.6rem 1rem; font-size: 0.95rem; }
|
||||
.badge-disk { font-size: 0.75rem; }
|
||||
.orphan-section { background-color: #fff8e1; }
|
||||
.config-table td, .config-table th { vertical-align: middle; }
|
||||
.config-table .form-control-sm { min-width: 120px; }
|
||||
.text-truncate-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">{{ friendly_name }}</h2>
|
||||
<small class="text-muted">
|
||||
Image Configuration
|
||||
— OS Selection: <strong>{{ config.os_selection or 'Not set' }}</strong>
|
||||
</small>
|
||||
</div>
|
||||
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
|
||||
Edit Unattend
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 1: Hardware Models ==================== #}
|
||||
<div class="card section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Hardware Models
|
||||
<span class="badge bg-secondary ms-1">{{ config.hardware_models|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
|
||||
Add
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success ms-1" id="saveHwModels">
|
||||
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>
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="hwModelsTable">
|
||||
<thead class="table-light">
|
||||
<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 bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% 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 section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Driver Packs
|
||||
<span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
|
||||
Upload .zip
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success ms-1" id="saveDrivers">
|
||||
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-responsive">
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="driversTable">
|
||||
<thead class="table-light">
|
||||
<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" title="{{ drv.FileName or drv.get('fileName','') }}">
|
||||
<small>{{ drv.FileName or drv.get('fileName','') }}</small>
|
||||
</td>
|
||||
<td><small>{{ drv.osId }}</small></td>
|
||||
<td>
|
||||
{% if drv._on_disk %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
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="card-footer orphan-section p-0">
|
||||
<div class="px-3 py-2 d-flex justify-content-between align-items-center">
|
||||
<a class="text-decoration-none" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
|
||||
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
|
||||
<small class="text-muted ms-1">.zip files on disk, no JSON entry</small>
|
||||
</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() }}">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<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><small>{{ orph.fileName }}</small></td>
|
||||
<td><small class="text-muted">{{ orph.relPath }}</small></td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('images_drivers_adopt', image_type=image_type) }}"
|
||||
class="d-flex gap-1">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="filename" value="{{ orph.fileName }}">
|
||||
<input type="text" class="form-control form-control-sm" name="family"
|
||||
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
|
||||
<input type="text" class="form-control form-control-sm" name="destination_dir"
|
||||
placeholder="destinationDir" required style="width:11rem;">
|
||||
<button type="submit" class="btn btn-sm btn-success">Adopt</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="px-3 py-2 text-end">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
<h5 class="modal-title">Upload driver .zip</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<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="mb-3">
|
||||
<label class="form-label">Family ID (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" 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="mb-3">
|
||||
<label class="form-label">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" 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 section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Operating Systems
|
||||
<span class="badge bg-secondary ms-1">{{ config.operating_systems|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-success" id="saveOs">
|
||||
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>
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="osTable">
|
||||
<thead class="table-light">
|
||||
<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>{{ osv.id }}</td>
|
||||
<td>
|
||||
{% if osv.isActive %}
|
||||
<span class="badge bg-success badge-disk">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary badge-disk">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry._on_disk %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% 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 section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Packages
|
||||
<span class="badge bg-secondary ms-1">{{ config.packages|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
|
||||
Upload file
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success ms-1" id="savePackages">
|
||||
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-responsive">
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="packagesTable">
|
||||
<thead class="table-light">
|
||||
<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 }}"><small>{{ pkg.name }}</small></td>
|
||||
<td class="text-truncate-cell" title="{{ pkg.comment }}"><small>{{ pkg.comment }}</small></td>
|
||||
<td class="text-truncate-cell" title="{{ pkg.fileName or pkg.get('FileName','') }}">
|
||||
<small>{{ pkg.fileName or pkg.get('FileName','') }}</small>
|
||||
</td>
|
||||
<td><small>{{ pkg.osId }}</small></td>
|
||||
<td>
|
||||
{% if pkg.enabled %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if pkg._on_disk %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
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">
|
||||
<h5 class="modal-title">Upload package</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<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="mb-3">
|
||||
<label class="form-label">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" 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 %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -18,38 +18,41 @@ function cancelImagingReload() {
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="mb-0">Imaging Progress</h2>
|
||||
<small class="text-muted">Live via SSE (15s polling fallback). Client pushes -> <code>/imaging/status</code>; log-inferred bays in yellow.</small>
|
||||
<h1>Imaging Progress</h1>
|
||||
<small class="text-light">Live via SSE (15s polling fallback). Client pushes → <code class="mono">/imaging/status</code>; log-inferred bays in yellow.</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:#6c757d;"></span>
|
||||
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
|
||||
<div class="header-actions">
|
||||
<span class="status-indicator">
|
||||
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:var(--secondary);"></span>
|
||||
<span class="badge badge-secondary badge-lg"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
|
||||
</span>
|
||||
{% if sessions %}
|
||||
<form method="post" action="{{ url_for('imaging_delete_all') }}"
|
||||
onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');"
|
||||
style="display:inline;">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Clear all</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-danger pxe-btn-sm">
|
||||
<i class="bi bi-trash"></i> Clear all
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-field mb-3">
|
||||
<input id="imaging-search" type="search" class="form-control form-control-sm"
|
||||
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
{% if not sessions %}
|
||||
<div id="imaging-empty" class="card">
|
||||
<div class="card-body text-center text-muted py-5">
|
||||
<p class="mb-1">No imaging sessions yet.</p>
|
||||
<p class="small mb-0">A PC being imaged will post status here, or appear
|
||||
automatically once it touches DHCP / TFTP / boot.wim.</p>
|
||||
</div>
|
||||
<div id="imaging-empty" class="pxe-card text-center text-light" style="padding: 3rem 1.25rem;">
|
||||
<p class="mb-1"><i class="bi bi-hdd-network" style="font-size: 1.75rem; opacity: 0.5;"></i></p>
|
||||
<p class="mb-1">No imaging sessions yet.</p>
|
||||
<p class="small mb-0">A PC being imaged will post status here, or appear
|
||||
automatically once it touches DHCP / TFTP / boot.wim.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -58,10 +61,10 @@ function cancelImagingReload() {
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body small text-muted">
|
||||
<strong>How to push status from an imaging client:</strong>
|
||||
<pre class="mb-0 mt-2">POST http://172.16.9.1:9009/imaging/status
|
||||
<div class="section-card mt-3">
|
||||
<div class="section-title">How to push status from an imaging client</div>
|
||||
<div class="pxe-card-body">
|
||||
<pre class="mono mb-0" style="white-space: pre-wrap;">POST http://172.16.9.1:9009/imaging/status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -140,19 +143,19 @@ Content-Type: application/json
|
||||
|
||||
function startSSE() {
|
||||
if (!window.EventSource) {
|
||||
setDot('#ffc107', 'EventSource unsupported - polling only');
|
||||
setDot('var(--warning)', 'EventSource unsupported - polling only');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sse = new EventSource(STREAM_URL);
|
||||
} catch (e) {
|
||||
setDot('#dc3545', 'SSE failed - polling');
|
||||
setDot('var(--danger)', 'SSE failed - polling');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
sse.onopen = function() {
|
||||
setDot('#198754', 'live stream connected');
|
||||
setDot('var(--success)', 'live stream connected');
|
||||
stopPolling();
|
||||
};
|
||||
sse.onmessage = function(ev) {
|
||||
@@ -163,7 +166,7 @@ Content-Type: application/json
|
||||
refreshTiles(false);
|
||||
};
|
||||
sse.onerror = function() {
|
||||
setDot('#dc3545', 'live stream lost - polling fallback');
|
||||
setDot('var(--danger)', 'live stream lost - polling fallback');
|
||||
try { sse.close(); } catch (e) {}
|
||||
sse = null;
|
||||
startPolling();
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
{% block title %}{{ session.serial }} - Imaging Session{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="mb-0">{{ session.serial }}
|
||||
{% if session.hostname_target %}<small class="text-muted">{{ session.hostname_target }}</small>{% endif %}
|
||||
</h2>
|
||||
<small class="text-muted">
|
||||
{% if session.pctype %}<span class="badge bg-info text-dark">{{ session.pctype }}</span>{% endif %}
|
||||
{% if session.machinenumber %}<span class="badge bg-secondary">#{{ session.machinenumber }}</span>{% endif %}
|
||||
<span class="badge bg-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
|
||||
</small>
|
||||
<h1>{{ session.serial }}
|
||||
{% if session.hostname_target %}<span class="text-light" style="font-size:14px; font-weight:400;">{{ session.hostname_target }}</span>{% endif %}
|
||||
</h1>
|
||||
<div style="margin-top:0.5rem; display:flex; gap:0.4rem; flex-wrap:wrap;">
|
||||
{% if session.pctype %}<span class="badge badge-info">{{ session.pctype }}</span>{% endif %}
|
||||
{% if session.machinenumber %}<span class="badge badge-secondary">#{{ session.machinenumber }}</span>{% endif %}
|
||||
<span class="badge badge-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||
« Back to dashboard
|
||||
<i class="bi bi-arrow-left"></i> Back to dashboard
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm copy-summary-btn">
|
||||
Copy support summary
|
||||
<button type="button" class="btn btn-primary btn-sm copy-summary-btn">
|
||||
<i class="bi bi-clipboard"></i> Copy support summary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,101 +26,97 @@
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Session metadata</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-4">Serial</dt><dd class="col-8"><code>{{ session.serial }}</code></dd>
|
||||
<dt class="col-4">Hostname</dt><dd class="col-8"><code>{{ session.hostname_target or '-' }}</code></dd>
|
||||
<dt class="col-4">MAC</dt><dd class="col-8"><code>{{ session.mac or '-' }}</code></dd>
|
||||
<dt class="col-4">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
|
||||
<dt class="col-4">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
|
||||
<dt class="col-4">Intune ID</dt><dd class="col-8">
|
||||
{% if session.intune_device_id %}
|
||||
<code style="word-break: break-all;">{{ session.intune_device_id }}</code>
|
||||
{% else %}-{% endif %}
|
||||
</dd>
|
||||
<dt class="col-4">Started</dt><dd class="col-8"><code>{{ session.started_at or '-' }}</code></dd>
|
||||
<dt class="col-4">Last update</dt><dd class="col-8"><code>{{ session.last_updated or '-' }}</code></dd>
|
||||
<dt class="col-4">Stage</dt><dd class="col-8">
|
||||
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
||||
{% if session.current_stage %} - <code class="small">{{ session.current_stage }}</code>{% endif %}
|
||||
</dd>
|
||||
{% if session.previous_run_at %}
|
||||
<dt class="col-4">Prior run</dt><dd class="col-8"><code>{{ session.previous_run_at }}</code></dd>
|
||||
{% endif %}
|
||||
{% if session.error %}
|
||||
<dt class="col-4 text-danger">Error</dt>
|
||||
<dd class="col-8 text-danger">{{ session.error }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="section-card">
|
||||
<div class="section-title">Session metadata</div>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-4 text-light">Serial</dt><dd class="col-8"><span class="mono">{{ session.serial }}</span></dd>
|
||||
<dt class="col-4 text-light">Hostname</dt><dd class="col-8"><span class="mono">{{ session.hostname_target or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">MAC</dt><dd class="col-8"><span class="mono">{{ session.mac or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
|
||||
<dt class="col-4 text-light">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
|
||||
<dt class="col-4 text-light">Intune ID</dt><dd class="col-8">
|
||||
{% if session.intune_device_id %}
|
||||
<span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span>
|
||||
{% else %}-{% endif %}
|
||||
</dd>
|
||||
<dt class="col-4 text-light">Started</dt><dd class="col-8"><span class="mono">{{ session.started_at or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">Last update</dt><dd class="col-8"><span class="mono">{{ session.last_updated or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">Stage</dt><dd class="col-8">
|
||||
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
||||
{% if session.current_stage %} - <span class="mono">{{ session.current_stage }}</span>{% endif %}
|
||||
</dd>
|
||||
{% if session.previous_run_at %}
|
||||
<dt class="col-4 text-light">Prior run</dt><dd class="col-8"><span class="mono">{{ session.previous_run_at }}</span></dd>
|
||||
{% endif %}
|
||||
{% if session.error %}
|
||||
<dt class="col-4 text-danger">Error</dt>
|
||||
<dd class="col-8 text-danger">{{ session.error }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Stage timeline</div>
|
||||
<div class="card-body p-0">
|
||||
{% if session.stage_history %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<div class="section-card">
|
||||
<div class="section-title">Stage timeline</div>
|
||||
{% if session.stage_history %}
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for h in session.stage_history %}
|
||||
<tr>
|
||||
<td class="small"><code>{{ h.ts }}</code></td>
|
||||
<td><span class="badge bg-secondary">{{ h.stage_index }}</span></td>
|
||||
<td><span class="mono">{{ h.ts }}</span></td>
|
||||
<td><span class="badge badge-secondary">{{ h.stage_index }}</span></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
||||
<span class="badge badge-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
||||
{{ h.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small"><code>{{ h.current_stage or '' }}</code></td>
|
||||
<td><span class="mono">{{ h.current_stage or '' }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="p-3 text-muted small">
|
||||
No stage transitions recorded yet. The client only logs a row when
|
||||
stage_index advances or status flips to succeeded/failed.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-indicator">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>No stage transitions recorded yet. The client only logs a row when
|
||||
stage_index advances or status flips to succeeded/failed.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="section-card">
|
||||
<div class="section-title" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>Full log
|
||||
{% if full_log_truncated %}
|
||||
<span class="badge bg-warning text-dark ms-2">truncated (last 1 MB)</span>
|
||||
<span class="badge badge-warning" style="margin-left:0.5rem;">truncated (last 1 MB)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary copy-log-btn">Copy</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm copy-log-btn"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if full_log %}
|
||||
<pre id="full-log" class="bg-light p-2 mb-0 small" style="max-height: 36rem; overflow-y: auto; white-space: pre-wrap;">{{ full_log }}</pre>
|
||||
{% else %}
|
||||
<div class="p-3 text-muted small">
|
||||
No log content. The client has not pushed any <code>log_lines</code>
|
||||
entries yet, or the sidecar file was cleared.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if full_log %}
|
||||
<pre id="full-log" class="mono mb-0" style="max-height: 36rem; overflow: auto; white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{{ full_log }}</pre>
|
||||
{% else %}
|
||||
<div class="status-indicator">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>No log content. The client has not pushed any <code>log_lines</code>
|
||||
entries yet, or the sidecar file was cleared.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if session.log_tail %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
|
||||
<div class="card-body p-0">
|
||||
<pre class="bg-light p-2 mb-0 small" style="max-height: 12rem; overflow-y: auto;">{% for line in session.log_tail %}{{ line }}
|
||||
<div class="section-card">
|
||||
<div class="section-title">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
|
||||
<pre class="mono mb-0" style="max-height: 12rem; overflow: auto; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for line in session.log_tail %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -153,15 +149,17 @@ function copyText(text) {
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('copy-log-btn')) {
|
||||
var logBtn = e.target.closest('.copy-log-btn');
|
||||
var summaryBtn = e.target.closest('.copy-summary-btn');
|
||||
if (logBtn) {
|
||||
var pre = document.getElementById('full-log');
|
||||
if (pre) {
|
||||
copyText(pre.textContent).then(function() {
|
||||
e.target.textContent = 'copied!';
|
||||
setTimeout(function() { e.target.textContent = 'Copy'; }, 1200);
|
||||
logBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
|
||||
setTimeout(function() { logBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy'; }, 1200);
|
||||
});
|
||||
}
|
||||
} else if (e.target.classList.contains('copy-summary-btn')) {
|
||||
} else if (summaryBtn) {
|
||||
var lines = [];
|
||||
document.querySelectorAll('dl dt').forEach(function(dt) {
|
||||
var dd = dt.nextElementSibling;
|
||||
@@ -182,8 +180,8 @@ document.addEventListener('click', function(e) {
|
||||
});
|
||||
}
|
||||
copyText(lines.join('\n')).then(function() {
|
||||
e.target.textContent = 'copied!';
|
||||
setTimeout(function() { e.target.textContent = 'Copy support summary'; }, 1200);
|
||||
summaryBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
|
||||
setTimeout(function() { summaryBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy support summary'; }, 1200);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,192 +1,180 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Image Import - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">Image Import</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Network Upload Import -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
Import from Network Upload
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if upload_sources %}
|
||||
<form method="POST" id="uploadImportForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="uploadSource" class="form-label fw-semibold">Source</label>
|
||||
<select class="form-select" name="source" id="uploadSource" required>
|
||||
<option value="">-- Select upload source --</option>
|
||||
{% for src in upload_sources %}
|
||||
<option value="{{ src }}">{{ src }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Files uploaded via SMB to <code>\\172.16.9.1\image-upload</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="uploadTarget" class="form-label fw-semibold">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="uploadTarget" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory. Shared resources
|
||||
(Out-of-box Drivers) are stored once and linked across all image types.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info d-flex align-items-start" role="alert">
|
||||
<div>
|
||||
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
|
||||
into a shared directory and symlinked for each image type to save disk space.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
||||
<div>
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="uploadImportBtn">
|
||||
Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<h5 class="mt-3 text-muted">No Upload Content Found</h5>
|
||||
<p class="text-muted mb-0">
|
||||
Map <code>\\172.16.9.1\image-upload</code> on your Windows PC and copy
|
||||
the Deploy directory contents there.
|
||||
</p>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB Import -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Import from USB Drive
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if usb_mounts %}
|
||||
<form method="POST" id="importForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="source" class="form-label fw-semibold">Source (USB Mount Point)</label>
|
||||
<select class="form-select" name="source" id="source" required>
|
||||
<option value="">-- Select a mounted USB drive --</option>
|
||||
{% for mount in usb_mounts %}
|
||||
<option value="{{ mount }}">{{ mount }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Select the mounted USB drive containing the WinPE deployment content.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="target" class="form-label fw-semibold">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="target" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory for this image type.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
||||
<div>
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="importBtn">
|
||||
Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<h5 class="mt-3 text-muted">No USB Drives Detected</h5>
|
||||
<p class="text-muted mb-0">
|
||||
No mounted USB drives were found under <code>/mnt/</code> or <code>/media/</code>.<br>
|
||||
Mount a USB drive and refresh this page.
|
||||
</p>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Current Image Status
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td class="small">{{ img.friendly_name }}</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge bg-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
['importForm', 'uploadImportForm'].forEach(function(formId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
var btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Image Import - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-box-arrow-in-down"></i> Image Import</h1>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Network Upload Import -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-hdd-network"></i> Import from Network Upload</div>
|
||||
{% if upload_sources %}
|
||||
<form method="POST" id="uploadImportForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field">
|
||||
<label for="uploadSource" class="form-label">Source</label>
|
||||
<select class="form-select" name="source" id="uploadSource" required>
|
||||
<option value="">-- Select upload source --</option>
|
||||
{% for src in upload_sources %}
|
||||
<option value="{{ src }}">{{ src }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Files uploaded via SMB to <code class="mono">\\172.16.9.1\image-upload</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="uploadTarget" class="form-label">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="uploadTarget" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory. Shared resources
|
||||
(Out-of-box Drivers) are stored once and linked across all image types.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
|
||||
into a shared directory and symlinked for each image type to save disk space.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" id="uploadImportBtn">
|
||||
<i class="bi bi-play-fill"></i> Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-cloud-slash" style="font-size:2rem;color:var(--text-light);"></i>
|
||||
<h5 class="mt-3" style="color:var(--text-light);">No Upload Content Found</h5>
|
||||
<p class="mb-0" style="color:var(--text-light);">
|
||||
Map <code class="mono">\\172.16.9.1\image-upload</code> on your Windows PC and copy
|
||||
the Deploy directory contents there.
|
||||
</p>
|
||||
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- USB Import -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-usb-drive"></i> Import from USB Drive</div>
|
||||
{% if usb_mounts %}
|
||||
<form method="POST" id="importForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field">
|
||||
<label for="source" class="form-label">Source (USB Mount Point)</label>
|
||||
<select class="form-select" name="source" id="source" required>
|
||||
<option value="">-- Select a mounted USB drive --</option>
|
||||
{% for mount in usb_mounts %}
|
||||
<option value="{{ mount }}">{{ mount }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Select the mounted USB drive containing the WinPE deployment content.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="target" class="form-label">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="target" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory for this image type.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" id="importBtn">
|
||||
<i class="bi bi-play-fill"></i> Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-usb-drive" style="font-size:2rem;color:var(--text-light);"></i>
|
||||
<h5 class="mt-3" style="color:var(--text-light);">No USB Drives Detected</h5>
|
||||
<p class="mb-0" style="color:var(--text-light);">
|
||||
No mounted USB drives were found under <code class="mono">/mnt/</code> or <code class="mono">/media/</code>.<br>
|
||||
Mount a USB drive and refresh this page.
|
||||
</p>
|
||||
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-list-check"></i> Current Image Status</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td>{{ img.friendly_name }}</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge badge-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
['importForm', 'uploadImportForm'].forEach(function(formId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
var btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,86 +24,77 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
|
||||
<div class="mb-2">
|
||||
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn-link" style="padding:0;">← All reports</a>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="text-muted small mb-1">
|
||||
<a href="{{ url_for('blancco_reports') }}" class="text-decoration-none">← All reports</a>
|
||||
</div>
|
||||
<h2 class="mb-0">Drive Erasure Certificate</h2>
|
||||
<code class="small text-muted">{{ filename }}</code>
|
||||
<h1>Drive Erasure Certificate</h1>
|
||||
<code class="mono" style="color:var(--text-light);">{{ filename }}</code>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('blancco_download_report', filename=filename) }}"
|
||||
class="btn btn-sm btn-outline-primary">Download XML</a>
|
||||
class="pxe-btn pxe-btn-ghost">
|
||||
<i class="bi bi-download"></i> Download XML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hero summary: state badge + standard + drive count + erasure datetime #}
|
||||
{% set first = (data.erasures or [{}])[0] %}
|
||||
{% set overall_state = first.state or 'Unknown' %}
|
||||
<div class="card border-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }} mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Overall result</div>
|
||||
<div class="fs-3">
|
||||
<span class="badge bg-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }}">
|
||||
{{ overall_state }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Erasure standard</div>
|
||||
<div>{{ first.erasure_standard_name or '-' }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Drives erased</div>
|
||||
<div class="fs-5">{{ data.erasures | length }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Elapsed time</div>
|
||||
<div class="fs-5"><code>{{ first.elapsed_time or '-' }}</code></div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Erased at</div>
|
||||
<div><code class="small">{{ first.timestamp or meta.date or '-' }}</code></div>
|
||||
</div>
|
||||
{% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' %}
|
||||
{% set overall_stat = 'success' if overall_state == 'Successful' else 'danger' if overall_state else '' %}
|
||||
<div class="dashboard-grid mb-4">
|
||||
<div class="stat-card {{ overall_stat }}">
|
||||
<div class="label">Overall result</div>
|
||||
<div class="value" style="font-size:1.5rem;">
|
||||
<span class="badge badge-lg bg-{{ overall_tone }}">{{ overall_state }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Erasure standard</div>
|
||||
<div class="value" style="font-size:1.05rem;">{{ first.erasure_standard_name or '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Drives erased</div>
|
||||
<div class="value">{{ data.erasures | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Elapsed time</div>
|
||||
<div class="value mono" style="font-size:1.5rem;">{{ first.elapsed_time or '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Erased at</div>
|
||||
<div class="value mono" style="font-size:1rem;">{{ first.timestamp or meta.date or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Customer</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
|
||||
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
|
||||
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
|
||||
{% for k, v in company.items() %}
|
||||
{% if k not in ['business_name','business_location','customer_license'] %}
|
||||
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-grid mb-3">
|
||||
<div class="section-card">
|
||||
<div class="section-title">Customer</div>
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
|
||||
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
|
||||
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
|
||||
{% for k, v in company.items() %}
|
||||
{% if k not in ['business_name','business_location','customer_license'] %}
|
||||
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">System</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
|
||||
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
|
||||
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code>{{ sysinfo.serial or '-' }}</code></dd>
|
||||
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd>
|
||||
<dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge bg-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd>
|
||||
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card">
|
||||
<div class="section-title">System</div>
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
|
||||
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
|
||||
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code class="mono">{{ sysinfo.serial or '-' }}</code></dd>
|
||||
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd>
|
||||
<dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge badge-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd>
|
||||
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,48 +102,48 @@
|
||||
{% for er in data.erasures %}
|
||||
{% set t = er.target or {} %}
|
||||
{% set state = er.state or 'Unknown' %}
|
||||
{% set border = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
|
||||
{% set tone = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
|
||||
{% set steps = (er.steps or {}).step %}
|
||||
{% if steps is not iterable or steps is string or steps is mapping %}
|
||||
{% set steps = [steps] if steps else [] %}
|
||||
{% endif %}
|
||||
<div class="card border-{{ border }} mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong>
|
||||
{{ t.vendor or '' }} {{ t.model or '(unknown drive)' }}
|
||||
<code class="ms-2 small text-muted">{{ t.serial or '-' }}</code>
|
||||
<code class="mono ms-2" style="color:var(--text-light);">{{ t.serial or '-' }}</code>
|
||||
</div>
|
||||
<span class="badge bg-{{ border }}">{{ state }}</span>
|
||||
<span class="badge bg-{{ tone }}">{{ state }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Interface</div>
|
||||
<div class="eyebrow">Interface</div>
|
||||
<div>{{ t.interface_type or '-' }}</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Capacity</div>
|
||||
<div class="eyebrow">Capacity</div>
|
||||
<div>{{ hbytes(t.capacity or 0) }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Health</div>
|
||||
<div class="eyebrow">Health</div>
|
||||
<div>{{ t.health or '-' }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Errors</div>
|
||||
<div class="eyebrow">Errors</div>
|
||||
<div>{{ er.total_errors or 0 }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Sectors</div>
|
||||
<div class="eyebrow">Sectors</div>
|
||||
<div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if steps %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<div class="table-container">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:3rem;">#</th>
|
||||
<th>Step</th>
|
||||
@@ -167,13 +158,13 @@
|
||||
<tr>
|
||||
<td>{{ s.number or loop.index }}</td>
|
||||
<td>{{ s.type or '-' }}</td>
|
||||
<td class="small text-muted">{{ s.pattern or '-' }}</td>
|
||||
<td class="small" style="color:var(--text-light);">{{ s.pattern or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}">
|
||||
{{ s.state or '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td><code class="small">{{ s.elapsed_time or '-' }}</code></td>
|
||||
<td><code class="mono">{{ s.elapsed_time or '-' }}</code></td>
|
||||
<td>{{ s.errors or 0 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -185,27 +176,27 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header">Document signing & provenance</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-3">Document ID</dt>
|
||||
<dd class="col-sm-9"><code class="text-break">{{ meta.document_id or '-' }}</code></dd>
|
||||
<dd class="col-sm-9"><code class="mono text-break">{{ meta.document_id or '-' }}</code></dd>
|
||||
<dt class="col-sm-3">Product</dt>
|
||||
<dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd>
|
||||
<dt class="col-sm-3">Generated</dt>
|
||||
<dd class="col-sm-9"><code>{{ meta.date or '-' }}</code></dd>
|
||||
<dd class="col-sm-9"><code class="mono">{{ meta.date or '-' }}</code></dd>
|
||||
{% if data.license_consumption_ids %}
|
||||
<dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% for lc in data.license_consumption_ids %}<code class="d-block">{{ lc }}</code>{% endfor %}
|
||||
{% for lc in data.license_consumption_ids %}<code class="mono d-block">{{ lc }}</code>{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt class="col-sm-3">Integrity hash</dt>
|
||||
<dd class="col-sm-9">
|
||||
<details>
|
||||
<summary class="text-muted">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
|
||||
<code class="text-break small d-block mt-2">{{ meta.integrity }}</code>
|
||||
<summary style="color:var(--text-light);cursor:pointer;">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
|
||||
<code class="mono text-break d-block mt-2">{{ meta.integrity }}</code>
|
||||
</details>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -1,114 +1,113 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blancco Reports - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Blancco Erasure Reports</h2>
|
||||
<span class="badge bg-secondary fs-6">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
Drive Erasure Certificates
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if reports %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Model</th>
|
||||
<th>Date</th>
|
||||
<th>Result</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in reports %}
|
||||
<tr title="{{ r.filename }}">
|
||||
<td>{% if r.serial %}<code>{{ r.serial }}</code>{% else %}<span class="text-muted small">-</span>{% endif %}</td>
|
||||
<td class="small">{{ r.model or '-' }}</td>
|
||||
<td>{{ r.modified | timestamp_fmt }}</td>
|
||||
<td>
|
||||
{% if r.state == 'Successful' %}<span class="badge bg-success">Successful</span>
|
||||
{% elif r.state == 'Failed' %}<span class="badge bg-danger">Failed</span>
|
||||
{% elif r.state %}<span class="badge bg-secondary">{{ r.state }}</span>
|
||||
{% else %}<span class="text-muted small">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
{% if r.filename.lower().endswith('.xml') %}
|
||||
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
||||
class="btn btn-sm btn-outline-success" title="View formatted report">
|
||||
View
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Download">
|
||||
Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ r.filename }}" title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No erasure reports yet.</p>
|
||||
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Report Storage</h6>
|
||||
<p class="card-text mb-1">
|
||||
Blancco Drive Eraser saves erasure certificates to the network share
|
||||
<code>\\172.16.9.1\blancco-reports</code>.
|
||||
</p>
|
||||
<p class="card-text mb-0 text-muted">
|
||||
Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
|
||||
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = '/reports/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blancco Reports - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-shield-check"></i> Blancco Erasure Reports</h1>
|
||||
<div class="header-actions">
|
||||
<span class="badge badge-secondary badge-lg">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Drive Erasure Certificates</div>
|
||||
{% if reports %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Model</th>
|
||||
<th>Date</th>
|
||||
<th>Result</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in reports %}
|
||||
<tr title="{{ r.filename }}">
|
||||
<td>{% if r.serial %}<span class="mono">{{ r.serial }}</span>{% else %}<span class="text-muted">-</span>{% endif %}</td>
|
||||
<td>{{ r.model or '-' }}</td>
|
||||
<td>{{ r.modified | timestamp_fmt }}</td>
|
||||
<td>
|
||||
{% if r.state == 'Successful' %}<span class="badge badge-success">Successful</span>
|
||||
{% elif r.state == 'Failed' %}<span class="badge badge-danger">Failed</span>
|
||||
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
{% if r.filename.lower().endswith('.xml') %}
|
||||
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
||||
class="pxe-btn pxe-btn-success btn-row-action" title="View formatted report">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
|
||||
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ r.filename }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2"><i class="bi bi-inbox" style="font-size:1.75rem;"></i></p>
|
||||
<p class="mb-1">No erasure reports yet.</p>
|
||||
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="pxe-card mt-3">
|
||||
<h3 class="pxe-card-title"><i class="bi bi-hdd-network"></i> Report Storage</h3>
|
||||
<p class="mb-1">
|
||||
Blancco Drive Eraser saves erasure certificates to the network share
|
||||
<span class="mono">\\172.16.9.1\blancco-reports</span>.
|
||||
</p>
|
||||
<p class="mb-0 text-muted">
|
||||
Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
|
||||
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = "{{ url_for('blancco_delete_report', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,129 +1,820 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.cmd-editor {
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
min-height: 600px;
|
||||
height: 70vh;
|
||||
resize: vertical;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #333;
|
||||
padding: 1rem;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cmd-editor:focus {
|
||||
outline: none;
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.15rem rgba(13,110,253,.25);
|
||||
}
|
||||
.wim-info dt {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
}
|
||||
.wim-info dd {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">startnet.cmd Editor</h2>
|
||||
</div>
|
||||
|
||||
{% if not wim_exists %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
|
||||
Run the PXE server setup playbook and import WinPE boot files first.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Windows\System32\startnet.cmd</span>
|
||||
<span class="badge bg-secondary">boot.wim</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<textarea name="content" class="form-control cmd-editor" id="cmdEditor"
|
||||
spellcheck="false">{{ content }}</textarea>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
Editing the startnet.cmd inside <code>{{ wim_path }}</code>
|
||||
</small>
|
||||
<button type="submit" form="startnetForm" class="btn btn-primary">
|
||||
Save to boot.wim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Common startnet.cmd Commands</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<code class="d-block mb-1">wpeinit</code>
|
||||
<small class="text-muted d-block mb-2">Initialize WinPE networking</small>
|
||||
<code class="d-block mb-1">net use Z: \\172.16.9.1\winpeapps</code>
|
||||
<small class="text-muted d-block mb-2">Map Samba share for deployment</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<code class="d-block mb-1">wpeutil WaitForNetwork</code>
|
||||
<small class="text-muted d-block mb-2">Wait for network to be ready</small>
|
||||
<code class="d-block mb-1">Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
|
||||
<small class="text-muted d-block mb-2">Launch deployment script</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
WIM Info
|
||||
</div>
|
||||
<div class="card-body wim-info">
|
||||
<dl class="mb-0">
|
||||
{% for key, val in wim_info.items() %}
|
||||
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
||||
<dt>{{ key }}</dt>
|
||||
<dd>{{ val }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not wim_info %}
|
||||
<p class="text-muted mb-0">Could not read WIM info.</p>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Tab key inserts a tab in the editor instead of moving focus
|
||||
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
var start = this.selectionStart;
|
||||
var end = this.selectionEnd;
|
||||
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
|
||||
.stn-mono {
|
||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
letter-spacing: normal;
|
||||
tab-size: 4;
|
||||
-moz-tab-size: 4;
|
||||
}
|
||||
|
||||
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
|
||||
.raw-editor-wrap {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
background: #1e1e1e;
|
||||
overflow: hidden;
|
||||
height: 62vh;
|
||||
min-height: 460px;
|
||||
}
|
||||
.raw-gutter {
|
||||
flex: 0 0 auto;
|
||||
width: 3.25rem;
|
||||
padding: 1rem 0.5rem 1rem 0;
|
||||
text-align: right;
|
||||
color: #6a737d;
|
||||
background: #191919;
|
||||
border-right: 1px solid #2a2a2a;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
white-space: pre;
|
||||
}
|
||||
.raw-code {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.raw-highlight,
|
||||
.raw-textarea {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border: 0;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.raw-highlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #d4d4d4;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.raw-textarea {
|
||||
position: relative;
|
||||
display: block;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: #d4d4d4; /* visible if JS/overlay never activates (fallback) */
|
||||
caret-color: #d4d4d4;
|
||||
}
|
||||
/* only once the overlay is proven live do we hide the textarea's own glyphs */
|
||||
.raw-editor-wrap.overlay-live .raw-textarea { color: transparent; }
|
||||
.raw-textarea:focus { outline: none; }
|
||||
.raw-textarea::selection { background: rgba(65, 129, 255, 0.35); }
|
||||
|
||||
/* batch token colors (VS Code-ish dark) */
|
||||
.tok-comment { color: #6a9955; font-style: italic; }
|
||||
.tok-label { color: #dcdcaa; font-weight: 600; }
|
||||
.tok-keyword { color: #569cd6; }
|
||||
.tok-var { color: #4ec9b0; }
|
||||
.tok-string { color: #ce9178; }
|
||||
|
||||
/* ---- lint panel ---- */
|
||||
.lint-list { list-style: none; margin: 0; padding: 0; }
|
||||
.lint-list li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
padding: 0.4rem 0.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.lint-list li:last-child { border-bottom: 0; }
|
||||
.lint-line {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-light);
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
/* ---- diff viewer ---- */
|
||||
.diff-view {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border-radius: 0.25rem;
|
||||
max-height: 55vh;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.diff-add { color: #6a9955; }
|
||||
.diff-del { color: #f14c4c; }
|
||||
.diff-hunk { color: #569cd6; }
|
||||
.diff-meta { color: #808080; }
|
||||
.diff-identical {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* ---- misc ---- */
|
||||
.wim-info dl { margin: 0; }
|
||||
.wim-info dt {
|
||||
font-weight: 600; color: var(--text-light);
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.wim-info dd { margin: 0 0 0.65rem 0; color: var(--text); }
|
||||
.cmd-ref-item { margin-bottom: 0.85rem; }
|
||||
.cmd-ref-item code { display: block; margin-bottom: 0.2rem; word-break: break-all; }
|
||||
.cmd-ref-item small { color: var(--text-light); }
|
||||
.tab-content-pad { padding-top: 1.25rem; }
|
||||
.menu-num-cell { font-variant-numeric: tabular-nums; color: var(--text-light); width: 2.5rem; }
|
||||
.menu-move-btns { display: inline-flex; gap: 0.2rem; }
|
||||
.field-hint { font-size: 12px; color: var(--text-light); margin-top: 0.25rem; }
|
||||
.stn-modal { max-width: min(900px, 96vw); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>startnet.cmd Editor</h1>
|
||||
{% if wim_exists %}
|
||||
<div class="header-actions">
|
||||
<span class="badge badge-secondary"><i class="bi bi-hdd"></i> boot.wim</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not wim_exists %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
|
||||
Run the PXE server setup playbook and import WinPE boot files first.
|
||||
</div>
|
||||
{% if snapshots %}
|
||||
<div class="section-card">
|
||||
<div class="section-title">Snapshots</div>
|
||||
<p class="text-light">boot.wim is missing, but prior startnet.cmd snapshots are still on disk. Restore is unavailable until the WIM is present.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
|
||||
<ul class="nav nav-tabs editor-tabs" id="stnTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab">
|
||||
<i class="bi bi-sliders"></i> Settings
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab">
|
||||
<i class="bi bi-list-ol"></i> Boot Menu
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-shopfloor-btn" data-bs-toggle="tab" data-bs-target="#tab-shopfloor" type="button" role="tab">
|
||||
<i class="bi bi-diagram-3"></i> Shopfloor Sub-Menu
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab">
|
||||
<i class="bi bi-code-slash"></i> Raw
|
||||
{% if lint.errors %}<span class="badge badge-danger ms-1">{{ lint.errors|length }}</span>
|
||||
{% elif lint.warnings %}<span class="badge badge-warning ms-1">{{ lint.warnings|length }}</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab">
|
||||
<i class="bi bi-clock-history"></i> History
|
||||
{% if snapshots %}<span class="badge badge-secondary ms-1">{{ snapshots|length }}</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content tab-content-pad">
|
||||
|
||||
<!-- =============================== SETTINGS =============================== -->
|
||||
<div class="tab-pane fade show active" id="tab-settings" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="pxe-card">
|
||||
<h3 class="pxe-card-title">Boot Settings</h3>
|
||||
<p class="text-light" style="font-size:13px;">
|
||||
These edit only their targeted lines in startnet.cmd. Leave a field
|
||||
<strong>blank to keep</strong> the current value. Everything round-trips
|
||||
through the same boot.wim as the Raw tab.
|
||||
</p>
|
||||
<form action="{{ url_for('startnet_save_settings') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-field">
|
||||
<label for="setServerIp">PXE / Deployment Server IP</label>
|
||||
<input type="text" class="form-control stn-mono" id="setServerIp" name="server_ip"
|
||||
value="{{ settings.server_ip if settings.server_ip is not none else '' }}"
|
||||
placeholder="e.g. 172.16.9.1">
|
||||
<div class="field-hint">Address startnet.cmd maps the winpeapps share from.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="setTimeout">Menu Timeout (seconds)</label>
|
||||
<input type="number" min="0" class="form-control" id="setTimeout" name="menu_timeout"
|
||||
value="{{ settings.menu_timeout if settings.menu_timeout is not none else '' }}"
|
||||
placeholder="e.g. 30">
|
||||
<div class="field-hint">Whole seconds before the default menu entry auto-selects.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="setDefault">Default Menu Entry</label>
|
||||
<input type="text" class="form-control" id="setDefault" name="menu_default"
|
||||
value="{{ settings.menu_default if settings.menu_default is not none else '' }}"
|
||||
placeholder="e.g. 1">
|
||||
<div class="field-hint">The entry chosen when the timeout elapses.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-save"></i> Apply & Save to boot.wim
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="section-card wim-info">
|
||||
<div class="section-title">WIM Info</div>
|
||||
<dl>
|
||||
{% for key, val in wim_info.items() %}
|
||||
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
||||
<dt>{{ key }}</dt>
|
||||
<dd>{{ val }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not wim_info %}
|
||||
<p class="text-light mb-0">Could not read WIM info.</p>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =============================== BOOT MENU ============================== -->
|
||||
<div class="tab-pane fade" id="tab-menu" role="tabpanel">
|
||||
<div class="pxe-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="pxe-card-title mb-0">Boot Menu Entries</h3>
|
||||
<button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="menuAddBtn">
|
||||
<i class="bi bi-plus-lg"></i> Add Entry
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info" style="font-size:13px;">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>Appending</strong> a new image at the end always works. Reordering or
|
||||
removing an entry whose number the enrollment <code>%choice%</code> router
|
||||
cross-references may be <strong>refused by the server</strong> to keep numbering
|
||||
in sync - if so, the raw text is left untouched and the reason is flashed.
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('startnet_save_menu') }}" method="post" id="menuForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="items" id="menuItemsData" value="[]">
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="menuTable" style="white-space:normal;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:2.5rem;">#</th>
|
||||
<th>Label</th>
|
||||
<th>Target</th>
|
||||
<th>Target Image</th>
|
||||
<th style="width:9rem;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="menuBody"><!-- rows injected by JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="menuEmpty" class="text-light" style="padding:1rem 0; display:none;">
|
||||
No menu entries parsed. Use the Raw tab, or add an entry above.
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-save"></i> Save Menu to boot.wim
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================ SHOPFLOOR SUB-MENU ======================== -->
|
||||
<div class="tab-pane fade" id="tab-shopfloor" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="alert alert-info">
|
||||
The <strong>GEA Shopfloor</strong> choice drills into this data-driven sub-menu
|
||||
(<span class="stn-mono">menu.json</span> on the enrollment share). Reorder / rename / hide / add entries -
|
||||
no boot.wim edit. Each <strong>PC-type</strong> must be an existing
|
||||
<span class="stn-mono">shopfloor-setup/gea-shopfloor-*</span> handler (dropdown); WinPE falls back to the
|
||||
baked-in menu if the share/picker is unavailable.
|
||||
</div>
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="sfAddBtn"><i class="bi bi-plus-lg"></i> Add item</button>
|
||||
<button type="submit" form="sfForm" class="pxe-btn pxe-btn-primary pxe-btn-sm"><i class="bi bi-save"></i> Save shopfloor menu</button>
|
||||
</div>
|
||||
<form action="{{ url_for('shopfloor_menu') }}" method="post" id="sfForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="items" id="sfItemsData" value="[]">
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="sfTable" style="white-space:normal;">
|
||||
<thead>
|
||||
<tr><th style="width:3rem;">#</th><th>Label</th><th>PC-type (key)</th><th>Hint</th><th style="width:4rem;">Shown</th><th style="width:7rem;">Order</th><th style="width:3rem;"></th></tr>
|
||||
</thead>
|
||||
<tbody id="sfBody"><!-- rows injected by JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================= RAW ================================= -->
|
||||
<div class="tab-pane fade" id="tab-raw" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<div class="pxe-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm" id="rawDiffBtn">
|
||||
<i class="bi bi-file-earmark-diff"></i> Diff vs current
|
||||
</button>
|
||||
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary pxe-btn-sm">
|
||||
<i class="bi bi-save"></i> Save to boot.wim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="raw-editor-wrap" id="rawWrap">
|
||||
<div class="raw-gutter stn-mono" id="rawGutter" aria-hidden="true">1</div>
|
||||
<div class="raw-code">
|
||||
<pre class="raw-highlight stn-mono" id="rawHighlight" aria-hidden="true"></pre>
|
||||
<textarea name="content" class="raw-textarea stn-mono" id="cmdEditor"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off"
|
||||
wrap="off">{{ content }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-light">Editing startnet.cmd inside <code>{{ wim_path }}</code></small>
|
||||
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-save"></i> Save to boot.wim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Common startnet.cmd Commands</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="cmd-ref-item">
|
||||
<code>wpeinit</code><small>Initialize WinPE networking</small>
|
||||
</div>
|
||||
<div class="cmd-ref-item">
|
||||
<code>net use Z: \\172.16.9.1\winpeapps</code><small>Map Samba share for deployment</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="cmd-ref-item">
|
||||
<code>wpeutil WaitForNetwork</code><small>Wait for network to be ready</small>
|
||||
</div>
|
||||
<div class="cmd-ref-item">
|
||||
<code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code><small>Launch deployment script</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
<div class="section-card">
|
||||
<div class="section-title">
|
||||
Lint
|
||||
{% if lint.errors %}<span class="badge badge-danger">{{ lint.errors|length }} err</span>{% endif %}
|
||||
{% if lint.warnings %}<span class="badge badge-warning">{{ lint.warnings|length }} warn</span>{% endif %}
|
||||
</div>
|
||||
{% if not lint.errors and not lint.warnings %}
|
||||
<p class="text-light mb-0"><i class="bi bi-check-circle text-success"></i> No issues found.</p>
|
||||
{% else %}
|
||||
<ul class="lint-list">
|
||||
{% for e in lint.errors %}
|
||||
<li>
|
||||
<span class="lint-line">{{ ('L' ~ e.line) if e.line is not none else '--' }}</span>
|
||||
<span><i class="bi bi-x-octagon text-danger"></i> {{ e.message }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% for w in lint.warnings %}
|
||||
<li>
|
||||
<span class="lint-line">{{ ('L' ~ w.line) if w.line is not none else '--' }}</span>
|
||||
<span><i class="bi bi-exclamation-triangle text-warning"></i> {{ w.message }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section-card wim-info">
|
||||
<div class="section-title">WIM Info</div>
|
||||
<dl>
|
||||
{% for key, val in wim_info.items() %}
|
||||
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
||||
<dt>{{ key }}</dt>
|
||||
<dd>{{ val }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not wim_info %}
|
||||
<p class="text-light mb-0">Could not read WIM info.</p>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =============================== HISTORY =============================== -->
|
||||
<div class="tab-pane fade" id="tab-history" role="tabpanel">
|
||||
<div class="pxe-card">
|
||||
<h3 class="pxe-card-title">Snapshots</h3>
|
||||
<p class="text-light" style="font-size:13px;">
|
||||
Every save snapshots the prior startnet.cmd first. Diff a snapshot against the
|
||||
current boot.wim, or restore it (restoring also snapshots the current copy first).
|
||||
</p>
|
||||
{% if not snapshots %}
|
||||
<p class="text-light mb-0">No snapshots yet.</p>
|
||||
{% else %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Size</th>
|
||||
<th>Note</th>
|
||||
<th style="width:14rem;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in snapshots %}
|
||||
<tr>
|
||||
<td class="mono">{{ s.timestamp }}</td>
|
||||
<td>{{ '%.1f'|format(s.size / 1024) }} KB</td>
|
||||
<td>{{ s.note or '-' }}</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm snap-diff-btn"
|
||||
data-snapshot-id="{{ s.id }}">
|
||||
<i class="bi bi-file-earmark-diff"></i> Diff
|
||||
</button>
|
||||
<form action="{{ url_for('startnet_restore_snapshot') }}" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Restore this snapshot to boot.wim? The current startnet.cmd is snapshotted first.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="snapshot_id" value="{{ s.id }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-secondary pxe-btn-sm">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ============================= DIFF MODAL ============================= -->
|
||||
<div class="modal fade" id="diffModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered stn-modal">
|
||||
<div class="pxe-modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="diffModalTitle">Diff</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="diffModalBody"><div class="text-light" style="padding:1rem;">Loading...</div></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if wim_exists %}
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
var DIFF_URL = "{{ url_for('startnet_diff') }}";
|
||||
|
||||
// ------------------------------------------------------------------ escape
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- batch highlighter
|
||||
// Per-line so comments/labels never leak across lines. Operates on ESCAPED
|
||||
// text and only ever wraps whole matched tokens, so it cannot corrupt input.
|
||||
var KW = /\b(echo|set|setlocal|endlocal|if|else|for|in|do|goto|call|exit|pause|cls|start|del|copy|xcopy|robocopy|md|mkdir|rd|rmdir|cd|pushd|popd|choice|timeout|ping|reg|net|use|wpeinit|wpeutil|ver|title|color|shutdown|wmic|diskpart|dism|bcdboot|drvload|not|exist|defined|errorlevel|equ|neq|gtr|geq|lss|leq)\b/gi;
|
||||
|
||||
function hlLine(raw) {
|
||||
var line = esc(raw);
|
||||
var trimmed = line.replace(/^\s+/, '');
|
||||
// whole-line comment (rem, ::, @rem)
|
||||
if (/^(@?rem\b|::)/i.test(trimmed)) {
|
||||
return '<span class="tok-comment">' + line + '</span>';
|
||||
}
|
||||
// label line ( :name )
|
||||
if (/^:[^:\s]/.test(trimmed)) {
|
||||
return '<span class="tok-label">' + line + '</span>';
|
||||
}
|
||||
// token pass: strings first, then %vars%, then leading keyword-ish words
|
||||
line = line.replace(/"[^&]*"|"[^"]*"/g, function (m) {
|
||||
return '<span class="tok-string">' + m + '</span>';
|
||||
});
|
||||
line = line.replace(/%[^%\s]+%|%%?[a-zA-Z]\b|![^!\s]+!/g, function (m) {
|
||||
return '<span class="tok-var">' + m + '</span>';
|
||||
});
|
||||
line = line.replace(KW, function (m) {
|
||||
return '<span class="tok-keyword">' + m + '</span>';
|
||||
});
|
||||
return line;
|
||||
}
|
||||
|
||||
var wrap = document.getElementById('rawWrap');
|
||||
var ta = document.getElementById('cmdEditor');
|
||||
var hl = document.getElementById('rawHighlight');
|
||||
var gutter = document.getElementById('rawGutter');
|
||||
|
||||
function renderHighlight() {
|
||||
if (!ta || !hl) return;
|
||||
var text = ta.value;
|
||||
var lines = text.split('\n');
|
||||
var out = [];
|
||||
for (var i = 0; i < lines.length; i++) out.push(hlLine(lines[i]));
|
||||
// trailing newline guard so the overlay height tracks the textarea
|
||||
hl.innerHTML = out.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function renderGutter() {
|
||||
if (!ta || !gutter) return;
|
||||
var n = ta.value.split('\n').length;
|
||||
var buf = [];
|
||||
for (var i = 1; i <= n; i++) buf.push(i);
|
||||
gutter.textContent = buf.join('\n');
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
if (hl) { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; }
|
||||
if (gutter) { gutter.scrollTop = ta.scrollTop; }
|
||||
}
|
||||
|
||||
if (ta) {
|
||||
try {
|
||||
renderHighlight();
|
||||
renderGutter();
|
||||
syncScroll();
|
||||
// Only hide the textarea glyphs once the overlay is confirmed built.
|
||||
if (wrap && hl.innerHTML) wrap.classList.add('overlay-live');
|
||||
|
||||
ta.addEventListener('input', function () {
|
||||
renderHighlight();
|
||||
renderGutter();
|
||||
syncScroll();
|
||||
});
|
||||
ta.addEventListener('scroll', syncScroll);
|
||||
|
||||
// Tab key inserts a real tab instead of moving focus.
|
||||
ta.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
var start = this.selectionStart, end = this.selectionEnd;
|
||||
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + 1;
|
||||
renderHighlight(); renderGutter(); syncScroll();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// Highlight is a nicety; on any failure fall back to the plain textarea.
|
||||
if (wrap) wrap.classList.remove('overlay-live');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- diff modal
|
||||
var diffModalEl = document.getElementById('diffModal');
|
||||
var diffModal = diffModalEl && window.bootstrap ? new bootstrap.Modal(diffModalEl) : null;
|
||||
|
||||
function renderDiff(diffText) {
|
||||
var body = document.getElementById('diffModalBody');
|
||||
if (!diffText) {
|
||||
body.innerHTML = '<div class="diff-identical"><i class="bi bi-check-circle"></i> ' +
|
||||
'No differences - identical to the current boot.wim.</div>';
|
||||
return;
|
||||
}
|
||||
var lines = diffText.split('\n');
|
||||
var html = '';
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var ln = lines[i], cls = '';
|
||||
if (/^\+\+\+|^---/.test(ln)) cls = 'diff-meta';
|
||||
else if (ln.charAt(0) === '@') cls = 'diff-hunk';
|
||||
else if (ln.charAt(0) === '+') cls = 'diff-add';
|
||||
else if (ln.charAt(0) === '-') cls = 'diff-del';
|
||||
html += '<span class="' + cls + '">' + esc(ln) + '</span>\n';
|
||||
}
|
||||
body.innerHTML = '<pre class="diff-view">' + html + '</pre>';
|
||||
}
|
||||
|
||||
function openDiff(title, payload) {
|
||||
var body = document.getElementById('diffModalBody');
|
||||
document.getElementById('diffModalTitle').textContent = title;
|
||||
body.innerHTML = '<div class="text-light" style="padding:1rem;">Loading diff...</div>';
|
||||
if (diffModal) diffModal.show();
|
||||
fetch(DIFF_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (j) { return { ok: r.ok, j: j }; });
|
||||
}).then(function (res) {
|
||||
if (!res.ok) {
|
||||
body.innerHTML = '<div class="alert alert-danger mb-0">' +
|
||||
esc(res.j.error || 'Diff failed.') + '</div>';
|
||||
return;
|
||||
}
|
||||
renderDiff(res.j.diff || '');
|
||||
}).catch(function () {
|
||||
body.innerHTML = '<div class="alert alert-danger mb-0">Could not reach the diff endpoint.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
var rawDiffBtn = document.getElementById('rawDiffBtn');
|
||||
if (rawDiffBtn) {
|
||||
rawDiffBtn.addEventListener('click', function () {
|
||||
openDiff('Pending edit vs current boot.wim', { content: ta.value });
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.snap-diff-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var id = btn.getAttribute('data-snapshot-id');
|
||||
openDiff('Snapshot ' + id + ' vs current', { snapshot_id: id });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------- boot-menu editor
|
||||
var MENU = {{ boot_menu | tojson }};
|
||||
var IMAGE_TYPES = {{ image_types | tojson }};
|
||||
var body = document.getElementById('menuBody');
|
||||
var emptyMsg = document.getElementById('menuEmpty');
|
||||
var form = document.getElementById('menuForm');
|
||||
var hidden = document.getElementById('menuItemsData');
|
||||
|
||||
function optionsFor(selected) {
|
||||
var opts = '';
|
||||
var found = false;
|
||||
for (var i = 0; i < IMAGE_TYPES.length; i++) {
|
||||
var v = IMAGE_TYPES[i];
|
||||
if (v === selected) found = true;
|
||||
opts += '<option value="' + esc(v) + '"' + (v === selected ? ' selected' : '') + '>' + esc(v) + '</option>';
|
||||
}
|
||||
// preserve an existing target_image that is not in the current image list
|
||||
if (selected && !found) {
|
||||
opts = '<option value="' + esc(selected) + '" selected>' + esc(selected) + ' (unlisted)</option>' + opts;
|
||||
}
|
||||
opts = '<option value="">-- none --</option>' + opts;
|
||||
return opts;
|
||||
}
|
||||
|
||||
function rowHtml(item, idx, total) {
|
||||
return '' +
|
||||
'<td class="menu-num-cell">' + (idx + 1) + '</td>' +
|
||||
'<td><input type="text" class="form-control form-control-sm m-label" value="' + esc(item.label || '') + '" placeholder="Menu label"></td>' +
|
||||
'<td><input type="text" class="form-control form-control-sm m-target" value="' + esc(item.target || '') + '" placeholder="Target (goto/call)"></td>' +
|
||||
'<td><select class="form-select form-control-sm m-image">' + optionsFor(item.target_image || '') + '</select></td>' +
|
||||
'<td class="actions">' +
|
||||
'<span class="menu-move-btns">' +
|
||||
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-up" ' + (idx === 0 ? 'disabled' : '') + ' title="Move up"><i class="bi bi-arrow-up"></i></button>' +
|
||||
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-down" ' + (idx === total - 1 ? 'disabled' : '') + ' title="Move down"><i class="bi bi-arrow-down"></i></button>' +
|
||||
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-del" title="Remove"><i class="bi bi-trash text-danger"></i></button>' +
|
||||
'</span>' +
|
||||
'</td>';
|
||||
}
|
||||
|
||||
// Read the live DOM back into an array (so in-progress typing is captured).
|
||||
function collect() {
|
||||
var items = [];
|
||||
body.querySelectorAll('tr').forEach(function (tr) {
|
||||
items.push({
|
||||
label: (tr.querySelector('.m-label') || {}).value || '',
|
||||
target: (tr.querySelector('.m-target') || {}).value || '',
|
||||
target_image: (tr.querySelector('.m-image') || {}).value || ''
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
body.innerHTML = '';
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = rowHtml(items[i], i, items.length);
|
||||
body.appendChild(tr);
|
||||
}
|
||||
if (emptyMsg) emptyMsg.style.display = items.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
if (body) {
|
||||
render(MENU.map(function (m) {
|
||||
return { label: m.label, target: m.target, target_image: m.target_image };
|
||||
}));
|
||||
|
||||
body.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
var tr = btn.closest('tr');
|
||||
var rows = Array.prototype.slice.call(body.querySelectorAll('tr'));
|
||||
var idx = rows.indexOf(tr);
|
||||
var items = collect();
|
||||
if (btn.classList.contains('m-up') && idx > 0) {
|
||||
var t = items[idx - 1]; items[idx - 1] = items[idx]; items[idx] = t;
|
||||
render(items);
|
||||
} else if (btn.classList.contains('m-down') && idx < items.length - 1) {
|
||||
var d = items[idx + 1]; items[idx + 1] = items[idx]; items[idx] = d;
|
||||
render(items);
|
||||
} else if (btn.classList.contains('m-del')) {
|
||||
items.splice(idx, 1);
|
||||
render(items);
|
||||
}
|
||||
});
|
||||
|
||||
var addBtn = document.getElementById('menuAddBtn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', function () {
|
||||
var items = collect();
|
||||
items.push({ label: '', target: '', target_image: '' });
|
||||
render(items);
|
||||
});
|
||||
}
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
hidden.value = JSON.stringify(collect());
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
const SF_AVAIL = {{ shopfloor_available | tojson }};
|
||||
const SF_ITEMS = {{ shopfloor_items | tojson }};
|
||||
const body = document.getElementById('sfBody');
|
||||
if (!body) return;
|
||||
const opts = sel => SF_AVAIL.map(k=>`<option value="${k}" ${k===sel?'selected':''}>${k}</option>`).join('');
|
||||
function row(it){
|
||||
const tr=document.createElement('tr');
|
||||
const key=it.key||(SF_AVAIL[0]||'');
|
||||
tr.innerHTML =
|
||||
`<td class="sf-n stn-mono"></td>`+
|
||||
`<td><input class="form-control sf-label" value="${(it.label||'').replace(/"/g,'"')}" placeholder="Menu label"></td>`+
|
||||
`<td><select class="form-select sf-key">${opts(key)}</select></td>`+
|
||||
`<td><input class="form-control sf-hint" value="${(it.hint||'').replace(/"/g,'"')}" placeholder="(optional)"></td>`+
|
||||
`<td style="text-align:center;"><input type="checkbox" class="sf-en" ${it.enabled===false?'':'checked'}></td>`+
|
||||
`<td><button type="button" class="pxe-btn pxe-btn-sm pxe-btn-secondary sf-up" title="Up"><i class="bi bi-arrow-up"></i></button> `+
|
||||
`<button type="button" class="pxe-btn pxe-btn-sm pxe-btn-secondary sf-down" title="Down"><i class="bi bi-arrow-down"></i></button></td>`+
|
||||
`<td><button type="button" class="pxe-btn pxe-btn-sm pxe-btn-danger sf-rm" title="Remove"><i class="bi bi-trash"></i></button></td>`;
|
||||
return tr;
|
||||
}
|
||||
const renum=()=>[...body.rows].forEach((r,i)=>r.querySelector('.sf-n').textContent=i+1);
|
||||
const add=it=>{body.appendChild(row(it||{}));renum();};
|
||||
body.addEventListener('click',e=>{
|
||||
const tr=e.target.closest('tr'); if(!tr)return;
|
||||
if(e.target.closest('.sf-rm')){tr.remove();renum();}
|
||||
else if(e.target.closest('.sf-up')&&tr.previousElementSibling){tr.parentNode.insertBefore(tr,tr.previousElementSibling);renum();}
|
||||
else if(e.target.closest('.sf-down')&&tr.nextElementSibling){tr.parentNode.insertBefore(tr.nextElementSibling,tr);renum();}
|
||||
});
|
||||
document.getElementById('sfAddBtn').addEventListener('click',()=>add({}));
|
||||
document.getElementById('sfForm').addEventListener('submit',()=>{
|
||||
const items=[...body.rows].map(r=>({label:r.querySelector('.sf-label').value.trim(),key:r.querySelector('.sf-key').value,hint:r.querySelector('.sf-hint').value.trim(),enabled:r.querySelector('.sf-en').checked})).filter(x=>x.label&&x.key);
|
||||
document.getElementById('sfItemsData').value=JSON.stringify(items);
|
||||
});
|
||||
(SF_ITEMS.length?SF_ITEMS:[]).forEach(add);
|
||||
if(!body.rows.length) add({});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user