Commit Graph

456 Commits

Author SHA1 Message Date
cproudlock
adb8542018 backups: show timestamps in the site timezone
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Backup timestamps read wrong because of two faults stacked, which is why it
looked like a single offset.

The API serialised naive ISO ("2026-08-07T12:00:00"), with nothing saying the
value was UTC. JavaScript's new Date() parses that as BROWSER-LOCAL, so every
timestamp shifted by the viewer's offset before any timezone formatting ran.
Every datetime this plugin stores is naive UTC, so the wire format now carries
a trailing Z.

The history view then formatted with toLocaleString(), i.e. the viewer's zone,
ignoring the site_timezone setting entirely. It now loads that setting and
formats through the shared formatInZone helper, matching NotificationsList.

The panel list label is built server-side with strftime, so a client cannot
correct it afterwards. It now converts to the site zone using the same Setting
lookup the notifications plugin uses - without that it showed UTC, four hours
out at West Jefferson.

Tests cover the wire format and that 16:30Z renders as 12:30 in
America/New_York.
2026-08-08 14:08:33 -04:00
cproudlock
c93ec7a949 backups: one tabbed DNC card, machine-numbered downloads, themed history
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
DNC Info becomes a single tabbed card - General, eFocas, Serial, NTSHR and
MARK - instead of a flat wall of every value. On 3204 that is 11 rows visible
rather than 22, and on 0600 eleven rather than 27, which also stops the card
unbalancing the detail page's two-column layout.

Everything DNC now lives on that one card, so the Part Marker panel is gone:
its settings are the MARK tab. The partmarker KIND is untouched and still
stores, dedupes and serves revisions - they are listed on the backup history
page - it simply contributes no card of its own, which on 145 of 147 machines
would have been an empty box.

Downloads are named for the machine: 3204.reg, and 3204-wow6432node.reg for
the dialect that imports outside NTLARS. The view had been rebuilding the name
from sourcefilename and producing 3204.reg-wow6432node.reg, so the revision now
carries assetnumber and both ends agree. That needed a viewonly relationship to
Asset - no backref, so the core asset side gains no dependency on this plugin.

The history page was hardcoded to light colours (#e0e0e0, #f4f9ff, #666) and
rendered as a white table on a dark page. It now uses the palette variables
throughout, per frontend/CLAUDE.md. The current-revision tint is a color-mix
against --primary so it reads in both themes rather than a baked light blue
that disappears on dark, and the diff columns are headed as well as red/green,
since colour alone does not survive a colourblind reader.
2026-08-07 15:22:45 -04:00
cproudlock
a33470a34b asset panels: add a tabs render mode, align the keyvalue grid
A panel with many fields was unusable. The detail page is a two-column
multicol (.content-column is display:contents, so the left/right wrappers are
dissolved and cards flow into one balanced flow), and a card cannot be split
across a column break. One 30-row panel therefore became a single unbreakable
block that dragged its column far past the other.

The tabs mode renders sections one at a time, so such a panel stays the height
of its largest section. Payload is {sections: [{label, fields}]}; sections that
end up with no fields are dropped rather than left as empty tabs. The active
tab is marked by an underline AND weight, not colour alone.

keyvalue rows now sit in two aligned columns instead of space-between: a ragged
right edge is hard to scan, and one long value (a UNC path) otherwise pushed
its label away from every other row.

Four specs cover the new mode, including that it drives panel visibility.
2026-08-07 15:22:31 -04:00
cproudlock
efe2f7e6ca backups: show a kind's panels only when that asset has data
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Every machine was getting a Part Marker Configuration panel, and only two of
the 147 known machines are part markers. A kind applies to an asset TYPE, but
whether a given asset ever carries that kind of backup is a property of the
individual machine, so type alone cannot decide what to show.

The generic renderer already handled this: a list panel is visible when it has
rows OR declares empty text. Declaring emptytext on both kinds defeated it and
forced them to render everywhere. emptytext now defaults to None on the base
class, neither bundled kind sets one, and the panel builder OMITS the key
rather than emitting null - a present-but-null 'empty' would still have kept
the panel on screen.

The DNC Info card carried empty text that could never be displayed, since
keyvalue visibility is decided purely on field count. Removed rather than left
to mislead.

A lathe now shows DNC Info and NTLARS history; 0600 and 0614 additionally show
Part Marker once something collects for them; a machine with no NTLARS data
shows no backup panels at all. The emptytext mechanism stays available for a
kind that genuinely wants to say "expected here, nothing yet".
2026-08-07 14:53:37 -04:00
cproudlock
565611e3d0 add backups to the universal site profile, and guard the list
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
site-profile-universal.json is what the released Windows installer is built
from, so a bundled plugin missing from it is invisible to the install wizard.
Worse, `flask plugin prune-schema` drops the tables owned by plugins the site
did not install (ADR-014), so backuprevisions would have been dropped at
provisioning on every new site - a table that shipped in the build, removed
because the profile never named it.

The same omission explains why `flask plugin upgrade-all` skipped backups:
upgrade_all_plugins iterates the REGISTRY, not the plugins directory, on
purpose - a plugin folder merely sitting on disk unadopted must not have its
DDL run as a side effect of a deploy. instance/ is gitignored, so any machine
that never ran `flask plugin install backups` has it on disk but unadopted.

Nothing in the suite caught the stale profile, so this adds two guards: every
bundled plugin carrying a manifest must appear in the universal profile, and
the profile must not name a plugin that does not exist. Verified the first one
fails with the profile as it was.
2026-08-07 14:44:33 -04:00
cproudlock
34f08d5306 backups: fix the history view's back link
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The view linked to /assets/<assetid>, which is not a route. Each asset type
has its own detail route keyed by its own id (/machines/<machineid>), and
there is no generic asset detail page to return to, so the link 404'd.

Uses router.back() instead: this page is only ever reached from an asset
panel, so history is the correct destination regardless of the asset type,
and it needs no per-type route table. Falls back to the machines list when
opened directly from a pasted URL.

The /api/assets/<id> call that fetches the heading name is unaffected - that
endpoint does exist; it was only the frontend route that did not.
2026-08-07 14:40:27 -04:00
cproudlock
bb4169cd8c backups: keep the seed script's docstring site-neutral
The import script is bundled in a multi-site product, so its usage examples
should not name one site's share layout or a dev box path.
2026-08-07 14:30:14 -04:00
cproudlock
a10e695d7c shopfloor dashboard: show upcoming cards, state chips, mixed-row headings
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
Scheduled notifications with a special display style rendered nowhere at all.
styledGroups only read notifications.current, so anything upcoming fell through
to the standard Upcoming list, which drops banner/carousel/grid styles. A
scheduled banner was therefore invisible until the moment it went live. Upcoming
items now join the styled groups, tagged so they can be told apart, and each row
sorts live cards ahead of not-yet-started ones.

State chips name what a card is doing: UPCOMING with its start time, or
RESOLVED for the tail a type's grace window buys it. Pending cards also dim to
0.72 so a glance never mistakes one for live. The dim rule is scoped to
.recognition-card.active.pending on purpose: inactive carousel cards sit at
opacity 0, and an unscoped .pending outranks that, which stacks the whole
carousel back into view at once.

Rows keyed by board category can hold more than one type, and the heading then
speaks for none of them. Such rows keep a neutral heading instead of wearing
the first type's colour, and each card carries its own type chip. The chip is
named rather than only coloured, since colour alone does not survive a
colourblind reader at board distance.

Carousel rows gain one pip per card, so a reader can see how many are in the
rotation and how long until theirs returns.
2026-08-07 14:28:01 -04:00
cproudlock
fca775c737 backups plugin: per-asset config backups with revision history
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Adds a kind-pluggable backups plugin. Configuration captured from a PC is
filed against the MACHINE it controls, with a revision history and download
back to the native format.

NTLARS/DNC is the first kind. Settings live in the controlling PC's registry
but describe the machine, so revisions attach to the machine's asset and carry
no foreign key to the PC: history survives a PC being replaced or deleted, and
sourcehostname records the handover.

Storage splits by kind. Parseable kinds store a dialect-neutral JSON
projection in ShopDB and re-render on download; opaque vendor formats (part
marker and similar) keep their bytes on the SFLD share with ShopDB holding
metadata and the UNC pointer.

Two .reg dialects exist in the wild: NTLARS's own Save... export omits the
WOW6432Node path segment, scripted exports include it. Parsing strips whichever
root matched, so a stored revision commits to neither and download offers both
(NTLARS Load... by default, WOW6432Node for direct reg import). Getting this
backwards is silent, so the dedup hash deliberately excludes sourcedialect and
both dialects of one config dedup to a single revision.

Dedup is load-bearing: the collector runs every GE-Enforce cycle across the
fleet, so a revision is inserted only when the content hash differs from that
asset's latest for that kind.

A freshly imaged PC opens NTLARS with a blank General tab. Recording that would
make an empty config the newest revision exactly when someone needs the last
good one, so a blank MachineNo is rejected rather than accepted as a change.
Two of the 320 known-good backups on the share already have that shape.

DNC Info card summarises the latest revision on the machine page: General
(Cnc, NcIF, HostType), eFocas, Serial, NTSHR when populated (only 18 of 147
machines), and MARK when the machine is a marker. MARK is gated on Cnc=MARKER
or the ShopDB machine type, not on the MARK key having content: MARK carries
serial defaults on 145 of 147 machines and DncPatterns reads YES on 103
including ordinary lathes, so neither identifies a marker.

The info card is owned by the kind (BackupKind.infopanel/buildinfo) and served
by a generic endpoint, so the expected successor to DNC ships its own card by
adding a class rather than changing the plugin or the panel wiring.

Also: schedule and retention settings with a prune that never drops the newest
or the oldest revision, and scripts/import_ntlars_backups.py to seed history
from the existing per-machine .reg files (144 of 147 resolve to assets).

Codec verified against all 320 real backups: round-trips clean through both
dialects. Bay-side generation verified on Windows against reg.exe export.
2026-08-07 14:24:42 -04:00
cproudlock
d180a693ee Paint each board row in its own notification type's colour
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
The carousel gold and the grid blue were hardcoded in the dashboard's CSS,
which was fine while carousel meant Recognition and grid meant Recertification.
Now that any type can take either style, a type arrived wearing somebody else's
colour: a green Kudos row came out gold, and every grid row came out
Recertification blue regardless of what was picked in the type settings.

Row headings now take the type's colour, and cards and tiles take theirs
through a --accent property, so a shared category row shows a red incident tile
beside a purple change tile. Heading text colour is computed from the
background's luma rather than listed per colour, since a site picks its own
hexes - gold needs dark text, navy needs white.

The card backgrounds were gold- and blue-tinted gradients; they are neutral
dark now so the accent is what carries the colour. The old values remain as CSS
fallbacks for a card that somehow arrives with no type.
2026-08-07 11:05:47 -04:00
cproudlock
245f94d344 Stop two dialogs going see-through in dark mode
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
--bg-card is deliberately translucent in dark mode (rgba(0,0,61,0.4)) so cards
glass over the page; --bg-card-solid exists for the things that must not. Two
hand-rolled modal panels used the former, leaving the notification-type and
access-protocol editors transparent over the overlay with the table legible
through them. The shared .modal in style.css already got this right.

Also writes down the page-vs-modal rule the codebase already follows, since
nothing stated it: a record with a detail page gets a routed form page, a lookup
row that only exists inside its list gets a modal over that list. Plus the modal
rules from the overlay-close fix - data entry never closes on a stray click,
confirmations may, and panels are painted solid.
2026-08-07 10:30:45 -04:00
cproudlock
ed9c91c47d Give a network device somewhere to put its IP
The network device form had no IP field, so the one thing people look up a
switch for could not be entered. The API had accepted an ipaddress since the
legacy import work - create, update and every read already carried it - but
nothing in the UI ever sent one, which also left the device off the "Devices on
this network" list, since that matches on the IP a device does not have.

The field sits next to Hostname and round-trips through the Communication row
the platform keeps IPs in, the same way the PC and printer forms do.

Clearing it now clears the stored address. The upsert helper returned early on a
blank value despite a docstring promising it cleared - harmless while no form
could submit one, wrong the moment this field existed.
2026-08-07 10:30:38 -04:00
cproudlock
76c184fe91 Give every notification type its own row on the board
The shopfloor board grouped cards by display style alone, so every type set to
grid landed inside the Recertification row and every carousel type inside
Recognition's - under a heading naming somebody else's type. Setting Awareness
to grid put awareness messages under "Recertification Required".

Each type now gets a row of its own, titled by its own name, and rotation state
is per row: two carousel rows advance on their own indexes instead of sharing
one counter, and two grid rows page independently.

For the other direction there is notificationtypes.boardcategory. Types sharing
a category share one row under the category name, so Change, Awareness and
Incident can sit together while Recognition and Recertification keep their own.
Blank - the default - means a row of its own. The category is part of the
grouping key along with the display style, since a category cannot merge a
banner with a row of tiles.

A card that names no employee now renders its message as the tile or card,
rather than a placeholder face above a blank name, which is what a grid type
like Awareness looked like before.

The layout fingerprint that makes open kiosks reload now covers the category
and the grace window, so a re-grouped board reaches screens that are already up.
2026-08-07 10:30:28 -04:00
cproudlock
a52e192501 Revert "Show recertification four at a time, two by two"
This reverts commit af60a77399.
2026-08-07 09:44:26 -04:00
cproudlock
af60a77399 Show recertification four at a time, two by two
Eight tiles across left each one too narrow to read at floor distance. The row
now shows four per page in a 2x2 block, cycling on the same seven-second timer,
so a name is legible from where people actually stand.

The tiles lay the photo beside the name rather than above it. Two rows of
stacked tiles would roughly double the section's height, and the board is
scaled to fit one screen, so height spent here shrinks everything else. The
photo grows to 120px and the name to 26px in the space that buys.
2026-08-07 09:22:41 -04:00
cproudlock
fef5e28952 Let an end time mean the card leaves the board
The shopfloor feed kept every ended notification up for a hardcoded 30 minutes,
flagged resolved. A card with an 8:00 end time was still on the board at 8:29,
which reads as an expiry that did not work - and in the carousel, grid and
banner sections it read that way with no visual sign at all, since only the
standard cards render the resolved state.

The tail is now notificationtypes.gracewindowminutes, set per type on the
Notification Types page and defaulting to 0, so an end time means what it says.
A type whose cards are worth acknowledging after they clear - an incident, say
- opts into a tail, and only that type's cards get one.

The feed widens its query to the largest configured tail and then holds each
row to its own type's window. That keeps one portable query rather than a
per-type interval expression in SQL, and with every type at 0 it collapses to
"still showing".

Also fixes resolved serializing as null rather than false for a card with no
end time, which the and-chain produced.
2026-08-07 09:22:34 -04:00
cproudlock
536a8f0825 Centre the parts kiosk on the screen instead of hugging the top
The kiosk runs full-screen on a wall-mounted display, where the interface sat
against the top edge with the rest of the screen empty below it. It now
centres as one block, vertically and horizontally.

The title was the other half of it: the header was space-between, which reads
as centred only on the steps that show the Start over button. On the first
step, with no button to balance it, the title sat alone at the left edge of
the column. The title is centred and Start over is taken out of the flow so it
keeps its corner without shifting the title on the steps that have it.

Centring uses `safe center`, with plain `center` as the fallback line. On a
screen too short for the content, plain `center` overflows in both directions
and the header ends up above the scroll origin, unreachable. `safe` falls back
to top-aligned there and the page scrolls normally.
2026-08-07 08:38:55 -04:00
cproudlock
593dd46525 Show the kiosk label prefix, and let a plugin declare the settings it owns
Three defects, all found on printedparts_label_prefix, all one root cause:
nothing in the framework knew that setting existed.

The parts kiosk runs logged out. An unauthenticated read of a setting is
limited to an allowlist, the key was not on it, so the kiosk got a 404 and
fell back to no prefix. An admin previewing the same page while logged in saw
the prefix, which is why it looked like it worked.

The same setting also looked like it would not save. The row did not exist on
a site that installed the plugin before the setting was added, so the first
save created it - under the placeholder category the settings API uses for
keys it does not recognise, where the plugin's settings page, which lists by
category, could no longer see it. The value was in the database the whole
time.

And the row was missing in the first place because seeding ran from
on_install / on_enable, which fire only on a state transition. Neither runs
again on an upgrade, so a setting added in a later plugin version never
reached a site that installed an earlier one. The comment claiming enable ran
every upgrade cycle was simply wrong.

A plugin now declares the settings it owns in get_settings_defaults(): key,
default, type, category, description, and whether a logged-out page may read
it. The framework seeds declared keys at install, at enable, and on every
flask plugin upgrade-all; files a first-time write under the declared
category; re-homes any row left in the placeholder category, value untouched;
and answers an anonymous read for keys marked public. Core carries no list of
any plugin's keys.

Contract 0.16.0 (additive optional hook). printedparts and printers move to
the hook and floor their core_version at 0.16.0. The dev database had two rows
in the misfiled state (printedparts_alert_email, employee_db_host); the first
repairs itself on the next upgrade pass.
2026-08-06 18:17:49 -04:00
cproudlock
1aeb3bd1d4 Reorder slides by dragging
Reordering meant clicking the up arrow repeatedly - moving a slide from the
bottom of a long playlist to the top was a dozen clicks and a page of
re-rendering. Rows are now draggable, with a grip so it looks it.

Applies to BOTH surfaces: the manager already switches between Lobby Display and
Shopfloor Screensaver, so one change covers the lobby TV and the EventSaver
playlist.

The drop target is shown as a line on the row being dropped against rather than
by shuffling rows under the cursor, which reads as the list fighting the drag.
The hover preview is dismissed when a drag starts, or it would sit over the list
for the whole gesture.

Drag and the up/down buttons now share reorderTo(), so both persist through the
same call and both recover the same way: a failed save reloads from the server
rather than leaving an order on screen that looks saved and is not.

dataTransfer.setData is set because Firefox starts no drag at all without it.
2026-08-06 10:11:09 -04:00
cproudlock
8fd2b3bcd2 Show a full-size slide on hover in the slide manager
The thumbnails are 120x68 and cropped with object-fit: cover, so any text on a
slide is unreadable and the edges are cut off. Picking the right slide to
reorder or delete meant opening images by hand to tell them apart.

Hovering a thumbnail now shows the whole slide, bounded by the viewport rather
than the image so a 3300x2550 upload does not fill the screen, with the filename
underneath.

Fixed position rather than inside the row: the list scrolls and a
relatively-positioned parent would clip it. pointer-events: none so the preview
can never sit between the cursor and the move or delete buttons.
2026-08-06 10:04:21 -04:00
cproudlock
a18335b61c Step through slides with the arrow keys
Somebody standing at a display who wants to hold a slide, or go back to one that
has just passed, had to wait for the whole rotation to come round again.

Left and right arrows step back and forward; PageUp and PageDown do the same, so
a presenter remote works without being told about it. The listener is on window
rather than an element because a display has nothing focused.

Stepping restarts the rotation timer instead of leaving it running. Advancing by
hand and then having it move again a second later, because the existing timer
was nearly up, reads as the display ignoring the keypress.

Ignored entirely when there is less than one slide to move to.
2026-08-06 07:27:59 -04:00
cproudlock
8e6f6ad58b Floor the calendar height so a short window cannot squash it
The viewport-relative height can resolve smaller than the grid needs on a short
window - a laptop with a docked browser, or a display in portrait - which would
have reintroduced the squashing it was meant to fix. Three floors now: the
container, the calendar root, and each day cell.

The day cell is the one that matters: six week rows cannot render shorter than
about 660px however short the window, so the grid stays readable rather than
collapsing back to strips.
2026-08-05 15:38:38 -04:00
cproudlock
658e5c2224 Give the calendar room, and put new notifications on the shopfloor board
The calendar was set to height 'auto', which sizes each week row to its own
content, so a month of mostly empty days collapsed into thin strips. It now
takes a viewport-relative height and expandRows shares that evenly across the
weeks, with a floor under each day cell so a short window squeezes the grid back
down rather than the rows vanishing.

New notifications now have "Show on Shopfloor Dashboard" ticked. The board is
where these are meant to be read, and starting unticked meant most were written
and then never appeared on it.

Only the default for a NEW notification. Editing an existing one still loads its
stored value, so nothing that was deliberately turned off gets flipped back on,
and the column default is left alone so an API or import caller that omits the
field keeps the behaviour it has today.
2026-08-05 15:37:46 -04:00
cproudlock
959db2922b Release 0.8.1
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The installer on the share was stamped 0.8.0 and contained sixteen commits that
were not in the v0.8.0 tag. A build that misreports its own version is exactly
what the version check in docs/RELEASING-WINDOWS.md exists to prevent, and it
would have left two sites unable to say what they were running.

Everything in 0.8.1 landed after v0.8.0 was tagged this morning, driven by two
sites entering real data for the first time: the blank-code 500, the two-slide
display that never rotated, modals discarding a part-filled form, filters
returning an empty page, model photos that could not be saved, and the shared
equipment catalog that lets a new site start with vendors, models and printer
supply part numbers already present.

CHANGELOG gains a 0.8.1 section, and the OpenAPI document follows __version__
rather than being restated.
v0.8.1
2026-08-05 14:50:06 -04:00
cproudlock
512d5fafac Stop an empty box appearing after Notes on the network device form
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
CustomFieldsInputs renders nothing when a site has defined no custom fields,
which is the normal state. The network form was the only one wrapping it in a
fieldset, and a fieldset draws its border whether or not anything is inside, so
an empty bordered box sat below Notes on every new network device.

Machines, PCs, printers and measuring tools all place the component bare. This
now matches them.
2026-08-05 14:25:10 -04:00
cproudlock
d8fe0a48b2 Stop a stray click outside a modal discarding what was typed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Operators reported losing a part-filled form by clicking slightly outside it.
Every data-entry modal closed on a backdrop click with no warning and no way
back - the worst possible response to a misplaced click, and it happens most to
someone adding their first records at a new site.

Close-on-overlay is removed from 35 modals across 30 files: anything containing
an input, textarea, select or v-model. They still close by Cancel or the X.

Confirmation dialogs keep it, because a delete prompt holds nothing to lose and
dismissing one by clicking away is the behaviour people expect. VendorsList
shows the distinction - its edit form no longer closes that way, its delete
confirmation still does.

The shared Modal component now defaults closeOnOverlay to FALSE. Every current
caller holds a form, a checkout, a stock adjustment or a map position being
picked, and not one passed the prop, so all of them had the same fault. A modal
that genuinely wants dismissing that way opts in explicitly.

Also regroups the operator console menu, which had grown to numbers 1-9 plus
three letters bolted on with no order to them. Actions are now grouped by what
they touch, keyed by their first letter, and the old numbers still work so
nobody who has used it for months is stopped by a rearrangement.

The menu also warns when the server is not fully provisioned and names the key
that fixes it, instead of reporting it as ordinary status lines that read as
normal unless you already knew what to look for. That check is cached for the
session because it shells out to flask twice and the answer does not change
while somebody reads the screen.
2026-08-05 13:42:20 -04:00
cproudlock
23b1dfff41 Load the equipment catalog from the console
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The installer offers the catalog as a tick-box, which covers new sites and
nobody else. A site that installed before it existed, or unticked the box and
later changed its mind, had to be talked through an RDP session and a flask
command - which is exactly the sort of thing the console exists to avoid.

`shopdb-admin.ps1 catalog`, and C on the menu. It runs the dry run FIRST and
prints what would be added, then asks before writing: somebody running this on a
site that has been live for years deserves to see what it would touch before it
touches anything. Answering anything but yes leaves it alone.

An older build without the seed-catalog command is reported as such and told to
update, rather than the failure being read as an empty catalog.

Repair is now on the menu too. It was reachable only by typing the verb, which
is little use to the operator most likely to need it.
2026-08-05 13:30:33 -04:00
cproudlock
367bc56a6d Ship the equipment catalog so a new site does not start empty
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
`flask seed reference-data` wrote a dozen generic model types and no vendors or
models at all, so adopting this platform began by retyping a catalog another
site had already spent a year building. That is the largest single obstacle to
standing a new facility up.

scripts/export_catalog.py dumps the catalog from a live instance to
shopdb/data/catalog.json, and `flask seed catalog` loads it. What travels:

  vendors 53, models 128, modelsupplies 146, modeltypes 35, machinetypes 21,
  computertypes 10, printertypes 9, networkdevicetypes 5, locationtypes 11,
  operatingsystems 14, measuringtooltypes 8, notificationtypes 3,
  accessprotocols 3

The 146 printer supplies are the most useful part after the models themselves:
every toner, drum and maintenance kit with its part number, colour, capacity
tier and page yield, already matched to the right model, instead of somebody
reading them off spent cartridges.

IDEMPOTENT and ADDITIVE. Records match on a natural key - a vendor by name, a
model by vendor plus model number, a supply by model plus part number - so a
second run adds nothing, and it never updates or deletes: a site that corrected
a description or pointed a model at its own photo keeps its version.

Catalog only. No assets, locations, employees, business units or anything with a
serial number: nobody wants one plant's machines appearing at another. Vendor
contact details are excluded too, since a rep's name and number belong to
whoever holds that relationship. supportteams, printerdrivers and customfields
are site-specific and deliberately absent.

Models and supplies reference their vendor by NAME rather than id, because ids
differ between databases and an id-keyed catalog would silently attach part
numbers to the wrong printer.

The installer offers it as a tick-box on a new "Starter data" page, defaulting
to on, passing -SeedCatalog to stage 3. Offered rather than assumed: a site that
machines nothing does not want 21 machine types cluttering its dropdowns.

Verified by loading into an empty database and running twice: every group
populated on the first pass, "Catalog already present, nothing to add" on the
second.
2026-08-05 13:16:57 -04:00
cproudlock
53c1f6476c Pick a network device's map position, and stop hardcoding one site's label prefix
Two things a second site ran into.

The network device form asked for the map position as two raw numbers, so
placing a device meant reading coordinates off another screen and typing them
in. Machines, PCs and printers have had a "Set Location on Map" picker all
along, and the network API already accepted mapx and mapy - only the form was
missing. Same picker, same modal.

The 3D parts kiosk hardcoded 'WJ' as the prefix shown before the number box,
with a comment inviting whoever needed something else to edit the source. That
is West Jefferson's gage-lab tag format and nobody else's, so another site's
operators were told to expect letters that are not on their labels.

It is now printedparts_label_prefix, set in Settings, defaulting to EMPTY - a
site that has not set one sees no prefix rather than inheriting another site's
convention. West Jefferson sets it to WJ once. The kiosk hides the prefix
entirely when unset and falls back to no prefix if the setting cannot be read,
because a cosmetic hint must never stop a kiosk working.

Not to be confused with printedparts_code_prefix, which mints item codes like
3DP0042 and was already configurable. That is the code we generate; this is the
tag already printed on the label.
2026-08-05 13:16:41 -04:00
cproudlock
b44108f70c Run one slideshow timer, not two, and honour each slide's own duration
A site added two slides to the lobby display and it never changed between them.

onMounted awaited fetchSlides, which starts the slideshow itself once it has
more than one slide, and then started it AGAIN unconditionally. Two timer chains
ran, and the second assignment to slideTimer lost the handle to the first, so
nothing could ever cancel it. Both fired about ten seconds later, milliseconds
apart, and each advanced one slide.

With exactly two slides that is 0 -> 1 -> 0 every cycle: the display looked
frozen. With three or more it advanced by two and merely skipped one, which is
why this survived so long - and why adding a third slide would have appeared to
"fix" it.

onMounted no longer starts it; fetchSlides owns that. scheduleNextSlide also
cancels any pending timer before setting a new one, so a future double-call
replaces the chain rather than leaking an untracked one.

While here: the feed has always sent a per-slide duration and the display
ignored it, hardcoding ten seconds, so a slide set to hold for a minute changed
after ten. It now uses the slide's own value, and the progress bar animates over
that same duration instead of finishing early and sitting full.
2026-08-05 13:16:41 -04:00
cproudlock
ead5bd8f58 Give the console a repair verb, and something real to check
A server whose migrations or seeds never finished does not fail politely. Most
pages answer 500 and settings endpoints answer 404 for keys that were never
created, which reads as a broken application rather than an unfinished install.
One site spent a morning being debugged that way.

`shopdb-admin.ps1 repair` runs what stage 3 of the installer runs: db upgrade,
plugin upgrade-all, and the three seeds. Every step is idempotent, so running it
on a healthy server changes nothing, and each step runs independently so one
failure does not silently skip the rest.

`check` now says so before anyone has to infer it:

    THIS SERVER IS NOT FULLY PROVISIONED
      - seed data is missing (permissions, settings or reference data)
    Most pages will answer 500 until this is fixed. Run:
      shopdb-admin.ps1 repair

That needs a real test to sit on, so `flask db-utils seed-state` reports each
seed group and exits non-zero when any is missing. Verified by emptying the
settings table inside a transaction: MISSING, exit 1, rollback clean. Without it
the console check would have looked reassuring while testing nothing - an older
build with no such command reports UNKNOWN rather than healthy, for the same
reason.
2026-08-05 13:16:24 -04:00
cproudlock
705dd771bd Store a blank optional unique field as NULL, and answer a duplicate with 409
A site reported "internal server error" adding a second business unit. It was
reproducible: create one with a blank code, create another with a blank code,
500.

A column that is unique and nullable accepts any number of NULLs - that is what
makes "optional but unique" work - and exactly ONE empty string. The form sent
'', so the first blank code saved and every one after it collided with it. The
field showed no asterisk because it genuinely is optional; the database just
behaved as though it were not.

This is not specific to business units. A dozen columns across core and the
plugins are unique and nullable - asset numbers, hostnames, item codes, subnet
names, gage-lab tags - and each was one blank form away from the same 500.
Fixing them an endpoint at a time would have left the next to be found by a
user, so a before_flush listener normalises blank to NULL on any unique nullable
text column. Listening on Session rather than on individual mappers covers
plugin models imported later, and avoids mapper-event semantics that differ
between SQLAlchemy versions.

A genuine duplicate is now a 409 with a readable message rather than a bare 500
with a traceback in the log: reusing a code that is taken is the caller's
mistake, not a server fault.

Verified against the development database: three business units with blank codes
all save, the blank stores as NULL, and a real duplicate code returns 409.
2026-08-05 13:16:23 -04:00
cproudlock
85ff25462e Reset to page one when a filter changes, and let the catalog carry a real type
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two unrelated things found while looking at blank printer types.

Selecting a filter while past page one returned an empty list. The filter asked
the server for page 5 of a result set that now had one page, and the screen said
nothing matched. useListQuery already resets the page - setSearch and setExtra
both do - but the filter dropdowns bypassed it and called the loader directly.
Nine list pages now route through applyFilter, which calls setPage(1) when it
needs to and loads directly when already on page one, so the composable's URL
watcher does not also fire and fetch twice.

scripts/retype_models.py addresses why printer types cannot be derived. The
catalog types every printer model "Printer": true, and useless, since it does not
say whether the product is a laser, a plotter or a label printer. That answer is
a property of the model - every VersaLink C405 is a laser MFP - but nothing
recorded it, so nothing could derive it. Recording it on the MODEL means the
existing backfill fills every printer by exact name match, and a printer added
later inherits the right type the moment its model is chosen.

It exports the models needing a decision to CSV with a type suggested from the
model number, a person corrects the column, and applying it is a dry run unless
given --commit. A suggested type is refused unless it already exists in that
asset class's own vocabulary, which is what keeps the later name match working.

The suggestion order matters and got this wrong first time: a generic plotter
pattern matched "Zebra ZT411" and filed a label printer as a plotter. Brands now
come before generic patterns, and the review step exists precisely because a
confident wrong guess would type every asset using that model.

Verified on the development database: 24 printer models need a decision, 22 got
a sensible suggestion, applying them let all 42 printers match a printertype by
name, and the transaction rolled back cleanly.
2026-08-05 11:26:23 -04:00
cproudlock
e22322dcc9 Show model type in the machines list
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The machine's own type is blank for the 134 machines that came from the classic
ASP database on machinetypeid=1, a LocationOnly placeholder the import refuses
to carry across as a real subtype. The catalog model knows what those machines
are, and its type is populated, so the column reads modeltypename under a
heading that says so.

Where both values exist they are identical - all 262 machines in the development
database match exactly - so nothing is lost by showing the one that is reliably
filled in.

This does not fix the underlying gap. A null machinetypeid also excludes a
machine from the map's subtype filter and drops its marker to the default
colour, and no column heading affects that. Only populating machinetypeid does,
which is what the backfill script is for.
2026-08-05 10:45:35 -04:00
cproudlock
cb18d170cf Say whose type it is
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two fields on the same page were both labelled "Type": the asset's own, and the
catalog model's. Only one of them was vague. "Model type" already says exactly
what it is; the bare "Type" did not say whose.

So the unqualified one is the one that changes. No new vocabulary, and "Model
type" reads correctly against it:

  Type  ->  Machine Type      (machines)
  Type  ->  PC Type           (computers)
  Type  ->  Printer Type      (printers)
  Type  ->  Device Type       (network devices)

Left alone everywhere the word is not ambiguous - measuring tools, subnets,
VLANs, notifications, supply types and the manifest editor have no model type on
screen to be confused with.

This is a labelling change only. It does not address the blank type column on
machines imported from the classic ASP database, which is a data gap the
backfill script fills; renaming a column heading was never going to put values
in it.
2026-08-05 10:33:47 -04:00
cproudlock
24266146d8 Show the model's type only when it differs from the asset's own
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Adding a "Model type" row next to "Type" put two rows in the Hardware section
that read identically. They come from different tables - modeltypes is the
catalog-wide list spanning every kind of asset, machinetypes is machine-only -
but the names line up in practice: all 262 machines in the development database
match exactly, which is the same fact that makes the type backfill safe.

So the row now appears only when the two disagree, which is the case worth
seeing: a model catalogued as one thing fitted to an asset recorded as another.
When they agree it says nothing and is hidden. Applied to machines, PCs,
printers and network devices, each compared against its own type table.
2026-08-05 10:16:21 -04:00
cproudlock
58b460fe3d Backfill an asset's type from its model, by exact name only
Correcting an earlier judgement. I said the model's type could not be used to
fill an asset's type, because modeltypes is the catalog-wide list covering every
kind of asset - it holds "Access Point", "Camera" and "Desktop PC" - and only
about two thirds of its names exist as machine types.

That is true across the whole catalog and misleading in practice. Restricted to
the models an asset class actually uses, the picture is different: all 262
machines in the development database map exactly, because the non-machine
entries are never used by machines. The blanks on the machines list are rows
whose type the database could already have supplied.

So the backfill now fills the type as well, under a rule that cannot mistype
anything: exact name match or nothing. A model type with no identically named
entry in the asset's own type table is reported with a count and left untouched,
so somebody can decide rather than have a guess written into their data. The
same shape covers computers, printers and network devices, each against its own
type table.

Verified against the development database by nulling one machine's type inside a
transaction: it was detected as fillable, the proposal read "LocationOnly" ->
"LocationOnly", the update restored exactly the original id, and the rollback
left the row unchanged.

Still a dry run unless given --commit, and a table missing the model column is
skipped, so it runs against a server whose network migration is not yet applied.
2026-08-05 10:11:50 -04:00
cproudlock
3f320fcc8b Derive an asset's vendor from its catalog model, and show the model's own type
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
An asset that carries a model but no vendor was showing a blank the database
could already answer: the model records its vendor, and both sides reference the
same vendors table. Machines, PCs, printers and network devices now fall back to
it.

The fallback is FLAGGED, not merged silently. to_dict sets vendorfrommodel and
the detail pages render "(from model)" beside the value, because the record
itself is still empty: the edit form shows an empty vendor box, and a page
implying the vendor is stored would be lying about where it came from.

The model's type is exposed under its own name, modeltypename, and shown as a
separate "Model type" row. It is deliberately NOT used to fill in the asset's
own type. modeltypes is the catalog-wide list covering every kind of asset - it
holds "Access Point", "Camera" and "Desktop PC" alongside the machine entries -
so it is a different taxonomy from machinetypes. Only about two thirds of the
names overlap, and mapping one onto the other would mistype the remainder, with
the failure mode being a machine labelled "Desktop PC".

scripts/backfill_vendor_from_model.py writes the derived vendor down for real,
since the display fallback leaves reports that read vendorid still seeing
nothing. It is a dry run unless given --commit, fills only rows where the
asset's vendor is NULL and the model names one, and never overwrites a vendor
somebody chose. It skips a table lacking either column, so it runs against a
server whose network migration has not been applied yet.

Verified against the development database by nulling one machine's vendor inside
a transaction: it was detected as fillable, restored to exactly its original
value, and the rollback left the row untouched.

FLASK_ENV is not forced by the script. The app already reads it from .env, and
overriding it demanded a SECRET_KEY the environment had no reason to supply.
2026-08-05 09:59:39 -04:00
cproudlock
f8c4246483 Fix model photo upload, and give network devices the model link the page assumed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Three faults around vendor-model photos, found while looking at why an uploaded
image did not appear.

Saving a model was blocked after uploading a photo. The Image URL field was
type="url", and an upload sets it to an application path such as
/api/models/image/model-120.png. Native url validation demands an absolute URL
with a scheme, so the browser refused to submit the form with "Please enter a
URL" for a value the page had just written itself. The field is now type="text",
which is what it always needed to be: it holds either a full web address or a
path on this server. documentationurl stays type="url".

The upload button did not appear when adding a model, only when editing one.
That was deliberate - the photo is stored as model-<id>.<ext>, so it cannot be
sent before the record has an id - but it reads as a missing feature, and the
hint explaining it was easy to miss. A photo chosen while creating is now held
and uploaded as soon as the model is saved, and it is dropped if the dialog is
cancelled, so it cannot land on the next model created in the same session.

Network devices could never show a photo. NetworkDeviceDetail.vue binds its hero
image to networkdevice.imageurl, but networkdevices carried only vendorid, with
no link to a catalog model, so nothing could populate it - a feature that looked
present and could not work. Machines, PCs and printers have carried
modelnumberid since July. This adds the same column and relationship, the
to_dict branch that exposes modelname and imageurl, the field on the API, and a
Model selector on the form so the link can actually be set.

The migration is guarded the same way employees0002photo is: on a fresh database
the tables come from the SQLAlchemy models, which already declare the column, so
an unconditional add fails with "duplicate column name". The foreign key is
created only on databases that can add one by ALTER; routing it through
batch_alter_table made Alembic's column sort raise "Circular dependency
detected" on the fresh-database test.

Deploying this needs `flask db upgrade` and `flask plugin upgrade-all` on the
server, not just a file copy.
2026-08-05 09:08:40 -04:00
cproudlock
92a90fcec6 Document publishing the installer as a release asset
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
The exe is attached to a release, never committed: most forges reject files over
100 MB inside a repository while allowing release assets far larger, and a
committed binary would sit in every future clone forever.

Also records that tags must be pushed explicitly. A plain push of the branch
does not carry them, so a release had nothing to hang off.
2026-08-05 07:44:57 -04:00
cproudlock
89e880afc3 Release 0.8.0
Some checks failed
CI / backend (push) Failing after 6s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
The Windows installer has never shipped under a version: v0.7.0 was tagged
before any of it existed, so every build handed out so far stamped a server with
0.7.0. Two servers running different builds were indistinguishable, and the
installer logged each upgrade as "same version already installed" rather than
recording what changed. This cuts the release that fixes that.

0.8.0 rather than a patch: the air-gapped installer is a new capability, and
pre-1.0 semantic versioning puts that in the minor slot (ADR-007).

CHANGELOG gains a 0.8.0 section covering the twelve defects a real Windows
Server 2019 install surfaced, the move from inferring "is this a re-run of my
install?" to recording it, and the operator documentation.

deploy/site-profile-universal.json is now in the repository. Released builds
were being produced from a profile in a temporary directory, so the next release
could not have been reproduced once that file was cleaned up.
docs/RELEASING-WINDOWS.md points at the committed profile and says why.

scripts/gen_openapi.py reads __version__ out of shopdb/__init__.py instead of
restating it. Its hardcoded copy had already drifted a release behind, which is
the same mistake that once shipped an installer stamped with the wrong version.
v0.8.0
2026-08-05 07:34:05 -04:00
cproudlock
fb53161578 Answer "is this a re-run of my install?" from a record, not from the machine
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
The installer inferred that question from whatever the server happened to look
like: a MySQL service exists, the database has tables, the site exists, the venv
exists. None of those record who created them. A retry after a failed first
install was therefore taken for an upgrade of somebody else's working system,
which produced two dead ends on exactly the retry the wizard invites: stage 3
demanded a mandatory backup of a database its own failed attempt had written,
and then refused to prune tables it had created minutes earlier, because core
migration 7d05 seeds access protocols owned by the computers plugin and any
profile without that plugin hit the refusal every single time.

An install record at ProgramData\ShopDB-Flask\install-state.json answers it
instead. It is written when provisioning STARTS rather than when it finishes,
because the run that dies halfway is precisely the run whose retry needs it, and
it records what this installer created as it goes, so a crashed run no longer
leaves the next one guessing from the machine.

During unfinished first provisioning the pre-migration backup becomes advisory
and prune may force, since every row present was written by an earlier attempt
of the same install. On an established install both stay exactly as they were.
The classification is deliberately asymmetric: an install predating this record
carries a version stamp and probably real data, so it is treated as established
and keeps the mandatory backup. Guessing "first run" there would arm
prune --force against live tables.

Get-CreatedItems comma-protects its return. A zero-length array returned from a
PowerShell function unrolls to $null, and $null.Count is fatal under StrictMode
2.0 - the same fault that made bundle verification fail on every install
earlier. The harness caught it before it shipped.

Tests: deploy/windows/installer/tests/test-install-state.ps1 exercises new
servers, retries, completed installs, unrecorded-but-stamped installs, records
naming another directory, corrupt records, and persistence across a crash.
tests/test_installer_state.py runs it wherever pwsh exists and asserts the
invariants as text everywhere else. Both were confirmed to fail when the prune
gate or the comma protection is removed.

pytest.ini stops collection walking into deploy/windows/installer/bundle, which
is build output holding a complete second copy of the application. Importing
every plugin twice made SQLAlchemy refuse a redefined table and the whole suite
fail to collect, on a tree with nothing wrong in it, purely because an installer
had been built first. It surfaced only when the bundle grew from four plugins to
thirteen.
2026-08-04 21:42:49 -04:00
cproudlock
412c2dc877 Record the code-signing decision
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Waiting for a certificate from the organisation's own certificate authority
rather than buying one from a public CA. Every server this installer runs on is
centrally managed and already trusts that root, so an internally issued
Authenticode certificate removes the unknown-publisher warning exactly where it
matters; a public certificate would buy trust on machines this software never
reaches.

Notes the interim measure that costs nothing: publish the SHA-256 through a
channel separate from the installer, since a hash beside the file is only as
trustworthy as write access to that location.

Wording avoids naming internal infrastructure, since docs/ is published.
2026-08-04 21:25:14 -04:00
cproudlock
1c04ff28b9 Close the remaining installer review findings
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Eleven findings, grouped by the root cause each belongs to.

Wizard input reaching a command line unchecked (ShopDBFlask.iss). Port fields
were spliced in bare and arrive as [int] parameters, so a blank or mistyped
port shifted every argument after it; both port fields are now validated as
1-65535 digits. A path ending in a backslash, which is what a drive root looks
like, ended its argument with \" and CommandLineToArgvW read that as an escaped
quote, so paths are now quoted through a helper that doubles the trailing
backslash. A drive root is refused outright as well: uninstall deletes the
application directory recursively, so installing to D:\ would have wiped the
drive on removal. The password handoff was written with SaveStringToFile, which
writes an AnsiString, and read back as UTF-8, so a correct non-ASCII password
was reported as wrong; it now goes out as UTF-8 without a BOM.

Launching without checking the result. Plugin deregistration invoked "flask
plugin uninstall" without --yes, and the command carries a click
confirmation_option that aborts with exit 1 when nothing can answer the prompt,
so it could never once have succeeded; the bare 2>&1 under EAP Stop then turned
that into a terminating error which the catch downgraded to a warning while the
plugin directory was deleted regardless. It now passes --yes, brackets the
error preference, restores the location in a finally, and keeps the code on
disk unless deregistration actually succeeded. MarkShortcutRunAs had four
quotes where it needed three, which kept the whole command inside one Pascal
literal so LnkPath was never interpolated and no shortcut ever got the
elevation flag; its exit code is now logged too.

Comparing IIS physical paths as raw strings. IIS stores the path as typed, so
it may carry environment variables or a trailing backslash. A Test-SamePath
helper now normalises both sides. That closes a real hazard in uninstall, which
matched applications on alias alone and would remove an unrelated application
of the same name under another site, unattended, since -OnFailure never
suppresses the confirmation.

Accepting existing IIS state without reconciling it. "Site already exists" took
the site however it was, so re-running with a different port left the old
binding while CORS_ORIGINS, the firewall rule and the smoke test all used the
new one, failing a working server. It now refuses with both ports named rather
than silently re-binding, and refuses a site of that name serving a different
directory.

Preflight rows drawn past the panel. The failures loop had no cap at all and
the warnings loop capped at 6, a number unrelated to the panel, which holds
about three rows. The cap is now measured from the panel height, applies to
both loops, and the footer counts what was actually left out instead of
inferring it.

Also: a failed upgrade now says the application pool is still stopped and how
to start it, rather than only "part-configured", since stage 2 stops a pool
that was serving. It is deliberately not restarted automatically, because after
a stage 3 failure the deployed code and the schema may disagree. shopdb-admin
Restart-App starts a stopped pool or site instead of recycling, which is a
no-op on a stopped pool and then reported the application as unresponsive. A
dead Write-Log line that parsed as three arguments is gone, and a preflight
warning no longer tells the operator to add a directory to a compiled exe.
2026-08-04 21:16:46 -04:00
cproudlock
ce521e84a5 Lock down backup directory ACLs, and let the uninstaller reach IIS
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Two findings from the installer review, both of which fail silently.

Database dumps were readable by every authenticated user. A directory created
under ProgramData inherits BUILTIN\Users:(I)(OI)(CI)(RX), and a dump contains
every row including the users table and its password hashes. The installer
applied an owner-only ACL, but only in the branch that CREATED the directory,
so a directory created first by the console (shopdb-admin.ps1 backup) kept the
inherited permissions and the installer could never repair it. The ACL is now
re-applied on every run rather than only on creation, and the grants are made
inheritable with (OI)(CI) so dumps written into the directory later are covered
too. shopdb-admin.ps1 applies the same hardening for the default location, and
for an operator-named path says the dump holds password hashes rather than
silently rewriting the ACL of a directory that is theirs.

Verified on Windows: before, the directory carried BUILTIN\Users:(I)(OI)(CI)
(RX); after, only SYSTEM and Administrators, and a file created inside inherits
exactly those two. Without (OI)(CI) that file would not have been covered.

The uninstaller could not remove anything in IIS. [UninstallRun] launched a
bare "powershell.exe", and the Inno uninstaller is a 32-bit process, so WOW64
resolved it to the 32-bit PowerShell, which cannot see the IIS provider. The
site, application pool and application survived, pointing at a directory that
HAD been deleted, while Windows reported a clean uninstall. It now uses the
same Sysnative path as the [Run] entry, which was the last unshielded launch
site in the file.
2026-08-04 21:04:54 -04:00
cproudlock
1d73bd477e Document what future Windows releases look like, for operators and for builders
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two audiences, two documents. Both were only in people's heads.

UPDATES-WINDOWS.md is for whoever runs a server: updates arrive as one
self-contained exe, an update takes two to four minutes, the site is down for
that time, .env and data and any hand-edited web.config are kept, unticking a
feature never removes it, the database is backed up and verified first, and a
downgrade is refused because migrations only go forwards. It covers both kinds
of security release, application and third-party, and explains that the
CycloneDX inventory staged on every server is what answers a published
vulnerability question. It also says plainly that the exe is not signed and the
checksum is the integrity check to rely on today.

It answers one question the existing docs did not address at all: the effect on
other sites sharing the same IIS server. The application pool is isolated and
the configuration is scoped to its own path, so other sites keep their own
handlers. What IS shared gets named rather than glossed: installing the IIS
modules and writing server-level configuration recycles application pools
across the server, which can drop requests in flight and clears in-memory
session state, though IIS is never stopped and no iisreset is issued. The two
IIS modules and the single permitted rewrite server variable are machine-wide
and stay behind on uninstall, deliberately, since another site may have come to
depend on them. The bundled database option collides on port 3306 with an
existing MySQL.

RELEASING-WINDOWS.md is for whoever builds releases: the three kinds of change
and the commands for each, why bundle-lock.json must be committed, the two
dependency traps that have each already cost a release, which generated files
must never be hand-edited, and the pre-release checks. It records the two known
gaps honestly - no code signing, and compiling still requires Windows and a
person.

UPGRADE.md and OPERATE-WINDOWS.md link to the operator document.
2026-08-04 20:54:05 -04:00
cproudlock
aeee210cf6 Repair a web.config that an earlier build made unusable
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Stage 4 deliberately leaves an existing web.config alone, because operators put
real changes in it: extra MIME maps, a /installers location, bindings, a
proxy-specific rule. Overwriting reverts those silently.

That rule had no exception, and earlier builds of this installer wrote an
<allowedServerVariables> block which is fatal on its own: the section is Deny
by default, so IIS rejects the entire file with 500.52 before
httpPlatformHandler runs. Any server already installed would therefore keep the
broken file forever, with re-running the fixed installer powerless to help,
since the first thing stage 4 does is decline to touch it.

Strip just that element, keeping every other edit, and only when it contains
nothing besides the variable this installer adds. A block holding anything else
is somebody's deliberate change and is left alone with a warning. The previous
file is copied to web.config.before-xff-fix first.

Exercised against four inputs: the file earlier builds wrote, which is repaired
and still parses as XML with the rewrite rule intact; a block with an
operator-added variable, which is left unchanged; an empty block, which is the
$null.Count trap under Set-StrictMode 2.0 and is why the filter is wrapped in
@(); and an already-correct file, which is a no-op.
2026-08-04 20:06:47 -04:00
cproudlock
95b0b77c13 Allow HTTP_X_FORWARDED_FOR at server level instead of declaring it per-application
The stage 5 smoke test failure was a locked config section, but not one of the
two the installer unlocks. A diagnostic collected from the server returned:

  HTTP 500.52 - URL Rewrite Module Error
  Module RewriteModule, Handler httpplatformhandler
  Error Code 0x80070021
  Config Error: This configuration section cannot be used at this path.
  Config File: \\?\C:\shopdb-flask\web.config

handlers and httpPlatform were both overrideMode Allow and locked false, so
the unlock had worked. The section at fault was a third one,
system.webServer/rewrite/allowedServerVariables, which ships
overrideModeDefault="Deny". web.config declared <allowedServerVariables>
locally for the X-Forwarded-For rule, and IIS rejects that declaration
outright, failing the entire configuration before httpPlatformHandler ran.
python was therefore never launched and C:\shopdb-flask\logs stayed empty,
which reads as a dead application or a permissions fault and is neither.

Unlocking the section would let every site on the machine declare arbitrary
server variables. The installer now adds the single variable to the
server-level allow list, checking first because a duplicate add is an error,
and web.config no longer declares it. The rewrite rule is unchanged.

Verified by applying the installer's own uncommenting to the template and
parsing the result: one rewrite element, no allowedServerVariables, the rule
still setting HTTP_X_FORWARDED_FOR from REMOTE_ADDR.

shopdb-diagnose.py checked only the two sections the installer unlocks, so it
could not have named this one; the IIS error page did. It now reports the
lock state of the rewrite sections as well.
2026-08-04 20:04:19 -04:00
cproudlock
10ee3a3c58 Add a stage 5 diagnostic collector
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The stage 5 smoke test failing tells us only that IIS did not return 200. The
cause is in one of four places, and finding out which has taken a round trip
per guess. This gathers all four in one pass and writes a single report.

It records what IIS actually answers on localhost, 127.0.0.1, ::1 and the
machine name, including the status code and the parsed text of the IIS error
page; the site, application, pool and module state from appcmd, plus the
override state of the two config sections httpPlatformHandler needs; the
contents of web.config and the resolved httpPlatform processPath; whether the
venv can import shopdb and call create_app; the application logs, separating a
missing log from an empty one; the ACLs the pool identity depends on; and
recent HttpPlatform, WAS and W3SVC event log entries.

Secrets never reach the report. Values are read from .env first, then scrubbed
from every section before the file is written, which covers command output and
tracebacks that might quote them. A password embedded in any connection URL is
also masked whether or not it came from .env.

Standard library only, so it runs on the bundled runtime or any system Python.
Verified end to end on a Windows VM: it correctly reported a 404 with the IIS
error code for an absent application, and that localhost resolves to ::1 first.
2026-08-04 19:56:13 -04:00
cproudlock
97391cdee4 Unlock IIS config after the application exists, and report why the smoke test failed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two defects found in the stage 4 and stage 5 logs from a Windows Server 2019
install.

The scoped config unlock ran before the thing it unlocks existed. appcmd
resolves its location argument against applicationHost.config, but the unlock
was issued from the ACL block, ahead of New-WebApplication. On a first install
"Default Web Site/shopdb" is not there yet, so appcmd returned 80070003, "the
system cannot find the path specified", and the code fell through to unlocking
the section for the entire machine. That fallback exists for servers which
refuse the scoped form; it was instead the only path a first install could
take, so every install silently granted handler delegation server-wide. Moving
the block below site and application creation lets the scoped unlock work.

The smoke test discarded the diagnosis. Invoke-WebRequest raises on any
non-2xx, and the catch block kept nothing from the exception, so a fault IIS
had already identified by status code was reported as "site did not return
200 ... check the logs". It now records the status code and the text of the
IIS error page, and prints the tail of the HttpPlatform stdout log, which is
where a Python traceback lands. It also distinguishes a missing log from an
empty one: the first means the pool never launched python, the second that
python started and wrote nothing.

A non-200 that did not raise, such as a redirect, skipped the retry delay, so
the loop could spend all twelve attempts at once and report a timeout without
having waited.
2026-08-04 19:43:30 -04:00