a10e695d7cce09b4c5a6fbd552f9b93bf41a6ba9
449 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a10e695d7c |
shopfloor dashboard: show upcoming cards, state chips, mixed-row headings
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. |
||
|
|
fca775c737 |
backups plugin: per-asset config backups with revision history
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. |
||
|
|
d180a693ee |
Paint each board row in its own notification type's colour
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. |
||
|
|
245f94d344 |
Stop two dialogs going see-through in dark mode
--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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
a52e192501 |
Revert "Show recertification four at a time, two by two"
This reverts commit
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
959db2922b |
Release 0.8.1
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 |
||
|
|
512d5fafac |
Stop an empty box appearing after Notes on the network device form
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. |
||
|
|
d8fe0a48b2 |
Stop a stray click outside a modal discarding what was typed
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. |
||
|
|
23b1dfff41 |
Load the equipment catalog from the console
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. |
||
|
|
367bc56a6d |
Ship the equipment catalog so a new site does not start empty
`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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
85ff25462e |
Reset to page one when a filter changes, and let the catalog carry a real type
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. |
||
|
|
e22322dcc9 |
Show model type in the machines list
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. |
||
|
|
cb18d170cf |
Say whose type it is
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. |
||
|
|
24266146d8 |
Show the model's type only when it differs from the asset's own
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. |
||
|
|
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. |
||
|
|
3f320fcc8b |
Derive an asset's vendor from its catalog model, and show the model's own type
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. |
||
|
|
f8c4246483 |
Fix model photo upload, and give network devices the model link the page assumed
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. |
||
|
|
92a90fcec6 |
Document publishing the installer as a release asset
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. |
||
|
|
89e880afc3 |
Release 0.8.0
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 |
||
|
|
fb53161578 |
Answer "is this a re-run of my install?" from a record, not from the machine
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. |
||
|
|
412c2dc877 |
Record the code-signing decision
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. |
||
|
|
1c04ff28b9 |
Close the remaining installer review findings
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. |
||
|
|
ce521e84a5 |
Lock down backup directory ACLs, and let the uninstaller reach IIS
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. |
||
|
|
1d73bd477e |
Document what future Windows releases look like, for operators and for builders
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. |
||
|
|
aeee210cf6 |
Repair a web.config that an earlier build made unusable
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. |
||
|
|
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. |
||
|
|
10ee3a3c58 |
Add a stage 5 diagnostic collector
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. |
||
|
|
97391cdee4 |
Unlock IIS config after the application exists, and report why the smoke test failed
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. |
||
|
|
a352a21a10 |
Declare packaging as a runtime dependency
shopdb/plugins/loader.py imports packaging.specifiers and packaging.version at module scope, but packaging was never listed in requirements.in. It was present in every development and CI environment as a transitive dependency of pytest, so the full suite passed while a venv built from requirements.txt alone could not import shopdb at all. The Windows installer builds exactly such a venv, so stage 3 failed on a customer server with ModuleNotFoundError: No module named 'packaging', after the runtime and all wheels had installed successfully. Add packaging to requirements.in, recompile the hashed lockfile, and add the wheel to the offline wheelhouse with the matching bundle-lock entry. The recompile also picked up newer uv formatting: inline environment markers on cffi and greenlet and shorter "via" comments. The pinned distribution set and every existing hash are unchanged. tests/test_runtime_dependencies.py guards the general case by scanning shopdb/, plugins/ and scripts/ for unconditional third-party imports and asserting each maps to a distribution pinned in requirements.txt. Test dependencies are the blind spot for this class of failure, since they are present wherever the suite runs and absent wherever it does not. |
||
|
|
f6b621d126 |
fix(installer): keep -Wait, and use the stage-0 handoff even when .env exists
Two defects from a Server 2019 run that got further than any before it - the payload verified against the lock on a real server for the first time. EMPTY EXIT CODE. "Python install failed (exit )" on an install that had actually worked. Dropping -Wait to make -TimeoutSec enforceable left $p.ExitCode unreadable: PowerShell only reliably populates it on a waited process. -Wait is restored and the trade is now explicit - exit codes are load-bearing here, 1639 vs 1603 vs 3010 is the entire diagnosis, and a bounded wait is not worth losing them for. -TimeoutSec is advisory: logged as an expected duration so a hang is identifiable, not enforced. The code is also read defensively now, and an unreadable one fails loudly rather than being taken for success. That timeout has never worked - -Wait made the block dead code from the start - so nothing is lost that was ever there. Trying to fix it broke something that was working, which was the wrong trade to make silently. STALE .env PREFERRED OVER A GOOD HANDOFF. The .dbpass fallback sat in the else of "if .env exists", so it was consulted only when .env was absent. A part-finished install HAS an .env, holding whatever password stage 2 last wrote; if stage 0 has since regenerated the credential, .env is stale and .dbpass is correct - and the installer preferred the stale one, giving "Access denied" with the right password sitting unread on disk. The handoff is now applied before the branch, so it covers both, and only when .env points at the local server so it can never redirect a site whose database lives elsewhere. |
||
|
|
21110b86eb |
fix(installer): clear the retry path, which is the path everyone is actually on
Five defects from the Windows-defect review, each confirmed against the code
before changing it. Four of the five only fire on a RE-RUN - and after eight
attempts a re-run is the normal case, not an edge case, which is exactly why
they survived.
Invoke-Native, three defects in one function:
- Any non-zero exit was failure. 3010 and 1641 mean "done, reboot required",
and the VC++ redistributable returns 3010 on a server with a pending file
rename - an ordinary state on a freshly patched box. It is now an accepted
outcome for the installers that can report it, logged as a warning so the
operator knows a reboot is owed.
- -Wait blocks inside Start-Process until the child exits, so the -TimeoutSec
block below it could never run. Every timeout on every MSI was decorative.
The wait is now bounded here, followed by a parameterless WaitForExit so the
redirected output is flushed before it is read.
- The Python bootstrapper ran /quiet with no /norestart, free to reboot the
server mid-install.
Stage 0 refused to run when a MySQL service existed - including the MySQL84 it
had registered itself. Every bundled-database retry dead-ended while the wizard
promised that re-running was safe. A foreign MySQL still blocks; ours is started
if stopped, and the create-the-server block is skipped. It also no longer tries
to bootstrap through a root account whose password it set on the previous run:
with the handoff present there is nothing to do, and without it there is no safe
automatic recovery, so it says what to do instead of guessing.
Stage 4's appcmd unlock used '2>&1' under $ErrorActionPreference = 'Stop', which
turns any appcmd stderr into a terminating error - so the exit-code test and the
server-wide fallback, the whole reason the block exists, were unreachable, and
the stage aborted after Python, the venv, the schema and the ACLs had been
changed.
Stage 3 ran prune-schema and treated its refusal as a failure. Refusing is the
designed outcome when a table holds rows, signalled with SystemExit(1), so
Invoke-Native killed the stage and the reporting written to explain the refusal
was unreachable. Core migration 7d05 seeds access protocols owned by the
computers plugin, so any profile omitting computers hit this on every retry.
The preflight's MySQL 5.6 index-flag check is a warning, not a blocker. It
inspects the LOCAL MySQL, which may not be the database being installed against;
stage 3 checks the one actually chosen. Same class as the HttpPlatformHandler
blocker fixed earlier.
|
||
|
|
5f350179b1 |
fix(installer): undo a fix applied twice, and load the checker at script scope
A review of the installer for Windows-only defect classes found seven live issues. These two would have stopped the next attempt on any server. DOUBLE-APPLIED GUARD. Yesterday's $null.Count fix was applied at BOTH ends: Test-BundleLock returns ,$problems, and the call site also wrapped it in @(). The comma already hands the array back intact, so the extra @() nests it and .Count becomes 1 regardless of how many problems there are. Every install would have failed with "the bundle does not match bundle-lock.json (1 problem(s))" on a byte-perfect payload. Applying the same guard at both ends was worse than applying it at neither. Verified in a Windows VM against a real bundle: clean 0, tampered 1, restored 0. DOT-SOURCE SCOPE. bundle-lock.ps1 was dot-sourced INSIDE Assert-BundleIntegrity, which loads it into that function's scope - every helper it defines disappears when the function returns. Assert-BundleIntegrity itself worked; the next caller, Get-WheelhousePythonTag, died with "The term 'Get-JsonProperty' is not recognized". It only fires where a venv already exists, so greenfield was fine and every retry after a part-completed install was not. Now loaded once at script scope, guarded so the stages that run without a bundle still work. Both were confirmed by running them rather than by reading: the nesting with a three-case pwsh test, the scoping with a minimal repro. |
||
|
|
14fedcee4c |
fix(installer): a clean payload crashed stage 2
Reported from Server 2019: "The property 'Count' cannot be found on this object" immediately into stage 2. Test-BundleLock returns an array of problems, and an EMPTY array means the payload is exactly right. PowerShell unrolls a zero-element return into $null, and under Set-StrictMode 2.0 $null.Count throws - so the branch that runs when everything is correct was the one that could not run. Every failing bundle got past it fine, which is why nothing caught it until the 8.3 path fix made verification succeed for the first time on a real server. Fixed at both ends: the call site wraps in @(), and Test-BundleLock returns ,$problems so no caller can be handed $null or a bare string depending on how many problems there happen to be. The other .Count uses in this file were already @()-wrapped and are unaffected. |
||
|
|
fe091e751a |
fix(installer): the bundled database rejected connections it should have accepted
Two defects in the stage 0 bootstrap, both surfacing as "Access denied" on a server where the operator was holding the correct password. CREATE USER IF NOT EXISTS is a no-op on an existing user - it does NOT change the password. Stage 0 generates a fresh password every run and overwrites .dbpass with it unconditionally, so any path that re-runs the bootstrap over an existing account left the handoff holding a password the server had never been told. ALTER USER now follows each CREATE, so the stored password and the handoff always agree. The user was also only created for 'localhost' and '127.0.0.1'. On current Windows, 'localhost' resolves to the IPv6 loopback FIRST, so an operator who types localhost rather than 127.0.0.1 arrives as '<user>'@'::1' - an account that did not exist - and MySQL answers "Access denied" naming a host they never typed. The ::1 account is now created and granted alongside the other two. Note the datadir guard means the first defect could not fire on a straightforward re-run - stage 0 refuses a non-empty data directory before reaching the bootstrap. It was still wrong, and reachable once the directory has been cleared by hand, which is what the failure message tells operators to do. |
||
|
|
189a474082 |
fix(installer): do not demand a password the installer already holds
Asked why the password box does not pre-fill from .dbpass. It should not - but it should not have been demanding a password either. .dbpass is the ACL'd handoff stage 0 writes when it creates the database itself, and stage 2 already reads it automatically when no password is supplied. The wizard, though, required a password whenever .env was absent, without checking for the handoff. On a server where stage 0 had completed but stage 2 had not - which is exactly what a partly-failed install leaves - the operator was blocked on a secret the installer already had, and sent hunting for a generated password they were never meant to handle. Blank is now accepted when either .env or .dbpass is present, and the sign-in page says so when it sees a handoff. Deliberately NOT pre-filled into the password box, for two reasons. It is the only copy of a generated password, so round-tripping it through a UI control and back out through a temporary password file adds exposure for no benefit - stage 2 reads the file directly. And .dbpass belongs to the BUNDLED database; on the existing-database page the operator is pointing at someone else's server, where a locally generated password is simply the wrong answer. |