b7cfd2b1983f98ac809445c63dba123cfe3fcfb0
606 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b7cfd2b198 |
A supply nobody could read is unknown, not empty
Reported as "CSF04 now reports all 0%", on the printer's own page as well as
the reports. Every supply on one host reading zero at the same instant is not
what a set of cartridges does; it is what a host that stopped answering looks
like.
getsuppliesbyip turned every unreadable item into 0:
try:
level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError):
level = 0
An item that has never collected returns lastvalue as an EMPTY STRING, so
float('') raised and the handler substituted zero. lastclock was fetched in the
same request and read by nothing at all, so a reading from three weeks ago was
presented as current.
Zero is a legitimate level meaning spent, which is what made the substitution
dangerous rather than merely wrong. Downstream it reached `level <=
EMPTY_LEVEL`, which set daysleft to 0, which put the printer on the order list:
the report asked purchasing to buy a full set of cartridges for a printer
nobody had measured. On a waste container it was worse - waste is scored as
100 - level, so an unreadable one read as 100% full, the alarm state.
_readlevel now returns (level, lastseen) with level None for no usable reading:
empty, missing or unparseable lastvalue, or a lastclock older than 24h. A
lastclock that is absent is not treated as stale - a reading that cannot be
aged is not thereby wrong, and discarding it would lose real levels. A genuine
"0" is still 0, which is the half that keeps an actually-empty cartridge on the
order list.
classifysupply gains an 'unknown' status with remaining None. It is not
critical: critical is a claim someone acts on, and it may not be made about a
supply nobody measured.
The low-supplies report skips unknown rather than listing it as needing
replacement, and counts it in summary.unknown - a printer that has gone quiet
should be visible as that, not absent and not masquerading as empty.
_annotate_supply reads .get('level') with no 0 default, which would have put
the whole bug back.
PrinterDetail already rendered `level !== null ? ... : 'N/A'`, so it starts
telling the truth as soon as the backend sends null; the UI had been written
for a case the service never produced. It gains the unknown style - muted, not
a severity colour - and a tooltip separating "no reading at all" from "no
reading since <date>", which are different faults with different fixes.
Alerts needed no change: float(None) raises and that loop already continues,
so unknown supplies stop firing low-toner emails as a consequence.
12 of the 16 new tests fail on the old code. The 4 that pass either way pin
that real zeros still behave, which is the regression this fix could cause.
Not verified against CSF04's actual Zabbix data - dev has no reachable Zabbix.
This fixes the mechanism that turns unknown into 0%; /api/printers/<id>/supplies
now distinguishes them, with null for no reading and 0 for a measured zero.
|
||
|
|
f9d298c7f0 |
Name the toner on the forecast, keep the number on the hover
The forecast identified a cartridge by its part number alone. 'W2020X' only tells you which toner it is once you already know, and both places that name a supply - the Order now list and the Part column - showed nothing else, so the report read as a list of codes. Both now show marketingname, which is what the box says: '414X Black'. The part number moves to the title attribute, one hover away, because it is still the thing an order is placed against. A supply with no name on file falls back to the number rather than showing an empty cell; marketingname is nullable. 'Copy list' is deliberately untouched. It writes the part number verbatim, which is what purchasing pastes into an order, and a spec pins that the name does NOT reach that text. firstPart() now returns the whole part row rather than its number, since the cell needs both halves. It has one caller. Dropped the .partnumber style with its .unmapped variant: nothing renders that class any more, and the variant was already dead - the unmapped block lists no part number by definition. Verified by mounting the component against a fixture. The dev box has no Zabbix, so the page itself has no rows to look at there. |
||
|
|
5001aedcf9 |
Offer a spelling on every list, not just the global search
Correction only helped on the global search page, and people search from the list they are already on. Extending it turned out to be a shape question rather than a volume one: thirty routes take a search parameter across a dozen files, so carrying a suggestion in each of their responses is a large change today and one more thing every future plugin author has to remember. So the suggestion moved to ITS OWN ENDPOINT, /api/search/suggest, which any page can call after rendering no rows. A page that has not adopted it shows nothing, which is exactly what it showed before - nothing breaks by omission. The plumbing lives in useListQuery, which already owned the search term, so a list needs three lines: take `suggestion` and `reportCount` from the composable, call reportCount(rows.length) after a fetch, and drop SearchSuggestion into the empty state it already has. A list that never calls reportCount never offers a suggestion. Wired: global search, machines, printers, PCs, network devices, measuring tools, knowledge base, vendors. NOT wired, deliberately: the type and reference lists (machine types, PC types, VLANs, subnets, operating systems and the rest), which are small controlled vocabularies nobody typo-searches, and USB, whose empty state has a different shape and wants doing by hand rather than by pattern. TRAP FOUND WHILE WIRING IT, and left commented in every page: applying a suggestion by calling setSearch alone updates the box and the URL and does NOT reload the list. setSearch only syncs the URL, and the watcher that would reload is suppressed because search.value already holds the new term - the same trap the global search page documents in performSearch. Each page calls its own load function directly. The composable guards a stale answer arriving after a newer search was typed, never offers back the word that was typed, and swallows its own errors: a search that found nothing is already the answer, and failing to improve on it is not worth an error in front of anyone. The route-parity gate caught the new endpoint being served without an entry in docs/api-inventory.json, which is hand-written on purpose; added, and the spec regenerated from it (283 paths, 418 operations). That regeneration also carries the openapi version to 0.12.0, left over from the release. |
||
|
|
1abb6430f5 |
Put a code on the back of a card, a picture on the front, and the page in the app
The back side landed last commit carrying text by default, and a back laid out as text-only renders no code box at all. So a two-sided run came out with a blank reverse, which reads as the code having failed rather than as a setting doing exactly what it said. The back now defaults to carrying a code, takes its own content per row, and chooses its own symbology - a QR on the front that opens the record, a CODE128 on the back that the bench scanner already reads. Text-only is refused while the back carries a code, and the contradiction is reconciled on load, so a saved setting from the broken combination repairs itself rather than printing another blank box of cards. A picture can go on a label: above the code, beside it, or behind everything as a watermark, on either side or both. The file is read in the browser and never sent anywhere, which is what makes it usable at an air-gapped site, and it is deliberately not saved between visits - a data URL runs to megabytes and would take every sizing value with it when the quota gave out. The watermark says plainly that it costs the scanner contrast. The generator was a full-screen route with no navigation and a narrow column of controls against the left edge. That shape exists so a print cannot put the sidebar on label stock, which is a real hazard and the reason core's label pages are built that way - but it is a hazard the print stylesheet can answer, and style.css now hides the shell at print time. A tool someone spends ten minutes in gets the navigation and the full width; the stock still gets nothing but labels, confirmed against a print PDF rather than a preview. Two smaller things found while checking the above. A test print paired each page with a rendered code by loop position, while the codes are keyed to position in the whole run, so a two-sided test card could carry the wrong code; the index now travels with the page. And the back's dotted preview outline outspecified the print reset, drawing a dashed rule down the edge of every second card. |
||
|
|
1593ee8204 |
Offer a spelling when a search finds nothing
The corrector landed in
|
||
|
|
fa0f6d7ebc |
Record what nobody can find, and a spelling corrector that refuses to guess
Two halves of one question - how to handle a misspelled search - deliberately kept separate, because only one of them should be turned on today. WHAT NOBODY CAN FIND IS NOT RECORDED ANYWHERE. A search returning zero results is the only evidence of the gap between what people look for and what is there, and it vanished. Logged now at INFO with a stable prefix, so a week of it greps into a list. That list is what should decide whether correction is worth wiring in: multi-word matching and cross-field matching both changed in the last day, so a good share of what used to fail may already be found. The failures left over might be typos, or vocabulary nobody has entered, or records that genuinely do not exist - and each wants a different answer. THE CORRECTOR IS BUILT AND NOT WIRED IN. shopdb/core/services/spellfix.py, with tests, ready to attach in about ten lines once there is evidence about what to attach it to. NOT SOUNDEX, which was the obvious candidate. MySQL has it and SQLite does not, and the suite runs on SQLite while production runs MySQL - so a SOUNDEX() in a query is either an error in every test or a production path no test executes. It is also wrong for this data: soundex is English-name phonetics, four characters wide, and it DISCARDS DIGITS, so CSF16 and CSF17 hash identically. Half of what people search here is an identifier. So: character distance in Python, same behaviour on both dialects. THE VOCABULARY IS THE DATA. No dictionary holds Genspect, Telesis, Keyence or wax-trace. Terms come from the columns being searched, which also means a vendor added this morning is correctable this morning. TWO GUARDS, and they are the point rather than a detail. Digits must match EXACTLY: CSF16 to CSF17 is one edit and a different bay, so anything carrying digits is either right or not correctable - while cfs16 to csf16 still works, because the guard is on the digits and not on identifiers wholesale. And the first character must match, since typos land mid-word far more often than on the first key, which costs almost no recall. Distance is Damerau-Levenshtein so a transposition costs one edit rather than two - Keyecne for Keyence is the commonest error there is, and plain Levenshtein pushes it past the threshold on short words. Allowed distance scales with length. A tie returns NOTHING: two equally good candidates means there is no answer, and offering either implies a confidence that is not there. It suggests; it never rewrites. Silently searching for something else is how somebody orders the wrong cartridge. 13 tests, weighted toward the refusals, because those are the cases where being wrong costs something. |
||
|
|
41d875da4e |
Put the hover marker back in the box's coordinate space, letterbox and all
|
||
|
|
2bf1531d5b |
Measure a hover marker against the drawing, not the box around it
Reported from the floor: some assets showed their hover mini-map marker way off to the left, outside the map, while the SAME asset sat correctly on the main map. The data was right; the preview was drawing it wrong. The preview is a fixed 390x300 box and the drawing was painted into it with object-fit: contain, which letterboxes or pillarboxes to keep the drawing's own aspect ratio. The marker, though, was positioned at a percentage of the BOX. So the marker and the drawing were measured in two different coordinate spaces, and they only agreed when the level's aspect ratio happened to be 390:300. A level near that ratio looked perfect - 3300x2550 is 1.294 against the box's 1.300 - which is why this read as "some devices" rather than "the feature is broken". A taller drawing pillarboxes, every marker's x shifts, and one near the left edge lands in the grey gutter beside the map. The image now SIZES the frame instead of filling it: the transform box shrink-wraps the image and the image scales down to fit. A percentage of the frame is therefore a percentage of the drawing by construction, at any aspect ratio, and the letterboxing moves out to the preview around it where it does no harm. dimensionsFor still converts the stored pixels to percentages - that part was always right. NOT visually confirmed against a live instance. The reasoning is traced and the build is clean, but the shape of this bug is that it looks correct on any level close to the old box ratio, so it wants a look at an asset that was landing left and one that was already fine. Related and NOT addressed: the wheel zoom applies translate percentages that resolve against the element and are then multiplied by scale, so a zoomed preview may still drift. At rest, which is the reported case, it is exact. |
||
|
|
d0eeaa08d5 |
Find a knowledge base article by words from different fields
An article's searchable text lives in three places: its title, its keywords,
and the name of its topic. Nobody typing a search knows or cares which word
came from which, so 'CMM Community' means "the article tagged community, under
the CMM topic". Both search paths returned nothing for it.
The plugin's own listing, which is what the KB page calls, built a single
ilike('%CMM Community%'). That needs the whole phrase contiguous in ONE field,
and no article has it: 'CMM' is only in the topic's name, 'Community' only in
the keywords. Global search already split the query into words but only ever
looked at title and keywords, so a word that only the topic could satisfy
failed there too - the same bug wearing a different face.
Every word must now be found somewhere across all three fields, in any order.
_word_match gains an `extra` hook for a word that a RELATED row satisfies
rather than a column of this table, which is how the topic joins in without
colliding with the sort=='topic' join.
The retired-topic rule is unchanged and pinned on both paths: matching more
words is not a way past it.
Verified against the 235-article dev library, where all three of these
returned nothing before: 'Fieldglass Jefferson' (title word plus topic word),
'outage notification' (two keywords, not adjacent), 'compucom Jefferson'.
The same phrase-only pattern is repeated in about ten other list endpoints
(computers, machines, printers, network, printedparts, measuringtools, usb,
notifications). Left alone here - fixing them properly means promoting the
word-match helper onto the shopdb.api contract surface rather than copying it
per plugin, which is a contract bump.
|
||
|
|
5de3fe4b40 |
Note secret sharing as a tabled tool, with the reasoning attached
Evaluated a Password-Pusher-style service as a Tech Tool and tabled it. The note carries the decisions rather than just the idea, because those are what cost the time: it is NOT a Tools-plugin candidate as it stands (that plugin is browser only by design, with get_blueprint and get_models both empty), the sender must always be authenticated, a FILE recipient should be logged in too - which costs nothing on an intranet-only server where anyone who can resolve the link already has an account - and zero-knowledge encryption is right for a credential but questionable for a controlled document, where a server that cannot say what was shared is a liability rather than a feature. Intranet-only hosting is what makes the idea viable at all: a link mailed outside is dead and no ciphertext reaches a third party. Pointing staff at the public hosted instance instead would reinstate every problem this avoids. |
||
|
|
39ede87d55 |
Give a label its own gap, its own position, and a back side
Three things the code generator could not do, each of which had someone
working around it with a control that meant something else.
The gap between a code and its caption was the quiet zone. That is the
clear space a scanner needs around the code, so closing up a caption meant
shaving the thing that makes the code readable, and the only way to get a
tight label to scan was to push the caption further away than it looked
like it should be. Quiet zone now means the code's own margin and nothing
else; gap means the distance to the caption, and adds to the quiet zone
rather than eating into it. Each stock preset carries the gap that suits it.
Position had one pair of controls and they moved the label box on the
stock. That is the fix for a printer whose origin is off, and it is not the
fix for a code sitting in the wrong place on a label - but it was the only
lever there, so it got used for both. The label div is now the media and
holds still; everything printed sits in a content wrapper that Align,
Vertical and Content X/Y move within it. The old pair kept its behaviour
and is labelled Media X/Y.
A badge card has two sides and the generator only ever emitted one page per
card. The back can now carry the same code again, a fixed block of text,
its own per-row content, or nothing. Page order is a choice because the two
kinds of printer want opposite things: interleaved for a duplex card
printer, which takes pages two at a time, and grouped for a single-sided
one whose stack gets flipped and reloaded. A row that leaves its back
column empty still gets a blank back page - a page count that varies per
row shifts every later back onto the wrong front, which prints a box of
perfectly readable cards that are all wrong.
CSV parsing and page building moved to plugins/tools/frontend/labelPages.js
(committed in
|
||
|
|
875fde9f48 |
Fix what the last round of device fixes broke, and two dips it missed
A review of |
||
|
|
0c0c7be439 |
A toner rate needs days behind it, not just readings
Reported from the floor: two printers showing 81 and 83 percent, both forecast to run out in a fortnight. That is a rate near 5.8 percent a day on a cartridge barely touched. burn_rate required four readings and a 2 point drop, and checked only that some time had passed - not how much. Supply items are commonly polled every few minutes, so four readings can span a quarter of an hour, and a 2 point drop across fifteen minutes extrapolates to nearly 200 percent a day. The report then sorted that confident wrong number into "soon", next to cartridges that really are about to run out. It did not look broken; it looked urgent. A rate now needs two days behind it. Two days is the smallest span that survives a printer's daily rhythm, so one heavy morning does not become the whole picture. Below that the answer is "not enough history yet", which the report already has a home for: band() returns None and the row lands in the "No estimate yet" section with its reason shown, rather than competing for attention with real urgency. This is the third face of the same fault. A phantom replacement truncated the run - "2 replacements in 2.3 days" was the same printer saying so - and the rate was then fitted to whatever short stub remained. The near-full rule and the dip filter stop the truncation; this stops a stub from producing a number at all. |
||
|
|
8b9b9363ee |
Toner: count cartridge changes that happened, and rate a burst as one burst
Two things reported from the floor, one cause each. "5 CHANGES IN 90 DAYS, THAT'S HARD TO BELIEVE." It was. A replacement was any +10 rise between readings, with no check on where it landed, so two shapes that are not swaps scored as swaps: a supply reading 0 or near-0 while it was out of the machine and then reading normally again, and a coarse gauge ticking back up after a reseat or a power cycle. A swap must now also LAND near full, because that is what a new cartridge reads, and a single dip that RECOVERS to roughly where it came from is dropped before anything looks at it. The dip filter keys on shape rather than cause, which is why it holds for all of them - a supply pulled out to be shaken, a door open mid-poll, or a site whose preprocessing maps the Printer MIB's unknown sentinels onto 0. It is NOT a Zabbix timeout: an item that does not answer records nothing rather than writing a zero. A genuine near-empty reading before a real swap does not recover, it jumps to full, so it survives and its swap still counts. find_replacements and current_run now read one predicate. When they disagreed, a phantom rise reset the run and threw away the history the estimate needed - so the bad count was quietly damaging the rate as well, which is why both were wrong at once. Expect replacement counts to FALL and per-cartridge history to lengthen. A BURST BIASED THE RATE FOR THE LIFE OF THE CARTRIDGE. The rate was the slope between the first and last reading of the run, and two endpoints cannot tell "steady" from "burst then stopped". A cartridge that lost 20 percent in two days and then barely moved for a month read as 0.83 percent/day forever after, so the report kept promising it would run out long after printing slowed. It is now the median of the per-interval rates: the burst is one interval among many rather than one of two points. Rising intervals are dropped as noise; flat ones stay in at zero, because a cartridge that did not move is real information. If every interval is flat or rising yet the run dropped overall, it falls back to the whole-run slope rather than reporting nothing. Where the intervals disagree by 5x or more the rate carries a marker and an explanation on hover. The number is still the best estimate available; the flag stops it reading as a measurement. The "Changed" column is "Replacements", and its cell says "2 in 90d" rather than "2 / 90d", which was read as a date, a ratio and a version number. |
||
|
|
d60ed602a1 |
Stop three ways the collector and the forms wrote things nobody asked for
A review of last week's device-identity work found these; two were writing bad data and one was reproduced against a live server before being fixed. AN UPDATE COULD BLANK AN ASSET NUMBER, on all six asset update paths. Create validates it and the column is NOT NULL, but the conflict check only runs when the value DIFFERS, and '' collides with nothing - so an empty assetnumber went straight through to a required column. This is the likely source of the assets found with no number: a form that loaded blank and was then saved. MACHINEFORM COULD LOAD BLANK AND LET YOU SAVE IT. One try/catch wrapped eight reference loads AND the machine fetch, so a single transient failure among them - one page of listAll() timing out during a collector cycle is enough - rejected the whole block and rendered a fully editable EDIT form with every field empty, the error banner far below next to Save. Typing an asset number and saving then wrote the blanks over a real machine. The record now loads in its own try, and a failure shows the reason INSTEAD of the form: an empty edit form is indistinguishable from a record whose fields are genuinely empty. NAMING A DEVICE THAT DID NOT RESOLVE STILL MINTED A TWIN. Both device paths warned "not linked" and then fell through to mint <HOST>-PARTMARKER or <HOST>-CMM - the hostname-derived twin the resolution order exists to prevent. The warning was true about the typo'd number and false about the twin. Naming a device is a commitment: if the name does not resolve, or resolves to the wrong kind of thing, link nothing and say so. Silence still means "work it out", so a bay with no file keeps the reuse-then-mint behaviour it always had. TWO PCS COULD BOTH HOLD ONE DEVICE, ACTIVELY, WITH NO WARNING. Verified against a live server: report as one host, then as another naming the same marker, and both controls rows stayed active. Neither device path had ever looked at who else held the target - only at links whose source was THIS PC - so a replaced PC kept its link forever and an asset-id.txt copied to a second bay claimed the device silently. It now reuses the machine link's rule rather than inventing a second one: an incumbent that has gone quiet past the claim window or been moved off In Use has yielded and is archived, never deleted; a live incumbent keeps the device and the challenger is recorded dormant. The swap test asserted the old behaviour and now asserts the new one, split in two: a live incumbent keeps it, and handover completes once the incumbent yields. Two other tests were passing while their names lied - the unknown-device one checked only that the typo'd asset was not created, not that nothing was linked, and it passed while a twin was minted beside it. |
||
|
|
85931db0fa |
Network devices: filter models by vendor, and style the hero like every other page
Three fixes, all of them a page not doing what its siblings already do. MODELS WERE NOT FILTERED BY VENDOR. PCForm, MachineForm and PrinterForm each narrow the model list once a vendor is chosen; NetworkDeviceForm bound the whole catalogue, so picking Palo Alto still offered every Dell and Zebra model. Same computed as the others, including the same rule that no vendor selected shows everything - an empty dropdown reads as "no models exist" when it means "pick a vendor first". Audited the rest: this was the only gap. The other views holding a modelnumberid have no vendor picker to filter against, and settings ModelsList is where a model's vendor is ASSIGNED, where filtering would be circular. THE HERO RAN THE LABEL INTO THE VALUE - "Asset #FW-OAV..." as one string. The page used detail-item / label / value, which match nothing in the stylesheet, so the two spans got no layout at all. Every other detail page uses hero-detail / hero-detail-label / hero-detail-value, which stacks a small uppercase label above the value. Renamed to those; no CSS added, because the styles already existed and this page simply was not using them. It was the last page using the unstyled names. MACHINES LIST LINKED BY THE WRONG ID on its fallback path. `/machines/:id` keys on machineid, the plugin extension id, and the row click and View button fell back to `item.assetid` - which lands on whichever machine happens to carry that number: a wrong page that looks right, which is worse than a 404. That defect has been fixed twice before in other views (AssetRelationships, then the GE-Enforce reports table) and BackupHistory carries a comment warning about it; this was the fourth copy. The list endpoint always sets item.machine, so the fallback could not actually fire here - it is removed as a latent trap rather than a live bug, and with no machineid the cell now shows plain text rather than a link that misleads. |
||
|
|
c73b53f613 |
Make phase 3 a correctness fix, not a tidy-up
Phase 3 was written as "the collector reads a registry value". It is all three components moving together: imaging writes DeviceId, GE-Enforce resolves device identity registry-first, and the collector reads the registry ahead of the file. The argument for it is already in the fleet's own code. Install-FromManifest resolves TargetMachineNumbers gating from the eDNC registry BEFORE machine-number.txt, and says why: the file is written ONCE by startnet.cmd at the PXE menu and is not updated on reassignment, so it goes stale. asset-id.txt inherits that defect exactly. Nothing rewrites it when a device is swapped, moved between bays or replaced, and phase 1 put it at the TOP of the resolution order - so a stale value is adopted with no warning, because it still resolves. A file only imaging writes cannot be the top of a resolution order for something that changes during a PC's life. Also records the consequence: phase 3 needs a reassignment path the way Set-MachineNumber exists for the bay number, or it moves the staleness from a file into a registry value instead of curing it. |
||
|
|
30834cd794 |
Say plainly that one registry value covers every device family
The phase 3 section named the key and left the scope to be inferred from the fact that deviceid is one field. Spelling it out: DeviceId is a single value, not a key per device type, and the pc-type decides which sync consumes it - so CMM, Keyence, Genspect, wax-trace and part markers all read the same value, and a family declared later through subordinatedevice_<pctype> is covered the day it is declared. Also records what the value REPLACES per family, which differs: for a CMM it replaces cmmid.txt as identity only, for a part marker it replaces minting from the hostname, and for the other three it replaces nothing because they never had an identity at all. |
||
|
|
89ab9706d2 |
Write down where device identity is going, in three phases
Phase 1 shipped without a plan around it, and the next two steps both touch a different repository and the live boot image, so the reasoning needs to outlive this week rather than being rediscovered from the code. The finding that shapes the whole thing: cmmid.txt CANNOT simply be retired. It is a PATH KEY, not just an identity - Verify-And-Heal-Staging stages installers-post\cmm\backups\<cmmid>, 09-Setup-CMM locates the staged set by it, and Restore-CMM restores from <BackupRoot>\<cmmid> as a same-bay operation. Only one of its five consumers is an identity use. That one is also the one doing the job badly, reporting a CMM's INSTRUMENT id in the machinenumber channel so the server can resolve it back to a measuring tool at step 4 of its own fallback chain. So phase 2 has imaging write asset-id.txt ALONGSIDE cmmid.txt - two files, two questions - and demotes the identity use while every path use stays exactly where it is. cmmid.txt survives phase 3 too; it is not on any deletion list. Phase 3 is the registry, HKLM:\SOFTWARE\GE\ShopDB\DeviceId. No new key and no new read path: the collector already opens that key for BaseUrl and ApiToken and Install-GEEnforce already writes it. The proposal argues for doing phase 3 BEFORE phase 2 rather than after, because then imaging writes the registry for new bays and skips the file era entirely - the file survives only for bays staged between phases, and the cleanup is deleting a branch instead of sweeping a fleet. Also recorded: the rail that must not be crossed (machinenumber is what TargetMachineNumbers gates on, so a device id in that channel stops every bay-gated entry from matching, silently, forever), what each phase does NOT change, per-phase rollback, and a note that phase 1 stops new duplicates without merging the ones a PC swap has already made - so the reconciliation backlog should be read BEFORE imaging starts writing identities over the top of it. |
||
|
|
68b86d459e |
One enrollment file names the device, for every bay without a registry
A bay with an NTLARS/eDNC MachineNo registry is identified by it, and a CMM by cmmid.txt. Everything else - Keyence, Genspect, wax-trace, part markers, and whatever a site declares next - had no stable identity at all, and the two mechanisms that stood in for one both key off the PC: reuse looks for a prior link from THIS PC asset, and minting builds `<PC number>-<SUFFIX>` where a PC's number is its hostname. Both survive a re-image. NEITHER survives a PC SWAP. A new hostname is a new PC asset with no prior link and a predicted number that has never existed, so the same physical device gets a second record while the first keeps its config and its backup history under a dead PC's name. That is how 43 legacy MT-#### tools ended up shadowed by minted twins, three records deep in places. The metrology path learned this and gained an explicit id file; the part-marker path was modelled on the metrology path as it stood BEFORE that fix, so it inherited the defect - and its own docstring said so, describing minting "the same way it already mints a CMM". C:\Enrollment\asset-id.txt is now that identity for both, and for anything declared later through subordinatedevice_<pctype>. It holds one line, the device's assetnumber, and deliberately does NOT record what kind of device it is: the pc-type already does, so a new device type needs no new file and no client change. Resolution puts it first, ahead of everything PC-derived. NOT machine-number.txt. machinenumber answers "which bay is this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device there would silently stop every bay-gated manifest entry from matching. A part marker still files partof the operation from machinenumber; asset-id.txt changes which marker the PC controls, not which operation that marker belongs to. The wire field is `deviceid` rather than `assetid`, because assetid is already this contract's RESPONSE field for a PC's integer primary key - the two would have sat side by side meaning different things. measuringtoolid, shipped in 0.12.0, is accepted as an alias and the client still reads the older file, so a bay staged in the last day keeps reporting; deviceid wins when both arrive. Two guards, shared by both device families: a value that resolves to nothing warns instead of minting a phantom, and a value that resolves to the WRONG KIND of asset is refused with the asset named, so a machine number pasted into the file cannot be filed under a device label the collector also owns. Row creation is now one helper instead of a copy per path, because the named and minted branches both need get-or-create on the extension row and the control link. 1744 tests green, including a new file that pins the swap case both ways - with the file one device, without it two. Five of its tests failed first time because the fixtures built bare assets with no extension row and the type guard refused them, which is the guard working. VERIFIED ON WINDOWS 11 (build 26200), five cases: the new file alone; both files present, new winning; the legacy file alone; neither, sending no field; and a padded value with a trailing line. |
||
|
|
21afa0b56e |
Let a metrology bay name the instrument it drives
The server has accepted `measuringtoolid` since the adoption work landed, and it is the FIRST entry in the resolution order precisely because it is the identity that survives a PC swap. Nothing ever sent it. The reporter read the eDNC registry, cmmid.txt, machine-number.txt and pc-type.txt, and its own comment said metrology bays have no per-bay id and therefore send nothing - so a Keyence or Genspect bay fell through all four steps to minting, which the server's own docstring calls the last resort. Minting derives the asset number from the HOSTNAME, so a permanent instrument inherits the identity of whichever PC drove it that week: replace the PC and either the number lies or a second tool appears for the same physical unit. That is how 43 legacy MT-#### tools ended up shadowed by minted twins. The half that prevents it was built, tested and undeliverable. The reporter now reads C:\Enrollment\measuringtool-id.txt and sends it when present. Its own file, NOT machine-number.txt: machinenumber answers "which bay is this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a tool there would silently stop every bay-gated manifest entry from matching. The paste-ready reporter in COLLECTOR-INTEGRATION.md is a second implementation of the same payload, so it gets the same resolver rather than being left to drift. The field was also missing from the payload table and from the classic api.asp mapping, and there was no prose anywhere describing how a tool is resolved - added, including why minting is last and what the two guards refuse. VERIFIED ON WINDOWS 11 (build 26200), four cases: a named instrument is read and sent; no file sends no field and exits 0; a whitespace-only file behaves as absent rather than sending an empty string; and a padded value with a second line yields the first line trimmed. |
||
|
|
adb30c8875 |
Release 0.12.0
Printers become a property of the bay, and the container stops throwing away the site's own files every time it is updated. Printer assignment is now end to end. The assignment belongs to the MACHINE and reaches whichever PC controls it, so a reimaged or swapped bay comes back with its printers and nothing had to be saved off the old box. One picker serves both forms rather than two implementations of the same override. A relationship type can declare itself singular, so setting a second default REPLACES the first instead of silently losing to the older row. Alongside the assigned half there is now an observed half: a bay reports what it actually has, kept strictly apart from what it is told to have, because a drifted bay's own state becoming its desired state would make every configuration error permanent. The client script corrects a drifted queue in place rather than only installing a missing one, and a driver rollout can be spread across waves so 300 bays do not pull 30 GB through one five-minute window. DOCKER SITES SHOULD READ THIS PARAGRAPH. The compose stack never persisted the instance directory, so `docker compose build api && up -d api` - the update path the docs themselves gave - discarded plugins.json and every upload with it. It is a volume now, and DEPLOY.md carries the one-time rescue for a stack that predates it. The image also could not be built at all: the frontend stage never copied the plugin staging script its own prebuild hook runs, so every build since that script landed failed. Air-gapped sites need a fresh offline bundle, because the stack moves to MySQL 8.4 LTS and an existing tarball carries only the 8.0 image. The Windows installer already bundled MySQL 8.4, so this closes a gap between the two halves rather than moving anyone. Adds a topology migration guide: IIS to Docker, Docker to a new host, and back. The data moves cleanly; what costs time is that the server address is baked into GE-Enforce manifests, the generated collector script, the printer client scripts and printed QR codes. 1736 tests green. Contract stays at 0.20.0 - nothing under shopdb/api changed. |
||
|
|
c6c806667e |
Run the database version the rest of the product already recommends
INSTALL-WINDOWS-IIS.md has said MySQL 8.4 LTS is standard for new installs since 8.0 reached end of life in April 2026, while both compose files and the offline bundler still pinned 8.0. A site reading the Windows runbook and a site reading the Docker one were being told to run different servers, and the migration page written this week sent people onto the dead one. Verified against a real server rather than by editing a tag: 8.4.11, core chain plus five plugin chains applied clean, 66 tables at a single utf8mb4_unicode_ci collation, six alembic version tables. The image's PyMySQL authenticates against 8.4's caching_sha2_password, which is what requirements.in already pins cryptography for. Existing servers need one thing done FIRST: 8.4 removes mysql_native_password, so an account created on 5.6 or 5.7 must be moved to caching_sha2_password before the upgrade or it cannot authenticate afterwards. In-place also has no downgrade path, and 5.7 cannot reach 8.4 in one hop. For databases this size a dump into a fresh 8.4 server is the better trade: same outage, and the old server stays as the rollback. Air-gapped sites need a fresh offline bundle, because the tarball carries the MySQL image alongside the app image. Also here, found by having it bite during that verification: the db healthcheck pinged over the unix socket, and the entrypoint's init pass answers on the socket while running the server with --skip-networking. The probe therefore reported healthy DURING init, which is what `depends_on: service_healthy` gates api and migrate on. A ping passed at 8 seconds and the next query failed because the server was mid-restart. Probing 127.0.0.1 keeps it red until the real server is listening. |
||
|
|
a7f5d2d0bf |
Say how a site moves between stacks, in one place
The pieces existed across three pages and nothing connected them, so "can we go from IIS to Docker" had no answer to point at. It is a fair question with a short answer: the application keeps state in exactly two places, the database and the instance directory, and nothing is encrypted at rest with SECRET_KEY or JWT_SECRET_KEY, so a move is a dump plus a directory copy. The schema is identical across topologies. What the page spends its length on is the part that is NOT the data, because that is where the time goes. The server address is baked into things that are not the server: GE-Enforce manifests, the generated collector script, the printer client scripts, and printed QR codes, which cannot be swept at all. Keeping the hostname and repointing DNS makes the migration invisible to the fleet; changing it does not. Three other traps, each of which has a symptom that shows up later rather than at cutover: an aliased IIS site needs MOUNT_PATH and a dist built for that subpath, while the image builds for the root; an older dump can carry latin1 or 3-byte utf8 table definitions that load quietly into a utf8mb4 server and only misbehave on the first accented name; and a restored instance directory needs chown, because docker cp writes under the copying user's uid and the container runs as shopdb. Covers both directions plus Docker to a new Docker host, and ends with a cutover checklist that leaves the old stack stopped rather than removed until a bay has checked in on a working day. Also fixes two links in START-HERE that pointed at files which are not there: the ADR index needed its adr/ prefix, and LLM-GUIDE.md is llms.txt. |
||
|
|
417f8a3dd4 |
Keep a site's own files when its container is replaced
`db_data` was a volume and the instance directory was not, so the documented update path - `docker compose build api && up -d api` - recreated the container and discarded everything the site had written. `plugins.json` is only the loud part: maps, branding, model and application images, employee photos, warranty proofs, slides, printed-part files and the Dell OAuth token all live under instance_path too. MySQL rows survive and point at files that are gone, so the second symptom is images 404ing rather than an error anybody sees. Reported by an adopting site, which read it as having updated too fast. It had not; nothing it could have done differently would have kept those files. DEPLOY.md had been telling sites to back up `instance/` since it was written. The template never gave them anything to back up. The air-gap `migrate` service mounts the volume too, because `flask plugin upgrade-all` rewrites plugins.json and that service exits immediately after. The image now creates instance/ ITSELF, owned by the app user. Docker seeds an empty named volume from image content at the mountpoint, ownership included; with no such directory in the image the mountpoint is created root-owned 0755 and the container, which runs as shopdb, cannot write into its own instance directory. Caught by running the built image rather than by reading it: the volume mounted clean and `touch` came back Permission denied. Verified fixed the same way. A stack that predates the volume needs its files moved across ONCE, while the old container still exists - the volume is seeded from image content, and the image ships instance/ empty, so it comes up empty rather than inheriting the old container's writable layer. DEPLOY.md carries the procedure, including the chown after `docker compose cp`, which writes files under the copying user's numeric uid rather than the app user's. Also here, found while checking what an upgrade actually runs: the connected update steps ran `flask db upgrade` and stopped. Per-plugin Alembic chains (ADR-008) are not part of that, so a connected site taking an image with a bumped plugin migration ran the core chain and silently skipped every plugin chain. The air-gap stack had it right all along. Both commands are in Step 9 now, plus a `db current` check against `db heads`. |
||
|
|
375dd3fb9d |
Copy the plugin staging script into the build that needs it
`npm run build` fires a `prebuild` hook that runs `node ../scripts/stage-frontend.mjs`, the script that copies each plugin's frontend into the Vite tree and codegens routes.gen.js. The Dockerfile's frontend stage copied `frontend/` alone and flattened it to the stage root, so `../scripts` resolved to `/scripts`, which does not exist. Every image build since the staging script landed has died there on `Cannot find module`. The stage now keeps the repo-relative layout - frontend/, scripts/ and plugins/ as siblings under /build - because the script resolves its repo root as its own directory's parent. The final stage copies from /build/frontend/dist to match. Verified with a real build, not by inspection: 16 plugin frontends staged, vite produced dist, and the finished image carries frontend/dist with 241 assets. This also unblocks SITE_PLUGINS lean image builds, which until now only scripts/build-site.sh could do. |
||
|
|
3f7cc37b00 |
Spread a driver rollout across waves so the fleet cannot stampede its own share
GE-Enforce gives each PC a start offset of SHA256(hostname) % 5 MINUTES and then repeats every five minutes. That was sized for reading a few KB of manifest JSON. A driver set is 100 MB for the two universals and 226 MB for a full site, so the day a driver entry lands, every bay pulls it inside one five-minute window: roughly 30 GB across 300 bays, at something like 800 Mbps, on the same share the whole floor needs for everything else. The failure mode is not slow printers, it is a floor that stops converging. -WaveStart with -Waves spreads that out. Each PC derives its own wave from its hostname, so there is no central coordination, no per-bay configuration, and no list of who has had it yet. The hash is the same idiom Register-GEEnforce.ps1 already uses for its start offset, SHA-256 rather than MD5 because FIPS-enforced bays disable MD5 outright and would throw. Measured over 300 hostnames at 10 waves: 23 to 44 bays per wave against a mean of 30, so the peak wave moves about 4.3 GB rather than the 3 GB an average implies. Hash bucketing is uneven and the peak is what sizes a link, so do not quote the mean. THE GATE RUNS BEFORE THE MANIFEST IS READ, because the manifest is on the share too. A bay that is not due must not touch the share at all - one read is cheap, 300 bays deciding to read in the same five minutes is the entire problem. It FAILS CLOSED on an unparseable date. Failing open would restore exactly the stampede this exists to prevent, and 30 GB cannot be un-sent, whereas a typo that installs nothing says so in the log every cycle and is fixed in a minute. A bay powered off during its wave installs on its next cycle instead. The wave is an earliest-time, not a deadline, so nothing needs chasing afterwards. -TestOnly reports a bay whose wave has not opened as COMPLIANT, because not installed is genuinely its desired state today; DSC would otherwise call SetScript every pass to be told to wait. -IgnoreWave is for proving a pilot bay before opening anything. Verified on Windows 11 build 26200, six paths: not-due installs nothing and exits 0; TestOnly while not due exits 0; a garbage date exits 1 having installed nothing; -IgnoreWave installs against a future start; an opened wave installs; and no wave arguments at all installs, which is what imaging needs. |
||
|
|
2d09fa3201 |
Collect what bays actually have, separately from what they are told to have
ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the observed half makes a rollout a review instead of a typing exercise: the floor reports itself in, you look, and you adopt. The collection uses the mechanism that already exists rather than a new one. POST /api/collector/printers dispatches to the printers plugin's apply_collector_payload, the same ADR-006 hook the computers and backups plugins implement. New client script, new plugin-owned table, no new transport and no new credential. OBSERVED AND ASSIGNED STAY APART, and that is the point rather than a detail. A collector report can never write an assignment row: _reconcile_edges is the only function that writes usesprinter/defaultprinter, it has two call sites, and both are authenticated routes a human calls. If a drifted bay's own state were allowed to become what it is told to install, every configuration error would become permanent the next time that PC checked in. Seeding an assignment from observed state is explicit - POST /assignments/seed-from-observed - because a rollout adopts many machines at once. It routes through the same _reconcile_edges as the editor, so there is one write path with two doors, and a queue matching no known printer is REFUSED rather than guessed into an assignment. That last rule is the lesson from the measuring tools: adopting on a weak key produced 43 duplicate instruments. Two fixes on top of what the agents built. The replace deleted a host's previous rows by exact case-folded name while the read path treats a short name and its FQDN as one machine, so a PC that changed spelling appeared to hold every queue twice - which reads as drift that is not there. And the client sent 'reportedat' where the declared schema said 'observedat'. Also here: the legacy loader now imports machines.printerid, the classic system's record of each machine's default printer, which it silently dropped - the production import would have lost every one. And Set-ShopdbPrinters.ps1 finally registers the per-user logon task, staging Apply-ShopdbDefaultPrinter.ps1 to C:\ProgramData first because the share it lives on is mounted only during the enforcement cycle and the task runs at logon when it is gone. VALIDATED ON WINDOWS 11 (build 26200), not just on Linux pwsh, which parses these scripts happily and executes none of the spooler branches. The reporter: posts a correct payload with the X-API-Key header; resolves BaseUrl and CollectorKey from HKLM when given no arguments; suppresses the virtual queues by port; resolves port addresses; and reads the CONSOLE USER's default out of HKU rather than SYSTEM's own, which is a different and usually wrong answer. Two results matter more than the rest. With the spooler stopped, both the cmdlet and the CIM path fail and the script posts NOTHING - verified against a capture server that recorded zero requests, where an empty list would instead have erased that host's observed rows and read as a bay that lost its printers. A genuinely empty host still posts [], because that is a real and different fact. The logon task registers as the Users group at Limited, and falls back to the well-known SID S-1-5-32-545 when the group name will not resolve, as it will not on localised Windows. It was then run with the source directory RENAMED AWAY, to stand in for the share being unmounted, and it still moved the user's default - which is the whole reason the script is staged to C:\ProgramData rather than run from where it lives. The guarantees against damage were re-checked rather than assumed: an empty assignment changes nothing, an unreachable server changes nothing, -WhatIfOnly leaves no queue, no task, no staged file and no registry value behind, and a drifted queue is repointed IN PLACE with Set-Printer so whoever has it as their default keeps it. Not covered by any of this: the driver-staging path, which needs a real vendor package rather than the class drivers a VM ships with. |
||
|
|
1a5a1cd43d |
Correct a drifted print queue instead of leaving it wrong
A queue was matched by NAME alone, so a bay whose printer had moved, or whose queue was built on a driver the site has since replaced, looked converged and printed to the wrong device. Absence was fixed; drift was not. Set-ShopdbPrinters.ps1 now repoints a queue whose port does not match the address ShopDB holds, and swaps a queue built on the wrong driver. Both are things ShopDB is authoritative about: where the printer IS, and what drives it. CORRECTED IN PLACE with Set-Printer, never removed and recreated. The queue keeps its name, its sharing, its permissions, and whoever has it as their default keeps it - which is what makes this safe to run every cycle on a live floor. There is still no removal code path in this script at all. Two guards, because a repair that breaks a working printer is worse than drift: the driver is only swapped when the wanted one is actually staged, and -WhatIfOnly reports both kinds of correction without making either. Verified on Windows against a queue that had the right name, the wrong port, the wrong driver AND was the logged-on user's default: both fields were corrected and the queue was still the default afterwards. The earlier no-op guarantees were re-run and still hold - nothing assigned changes nothing, and an assignment with no default leaves the user's own default alone. |
||
|
|
e2c45d33bc |
One printer picker for machines and PCs, and one default per asset
The assignment belongs to the MACHINE, and until now there was no way to set it except the generic relationships card or the API - the form for the thing the feature is about did not exist. MachineForm now carries the picker, and PCForm uses the SAME component rather than its own copy: the PC's set overrides the machine's, and two implementations of that would drift, with the two ends of an override disagreeing being exactly the bug nobody would spot. The shared picker also fixes what PCForm did on save. It wrote row at a time through the generic relationship endpoints, which is a non-atomic reconcile: an HTTP failure part way left a PC half-assigned with nothing recording what was meant. It now calls the reconcile endpoint, which validates the default before writing anything. A relationship type can now say it allows one active row per asset (relationshiptypes.issingular, migration 7d34), and defaultprinter says it. Cardinality belongs to the type rather than the printers plugin: core's create path is where every hand-made link passes, and the next type meaning "exactly one" gets the rule for free. Setting a second default REPLACES the first instead of refusing, because "make this the default" means that - and a card answering 409 would leave the user hunting for the old row. Without it the schema was happy to hold two defaults: the unique constraint is (source, target, type), so two different targets are two valid rows, and the resolver takes the OLDEST - the new default silently lost. Proven by disabling the new rule and watching the tests fail. FOUND WHILE TESTING IN A BROWSER, and it was not mine: MachineForm read .data.data off computersApi.listAll(), which resolves to the ARRAY - fetchAllPages has already unwrapped every page. The whole parallel load threw into the catch, so every dropdown on the machine edit form came up empty and the machine's own values never loaded. A build cannot see this; only opening the page can. GET /api/printers/assignments/for-asset/<id> returns an asset's OWN assignment, without inheritance, because the editor must show what this asset's rows say - otherwise a machine's printers appear ticked on the PC that inherits them and unticking one silently creates an override. |
||
|
|
72b3904f71 |
Release 0.11.3
The last of the buildings-and-levels bugs, and the shop-floor clients brought into the product. Anyone on 0.11.0 through 0.11.2 should take this. Every hover mini-map in the product reported "this asset has a position but no level" - the levelid prop added in 0.11.0 was passed by none of its seven call sites - and the map PDF printed markers from every floor onto one sheet, which nobody can correct once it is carried onto the floor. The legacy import loader, still to run against production, created markers with no level at all. The gate that should have caught all three asked whether a FILE mentions levelid rather than whether each position does. It now checks per occurrence. Also: printers can be assigned to a MACHINE and reach whichever PC controls it, so a reimaged bay reinstalls its own printers with nothing saved off the old PC; printerdrivers can name a vendor, so two rows cover 41 of 44 printers instead of twenty-one near-duplicates; and the collector reporter and EventSaver now live in the repository with no site baked into either, the reporter generated per site on request. The client scripts were validated on Windows 11 against a live ShopDB, not only by the suite: a bay with no rows of its own created both queues from its machine's assignment, bound them to the right universal drivers, and set the per-user default. The version and the changelog are the release; the detail is in the entry.v0.11.3 |
||
|
|
8cedf674fb |
Resolve a driver by vendor, and converge a bay's printers from ShopDB
Two rows now cover 41 of 44 printers. printerdrivers could only bind a driver to ONE modelnumberid, so the HP and Xerox universal drivers - which between them cover almost the whole floor - would have needed 21 near-duplicate rows pointing at the same package. That is a table nobody keeps true, and it is why 42 of 44 printers resolved no driver at all. printerdrivers gains vendorid, and resolution runs most-specific-first: the printer's model, then its vendor, then the pre-vendorid convention of matching the vendor word in the driver's name so a site that populated the table before the column existed does not silently lose every driver on upgrade. A row that names a vendor is never matched by its text, because a mis-set vendor resolving to the wrong package is worse than resolving to none. Six rows now resolve 44 of 44 printers at the reference site, and the DesignJet correctly takes its own driver over the HP universal one. Set-ShopdbPrinters.ps1 is the client half: ask for-host, create the queues that are missing, record the desired default. It NEVER removes a queue - a bad minute from the API must not take printers away from a working bay - and it never fetches a driver, because downloading 48 MB while somebody waits to print is the wrong moment. The common scope stages those. Apply-ShopdbDefaultPrinter.ps1 applies the default in the USER's context, which is the only context that can: SYSTEM cannot set a per-user default for somebody else. It also turns off "Let Windows manage my default printer", without which Windows silently overwrites the choice the next time anyone prints elsewhere - a fix that undoes itself within a day. VALIDATED ON WINDOWS 11 AGAINST A LIVE SHOPDB, not only by tests. Printers were assigned to a MACHINE; a PC controlling it, holding no rows of its own, created both queues bound to the right universal drivers, recorded the default and set it, and a second run changed nothing. The first attempt failed with "Relationship types are not seeded - run: flask seed reference-data", which is the deployment trap the plan predicted, caught by an explicit error rather than silently resolving nothing. |
||
|
|
0dc0ac13c8 |
Assign printers to a machine, and let the PC that drives it inherit them
Printers belong to the bay, not to the box currently driving it. The assignment
goes on the MACHINE asset and reaches whichever PC controls it, so a reimaged or
swapped PC comes back with the right printers and nothing had to be saved off the
old one. The asset register is the backup.
New relationship type usesprinter ("this printer is installed here"), beside the
existing defaultprinter ("which of them is the default"), both seeded and both
given a propagation rail through controls. The rails are consumed at READ time
only: the create-time fan-out skips directional through-types, and controls is
directional, so assigning a printer to a machine does not copy rows onto its PC.
That is what keeps own-beats-inherited possible.
Resolution for a PC is its OWN rows if it has any, otherwise one hop out along
controls to the machines it drives. Whole set at a time, not merged: a PC with
its own assignment is overriding the bay deliberately, and the UI has to say so
or a tech "fixing" a bay by editing the PC will shadow the machine's record and
wonder why they keep disagreeing.
GET /api/printers/for-host/<hostname> is what the convergence client asks every
cycle. Resolved by hostname because the collector upserts PCs by hostname and an
office PC has no machine number. An unknown host, a site without the computers
plugin, and nothing assigned all return an empty set - that is the client's
designed no-op and it must stay indistinguishable from "assigned nothing".
PUT /api/printers/assignments/for-asset/<id> reconciles the whole set in one
call. The endpoint was specified, documented and asserted by three tests, and
never written - the verification pass caught that, with four failures. It
validates the default BEFORE any write, so a rejected request changes nothing;
soft-deletes rows that went away; and REACTIVATES soft-deleted rows rather than
inserting, because the unique constraint spans inactive rows and a blind insert
after an unassign raises IntegrityError on MySQL while passing on SQLite.
One default per asset, enforced here because the schema cannot: the constraint is
(source, target, type), which accepts two different defaults quite happily. Two
active defaults are still reachable through the generic relationships endpoint,
where the oldest silently wins - recorded in the proposal as the next thing to
close.
printerdrivers gains drivername: the exact string the INF declares, which
Add-PrinterDriver matches on and nothing else. Deriving it by parsing INFs on
hundreds of bays is fragile; a human confirming it once is not.
|
||
|
|
03d0754fdc |
Stage printer drivers as a deployable set, for the common scope
Assigning a printer to a bay is useless if the bay cannot install it, and the fleet data says why that mattered: 42 of 44 printers could not resolve a driver. This is the delivery half - the drivers themselves, staged once per bay, so that creating a queue never waits on a download. Install-ShopdbPrinterDriver.ps1 does one driver: trust the package's signer, then pnputil /add-driver, then Add-PrinterDriver. Install-ShopdbPrinterDrivers.ps1 does a site's whole set from drivers.json, and answers a compliance question with -TestOnly, which is what makes it a clean DSC Script resource rather than a fire-and-forget install. Deliberately SEPARATE from assignment. Drivers are large, near-identical across a fleet and change rarely; assignments are small, per-bay and change often. Staging the set in the GE-Enforce common scope means the assignment client only ever creates a queue - it never fetches a 48 MB package while somebody is waiting to print, or discovers the share is unmounted at the worst moment. THE SIGNER TRUST STEP IS THE WHOLE TRICK, and it took a real driver to find it. certutil -addstore on the .cat file satisfied the Xerox package and failed every HP INF with "The publisher of an Authenticode(tm) signed catalog has not yet been established as trusted" - a coin toss, not a mechanism. The certificate is now extracted with Get-AuthenticodeSignature and added to Trusted Publishers, for every catalog under the package rather than the first INF's neighbours. On a locked bay there is no prompt to answer, so the old failure was silent. Verified on Windows against real packages, not by reading: all six drivers this site needs install through the script, a second run is a no-op, a wrong driver name fails with the names the package actually offers, and the DSC cycle behaves - TestOnly exits 1 on a clean box, install exits 0, TestOnly then exits 0. The packages themselves stay out of git: they are licensed vendor binaries, and they belong on the share beside the other imaging payloads. DEPLOYING-DRIVERS.md carries the GE-Enforce entry, the DSC configuration and the Intune shape, plus the constraint that has cost a session before: the SFLD share is mounted only during the enforcement cycle, so this runs as a manifest entry and never as its own scheduled task. |
||
|
|
2083029ff2 |
Generate the collector script per site, and bring EventSaver into the repo
A site adopting ShopDB had to be handed two files and told what to edit in them. Both are now the product's, and one of them the server writes for you. GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with this site's values already in it: site_base_url becomes the -ApiUrl default and the new computers_routableranges setting becomes -AllowedRanges. Only the PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift from the first - and everything stamped stays overridable by argument or registry, because a bay may need to differ from its site. Settings > Computers > Asset reporter edits the ranges, downloads the script and shows its SHA-256. The collector key is deliberately not stamped in, and a test fails if it ever is. That file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly; it stays in the registry, provisioned per ADOPTING-AT-ANOTHER-SITE.md. The routable ranges are the last thing that was hardcoded in that script. They are now a setting, so West Jefferson's two CIDRs move out of source code and into that site's own configuration - which is what ADR-015 asks for - and a site that sets nothing still works, because the script falls back to the NIC carrying the default route. EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and EventSaver.ini, no compiled .scr - a binary is a release asset, like the installer exe. The share path that was compiled into Config.Folder is gone. It used to be the fallback when the ini was missing, which silently pointed a new site at the reference site's file server; it is now empty, and failing visibly beats displaying another site's slides. Verified by compiling the edited source in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0. Also: the DSC example in the adoption guide gains a CollectorRanges resource and stops passing -ApiUrl to a script that already reads BaseUrl from the registry the same example writes, and the guide points at the generated download instead of hand-editing a URL. The contract test caught the endpoint importing shopdb directly for the version string, which ADR-002 forbids a plugin from doing. The product and contract versions are in app.config now, which a plugin reads through current_app. Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and let the bay install them, with what the fleet data says about drivers - HP and Xerox cover 41 of 44 printers with universal drivers, there are no Brother printers at all despite 208 files of Brother inkjet drivers in the installer, and printerdrivers holds one row pointing at a per-model folder instead of a universal driver. |
||
|
|
96f127f8c8 |
Bring the collector script into the repo, with no site in it
Report-AssetToShopDB.ps1 lived on one site's imaging share and was, per the adoption guide, "provided on request" - which is not a distribution mechanism for a product meant to be adopted. It now lives in plugins/computers/client/, beside the collector contract it implements (ADR-006), so the two version together. Three things named West Jefferson and no longer do (ADR-015): - The server. It resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl - the value Install-GEEnforce.ps1 already writes and the enforcement client cannot run without - or from -ApiUrl. With neither it logs and exits 0 rather than posting somewhere wrong. Any bay running this script runs the enforcement client, so the value is present wherever it is deployed. - The corporate VLANs. Two hardcoded CIDRs decided which NIC's address was reported, with a comment reading "update if site re-VLANs". A site may now name its ranges (-AllowedRanges, or a CollectorRanges registry value); with none configured the NIC carrying the DEFAULT ROUTE is used, which expresses "the routable NIC, not the controller NIC" without knowing any site's addressing. VERIFIED IN THE WINDOWS VM, not by reading it - and the VM earned its keep. The local array was called $allowedRanges, which is the SAME VARIABLE as the [string] parameter $AllowedRanges because PowerShell names are case-insensitive; the array was silently coerced to an empty string, and .Count on a scalar string is 1. The script therefore believed a range was configured, skipped the default-route fallback, and reported no IP at all. Linux pwsh parsed it happily. Renamed to $rangeList, and the four paths were then confirmed on Windows: no config skips cleanly, BaseUrl resolves from the registry, an unconfigured site picks the default-route NIC, a configured range selects or excludes as asked. |
||
|
|
f34b9ca710 |
Carry the level everywhere a position is drawn, and gate it per occurrence
The hover mini-map said "This asset has a position (2835, 1410) but no level" for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid prop, NONE of its seven call sites were taught to pass one - printer, machine and PC detail pages, the toner report, enforcement reports, the warranty chip and the dashboard cards - so the component correctly reported a missing level and the preview never drew. Two payloads behind those views also emitted mapx/mapy with no level: the toner report and the enforcement report. The map PDF export had the ORIGINAL bug still in it: it plotted every filtered asset onto the sheet, so exporting the ground floor printed second-floor markers on it. Worse than on screen, because nobody can correct a sheet once it has been printed and carried onto the floor. It now exports only the level being viewed. The legacy import loader sent mapleft/maptop with no level at three call sites. That loader is the one still to run against production, and every marker it created would have been undrawable. It now resolves the site's default level - the legacy schema predates levels and has one floor plan, so that is what its coordinates mean. THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid', not whether each position does: one module emitted 'mapx' six times and 'levelid' once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/ and plugins/, and fails any Vue file that binds tooltip coordinates without :levelid. Both new rules were confirmed to fail the build against planted violations before being relied on. Printer QR labels: the asset number is no longer printed. A label now reads name (8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber because that is where sites actually keep it - every printer here has an empty name field, so preferring the Windows queue name alone would have printed a blank line on every label. |
||
|
|
8bde89c47e |
Release 0.11.2
Bug fixes for the buildings-and-levels work in 0.11.0, every one of them found by using the feature rather than by the suite. Anyone running 0.11.0 or 0.11.1 with more than one level should take this release: the map drew markers from every level on whichever floor plan happened to be showing, which is the failure ADR-017 exists to prevent, and it was in the one component that draws the map. The viewer also had no way to choose a level, and the editor never accepted a click at all - its handler was bound only if the map was already a picker at mount, and the editor opens with nothing selected. Also: a level's native size could not be set (the settings page had no height field), the same search could return different rows because fourteen searchers truncated without an ORDER BY, and both map pages carried a scrollbar from subtracting the wrong page chrome from the viewport height. No schema change, and the plugin contract stays at 0.20.0. The version and the changelog are the release; the detail is in the entry.v0.11.2 |
||
|
|
afd3dce493 |
Give the map editor's search box room for its own placeholder
The previous commit stopped the panel header from colliding with the "Assets" heading, but left the search box sharing a row with the type dropdown: 139px of input for a placeholder needing 216px, so it still read "Search name o". A control whose own label does not fit is not a narrower control, it is an unlabelled one. One control per row in a 318px panel. Measured in the browser: the input is now 286px against 216px of text, so the placeholder reads in full. |
||
|
|
89248407e7 |
Make the map editor accept a click, and unclutter its panel
Clicking the map in the editor did nothing, ever. The click handler was bound inside `if (props.pickerMode)` at mount, and the editor mounts with no asset selected, so the handler was never attached; selecting an asset flipped the prop but nothing rebound it. The asset forms were unaffected because their picker mounts inside a modal that is already in picker mode, which is why this looked like an editor-only fault. The handler is now bound unconditionally and handleMapClick keeps its own picker-mode guard. Verified in a browser against the dev instance, both ways: with the old binding a click on a selected asset produced no position at all; with the fix the same click reports 1652, 1138. Markers are now drawn in picker mode too. Placing one relative to the machines already on the floor is the entire task, and the old code skipped rendering them whenever the map was a picker. Two things that looked like stray widgets: - The editor's panel header put a heading and three controls on one row inside a 320px panel, squeezing the search box until its placeholder read "Search na". The heading takes its own row and the controls share the next. - The legend drew its bar and border even with nothing to put in it, which read as an empty input box under the toolbar. It renders only when it has entries. |
||
|
|
aa6db94179 |
Fix the levels viewer: markers were drawn on whichever plan was showing
Five defects, four of them mine from 0.11.0, found by using the feature. THE SERIOUS ONE: ShopFloorMap never checked a marker's level. It skipped null coordinates and drew everything else on whatever blueprint was displayed, so level 1 markers appeared on level 2 - the exact failure ADR-017 exists to prevent, in the one component that draws the map. The build gate did not catch it because that rule checks files EMITTING mapx, not the component consuming it. Markers are now filtered to the drawn level, and a position with no level is omitted rather than approximated. The map page had no way to choose a level at all. It read currentlevelid only to title the PDF, so a second floor was unreachable from the viewer that most people use. Adds a level selector (hidden when a site has one level), passes it to the map, and switches the drawing, the bounds, the coordinate space and the markers together - swapping the image without the bounds would place every marker against the wrong scale. Searching the map now follows results across levels: a search whose matches are all on another floor showed an empty map while the filter counted them. Floor map settings had NO height input - only width - so a level's native size could not be set even while empty, which is the one time it is editable. Both fields are there now, and size is editable on a level that has markers, because refusing it blocked the case the feature was built for: a new blueprint of new dimensions on a floor already full of markers. It confirms first and points at landmark recalibration. Search results differed between the sidebar box and the results-page box: - 14 of 16 searchers truncated with .limit() and no ORDER BY, so the database could return a DIFFERENT subset of matching rows for the same query. Every searcher now ends in a total order (display key plus primary key). - Searching a term already in the URL was a duplicate navigation the router aborts, so the route watcher never fired and the button did nothing. The sidebar never hit this, because it always navigates from another page - which is why the two boxes appeared to disagree. Also removes a scrollbar from both map pages. They subtracted 2rem and 40px from 100vh for the page chrome, which is really 90px of padding on .main-content, so each overflowed by the difference. The padding is now a CSS variable both the layout and the pages read. Measured in the browser before and after: 1058 vs a 1000px viewport, now 1000. |
||
|
|
dd503be4ba |
Release 0.11.1
A patch release. The pagination-cap fixes, and the documentation that missed the 0.11.0 installer by a few minutes. docs/UPGRADE.md is one of the documents the Windows installer puts on the server, and its buildings-and-levels section was committed after 0.11.0 was tagged. So 0.11.0 shipped the page an operator reads WHILE upgrading without the guidance about that upgrade - which matters most on an air-gapped box, where there is no other way to reach it. The installer now also ships FLOOR-MAP.md for the same reason the Windows runbooks ship. No application code changed on the server side and the plugin contract stays at 0.20.0. The version and the changelog are the release; the detail is in the entry.v0.11.1 |
||
|
|
5f5ed7b1d4 | Record the pagination-cap fixes in the changelog | ||
|
|
62f4a42210 |
fix: page past the 100-row cap in batch label sheets and asset pickers
Follow-up to the application-picker fix. Three of these were already wrong on data that exists today, not merely latent. Batch label printing is the worst of them: AssetLabelBatch asked for 500 machines or PCs, got 100, and printed a sheet that looked complete. With 262 machines and 290 PCs in the catalogue that is a physically short run with no error anywhere - the operator finds out at the label printer, or later at the bay with no label on it. PrinterQRBatch, USBLabelBatch and PrintedPartsLabels had the same shape and are fixed alongside it, before their tables cross 100 too. MachineForm's "controls" PC dropdown offered the first 100 of 290, so a machine could not be linked to a PC sorting late in the list. NetworkDeviceForm had it for models, which are already past 100 - and the same file already called modelsApi.listAll() correctly two lines away. Adds listAll() to the machines, computers, printers, network, measuring-tools, USB and printed-parts APIs, all delegating to fetchAllPages(). Still outstanding: callers of vendors, locations, business units and the type catalogues that ask for more than 100. Those tables are all well under the cap today, so they are correct for now and wrong the day they are not. |
||
|
|
9c1c6c5729 |
fix: page past the 100-row cap in application pickers
get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports nothing about having done so, so a caller asking for perpage: 1000 gets the first 100 rows and a success response. Every picker built that way looked complete and was not. Found on a live site with 126 active applications: the 26 sorting last were absent from the knowledge-base topic dropdown, so an article could not be filed against them. Nothing was wrong with those application records, and editing them could never have helped. Adds fetchAllPages() to the api module, generalizing the one call site that already handled this correctly (modelsApi.listAll), and points the four application pickers at a new applicationsApi.listAll(): the KB article form, the KB list's topic filter, the notification form, and the report filter builder. Lists that render a page at a time are untouched - they page for a reason. Other callers still asking for more than 100 rows of vendors, locations, models, subnets and the rest are latent: correct only while those tables stay under 100, and silent on the day they do not. |
||
|
|
741dda5be7 |
Document buildings and levels where the old single-map model was still taught
The 0.11.0 release changed what a map position means and six documents still described the model it replaced. Each of these could have caused a real mistake rather than being merely out of date: - IMPORT-API mapped legacy mapleft/maptop to mapx/mapy with no mention of the level, so a scripted import - including the classic-ASP one still to run against production - would have produced markers the map shows as "level unknown". It now maps levelid too and says how to resolve the default level. - API-REFERENCE enumerates the unauthenticated surface in full, because that is what a deploy reviewer reads, and the three public /api/maplevels reads were missing from it. Also records why the write split is asymmetric: repositioning needs assets.edit, creating a level needs admin, since a level's dimensions are the coordinate space every marker on it is expressed in. - CONFIG still presented the four map_* settings as live, telling the reader to re-upload a blueprint in a settings page that no longer drives the map. They are marked superseded and kept for downgrade. - UPGRADE gained a 0.11.0 section: nothing moves on screen, and replacing a blueprint with one of different dimensions moves every marker on that level, so recalibrate from landmarks rather than editing width and height. - PLUGIN-HOOKS now states that a map overlay keys on assetid and must not return coordinates or a level - a second copy of a position is one that can disagree. Adds FLOOR-MAP.md, the operator's page: loading a plan, placing markers, and what to do when the plan changes, with the reasoning left in ADR-017. START-HERE routes to it from the new-site path, and specifically as the page to read BEFORE a floor plan changes. |
||
|
|
24647aa8e8 |
Release 0.11.0
The floor map became a set of drawings instead of one picture, and search now reaches what the forms have been collecting all along. A site can hold more than one building and a building more than one level, and every map position records which drawing its coordinates belong to. An existing single-map site upgrades into one building and one default level carrying the blueprint it already had, and nothing moves on screen. Landmark recalibration moves a level's markers onto a redrawn plan, solved per axis from the landmarks and never from the image dimensions, dry run by default, snapshotted before it applies. Two identifiers the UI collects were findable in almost no way and two plugins' records in no way at all: gaugelabreference matched only measuring tools, maintenancereference matched nothing anywhere, and USB devices and printed items were absent from search entirely because neither is an asset. All four are searchable now, from the global bar and from every list's own search box. Contract 0.20.0 is additive: a plugin that writes a map position must write its level with it. The version and the changelog are the release; the detail is in the entry.v0.11.0 |
||
|
|
aede5ac621 |
Match the optional identifiers in every list's search box
gaugelabreference and maintenancereference are collected on the form and shown on the detail page for all five asset types, and no list's ?search= matched either. Someone holding a physical tag could read it off the machine, type it into the list they were already looking at, and get nothing back - while global search, fixed in the previous commit, found it. The clause is copy-pasted once per plugin, which is how all five came to omit fields their own forms collect, so the test is one parametrized pass over all five lists rather than five per-plugin tests that would drift the same way. It also pins that widening the clause did not turn the box into a pass-through. The api-inventory entries enumerate the fields each search matches, so all five were stale the moment the queries changed; updated with the OpenAPI spec. Not touched: the collector does not send either identifier, correctly - a lab-assigned tag is not something an agent on a PC can discover. The legacy import loader does not map them either, and the classic schema has no gauge-named column to map from. |
||
|
|
3324dbd91e |
Buildings and levels for the floor map, and make every identifier searchable
The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database. |
||
|
|
7d9a54ca0f |
Release 0.10.0
A privilege-escalation fix in the Windows installer, the multi-site portability work, and a documentation and API surface that a second site can rely on. The installer created C:\shopdb-flask, C:\Python314 and C:\MySQL84 without breaking inheritance from the drive root, whose default DACL grants Authenticated Users an inherit-only Modify. Two files inside those roots are executed by someone else - shopdb-admin.ps1 self-elevates, and instance config.py is loaded unconditionally by the app - so any logged-on user could replace either and wait. Verified fixed as SYSTEM in the Windows 11 VM against the shipped functions. Contract 0.19.0 is breaking: get_dashboard_widgets returns data and shape, not a component name. The change itself shipped earlier without a bump, with the contract docs still teaching the dead shape; both are corrected. The version and the changelog are the release; the detail is in the entry.v0.10.0 |