65 Commits

Author SHA1 Message Date
cproudlock
0d9c4fdac4 Rename every copy of an MTConnect device name, not just the agent's
Set-MachineNumber rewrote one Devices.xml per variant and restarted the agent.
That is right for Okuma and eDNC, which keep the device name in that one file -
checked across 7 Okuma and 15 eDNC bay captures. Fanuc and Makino keep it in
several, and editing only the agent's copy leaves the adapter streaming under
the old identity.

It has already happened. Seven of thirty-four captured Fanuc bays have the
agent on the right machine number and the adapter still on the bay it was
imaged from: 4007 on a 7801 Toshulin, 3031 on a 7804, 2005 against 2006 on a
dual-spindle pair. Both captured Makino bays have Devices.xml on the machine
number and the other three files still on MAKINO-1.

Update-MTConnectVariantName renames across a declared set of files with the
owning services stopped first. Makino needs that ordering more than most: the
vendor's guide says the Adapter Manager rewrites the COMPLETE configuration
from memory as it stops, so an edit made while it runs is discarded, and that
the name must be identical in every file or the agent may not start.

It collects every name in play before rewriting rather than discovering one.
A bay half-renamed by the old code carries two at once, and converging on the
one that happened to be found leaves the other behind - which is the state this
is meant to end, not reproduce. Nothing is written if a service will not stop,
and whatever was stopped is started again.

Tested on Windows 11 against copies of the real captures: the 7801 bay (adapter
4007) converges to 7801 in two files and leaves the correct agent alone; the
7502 Makino bay converges all four; second runs report no changes. Service
ordering verified by process id, using stand-in services - sc.exe fakes named
after the real ones are not real services, so the service list is a parameter
defaulting to the production names.

Does not fix the bays already in this state; they need a run each.
2026-08-11 14:28:03 -04:00
cproudlock
54cbe6b5d6 Bring the share's common scripts under version control
Sixteen files that run on every shopfloor PC existed only on the SFLD share.
The cost showed up while debugging the NTLARS backup: the script that posts to
ShopDB could not be read, reviewed or diffed, so its behaviour was inferred
from log output for most of a day. It turned out to hold a silent fallback that
had been governing the whole fleet for months.

Imported as-is from tsgwp00525-v2, no edits:

  lib/ShopdbBackupClient.psm1        the shared backup client
  scripts/Backup-NtlarsSettings.ps1  converted to use it
  scripts/Set-ShopdbCollectorKey.ps1 collector credential delivery
  scripts/Test-RegExport.ps1         exercises the .reg codec with mocks
  scripts/Set-EventSaver*.ps1        kiosk power / screensaver / disable
  scripts/Setup-OpenText.*           OpenText install + toolbar
  scripts/Migrate-PCType.ps1, Select-KioskType.ps1, Set-FmsHostsEntry.ps1,
  scripts/ensure-vnc-firewall.ps1, Install-AcroReader.cmd, Install-Oracle11r2.cmd

lib/Install-FromManifest.ps1 is also updated from the share, which was 37 lines
AHEAD of this repo and purely additive: the Add-EnforceResult reporting added
during the kiosk API cutover, done live and never committed back. Nothing was
removed.

Checked for embedded secrets before committing; there are none.
Set-ShopdbCollectorKey deliberately reads its token from a sibling file on the
share rather than holding it, so the script is safe to track.

The share remains what actually runs. This makes it reviewable, and makes the
next drift visible as a diff rather than a surprise.
2026-08-11 12:36:38 -04:00
cproudlock
b075939c38 GE-Enforce: roll undated logs so retention can actually reach them
The prune drops a *.log by LastWriteTime. A log written to a fixed filename is
appended every cycle, so its LastWriteTime is always now, it can never be older
than any cutoff, and it grows without limit. ntlars-backup.log, eventsaver.log
and shopdb-collector-key.log all do this. On one part-marker PC eventsaver.log
had reached 11,000 lines and ntlars-backup.log 3,234, of which 3,217 were the
same "Throttled" line - the four events that mattered were unfindable without
grep -v.

Undated logs are now rolled to <name>-YYYYMMDD.log before the prune runs, which
stops them being written to and lets them age out on the existing 30 day
retention. The owning scripts need no change: they append with Add-Content or
Tee-Object, which recreate a missing file on the next write. This matters
because those three scripts are live-share artifacts, not in this repo.

Rolled under the date of its FIRST LINE, so the stamp matches the contents.
That is also the only trustworthy signal: CreationTime is not, because NTFS
file tunneling gives a recreated file the old creation time when it reappears
within 15 seconds, so a busy log keyed on that would look stale the instant it
rolled and would roll again every cycle.

A log with no parseable timestamp is left alone until it passes 5MB, so an
unrecognised format still cannot grow forever. Empty files and already-stamped
files are skipped. Rolling onto an existing target appends rather than
overwriting, so a second roll on one day loses neither side.

Verified against real files under pwsh, including the append branch and the
owner recreating the file afterwards. The first attempt keyed on CreationTime
and a second used [datetime]::TryParse with an untyped $null, which throws "no
overload" and would have made the whole roll a silent no-op; the ref is now
pre-typed.
2026-08-11 09:03:28 -04:00
cproudlock
6d5fee786c Proposal: fold in what a day of fixing the current pipeline taught
New section 9a - five design rules, each from a bug fixed on 2026-08-06 and each
cheap to honour in a new design but expensive to retrofit:

  Absent and empty are different. Four scripts tested config as
  "not null AND Count > 0", so an explicitly empty list fell through to a
  hardcoded default - a kiosk configured with "desktopApps": [] was given the
  full machine-tool set. Configuring "none" produced "everything".

  Deny by default. preinstall.json had 16 of 21 entries at PCTypes ['*'] and the
  SMB manifest 18 of 25 unfiltered. Both filters worked perfectly; they just were
  not applied.

  Gate at the point of action. Call-site gates on PC type were bypassed by the
  finalization phase, silently.

  The image carries configuration. WJ Shopfloor.lnk is inside the WIM, so no
  pipeline change removes it.

  The ppkg is a hard boundary. Chrome, RealVNC, Tanium, CyberArk and the rest come
  from the package - thinning the image does not thin them.

Also corrects a premise in section 4.2: a first-boot selection UI cannot assume
the API is reachable, because ShopDB needs the AESFMA wifi SSID and the imaging
LAN has no route to it. Points at Install-ShopdbKiosk-WhenOnline.ps1 as the
reusable shape - arm a task at imaging, do nothing until the API answers, act
once, verify, self-remove.

Plus two risks, the diagnostics collector in the reference map, and a gotchas
section: HardwareDriver.json defeats ConvertFrom-Json on every PowerShell
version, Compress-Archive caps at 2 GB against an 8 GB package, and PowerShell
Core normalises backslashes on Linux so bay scripts can be tested here.
2026-08-06 17:36:17 -04:00
cproudlock
0e1b80b903 preflight: stop ConvertFrom-Json choking on the driver catalogue
The driver check - the most valuable check in this script - has never actually
run. It died on:

  Driver check failed: Cannot convert the JSON string because a dictionary that
  was converted from the string contains the duplicated keys 'FileName' and
  'FileName'

HardwareDriver.json carries both casings of the same fields - fileName and
FileName, destinationDir and DestinationDir. ConvertFrom-Json treats object keys
case-insensitively and refuses the document. -AsHashtable would handle it but
that is PowerShell 6+, and WinPE runs 5.1. Confirmed it throws on PowerShell 7
too, so no version of ConvertFrom-Json can read this file as-is.

Pulls the four needed fields out of each entry by regex instead, preferring the
lowercase key and falling back to the capitalised one, and unescaping the
backslashes in destinationDir.

Tested against the real 44-entry catalogue, all three outcomes:
  OptiPlex Micro 7020, pack present  -> OK, win11_optiplexd13mlk7020_a09.zip
  same model, pack removed           -> FAIL, names the missing path
  Surface Laptop 7                   -> FAIL, no pack matches

Worth noting the check was failing SAFE - a WARN that reads like a tooling
glitch rather than a missing driver pack. It would have stayed invisible until a
bay imaged with no NIC.
2026-08-06 17:24:04 -04:00
cproudlock
8907a86c42 Collector: decode StartupApproved 0x04 and 0x05
The post-fix capture from 579C144 reported three entries as unknown(0x04) -
SecurityHealth, RtkAudUService, WavesSvc. The decoder only knew 02/06 enabled and
03/07 disabled. 04 is also enabled and 05 also disabled; without them the report
says 'unknown' for entries that are perfectly ordinary.
2026-08-06 17:16:57 -04:00
cproudlock
a7cb7164d9 Kiosks stop installing shopfloor apps, and empty config stops meaning everything
PREINSTALL SCOPE

Sixteen entries carried PCTypes ['*'], so a Display kiosk installed Adobe
Acrobat, OpenText HostExplorer, WJF Defect Tracker, the shopfloor serial drivers
and twelve legacy VC++ redistributables. Those are now scoped to the nine
machine-tool types.

A Display now installs 3 of 21 entries:
  PowerShell 7.5.4                    SFLD-ApplyDSCConfig runs pwsh.exe 7
  VC++ Redistributable 2015-2022 x64  modern x64 runtime Edge/WebView2/PS7 lean on
  Display Kiosk App                   already Display-scoped

Kept as wildcards deliberately - removing the x64 runtime risks breaking the
kiosk itself, and PS7 is a hard dependency of the DSC task. Everything else a
kiosk has no use for. Edited as targeted text replacement, 16 line pairs, so the
file's formatting and its long _comment blocks are untouched.

THE LAST OF THE EMPTY-MEANS-DEFAULT BUGS

Configure-PC.ps1 and 07-TaskbarLayout.ps1 had the same test as the two fixed
earlier: $null -ne $cfg -AND .Count -gt 0, so an explicitly empty list fell
through to a hardcoded shopfloor default. Configure-PC is the one that actually
bit - it runs in the finalization phase and put these in the all-users Startup
folder on 579C144, timestamped mid-image:

    Defect Tracker.lnk   16:46:03
    Plant Apps.lnk       16:46:03

Plant Apps launching msedge --new-window on a kiosk. eDNC was in the same
hardcoded list and only escaped because its Test-Path guard found no DncMain.exe.

08-EdgeDefaultBrowser.ps1 had it too, with a Plant Apps + WJ Shopfloor + Dashboard
tab fallback. Harmless today because the Display profiles configure a real tab,
but one edit away from biting.

Get-ProfileValue returns $null only when a key is absent from BOTH the profile
and site-config, so $null is the only honest "not configured" signal and an
empty array means what it says.

VERIFIED against the post-fix capture: the S: mapper Run key and the machine
number prompt task are both gone, and 'ShopDB Kiosk Bootstrap' registered
correctly with boot and time triggers.

Also: the collector's StartupApproved decoder now handles 0x04/0x05, which that
same capture surfaced as unknown.

Still baked into the WIM and unaffected by any of this: WJ Shopfloor.lnk, dated
April, sitting in the all-users Startup folder.
2026-08-06 16:58:53 -04:00
cproudlock
28efde7d76 Stop Display bays getting machine-tool shortcuts, prompts and the S: mapper
Three fixes from the 579C144 diagnostics, all the same shape: a Display kiosk
being treated as a machine-tool bay.

desktopApps: configuring "none" produced "everything"

  "Display-Dashboard": { "desktopApps": [] }

06-OrganizeDesktop.ps1 tested $null -ne $cfgApps -AND .Count -gt 0, so an
explicitly empty list fell through to the hardcoded fallback and the kiosk was
given UDC, eDNC, NTLARS, WJ Shopfloor and Defect_Tracker shortcuts.
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 and an empty array
means what it says. startupItems carried the same test - harmless today because
its else branch has no fallback list, but commented so the two cannot drift.

Machine-number prompt on a machine with no machine number

Register-CheckMachineNumberTask.ps1 armed 'Prompt Machine Number' (AtLogOn,
BUILTIN\Users) whenever the number was the 9999 placeholder - which is always
true on a Display, because startnet only collects a real number for machine-tool
types. A logon dialog on a kiosk with no keyboard. Now skips PC types that have
no machine number by design, and clears any stale task.

S: mapper on a share-less PC

Displays are Entra-joined with local accounts and no SFLD credentials, so mapping
S: can only fail, once per logon, forever. Run-ShopfloorSetup already gated the
CALL on $noEnforceTypes, but the bay registered it anyway at 15:07:55 with no
"Skipping" line in the log - so something in the finalization phase reaches the
registrar past that gate. Rather than chase the caller, the registrar now gates
itself and removes a stale Run entry. The call-site gate stays; this makes the
outcome correct regardless of who invokes it.

That bypass is worth understanding separately - the same pattern would defeat any
call-site gate in the finalization phase.

All three parse clean and are deployed byte-identical to the share.
2026-08-06 16:23:18 -04:00
cproudlock
d5cf255443 Kiosk bootstrap: look for the installer where staging actually puts it
The bootstrap looked for C:\Enrollment\kiosk\Install-ShopdbKiosk.ps1, but
startnet copies the type-specific tree to
C:\Enrollment\shopfloor-setup\gea-shopfloor-display\ - so the payload lands
beside the bootstrap, not under C:\Enrollment.

As written it would have logged "Installer not staged" on every 15-minute cycle
and never installed anything, with no error anywhere. Exactly the silent-failure
shape this whole day has been about, and it would have looked like the API being
unreachable.

Resolves via a -KioskRoot parameter defaulting to kiosk\ next to the script, so
the bootstrap and its payload travel together regardless of where the tree is
staged. display-type.txt still comes from EnrollmentRoot, which is correct - that
one really is at C:\Enrollment.
2026-08-06 16:12:41 -04:00
cproudlock
7ed30f9b85 Display: install the ShopDB enforce client once AESFMA is reachable
A PXE-imaged display 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

Not a broken configuration - nothing had ever tried. Install-ShopdbKiosk.ps1
downloads itself from {BaseUrl}/installers/kiosk over HTTPS, and ShopDB is only
reachable after the bay joins the AESFMA wifi SSID, so it cannot run during
imaging. Nothing was arranged to run it afterwards.

09-Setup-Display.ps1 now registers 'ShopDB Kiosk Bootstrap' as a SYSTEM task at
boot and every 15 minutes. Install-ShopdbKiosk-WhenOnline.ps1 does nothing until
ShopDB answers, then runs the vendor installer once, verifies BaseUrl and the
enforce task exist, deletes the staged key and unregisters itself. A bay cabled
up days later still self-configures.

It deliberately does not reimplement the installer - it waits, invokes, verifies
and cleans up, so the vendor script can be replaced wholesale without touching
this.

The key file is LABELLED (collector=, fetch=) rather than positional. The two
tokens are not interchangeable and a mix-up is silent: a fetch token in the
collector slot leaves asset reporting broken while everything looks configured.
A bare unlabelled line is ignored rather than guessed at. Missing keys are not
fatal - the fetch token is unnecessary on an IP-allowlisted subnet, and the
installer skips the asset-report task rather than failing.

Staged on the share: the bootstrap, the vendor installer under kiosk\, and the
updated display setup. The key itself is NOT staged yet - see the commit
discussion; it needs to be labelled with which scope it carries first.
2026-08-06 15:58:15 -04:00
cproudlock
c4aeaaaa17 Collector: exclude the 8 GB package, fix base-URL detection, note the AESFMA dependency
Three defects found by the first real run, on 579C144.

16 GB collection, zip failed. run-enrollment harvests
C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
provisioning package existed TWICE under the trees being copied. Compress-Archive
cannot exceed 2 GB and died with "stream was too long". Copies now exclude
*.ppkg, *.wim and *.iso and cap individual files at 100 MB.

Base-URL detection matched the wrong value. The name pattern accepted anything
containing "base", so it picked up baseVersion=2.0.2 from
HKLM:\SOFTWARE\GE\SFLD\Credentials and then probed "2.0.2/api/docs" - making
both UNREACHABLE lines meaningless. Now matches the name exactly and requires the
value to start with http.

Unreachable was reported as a bare failure. ShopDB is only reachable once the bay
has joined the AESFMA wifi SSID, so on the imaging LAN or plain wired an
unreachable result is EXPECTED. It now says so, rather than looking like a fault.

The run still answered the question it was written for: no enforce client is
installed and nothing is scheduled to call the API, so this bay was never going
to reach GE-Enforce regardless of network.
2026-08-06 15:38:42 -04:00
cproudlock
54176e591f Collector: record whether each startup item is enabled or disabled
Disabling a startup item through Task Manager or Settings does not remove the Run
key or the Startup shortcut - Windows writes a flag into StartupApproved instead.
So the collector would have listed those entries as present with no indication
they had been switched off, and a bay where someone had already tidied up would
look identical to one where nothing autostarts.

Reads all five StartupApproved keys, including Run32 for the WOW64 view, and
decodes the first byte: 02/06 enabled, 03/07 disabled.

This matters for the current investigation because turning an item off is a
per-machine workaround - the next imaged bay installs and starts it again. The
fix is not installing it, which needs the app-to-autostart mapping this section
preserves.
2026-08-06 15:28:47 -04:00
cproudlock
d185e2b810 Collect everything needed to debug an imaged bay, in one pass
Every imaging investigation so far has meant walking to a bay and hand-copying
files, and each time discovering another thing we wished we had grabbed at the
same moment. This takes the lot. Read-only; changes nothing.

Collects: computer name (active AND pending, which is how you tell whether the
package's H<serial> -> F<serial> rename has landed or is still queued for the
next reboot), dsregcmd enrollment state, the package self-checks, installed
applications, everything that starts by itself, kiosk shortcut targets, Edge
policy, GE-Enforce config, drivers, disk, network, provisioning sessions, and the
whole of C:\Logs, Panther and the provisioning data plus the diagnostics evtx.

Two things it does deliberately:

Autostart is captured across all four surfaces - Run/RunOnce in BOTH registry
views, all Startup folders, non-Microsoft scheduled tasks, and auto-start
services outside C:\Windows. That combination is what identifies which installer
planted a given autostart entry. A 32-bit installer's Run key lands under
Wow6432Node where 64-bit tooling never looks, which is exactly how the old
Dashboard/Lobby autostart survived an earlier purge.

It TESTS the ShopDB GE-Enforce API rather than just reporting config. Config on
disk proves nothing - a client can be present, configured, and never once
succeed. It reports whether a client exists at all, whether anything is scheduled
to run it, the configured base URL, and then actually probes the endpoint. A 401
is a good result: it proves DNS, routing and TLS work and the service answered.
Only a timeout or DNS failure means unreachable. No token is sent.

Deliberately avoids Win32_Product - querying it triggers an MSI reconfigure of
every installed product, which is slow and can change the machine.

Run it BEFORE lockdown. What it captures is known-CURRENT, not known-good: a bay
straight off the line carries applications that should not be there, because
preinstall.json entries without a PCTypes filter install everywhere. The point is
to have an exact record of what imaging really produces so the unnecessary items
can be identified and filtered. After lockdown you cannot tell whether something
is absent because lockdown removed it or because imaging never installed it.

Staged on the enrollment share alongside the other shopfloor-setup scripts.
2026-08-06 15:24:25 -04:00
cproudlock
68f2ce29e4 Proposal: full reference map, and correct the LTSC autostart assumption
REFERENCES (section 13, was six lines, now a map)
Where every referenced thing lives, so the document is usable by someone without
today's context: this repo, the PXE server share, shopdb-flask, the on-bay log
locations, and the evidence bundles each claim came from. Plus re-derivation
recipes - pulling startnet out of boot.wim, extracting customizations.xml from an
8 GB .ppkg without unpacking it, reading the provisioning evtx, and the two
validation commands - and the 2026-08-06 commit list.

LTSC AUTOSTART - CORRECTED
Section 7.1 previously argued PESetup might already auto-start: AutoStartCountDown
is 31, disableauto.json is absent, and the operator on 579C144 clicked Next at 11
seconds, so nobody had waited long enough to find out.

Per the team's operational experience that is wrong for our media. LTSC builds
cannot autostart - the SELECT OS confirmation is mandatory regardless of the
countdown, and every image we produce is Windows 11 LTSC 24H2.

So the countdown is not a route to zero-touch. That promotes re-implementing the
imaging step from a contingency to the actual decision, and the risk table and
open questions now frame it as "re-implement, or accept one click per build" -
zero-touch being the only thing that requires it. The hands-off test is kept, but
only to confirm and record the behaviour first-hand.
2026-08-06 15:05:22 -04:00
cproudlock
f442ff2f65 Propose zero-touch, ShopDB-driven imaging
Design proposal, nothing built. Two ideas in one architecture: the image becomes
thin (partition, apply, join Entra, install the enforce client - everything else
is data pulled from the API and enforced by GE-Enforce), and imaging becomes a
job rather than a menu (open a machine in ShopDB, click Create this PC, pick the
zeroed box waiting in WinPE, and it images start to finish reporting progress
onto that machine's record).

Every claim about current behaviour is sourced from 2026-08-06 evidence -
decompiled binaries, live logs off bay 579C144, and the live share - and where
something is inferred it says so.

Covers: why the current early-decision chain fails silently (five worked examples
from one day); what already exists to build on; per-machine configuration records
replacing 147 .reg files and two CSVs, referencing payloads rather than embedding
them; the dual-NIC design with the firewall work that gates it; whether PESetup
needs replacing at all; the BPRT token shelf-life constraint on pre-imaged stock;
an ownership table; six delivery phases each useful alone; risks; and seven open
questions.

Two findings in here are worth acting on before any of it:

  disableauto.json is ABSENT from the media and AutoStartCountDown is 31, while
  the operator on 579C144 clicked Next at 11 seconds. PESetup may already
  auto-start and nobody has waited long enough to find out. One bay, hands off
  the keyboard, count to 40.

  dnsmasq is already safely bound (listen-address + bind-dynamic) so DHCP will
  not leak onto a GE-side NIC - but Samba binds to all interfaces and every ufw
  rule is ALLOW IN Anywhere, including SSH on a pxe/pxe account. That work gates
  dual-homing.
2026-08-06 15:02:08 -04:00
cproudlock
a0aceb4680 startnet: define the server address and share credentials once
The PXE server address appeared 14 times and the share credentials 13, so moving
the server or rotating the account meant editing every net use in the file and
rebuilding boot.wim - with no way to tell from a diff whether one had been
missed. Now set once at the top as PXESERVER / SHAREUSER / SHAREPASS.

Exactly one literal address remains, the definition on line 6. Prose in REM
comments is left alone so the paths stay readable.

This also reaches the post-boot self-heal for free. startnet already writes
C:\Enrollment\fetch-source.txt, and both Fetch-StagingPayload.ps1 and
Verify-And-Heal-Staging.ps1 read it (line1=UNC, line2=user, line3=pass) with
their own hardcoded values only as a fallback. Confirmed on 579C144:

  [13:30:35] [INFO] fetch-source.txt: UNC=\172.16.9.1\enrollment user=pxe-upload
  [13:30:36] [INFO] Mounting \172.16.9.1\enrollment as Z: (attempt 1/5)...

so the seeded file wins and the heal follows PXESERVER automatically. My earlier
claim that the heal scripts were hardcoded was wrong - they were already
config-driven and startnet was already seeding them. The hardcoding was all in
startnet itself.

Verified: set on line 13, first use on line 20; parens balance; every goto
resolves; 1005 CRLF lines with no bare LF; the deployed copy contains exactly one
literal address. boot.wim md5 7a9b832b.
2026-08-06 14:41:40 -04:00
cproudlock
bb08392b84 download-drivers: reuse an existing directory that differs only in case
Dell and GE manifests spell the same folder inconsistently - OptiPlex vs
Optiplex. Windows does not care, but the Samba share sits on a case-sensitive
filesystem, so a blind mkdir -p created a SECOND tree and the drivers split
between them.

That is exactly how the OptiPlex Micro 7020 pack went missing: the manifest asked
for OptiPlex/D13MLK while the 3.2 GB zip sat in Optiplex/D13MLK. PESetup found no
pack, logged a warning rather than an error, and the bay imaged with no network
drivers - so DNS failed at first boot and bulk enrollment could not reach the
CDN. Symptoms three steps from the cause.

mkdir_ci walks the path one component at a time and reuses whatever is already
there whatever its case, creating only genuinely new components. Callers must use
the RETURNED path, since it may differ in case from the requested one. Falls back
to plain mkdir -p rather than skipping a download if the resolve fails.

Both spellings still exist in the live catalogues; scripts/lint-driver-catalogue.py
reports a case mismatch as an error so the pair cannot silently drift again.
2026-08-06 14:38:54 -04:00
cproudlock
9d51c0b987 Ask tenant + PC purpose at the PXE menu; add the missing 3D kiosk display option
PC PURPOSE / TENANT FOR STANDARD AND ENGINEER

The capability was already there - the media carries all six MCL packages
(PERS/SH/LOAN x GCCH/RoW) and Start-BulkEnrollOrchestrator.ps1 picks one from
whatever Select-PCConfig.ps1 returns. The problem was WHEN it asks: a GUI at
first logon, so an otherwise unattended build stops at a dialog until someone
walks over.

Now startnet asks after Standard/Engineer is chosen, while the tech who knows
what the machine is for is standing at it, and writes {Tenant}_{Purpose} to
C:\Enrollment\pc-config.txt. A shim at the path the orchestrator already calls
returns that value and falls through to the vendor dialog when it is absent or
malformed - so "ask at first logon" stays available and bays imaged before this
are unaffected. The vendor script is preserved as Select-PCConfig-vendor.ps1;
replace THAT when the vendor ships a new one.

The tenant is not cosmetic. Insert-BPRTToPPKG.ps1 matches the bulk token on
Tenant AND Purpose, so this selects which token gets injected.

ONLY THE CHOICE IS PRE-SEEDED, deliberately. Injection stays at first logon
because it fetches an encrypted token table from mcl.dwcdn.geaerospace.com,
unreachable from the isolated imaging LAN - which is also why routing MCL
packages through run-enrollment.ps1 would not work: provtool would get a package
whose token is still the Exp_XXXXXXXX placeholder and join nothing.

Standard/engineer previously skipped staging entirely (no ppkg, no PCTYPE), so
the staging gate now also admits PCCONFIG.

3D PARTS KIOSK

The display submenu offered Dashboard and Lobby only. The GE-Enforce display
scope has always had a third entry - 3DPrintRoom -> /shopdb/parts-kiosk - so a
parts kiosk could only be set by editing display-type.txt by hand after imaging.
Added as option 3. The value is a KEY into that map, so the spelling matches
exactly.

Verified: startnet parens balance, every goto resolves, 994 CRLF lines with no
bare LF; shim parses clean. Deployed - boot.wim 4d16c946, shim staged with the
vendor dialog preserved.
2026-08-06 14:37:20 -04:00
cproudlock
68df59e117 Record who owns what, and report repo-vs-share drift
OWNERSHIP.md
Every expensive bug in this pipeline has had one shape: two systems setting the
same thing, last writer winning silently. Four happened on 2026-08-06 alone -
computer name (package vs run-enrollment), drive letters (PESetup vs a volume
finder), enrollment (package vs an at-logon -ManualFallback task that syspreped
finished machines), kiosk URLs (GE-Enforce vs site-config). Each is written down
with the evidence so the next person deletes a writer instead of adding one.

share-drift.py
The share is production and the repo is meant to describe it, but drift runs both
ways: live hand-edits nobody committed, and repo fixes never deployed. The
unattend outage lived only on the share while the repo copy was fine, and nothing
compared them.

Each mapped pair is classified. git-owned means the repo wins and the pair must
match - those fail the run. unreconciled means the two have genuinely diverged
and nobody has decided; reported, not failed. The unattends are unreconciled on
purpose: live is ~17 KB against ~12 KB in the repo, so a blind push would regress
production. Reads over SSH via base64 so BOM and CRLF survive the hop.

First run: 8 git-owned pairs all match, 4 known-unreconciled.

Run-ShopfloorSetup.ps1
Corrects a comment that was actively misleading. It claimed shopfloor PCs are
"vanilla by design" and that the orchestrator runs -ManualFallback to skip BPRT
injection and the package entirely. Shopfloor bays DO enrol - the SFLD package
joins Entra with its BPRT token and a human assigns the device category in
Intune. -ManualFallback runs sysprep /oobe /reboot, which is why wiring it to an
at-logon task destroyed the deployment chain.

The absent Entra wait is still correct, for a different reason: at that point the
bay is on the isolated PXE LAN with no route to Entra (579C144 held 172.16.9.81
and 172.24.19.142, neither in the production ranges). sync_intune retries until
the tech re-cables. "Entra ID Joined: false" right after imaging is normal.
2026-08-06 14:28:16 -04:00
cproudlock
d2200e8522 Preflight: check what PESetup fails on, before it fails on it
TWO SCRIPTS, DIFFERENT AUDIENCES

playbook/scripts/preflight.ps1 runs at the bay, called by startnet once the
media is mapped. It checks the four things that come straight out of PESetup's
own behaviour:

  secure boot   GatherDataSelection fails outright when SecurebootEnabled != 1
  disk >= 120GB MinRequiredSpaceWithoutCompression is 128849018880
  driver match  reimplements GetDriverByModel - family filter, untrimmed
                comma-separated substring tokens, first match wins - and checks
                the pack is actually on the media
  media age     media expires 30 days after build; Media.tag's timestamp is the
                local proxy

The driver check is the one that earns it. A miss is only a WARNING to PESetup,
so the bay images with no NIC, DNS fails at first boot, and enrollment cannot
reach the CDN - a symptom three steps removed from the cause. Advisory by
design: it reports and pauses on a blocker, the tech decides. Lives on the
enrollment share so it can be fixed without rebuilding boot.wim.

scripts/preflight.py runs on the server before a build day and aggregates
everything already built - driver catalogue lint, unattend lint, per-PCTYPE
media view verify - plus a new advisory firmware-coverage check that lists
catalogued models with no BIOS models.txt entry. That last one is how the
OptiPlex 7020 family sat uncovered: 127 catalogued models, 58 covered today.

First run: driver catalogues clean, all three unattends clean, firmware coverage
advisory only. PREFLIGHT PASSED.

Verified: both scripts parse clean (PowerShell parser / python), startnet parens
balance, every goto resolves, 915 CRLF lines with no bare LF. Deployed -
boot.wim md5 99fd3132, preflight.ps1 on the share.
2026-08-06 14:22:01 -04:00
cproudlock
8c21282024 Verify staging in WinPE, harvest imaging logs, make enrollment run once
Three changes aimed at the same failure mode: a bay that images green and is
silently unusable, diagnosed only by walking over and copying files off by hand.

VERIFY STAGING (startnet, at :pctype_done)
Checks pc-type.txt, Run-ShopfloorSetup.ps1, shopfloor-setup/common and
shopfloor-setup/<PCTYPE> exist on the applied volume before anything depends on
them, retries the small trees once, and prints a loud banner if the retry does
not fix it. The enrollment package is checked but never blindly re-pulled - it is
8 GB, so a miss is reported instead.
Done here because a tech is still at the machine: a short copy found in WinPE
costs 30 seconds, the same copy found at first logon costs 20 minutes, and found
never costs a rebuild. Four Display bays sat green at stage 2 for weeks.

HARVEST LOGS (startnet, after PESetup exits)
Collects X:\*.log, the generated X:\Unattend.xml, PESetup's own log from the
target's Panther directory and winpe-staging.log into
\<server>\enrollment\imaging-logs\<serial>\, plus a build-context.txt naming
PCTYPE, PPKG, machine number and media. All of it was being discarded at reboot.
Runs after PESetup exits so the logs are final, which means re-mapping Y: since
cleanup already dropped it. Best-effort throughout - a bay must never fail to
reboot because a log copy failed.

W: WAIT CAP 20 -> 45 MINUTES
270 polls instead of 120. Fine on NVMe either way, but a WIM apply can exceed 20
minutes on slow media, and the failure mode is the os_not_found banner plus
nothing staged. The loop still exits the moment the SYSTEM hive appears.

RUN-ENROLLMENT RUNS ONCE
Marker at C:\Enrollment\.ppkg-applied, written on exit 0 and also on 0x800700B7
ERROR_ALREADY_EXISTS. Observed running twice on 579C144; the second pass
re-applied a pending rename over the package's own and otherwise did nothing.

Verified: startnet parens balance, every goto resolves, 899 CRLF lines with no
bare LF; run-enrollment parses clean under the PowerShell parser. Deployed -
boot.wim md5 159c2a4d, live run-enrollment dce9d50a.
2026-08-06 14:18:25 -04:00
cproudlock
36be60e9ae run-enrollment: stop renaming the computer, the PPKG owns it
The package declares <DNSComputerName>F%SERIAL%</DNSComputerName>, so bays are
meant to come up as F<serial>. This script hardcoded a rename to E<serial>,
which is a second pending rename racing the package's own.

Observed on 579C144, 2026-08-06:

  13:31:55  run-enrollment  Rename-Computer -> E579C144      (pending)
  13:32:17  ppkg            Reboot/DeviceName -> F%SERIAL%   (pending)
            Provisioning-Diagnostics: "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 prefix silently beat the package.
The bay came up E579C144 with the ppkg reporting no errors at all - 535
Provisioning-Diagnostics records, every one of them Level 4.

Nothing in this repo ever produced an F name; grep for a prefix rule or a
namePrefix setting finds nothing. The convention only works if naming is left to
the package, so the rename is removed rather than corrected to F. That also
makes it site-agnostic: a different package can name bays differently without a
script edit.

The double execution is a separate defect and is left alone for now. With the
rename gone a second run is harmless: provtool returns 0x800700B7
ERROR_ALREADY_EXISTS and changes nothing.
2026-08-06 14:06:58 -04:00
cproudlock
b96ff7e970 Retire the shopfloor-dashboard route; point Lobby at its own page
The dashboard moved to /shopdb/shopfloor. Every reference here still pointed at
/shopdb/shopfloor-dashboard/, which no longer resolves.

Display-Lobby was also pointing at the DASHBOARD url, not the lobby one, so lobby
screens showed the shop floor dashboard. The routes now follow the authoritative
map in shopdb-flask plugins/geenforce/seed_display_scope.py:

    Dashboard    -> /shopdb/shopfloor
    Lobby        -> /shopdb/tv
    3DPrintRoom  -> /shopdb/parts-kiosk   (no imaging-time menu option yet)

Changed: urls.shopfloorDashboard, Display-Dashboard.edgeHomepage,
Display-Lobby.edgeHomepage, and the Shopfloor Dashboard fallback in
08-EdgeDefaultBrowser.ps1.

Note these values are now a backstop rather than the primary path. Displays are a
single image whose variant comes from C:\Enrollment\display-type.txt, and the
GE-Enforce display scope delivered over the shopdb API writes the kiosk Startup
shortcut itself - preferring the server-side role from Settings > Dashboard
Defaults and falling back to display-type.txt. That dispatcher also SWEEPS
shortcuts matching shopfloor-dashboard, so a stale value here would have been
deleted on the next enforce cycle rather than honoured.

Edited by line position, not string match: the two edgeHomepage values were
textually identical and belong to different display blocks. Formatting preserved
- a json.dumps round-trip reflowed 632 lines and was reverted.
2026-08-06 12:30:35 -04:00
cproudlock
d04865da0f docs: record the PESetup version actually in production; match LogonCount to the live unattend
PESETUP-INTERNALS.md was written from a decompiled 4.0.0.17. The media in
production reports 4.0.0.20 in its own log. Rather than restate the document as
4.0.0.20, which would claim a re-derivation that has not happened, it now names
both: line-level claims are 4.0.0.17, and the behaviour re-observed on bay
579C144 on 2026-08-06 is listed so a reader knows which parts are confirmed
current - media drive Z:, W: created by PrepareDisk and used for every copy
destination, the fallback Deploy\FlatUnattendW10.xml being the unattend that
loads, and driver selection by model.

FlatUnattendW10-shopfloor.xml carried LogonCount 7 while the live shopfloor
unattend has 12, and Run-ShopfloorSetup.ps1's comment about topping up the
autologon budget already said 12. The live value is the real one, so the repo
follows it.

Both files still lint clean under scripts/lint-unattend.py.
2026-08-06 12:25:30 -04:00
cproudlock
2b6189f739 startnet: stage against PESetup's own W:, and stop reporting failed copies as successes
Brings the repo back in line with what is actually running in boot.wim, validated
end to end on bay 579C144 today.

WHAT THIS FILE NOW DOES DIFFERENTLY

Applied-volume detection. The volume finder and the diskpart letter reassignment
are gone. PESetup hardcodes W: in nine places and creates it during its own disk
preparation, so there is nothing to search for, and searching actively broke
staging: a scan run before PESetup finished disking returned the PREVIOUS
install, startnet relabelled that partition W:, and PESetup's own DISKPART then
erased it mid-copy. Timestamps from that failure are in the comment so nobody
rebuilds the finder from the same premise. The wait is back on
W:\Windows\System32\config\system, which only exists once the WIM apply has
written it.

Copy reporting. Eight sites echoed "Copied ..." unconditionally, so a run where
every robocopy exited 16 and moved zero bytes still printed six success lines.
They now branch on errorlevel 8 and say FAILED, including in the staging log.
%ERRORLEVEL% is no longer printed inside parenthesised blocks - it expands at
parse time there and showed a stale value. The WaxTrace test moved above its
mkdir, which sets its own errorlevel.

Per-PCTYPE media. Maps Z: to _media\<PCTYPE> and probes
Z:\Deploy\Control\HardwareDriver.json before trusting it, falling back to the
shared media with a warning.

findstr -> find. findstr.exe is not in this WinPE image. It failed with errorlevel
9009, which "if errorlevel 1" read as true, so an SFLD package took the non-SFLD
branch and never got its BPRT-tagged name.

VERIFIED ON A REAL BAY, from C:\Enrollment\winpe-staging.log:

    PPKG=GCCH_Prod_SFLD_NoOffice_US_Exp_20260831_v4.16.ppkg
    Copied PPKG GCCH_Prod_SFLD_v4.16.ppkg as GCCH_Prod_SFLD_NoOffice_US_Exp_...
    Shopfloor 44 files / common 17 / _ntlars 147 / display 1 / preinstall 85
    FAILED: 0 across every copy
2026-08-06 12:25:18 -04:00
cproudlock
385acfefa7 Lint 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 its pass, so the machine stops at
a dialog with nothing configured and the only clue is an XPath buried in
C:\Windows\Panther\setupact.log.

On 2026-08-06 every shopfloor, standard and engineer build was failing exactly
that way:

    /settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
    Description = Value is invalid.   hrResult = 0x80220005   pass = specialize

An inlined "powershell.exe -Command ..." had grown to 676 characters in a field
capped at 259, and had been broken since the previous evening. Every check here
is mechanical and would have caught it before a bay was booted:

  path-too-long         RunSynchronousCommand/Path     > 259
  cmdline-too-long      SynchronousCommand/CommandLine > 1024
  description-too-long  Description                    > 256
  duplicate-element     a once-only element appearing twice
  not-well-formed       XML does not parse
  bom                   UTF-8 BOM (the live files have none, and python's
                        utf-8-sig silently ADDS one when writing them back)
  unknown-token         %token% PESetup will not substitute; %WINDIR% and the
                        other shell variables are excluded or it cries wolf

Reads over SSH via base64 so a BOM or CRLF survives the hop unchanged. Exits
non-zero on any ERROR so it can gate a deploy.

Immediately found a second live defect the manual review had missed: a 329-char
Description on gea-engineer Order 11, which would have failed the oobeSystem pass
once specialize started passing. All three live image types and both repo copies
now lint clean.
2026-08-06 12:24:59 -04:00
cproudlock
d5f7abc19f BIOS: cover the whole OptiPlex 7020 family, not just Tower Plus
An OptiPlex Micro 7020 reported "no update in catalog" at the WinPE menu. The
firmware was staged all along - OptiPlex_7020_1.22.1_SEMB.exe, 102 MB, sitting on
the share - but models.txt listed only "Tower Plus 7020". check-bios.cmd matches
with find /I, a substring test against the WMI model name, and "Tower Plus 7020"
is not a substring of "OptiPlex Micro 7020", so every Micro, SFF and Tower 7020
silently skipped its firmware update.

The 7010 family three lines above already shows the intended shape: one SEMB
package behind Micro, SFF Plus and Tower Plus entries. Dell packages 7020 the
same way, covering Micro, Micro Plus, SFF, SFF Plus, Tower and Tower Plus, so all
six now point at the one package and the stray Tower Plus line moves up to join
its family.

Verified by simulating the matcher over the manifest: all six variants resolve to
the 7020 package, no duplicate tokens, and 7010 still resolves to its own. Note
that "Small Form Factor 7020" spelled out does NOT match - the short form is
correct, since HardwareDriver.json's modelswminame field uses SFF 7020.

Deployed to the live share, where check-bios.cmd reads it directly off B:, so no
boot.wim rebuild is involved. Previous manifest kept at
/home/pxe/models-rollback-20260806.txt.
2026-08-06 11:10:13 -04:00
cproudlock
049dd7bde6 startnet: map the per-PCTYPE media view for shopfloor builds
Maps Z: to _media\<PCTYPE> instead of the shared gea-shopfloor media, so
PESetup's unfiltered CopyPackages stages only this type's payload rather than
every shopfloor payload on every bay.

The mapping is validated rather than assumed: it probes
Z:\Deploy\Control\HardwareDriver.json, which resolves only when the view exists
AND its symlinks are intact, so a dangling view cannot image silently off a
half-mapped drive. On any failure it falls back to the shared media with a
console warning - the bay still images, it just stages the union, which is
exactly today's behaviour. That fallback also covers the second WJ PXE box,
which has no _media views yet.

Z: is freed before the mapping. A leftover mapping makes net use fail with
"device already in use", and the probe would then pass against the OLD media -
a silent wrong-payload build, which is the failure this change exists to
prevent.

Views are built by scripts/build-pctype-media.py.
2026-08-06 11:03:12 -04:00
cproudlock
c7b17696d0 Build per-PCTYPE PESetup media views out of symlinks
CopyPackages copies the whole of <media>\Deploy\Applications to the target,
recursively, with no manifest and no filtering, so every bay built from the
shared gea-shopfloor media receives every shopfloor payload whatever its type.

The obvious fix - repoint a path inside the shared media as each bay picks its
type - races. Bays image concurrently and CopyPackages is fail-fast, so moving a
path while another bay is mid-copy either hands it the wrong payload or fails its
imaging outright, intermittently and unreproducibly.

Give each type its own media directory instead, built once and never mutated.
Everything in it is a symlink, so a type costs inodes rather than gigabytes and
the shared media stays untouched while bays read it. Samba already serves this
pattern: follow symlinks and wide links are on, and every image root reaches
Sources, Operating Systems, Out-of-box Drivers and Packages the same way.

Types come from the enrollment share's menu.json, the file the WinPE picker
already renders, so the media set cannot drift from the boot menu. Deliberately
NOT mirrored: the FlatUnattendW10.xml.pre-* backups and stray logs beside the
real unattend - the view is the clean set PESetup actually reads. Each type is
built into a scratch dir and swapped, so a bay mapping the path mid-refresh sees
the old tree or the new one, never a half-built one.

Applied on 172.16.9.1: 10 types, 190 links, all 60 critical paths resolve. An
SMB client sees ordinary directories and files - Sources, Control,
FlatUnattendW10.xml at 18078 bytes, HardwareDriver.json at 46113 - which is the
behaviour that actually matters, since PESetup reads this over SMB from WinPE.

Note for anyone extending this: passing the server-side script on stdin to
"echo pxe | sudo -S bash -s" is a silent no-op. Bash inherits the password pipe,
reads EOF, runs nothing and exits 0. It travels as a heredoc for that reason.
2026-08-06 10:56:18 -04:00
cproudlock
dfa026b4ee Lint the driver catalogue against PESetup's real matcher
A driver miss is only a warning: GetDriverByModel returns null, PESetup logs
"driver for [MODEL] not found" and images the machine anyway. The bay comes up
with no NIC and no WiFi, DNS fails at first boot, and bulk enrollment cannot
reach the CDN - symptoms far enough from the cause that the OptiPlex Micro 7020
pack sat missing and the Display MicroPC failures were blamed on a drive letter.

Reimplements the matcher from the decompiled source (docs/PESETUP-INTERNALS.md)
and reports what silently breaks it:

  virtual-platform  one such entry flips the tool into virtual-only mode and
                    hard-fails every physical machine
  empty-token       a trailing comma yields "", and Contains("") is true for
                    every model, so that entry swallows the catalogue
  token-whitespace  Split(',') does not trim, so " OptiPlex 3010" needs the
                    space present in the model string too
  case-mismatch     the share is case-sensitive; Optiplex vs OptiPlex splits the
                    tree and the pack is never found
  missing-zip       referenced pack absent
  family-mismatch   the family filter runs first, so a token whose line
                    contradicts the family field can never match
  shadowed          first match wins, so a later entry may be unreachable
  duplicate-token   osId is not part of the match, so a win10 pack can land on a
                    win11 build purely by ordering

--models resolves real WMI model strings through the same code, which is the
check that actually predicts a no-driver build. Exits non-zero on ERROR or
CRITICAL so it can gate a deploy.

Verified both ways: a synthetic catalogue carrying each defect reports all eight
and exits 1; the three live catalogues on 172.16.9.1 come back clean at 0. The
file listing needs find -L and the same anchoring as destinationDir - without
either, every zip check silently passes.
2026-08-06 10:46:28 -04:00
cproudlock
936902dc4d Document how PESetup.exe actually works
Written from the decompiled assembly rather than from observation. Three
long-standing beliefs about this tool are wrong, and each has cost real
debugging time:

W: is not a guess. PESetup hardcodes it in nine places - every copy
destination, the DISM offline sessions, bcdboot, reagentc - and creates it
during its own disk preparation. startnet.cmd's volume finder, diskpart
reassignment and W: wait loop are machinery built around a problem that does
not exist.

The copy steps do not filter. CopyPackages copies the whole of
Deploy\Applications recursively to W:\Deploy\Applications - no manifest, no
unattend parsing, no extension or size rules. Anything dropped there reaches the
target, which is the basis for staging our own payload without the enrollment
share. It is also fail-fast: one unreadable file fails the whole step.

A missing driver match is a WARNING, not an error. GetDriverByModel does a
substring test of comma-separated tokens with first-match-wins, the family
filter knows only Latitude, OptiPlex and Precision, and a single 'virtual
platform' entry in the catalogue fails every physical machine. A miss lets
imaging finish with no drivers, so no NIC, so DNS failures at first boot -
symptoms far from the cause, and easily misattributed to the drive letter.

Includes the bundle-extraction recipe so the next person can re-derive all of
this instead of trusting this document.
2026-08-06 08:04:48 -04:00
cproudlock
27e76467a8 manifest engine: resolve installer paths without the PS provider
Join-Path routes a drive-qualified path through the PowerShell provider, so
it returns null when that drive has gone away mid-cycle, and every consumer
then bound the null straight into Test-Path -LiteralPath and crashed the
entry with a message that names neither the entry nor the path.

Replace the six Join-Path $InstallerRoot sites with Join-InstallerPath, which
does plain string math, and have all six callers treat a null resolve as
"not found" and log it. Only the PS1 branch had a guard before, and it
covered a null Script value, not a null resolved path.

The helper deliberately avoids [IO.Path]::Combine: its separator and
rooted-path rules follow the host platform, so behaviour cannot be verified
off-Windows. It also rejects rooted values ("\x", "D:\x", "\\server\share"),
which Combine would have resolved outside the share root. Checked all 36
path values across the share manifests: none are rooted today.

Behaviour verified against the real manifest values (forward slashes,
backslashes, trailing-slash root, null, whitespace, rooted, UNC).
2026-08-03 17:23:00 -04:00
cproudlock
66c24b5d59 shopfloor: stop manifest entries hijacking GE-Enforce's drive letter
GE-Enforce mounts the SFLD share on W: and holds it for the whole enforce
cycle, passing W:\<pctype> as -InstallerRoot to Install-FromManifest. Two
scripts that run inside that cycle, as SYSTEM in the same drive namespace,
mapped W: for their own use and deleted it on exit:

  Restore-UDCData.ps1     manifest PS1 entry, mounts the UDC backup share
  Update-MachineNumber.ps1  "Apply Machine Number" task, same backup share

Once W: is gone, the next manifest entry's Join-Path resolves against a dead
drive qualifier. In Windows PowerShell 5.1 that emits nothing rather than
throwing, so the null lands in Test-Path -LiteralPath and the entry dies with
"Cannot bind argument to parameter 'LiteralPath' because it is null".

Observed on a collections bay: the controller-credential entry, which runs
immediately after UDC Data Restore, failed this way while the entry one line
earlier had resolved a W: path successfully one second before.

Restore-UDCData now uses R: and Update-MachineNumber uses N: for the UDC
backup share. Neither letter is referenced anywhere else in the tree; V: was
not an option because Restore-EDncReg and Update-MachineNumber's NTLARS
restore already use it.

The re-mount at GE-Enforce.ps1:239-244 does not cover this. It runs after the
manifest loop and blames idle SMB timeout, but the whole cycle took one
second, so timeout was never the cause.
2026-08-03 17:22:50 -04:00
cproudlock
1a175bc4fe FlatUnattend: revert broken prereqs block, keep OpenText shopfloor switch
The .NET3.5/VC++ prereqs block (11 RunSynchronousCommands with ~260-char doubled Paths + a long Description) made the answer file invalid for the specialize pass - 'Windows could not parse or process unattend answer file for pass [specialize]'. Rebuilt the template from the proven-working original with only the OpenText change (Order 19 -> Setup-OpenText.cmd, drop unattended.bat, short Description). Deployed to both live gea-standard + gea-engineer. The prereqs will be re-added correctly as a single short-Path wrapper cmd (install-prereqs.cmd) once imaging is confirmed.
2026-07-23 14:50:59 -04:00
cproudlock
01f524e8b3 std/eng OpenText: use shopfloor Setup-OpenText.ps1 (fans office+shopfloor profiles to all users)
Standard + Engineer imaged OpenText via the vendor Inno exe + unattended.bat, which left per-user profiles in SYSTEM's appdata (operators never saw keymaps/menus/macros) and only copied to Default+Shared. Switched their FlatUnattend to the shopfloor Setup-OpenText.cmd, which does the install via direct msiexec (OpenTextHostExplorer15x64.msi + ShopFloorx64.mst + SP1.msp) and fans the profile content (both office and shopfloor sets - Accessories/EB, Keymap, Menu, Profile) to ProgramData\Shared + Default user + every existing user. Kept both J2SE installs (Java). Dropped unattended.bat (its crude Default+Shared copy is superseded; NOTE its stale DNS SearchList reg add - old logon.ds.ge.com/rd.ds.ge.com domains - is no longer applied). Shopfloor opentext bundle staged into winpeapps/_shared/Applications/extra/opentext/.
2026-07-23 14:15:56 -04:00
cproudlock
3880151e71 webapp: shopfloor sub-menu tab + nav rename (the edits dropped by a failed git add)
The prior 'nest shopfloor menu as a tab' commit only captured the shopfloor_menu.html deletion - the git add also named the just-removed file, which errored the add and staged nothing else. This commits the actual integration that was already deployed to preview and prod: startnet_editor GET passes shopfloor_items/shopfloor_available, the Shopfloor Sub-Menu tab pane + sf-prefixed JS in startnet_editor.html, /shopfloor-menu redirects into the tab, and the sidebar 'startnet.cmd' -> 'Boot Menu' rename.
2026-07-23 13:58:08 -04:00
cproudlock
5f97e81dec webapp: nest shopfloor menu as a tab in the Boot Menu editor; rename nav to Boot Menu
The shopfloor sub-menu editor is now a 'Shopfloor Sub-Menu' tab inside the startnet editor (it IS a sub-menu of the boot menu), not a separate page - sf-prefixed IDs avoid clashing with the top-level Boot Menu tab. /shopfloor-menu GET + save now redirect back into that tab; standalone shopfloor_menu.html removed. Sidebar 'startnet.cmd' renamed to 'Boot Menu'.
2026-07-23 13:37:55 -04:00
cproudlock
626561a1fa shopfloor menu: data-driven from menu.json (picker + webapp editor)
Replace the hardcoded GEA Shopfloor PC-type sub-menu with a data-driven one:
- menu.json on the enrollment share lists the shopfloor items {key=PCTYPE, label, hint, enabled}; key must match a shopfloor-setup/gea-shopfloor-* handler dir.
- select-shopfloor-type.ps1 renders it in WinPE and writes the chosen PCTYPE (mirrors the CMM bay picker); startnet.cmd runs it and falls back to the baked-in menu if the share/picker is unavailable.
- Webapp /shopfloor-menu editor: reorder/rename/hide/add items; the PC-type is a dropdown of existing handler dirs (can't wire a choice to a non-existent type); writes menu.json. Nav link under Tools.
Kills the duplicated-knowledge problem (menu list was hardcoded in startnet AND the handler dirs AND site-config); add a PC-type = drop in the handler dir + it appears in the menu.
2026-07-23 13:27:08 -04:00
cproudlock
f1d9bdf478 startnet editor: harden _split_lines against doubled CRLF (\r\r\n)
A CRLF file re-CRLF'd (e.g. sed adding \r to already-CRLF lines) yields \r\r\n; the old _split_lines left a stray \r that split into a blank line between every line, so parse_boot_menu/settings/lint saw a garbled file and returned nothing. Now collapse any run of CR before a newline. Also re-deployed a clean-CRLF startnet.cmd into the live boot.wim (the earlier sed-based deploys had doubled the CR).
2026-07-23 11:42:51 -04:00
cproudlock
cc391529bd startnet editor v2: settings/menu forms, syntax-highlight+lint, backup/restore+diff
Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs:
- Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens.
- Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router.
- Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current.
- History: timestamped snapshots on every save, per-row diff + restore.
New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups.
Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN).
2026-07-23 11:37:24 -04:00
cproudlock
234295931e playbook: keep the driver store owned by pxe so download-drivers.py can push
download-drivers.py rsyncs driver packs into _shared/Out-of-box Drivers over ssh as the pxe user, but the store was created root:root so new model folders failed with Permission denied. Added a task to own the store pxe:pxe 0775 (root and the webapp still write it fine). Fixed live on 172.16.9.1 too (chown -R).
2026-07-23 11:13:11 -04:00
cproudlock
0ffdcc79ed enrollment: auto-activate uploaded ppkg via ppkg.conf (no boot.wim edit)
startnet.cmd now sources SOURCE_PPKG/PPKG_VER/PPKG_EXP from \\<pxe>\enrollment\ppkg.conf (baked-in values kept as fallback if the file is missing). The webapp writes ppkg.conf on upload for standard GCCH_..._v<ver>.ppkg files: version parsed from the filename, optional expiry field (blank keeps current), so a new ppkg goes live at next boot with no startnet/boot.wim edit. Enrollment page shows the active ppkg. ppkg.conf seeded on the share (v4.16) and the ppkg.conf-aware startnet.cmd deployed into boot.wim.
2026-07-23 11:02:43 -04:00
cproudlock
3b63697176 download-drivers: compare BIOS versions numerically, not as strings
parse_bios_catalog kept the 'latest' BIOS per model with a string compare, so e.g. '1.20.1' > '1.9.0' was False and it wrongly retained the older 1.9.0. Added _ver_tuple() and compare tuples of ints so the genuinely newest firmware wins.
2026-07-23 10:54:35 -04:00
cproudlock
0cb6b26c27 webapp import: checksum-aware sync (skip unchanged, update only changed)
Image import previously full-copied everything and rmtree'd existing target dirs on every run. Replaced the shutil copy/move/copytree/rmtree with deploy.sync_tree(), which shells out to rsync -a --checksum: files whose content already matches the target are skipped, only new or changed files are written, and existing target files not in the source are left untouched (merge, not mirror). move=True uses --remove-source-files (frees the SMB upload dir) and prunes emptied source dirs. Applies to the Deploy import, the _shared redirections, and the root-level items. Big re-imports now only rewrite what actually changed.
2026-07-23 10:45:58 -04:00
cproudlock
8fbae24b4d webapp: fix a11y contrast, heading hierarchy, prefix-safe modal actions
- Accessibility: dark ink (#00003d) on warning/success buttons, badges, and success alert - white text on #ff9500/#0ad64f failed WCAG AA (~2:1); dark-on-bright now passes.
- Heading hierarchy: sidebar brand h1 -> div.brand-title (with matching CSS selector), and standardized all page-header titles to a single <h1> per page (8 templates were h2).
- Prefix-safe modal JS: dashboard/backups/reports/enrollment delete+clone actions now build their form action from url_for(...'__K__'/'__F__').replace(...) instead of hardcoded paths, so they survive a URL-prefix mount.
2026-07-23 10:14:29 -04:00
cproudlock
18db077475 webapp: reskin to shopdb-flask design system
Adopt the shopdb-flask visual language across the PXE webapp (presentation only, Flask/Jinja logic unchanged):
- New static/pxe-theme.css: GE Aerospace palette (atmosphere-blue sidebar, sky-blue primary, avionics-green), Inter font stack, light/dark theming via data-theme + localStorage (key pxe-theme) with system-pref fallback, and card/button/table/form/badge/alert component styles layered over Bootstrap.
- base.html: shopdb-style sidebar (logo + title, nav sections, footer light/dark toggle) + theme boot script.
- All 13 content templates restyled to the new page-header + card/table/badge vocabulary; unattend_editor grouped per unattend-UX research.
- Fixed a pre-existing CRITICAL bug found during review: nested <form>s in image_config.html made Adopt submit the delete form and Delete-selected post every orphan filename regardless of checkboxes; split into standalone forms wired via the form= attribute.
Built by a Fable-orchestrated Opus workflow (17 agents). All 14 templates parse clean under Jinja2.
2026-07-23 10:05:09 -04:00
cproudlock
8df30017aa Playbook: reconcile live dnsmasq/samba/blancco hand-edits back into repo
The live PXE server carried working config the playbook would have clobbered on re-run. Committed back:
- dnsmasq: BIOS boot chain now the vendorclass PXEClient:Arch:00000 -> ipxe.pxe scheme (plus iPXE userclass HTTP chain), replacing the stale undionly.kpxe client-arch=0 scheme (undionly.kpxe was never staged in tftp-root). Bind by listen-address=172.16.9.1 + bind-dynamic instead of interface=+bind-interfaces.
- samba: add 'allow insecure wide links = yes' to the managed symlinks block (modern Samba silently disables wide links without it) and the [winpeapps_bios] read-only share.
- blancco iPXE menu: full BIOS/EFI arch split (direct-kernel vmlinuz-bde-linux / vmlinuz-ubuntu with ucode+kexec initrds) with a GRUB chain fallback, replacing the old single-line 'chain grubx64.efi'.
startnet.cmd was NOT a commit-back: repo is already ahead of the live boot.wim (deploy-pending).
2026-07-23 09:31:20 -04:00
cproudlock
b240cfea12 collections: add eDNC detection diagnostic scripts 2026-07-23 09:21:49 -04:00
cproudlock
e7be0f5391 gea-engineer: pre-stage .NET 3.5 + VC++ redists via FlatUnattend
Add RunSynchronous Orders 39-49: enable NetFx3 from staged sxs + install the VC++ 2005-2022 redistributables at specialize, before the first-logon engineering installer. Each command is if-exist guarded against the extra_engineering\prereqs payload, so gea-standard (which shares this template) skips them. Payloads staged on the share under gea-engineer Deploy\Applications\extra_engineering\prereqs.
2026-07-23 09:21:49 -04:00
cproudlock
0c4c2a245d Remove legacy ge-* image types (gea-only fleet)
Drop ge-standard/ge-engineer/ge-shopfloor-lockdown/ge-shopfloor-mce from image_types + standard_types, both startnet boot menus (choices + labels + net use), and the webapp IMAGE_TYPES/FRIENDLY_NAMES. Fleet is gea-* only; the empty ge-* winpeapps stub dirs were removed on the live share.
2026-07-23 09:21:49 -04:00
cproudlock
fdf63ab32f Shopfloor self-heal: bootstrap recovery scripts + Keyence/WaxTrace heal
Fixes Keyence (and CMM/WaxTrace) imaging where the target got only partial
data: the Y: SMB mount goes idle-dead during WIM apply, so WinPE staging dies
early (often just after site-config.json + ppkg). The first-logon self-heal
was meant to recover but its scripts were themselves staged past the death
point, so nothing ran.

- FlatUnattendW10-shopfloor.xml: new FirstLogonCommands Order-4 bootstrap that
  mounts the enrollment share fresh and pulls Fetch-StagingPayload /
  Verify-And-Heal-Staging / enrollment scripts into C:\Enrollment before the
  Order 5/6 heal runs. Recovery no longer depends on WinPE staging surviving.
  Keep CommandLine <=320 and Description <=252 chars: exceeding the unattend
  schema length limits makes the whole oobeSystem pass invalid (OOBE prompts,
  no autologon).

- Verify-And-Heal-Staging.ps1: add Keyence and WaxTrace heavy-payload heal
  branches (previously only CMM). Keyence re-pulls installers-post\keyence\
  <model> -> C:\KeyenceInstall\<model>; WaxTrace re-pulls the bundle (minus
  formtracepak) plus the bay-matched FORMTRACEPAK-V<ver>.iso.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:12:43 -04:00
cproudlock
64e1f7e088 Report-AssetToShopDB: log OS + boot time in the POST line
Adds os= and boot= to the POST log line so report-asset-*.log shows whether
the PC actually grabbed the OS version and LastBootUpTime (uptime source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:31:12 -04:00
cproudlock
287ec86c12 Report-AssetToShopDB: fleet-wide reporting, more fields, dual NIC
- collect logged-in user (console user via Win32_ComputerSystem, bare
  username), pc-type (C:\Enrollment\pc-type.txt), make/model, OS version
  (caption + DisplayVersion + build), last boot time (for uptime)
- report BOTH corp and controller NICs (physical only), each with MAC,
  tagged IsMachineNetwork; was corp-only before
- machine-number sourcing adds C:\Enrollment\cmm\cmmid.txt (CMM bay id)
  and skips the 9999 placeholder everywhere
- intended to run from common\ (every pc-type), not collections-only;
  api.asp patch-style update keeps it from clobbering other types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:41:36 -04:00
cproudlock
85e7d91b1a CMM: imaging manifest installs goCMM 2.12.3 (was 1.1)
Future PXE images now install goCMM 2.12.3 (NSIS, /S silent, -> C:\Program Files
\goCMM, runs no-admin). Detection switched to File on goCMM.exe (new install
path). The tsgwp00525 SFLD enforce manifest is deliberately left on goCMM 1.1 so
already-deployed bays are NOT auto-upgraded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:45:25 -04:00
cproudlock
2461804fc8 CMM: Backup-CMM also captures the whole C:\Program Files\WAI tree
Adds a wai_backup_<PC>_<ts>.zip (robocopy /E of C:\Program Files\WAI + the x86
path) alongside the goCMM + PC-DMIS backups, indexed in cmm-backup-index.json.
Captures machine/controller content beyond the per-version PC-DMIS grab. Can be
multi-GB if WAI holds the full PC-DMIS 2016 install.

NOTE: this only CAPTURES it. sync-cmm-backups.sh + Restore-CMM still handle only
gocmm/pcdmis zips - staging/restoring the WAI zip needs those updated too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:01 -04:00
cproudlock
48bc609eb5 CMM/DODA: fix DODA-bay profile resolution + goCMM 2.12 DataFolder + settings converter
- Get-PCProfile: subtype-strip fallback. DODA bays set pc-subtype.txt=doda, so
  the profile key became "gea-shopfloor-cmm-doda" which matched NO profile/alias
  -> Get-PCProfile returned null -> callers fell to hardcoded defaults (no
  PC-DMIS desktop icons; Defect Tracker / WJ Shopfloor / Plant Apps force-started).
  Now an unmatched compound key falls back to the bare pc-type (-> CMM). VM-tested:
  gea-shopfloor-cmm/doda + CMM/doda resolve to CMM (7 apps, PC-DMIS present);
  non-CMM unaffected.
- 09-Setup-CMM Step 2.5c: Active Setup seed for goCMM 2.12 DataFolder. goCMM 2.12
  stores its shared-data-dir in HKCU\Software\General Electric\goCMM\DataFolder
  (decompiled: RegistrySettings uses Registry.CurrentUser - per-user). Imaging as
  SupportUser wouldn't reach the ShopFloor operator's HKCU. Active Setup runs the
  StubPath once per user at first logon -> every user gets DataFolder=C:\geaofi\.
  VM-tested: StubPath writes the value with the trailing backslash intact.
- Convert-goCMMSettings.ps1: converts legacy goCMM 1.1 ApplicationSettings.xml ->
  goCMM 2.12 goCMMSettings.xml schema. VM-tested: output byte-identical to a real
  goCMM-2.12-produced CMM10 goCMMSettings.xml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:59:08 -04:00
cproudlock
132c57ab39 CMM: create C:\GE PC-DMIS FRONT END at imaging + move regserver helper to Public desktop
- Ensure-PCDMISFrontEnd.ps1/.bat: standalone fix for already-imaged bays -
  create C:\GE PC-DMIS FRONT END + grant Users/Auth Users Modify. PCDToIGES.exe
  writes its error log there in its catch block; on a fresh bay the dir is absent
  (legacy front-end setup isn't part of imaging), so ANY PCDToIGES error becomes
  an unhandled DirectoryNotFoundException that crashes the export and masks the
  real cause (confirmed live on a CMM bay).
- 09-Setup-CMM Step 2.5b: create that dir + ACL at imaging for every CMM bay.
- 09-Setup-CMM Step 2.5: Register-PCDMIS-COM.bat now lands on the Public desktop
  (visible to operator or SupportUser) instead of SupportUser-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:56:00 -04:00
cproudlock
99deaa6882 CMM: add PC-DMIS 2026.1 desktop shortcut + taskbar pin (site-config)
The CMM pcProfile listed PC-DMIS 2016 and 2019 R2 in desktopApps/taskbarPins but
not 2026.1, so freshly imaged 2026 bays (CMM11/12) got no PC-DMIS shortcut. Add
the 2026.1 entries pointing at C:\Program Files\Hexagon\PC-DMIS 2026.1 64-bit\
PCDLRN.exe. Test-Path gated by the shortcut generator, so it is a no-op on
2016/2019 bays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:49:55 -04:00
cproudlock
948d013539 preinstall: add VC++ 2015-2022 x64 redist (PC-DMIS 2026 vcruntime140_1.dll)
The x64 redist ladder in preinstall stopped at 2013, so 64-bit PC-DMIS 2026 had
no vcruntime140_1.dll (introduced in VC++ 2019/14.20) and PCDLRN.exe failed to
launch: "vcruntime140_1.dll was not found". The patched PC-DMIS MSI bypasses
Hexagon's Burn bundle, which would otherwise have pulled the runtime in - and the
existing 2010/2012 x64 entries (added for PC-DMIS 2016/2019) never got a
2015-2022 successor.

Add VC++ 2015-2022 x64 (vc_redist.x64.exe 14.44.35211, /install /quiet
/norestart), PCTypes ["*"], File-detected on vcruntime140_1.dll so it is
version-independent. Installer staged on the share at
pre-install/installers/vcredist/2022-x64/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:43:35 -04:00
cproudlock
6e218f3daf CMM/DODA: Install-DODA does the full unattended runtime+app install
The vendor GE_DODA_build5_Installer.exe is a multi-app NSIS bundler whose /S
silent mode is broken (nested sub-install -> MSI 1619); VM-confirmed it only
works interactively. So Install-DODA now drives the bundled components directly,
all silent:
  python-2.7.16 msi /qn  -> C:\Python27
  openjdk-1.8.0.232 msi /qn -> C:\Program Files\RedHat
  gs927w64.exe /S        -> Ghostscript 9.27
  vc_redist /quiet
  doda_build*.zip        -> C:\Apps\DODA  (+ writes .settings the zip lacks)
Then sets AFDA_INSTALL_DIR + Java PATH, precompiles .py->.pyc as admin, and
grants Users+Auth Users Full on C:\Apps\DODA so the locked-down operator can run
DovetailAnalysis.exe (writes .pyc/PreProcess/$TMP). Each step detects-and-skips;
idempotent. VM-validated end to end incl a non-admin operator-paths test.

Runtime binaries (python/jdk/gs/vc msis + doda zip) are staged on the share, not
committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:54:15 -04:00
cproudlock
b5b644a360 CMM/DODA: restore on DODA bays + grant Full on whole C:\Apps\DODA
- Restore-CMM: drop the skip-on-doda gate. DODA bays now restore the
  config-version PC-DMIS + goCMM settings like any other bay (they have backups
  now; DODA itself installs separately to C:\Apps\DODA and is unaffected).
- sync-cmm-backups.sh: update the stale "do not back up DODA bays" note.
- Install-DODA.ps1: grant Users + Authenticated Users Full on the WHOLE
  C:\Apps\DODA (was PreProcess only) - DODA writes output/temp throughout the
  folder as the locked-down operator. /T covers PreProcess.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:22:06 -04:00
cproudlock
bc89ba1cf3 CMM: add Register-PCDMIS-COM.bat desktop helper for post-license COM regserver
PC-DMIS COM must be registered for goCMM to connect, but Pcdlrn.exe /regserver
no-ops until PC-DMIS is licensed - which is a manual post-image step
(clmadmin.exe). So we cannot register at imaging time. Instead 09-Setup-CMM
drops a self-elevating one-click helper on the SupportUser desktop (a
pre-existing profile; avoids 06-OrganizeDesktop's Public-desktop sweep). The
tech runs it after activating the license; safe to re-run. Falls back to Public
Desktop if the SupportUser profile is absent. Copy happens before the Step 3
staging cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:10:44 -04:00
cproudlock
1487abdba5 CMM/DODA: enable on bays 4/10/11/12, fix PCTypes match, grant PreProcess ACL
Three fixes so DODA actually deploys on the four bays that need it:

- cmm-bay-config.csv: doda=yes for CMM4, CMM10, CMM11, CMM12 (was no on all
  bays). Drives doda.txt -> startnet pc-subtype.txt=doda -> the cmm-doda path.
- cmm-manifest.json: DODA entry PCTypes "cmm-doda" -> "gea-shopfloor-cmm-doda".
  The old value never matched: Test-PCTypeMatches builds the PC identity set as
  {gea-shopfloor-cmm, gea-shopfloor-cmm-doda, CMM} (the alias of the cmm type is
  bare CMM, no subtype variant), so "cmm-doda" was in no set and the entry was
  silently skipped. Must be the exact Type-SubType string.
- Install-DODA.ps1: grant Users + Authenticated Users Full on C:\Apps\DODA\
  PreProcess (icacls, SIDs, OI/CI inherit), applied every run. MergeFiles.exe
  writes there as the locked-down operator; without it the merge step fails.

Not yet pushed to the SFLD/enrollment share (server unreachable at commit time).
Not smoke-tested - no pwsh available here; logic-traced only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:35:39 -04:00
cproudlock
7af66575d0 CMM: gocmm-debug - capture the real startup NRE (PC-DMIS COM not registered)
debug.log from the bay shows the part group enumerates fine (14 .geop files
opened), then goCMM connects to PC-DMIS over COM and throws:

  System.ArgumentNullException: Value cannot be null. Parameter name: type
     at System.Activator.CreateInstance(Type type)
     at GEAE.Common.CMM.CMMInterfaces.PCDMIS.PCDMIS.<ConnectToSoftware>b__43_0()
  -> System.NullReferenceException at ConnectToSoftware(...)

Root cause: PC-DMIS automation server is not COM-registered, so
Type.GetTypeFromProgID returns null -> CreateInstance(null) throws -> the NRE
is the downstream symptom. Not part-group, not permissions, not calibration.

- PROBE 4: pull the .NET Runtime / Application Error crash stack for goCMM from
  the Application log so the next run captures the null in one shot.
- PROBE 5: (a) part-group UNC reachability; (b) PC-DMIS COM registration check
  (PCDLRN.* ProgID -> CLSID -> LocalServer32) that names the missing registration
  and the Pcdlrn.exe /regserver fix; plus install presence for context.
- .bat header documents the COM root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:31:11 -04:00
cproudlock
8e11f0824a CMM: gocmm-debug probes the startup NRE (part-group match), checks real exe
goCMM matches the registry 'Selected Part Group' against the
ApplicationSettings.xml <PartGroup FullName> entries with a case-sensitive
compare. No match -> SelectedPartGroup null -> "Object reference not set to
an instance of an object" at start. This is a different failure from the
registry SecurityException the script already probes.

- PROBE 3: read the reg value (32-bit view) + every FullName in the XML,
  Ordinal-compare, and report exact / case-only / no-match / missing-XML.
  Case-only and no-match name the d441abd canonicalization fix as the remedy.
- Version check now looks for the real goCMM.exe, not the nonexistent
  GEAOperatorFriendlyInterface.exe (was a false MISSING).
- .bat header documents both failure modes it now diagnoses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:08:00 -04:00
89 changed files with 12848 additions and 3163 deletions

95
docs/OWNERSHIP.md Normal file
View 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
View 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
```

View 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
```

View File

@@ -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>

View File

@@ -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 &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; 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\ &amp;&amp; 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 &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; 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\ &amp;&amp; install.bat"</CommandLine>
<Description>Install Microsoft Office</Description>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>

View File

@@ -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"]
}
]
}

View File

@@ -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
View 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

View File

@@ -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

View 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

View 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 ""

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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'

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View 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

View File

@@ -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

View 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

View 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%

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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}: $_"
}

View File

@@ -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

View 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%

View 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

View 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 " $_" }

View 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

View File

@@ -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
# ============================================================================

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
1 cmm_id pcdmis_version doda part_group
2 CMM1 2019 no S:\CMM\CMM1\HPTCMM1
3 CMM2 2019 no S:\CMM\CMM2\HPT
4 CMM3 2019 no S:\CMM\CMM3\VENTURE_CMM3
5 CMM4 2016 no yes S:\CMM\CMM4\Spool
6 CMM5 2019 no S:\CMM\CMM5\BLISKCMM5
7 CMM6 2019 no S:\CMM\CMM6\BLISKCMM6
8 CMM7 2019 no S:\CMM\CMM7\VENTURE_CMM7
9 CMM8 2019 no S:\CMM\CMM8\Venture CMM8
10 CMM9 2019 no S:\CMM\CMM9\BLISKCMM9
11 CMM10 2016 no yes S:\CMM\CMM10\Spool
12 CMM11 2026 no yes S:\CMM\CMM11\Spool
13 CMM12 2026 no yes S:\CMM\CMM12\Spool

View File

@@ -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",

View 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 ================"

View File

@@ -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)."

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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 ''

View File

@@ -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

View File

@@ -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 '==============================================='

View File

@@ -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
}

View File

@@ -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."
}

View 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 }
]
}

View File

@@ -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: $_"

View 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
}

View File

@@ -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" }
]

View File

@@ -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.

View File

@@ -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
View 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())

View File

@@ -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
View 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
View 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
View 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
View 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())

View File

@@ -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...

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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 -------------------------------------------------

View File

@@ -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):

View File

@@ -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
View 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; }
}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 &lsquo;<src> (copy)&rsquo;">
</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 &lsquo;<src> (copy)&rsquo;">
</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 %}

View File

@@ -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>
&nbsp;<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&lt;ver&gt;.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 %}

View File

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

View File

@@ -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 &rarr; <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();

View File

@@ -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">
&laquo; 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);
});
}
});

View File

@@ -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 %}

View File

@@ -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;">&larr; 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">&larr; 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 &amp; 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>

View File

@@ -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 %}

View File

@@ -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 &amp; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ---------------------------------------------------- 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(/&quot;[^&]*&quot;|"[^"]*"/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,'&quot;')}" 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,'&quot;')}" 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