REQ-D: restore waitress and tzdata to requirements.in. They existed ONLY in the
generated requirements.txt (hand-added in bf9e60e), so the next
`uv pip compile` would have silently removed the WSGI server and the IANA
timezone database from every Windows install.
REQ-E: split production and development requirements. requirements.txt was
installing pytest, pytest-cov, pytest-flask, coverage, iniconfig and pluggy onto
production servers. Verified on a real Windows Server box before this change.
CI, scripts/test-external-plugin.sh and the dev docs now use requirements-dev.txt.
REQ-F: standardise on Python 3.14. The repo declared four different versions
(Dockerfile 3.12, DEPLOY-WINDOWS-IIS 3.12, INSTALL-WINDOWS-IIS 3.13, CI 3.13,
plus README, web.config and PLUGIN-EXTERNAL-REPO). 3.14 is in active bugfix
support until ~Apr 2027 and supported to Oct 2030; 3.13 entered security-only in
Apr 2026. All four compiled dependencies publish win_amd64 wheels for 3.14
(cryptography via an abi3 wheel), verified by building an offline wheelhouse and
installing it on Windows Server 2025.
REQ-G: state MySQL 8.0 as the standard for new installs; 5.7+/5.6 remain
supported on an existing server.
Lockfiles regenerated with uv pip compile. Production deps 44 -> 38.
Each marker bound both a hover tooltip and a click popup. On the map page the
click handler routes to the detail page, so the popup opened and the
navigation discarded it in the same tick - it was never visible. Where it did
render (the map editor and the picker forms) its 'View Details' link only
served to pull the user off an unsaved form.
Keep hover as a glance and leave the click to the consumer. Removes the popup
markup, its styles, and the two now-unused detail-route helpers.
The ring only has to separate the mark from the surface; at 2px it read as
part of the mark. Markers go to 1.25, legend dots to 1.5px (they are larger,
so the same visual weight needs slightly more), and the PDF marker and swatch
strokes drop to match.
Two independent ways a restart leaves the SPA unable to navigate, both of
which look identical to a user - a click that does nothing.
1. The router awaits loadEnabledPlugins() to gate plugin routes. An app-pool
restart leaves that request hanging (IIS queues it while the worker starts)
and axios sets no timeout, so the navigation never resolves. Worse, the
promise is cached, so every later navigation awaited the same dead request
and stayed frozen long after the backend recovered. Bound the wait and fail
open on expiry, and drop the cached promise when an attempt times out or
fails so the next navigation retries. The setup-state probe in the guard
gets the same bound (it already fails open, defaulting to "complete").
2. A deploy replaces the content-hashed chunk files, so a tab open across it
asks for chunks that no longer exist and the dynamic import rejects with
nothing handling it. Reload once on a chunk-load error, via router.onError
and Vite's preloadError, guarded by a sessionStorage flag against a reload
loop and cleared on the next successful navigation.
Also stop index.html being cached: it names the hashed chunks, so a stale copy
points at files the deploy already deleted. It now revalidates while the
hashed assets under assets/ cache for a year.
Keying the ring off the fill singled out the light colors: on the map the
orange network-device marker took a black ring while its neighbours kept white
ones, and the legend dot for the same type wore a surface-colored border, so
the key did not match the markers.
Ring by the SURFACE instead - dark on the white blueprint, light on the dark
one - which is uniform within a theme and still works for every fill, since a
fill that resembles the ring is by definition far from the background. Legend
swatches take the same ring, the theme watcher redraws the markers (their ring
now depends on it), and the PDF uses the light-surface ring throughout because
it prints on white.
Markers sit on two very different surfaces - the light blueprint on white and
the dark blueprint over the near-black navy card - but the palette only ever
suited one. The grey "no subtype" default sat at 1.88:1 against white and the
orange asset-type step at 2.16:1, so both effectively disappeared on the light
blueprint. The fixed white ring made it worse: on white it added nothing.
Re-step the asset-type colors to versions of the SAME hues that clear 3:1
against both surfaces, replace the grey default with a neutral that clears
5.4:1 / 3.7:1, and derive the ring from the fill's luminance (light fill ->
dark ring, dark fill -> light ring) so every marker keeps a hard edge on
either background. The ring also rescues a washed-out color a user picks by
hand for a subtype, which no palette change can reach. The PDF export applies
the same rule to its markers and legend swatches, keeping print in parity.
Colors were chosen against a contrast/CVD validator rather than by eye. Note
that five simultaneous hues cannot all stay distinguishable under color-blind
simulation - past roughly five subtypes on screen, the legend and the hover
tooltip carry identity.
Adds computed contrast assertions so a future palette edit cannot
reintroduce a washed-out step.
exportPdf passed the raw map_blueprint_light setting value, which is a
root-relative /api path. Under /ops or /shopdb that resolves to the server
root and 404s, so the export died with "Failed to load blueprint image".
The on-screen map was unaffected because it goes through blueprintUrlFor,
which applies withBase - use that here too.
Same file, so this also carries the subtype auto-palette replacement that
goes with the marker-legibility change in the next commit.
Every asset list shows a Type column (and printers a Model, machines and
network a Vendor), but the search filters only looked at the asset number,
name, serial and hostname. Searching a type returned zero rows: 'Part Washer'
on machines, 'Standard' on PCs, 'Thermal' on printers.
Extend the search on machines, computers, printers, network devices,
measuring tools and the unified asset list to cover the type name plus the
vendor/model where the list shows them. Joins are outer joins so an asset
missing a type or vendor still matches on its own fields; the core list uses
a correlated EXISTS instead, since its type-name filter already joins
AssetType.
MapView carried its own copy of the per-type subtype-id lookup and it
never gained a Measuring Tool branch, so selecting any measuring-tool
subtype filtered out all assets. Marker coloring and the PDF export were
unaffected because both already used the shared getSubtypeId helper.
Point the filter at that shared helper and delete the duplicate copy in
ShopFloorMap too, so one definition serves filter, coloring and export.
Adds a table-driven spec covering every subtype-carrying asset type.
The old kiosk kept relaunching the dead URL from an HKLM Run value the 32-bit
Inno installer wrote - WOW64-redirected into SOFTWARE\Wow6432Node, which 64-bit
tooling (and the earlier purge) never saw. Broaden the sweep to both registry
views, every loaded user hive, Run/RunOnce/Policies-Explorer-Run, matching by
legacy name AND by any value pointing at the old URLs, plus every per-user and
common Startup folder.
The white-on-login was the old Dashboard/Lobby installer's leftover autostart
relaunching the dead old URL (404 -> white), not a network race - so the
wait-for-URL launcher solved the wrong problem. Go back to the plain direct
Edge kiosk shortcut and clean up any stale launcher file. The real fix (the
legacy HKLM Run-key + old .lnk purge) stays; it just has to be published.
At auto-login the Startup shortcut fired before the network was up, so Edge
--kiosk navigated to nothing and sat on a blank white page with no retry.
Point the shortcut at a hidden VBS launcher (wscript, no console flash) that
polls the kiosk URL until it responds (up to ~3 min) and only then launches
Edge fullscreen, so the first paint is the real page. Falls through to launch
anyway after the timeout so a display is never left dark.
Notification start/end times displayed and stored wrong by the tz offset
(a 2:34 PM entry showed 6:34 PM). Two stacked bugs: to_dict emitted stored
UTC as naive ISO (no offset) so the browser read it as local, and the form
filled the datetime-local input from toISOString() (UTC).
Fix and generalize to a configurable site timezone (multi-site):
- New setting site_timezone (default America/New_York), public, editable in
Settings > Site > Localization (common-zone dropdown).
- Backend tags datetimes UTC (_utc_iso); parse normalizes to naive UTC
(_parse_utc); daily-reset expiry uses the site zone (_next_site_time);
calendar allDay events key off the site-local day (_site_date).
- Shared frontend util datetime.js (Intl-based, DST-safe) converts between a
UTC instant and a site-zone wall clock. Notification form, list, and
calendar all render/enter in the site zone.
The MCP server README names Claude Desktop / Claude Code (it is an MCP
server for those clients), which trips the scrub gate. It is a standalone
local tool distributed via pxe-images/mcp/ + setup-mcp.cmd, not part of the
shipped product, so exclude it from the public repo like docs/ and tools/.
The old LobbyDisplay/Dashboard Inno installers planted an HKLM
...\CurrentVersion\Run value (plus a Startup .lnk). The dispatcher already
swept the stale .lnk/.url launchers but never the Run value, so a display
with our new ShopDB Kiosk.lnk still relaunched the old kiosk URL at logon
(the Run key beats the Startup shortcut). Remove the two legacy Run values
and kill any running old-URL Edge so the display self-heals to the resolved
target on the next enforce cycle.
A separate tool (not shipped in the app) that exposes a curated set of read
endpoints as MCP tools, so an LLM client can query the asset DB directly. Built
with FastMCP.from_openapi over docs/openapi.json; auth via a scoped PAT
(SHOPDB_TOKEN) or managed X-API-Key. Read-only: only GETs on the curated
allowlist become tools, all writes excluded. Runs anywhere that can reach the
API - never on the air-gapped box. Needs `pip install fastmcp` + testing in that
env (not installed in this repo's venv).
Generate docs/openapi.json (3.1, 362 operations) from the API inventory via
scripts/gen_openapi.py, and serve it with a self-hosted Redoc bundle at
/api/docs - no CDN, works on the air-gapped box. Also serve docs/llms.txt (a
concise LLM entrypoint) at /api/docs/llms.txt. New core 'docs' blueprint;
staticdocs/ excluded from the naming check (vendored minified JS).
Servers were imported as computers (a PC type) so they show under PCs, not
Network. This one-shot re-points each server's asset in place - assetid is
unchanged, so comms/relationships/map/name/location/audit all carry over; only
the extension row is swapped (computers -> networkdevices), the asset type is
flipped, and the device gets the 'Server' networkdevicetype (created if absent).
Identify servers by their computer type name (--type, default 'Server'). Dry-run
by default; --commit applies. Run on the target instance.
The /printer-installer map only reads the public install-list and downloads
the install .bat - both jwt-optional endpoints - so requiring auth was an
unnecessary gate. Drop requiresAuth; it now matches the other display/kiosk
tools (public).
The Inno printer installers hand-parsed JSON in Pascal (brittle brace-counting).
Add ?format=text to install-list (one printer per line, pipe-delimited:
printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy) and
to pc-default (printerid|windowsname), so the installer side is a split() with
no JSON parser. The web map keeps the default JSON.
Also resolve install-list's vendorname via the model (as the batch already does),
since the import sets the model, not the printer's direct vendorid - otherwise
the installers' HP/Xerox/Brother filter drops every prod printer.
Prod had 331 relationships, 268 computers, 204 machines, but the report came
back empty. The query only matched computer(source) -> machine(target), while
the import stores the general machinerelationships as machine(source) ->
PC(target) (only the synthetic measuring-tool links are PC -> tool). So the real
shop-floor edges never matched.
Make the query direction-agnostic (UNION of both orientations); a PC-runs-machine
report is conceptually undirected. Also drop the comtypeid=1 filter so the IP is
taken from the primary communication regardless of its type.
Test: a machine(source) -> PC(target) edge now appears in the report.
Two fixes for the printer install-batch on prod data:
1. Vendor was read only from the printer's direct vendorid, which the legacy
import never sets (it sets the model; legacy resolved vendor through the
model). Every prod printer came back vendor "unknown", so all fell into the
manual group and the universal PrinterInstaller.exe block never emitted. Now
resolve vendor via the model's vendor when the printer has no direct one, as
the classic installprinter.asp did.
2. Harden the download base URL. Behind IIS the app sees http on a loopback
port and url_root drops the /shopdb mount, giving a broken download URL when
site_base_url is unset. Fall back to https + the forwarded Host + script_root.
Test: a printer with no vendorid but an HP/Xerox model now groups universal.
Rebuilds the classic printer-installer feature: pick printers on the shopfloor
map, download a .bat that installs them.
Backend (asset_routes.py): GET /api/printers/install-batch?printerids=1,2,3
returns a .bat attachment. Groups printers the way the classic installprinter.asp
did - HP/Xerox via the universal PrinterInstaller.exe /PRINTER="a,b,c", printers
with a .exe installpath via that installer /SILENT, and anything else (no
installpath, or a .zip) listed for manual install instead of being run blindly.
Download URLs derive from the site_base_url setting + the IIS-served /installers
folder (no hardcoded host). Reuses the existing install-list query shape.
Frontend: PrinterInstallerMap.vue - full-screen Leaflet shopfloor map (reuses
mapConfig), a marker per network printer at its mapx/mapy, click to toggle-select,
sidebar with the selection + an Install button that downloads the batch. Toplevel
route /printer-installer, printersApi.installList(), and an Installer Map button
on the printers list.
Tests: install-batch grouping (universal/specific/manual) + requires-ids.
Two problems with application download/launch/doc links:
1. Stored paths like 'installers/Foo.exe' are relative, so an <a href> on
/shopdb/applications/6 resolved to /shopdb/applications/installers/Foo.exe.
New basePath.fileHref() mounts a relative path under the app base
(-> /shopdb/installers/Foo.exe) while leaving full URLs and UNC/file paths
untouched. Applied to installpath, applicationlink, and documentationpath in
the list and detail views.
2. Even the correct /shopdb/installers/Foo.exe 404s: httpPlatformHandler is
path="*", so IIS forwards it to Flask, which has no such route. Add a
web.config <location path="installers"> that clears the handler and serves
that subpath as IIS static (with .exe/.msi MIME), from a physical
APP_ROOT\installers folder.
The notes field is authored as HTML (the form says "HTML supported") but the
detail page interpolated it with {{ }}, so tags like <BR> showed as literal
text. Render via v-html through a DOMPurify sanitizer (utils/sanitizeHtml):
allow-list of formatting tags + links only, forces target=_blank
rel=noopener on links, strips scripts/handlers. Promote dompurify to a direct
dependency (was transitive via jspdf).
IIS ARR sets X-Forwarded-For to clientip:port, and the port changes every
connection. Left in, the audit log showed IP:PORT, the dashboard IP fallback
never matched a stored (portless) DashboardDefault.ipaddress, and login rate
limiting keyed per-connection instead of per-host. Add an IPv6-safe
clientip.client_ip / strip_port helper and use it in the audit log, the
dashboard resolver, and the login rate-limit key.
The KB list search matched only shortdescription + keywords, so searching a
topic (e.g. "Spotfire", the Application name) surfaced just the one article
whose title/keywords contained the word, not the others tied to it by topic.
Match the topic too via an appid IN (apps named like the term) subquery - used
instead of a join so it does not collide with the sort=topic join, and articles
with no app still match on title/keywords.
The .kb-* classes had no styles, so the KB entries rendered as bare inline
spans - shortdescription and keywords (both up to 500 chars) wrapped and mashed
into one block. Style each entry as a bordered clickable card: description as
the link title clamped to 2 lines, keywords split on whitespace into small
muted chips below.
The location option label read l.location, but the Location.to_dict() field is
locationname, so every option rendered blank - the dropdown looked empty and
"massive" (a long list of blank rows). Fixed across all five affected forms:
printers, computers, network devices, network device form, and the subnets
location filter. Other .location uses (printer-driver URL, search-result label,
report bylocation key) are legitimately different fields, left alone.
Also require a model on the printer form: asterisk + required attr, plus a JS
guard in savePrinter (the native required is skipped while the select is
disabled with no vendor picked) that points the user at the vendor first.
AuditLogs: the scoped table-layout:fixed + width:100% forced the table to fit
the settings pane, so cells ellipsis-clipped (Timestamp/User/IP fell off) rather
than scrolling. Drop it so columns size to content and the container scrolls
horizontally (global .table-container is overflow-x:auto). Only the free-form
Name/ID cell stays bounded (320px + title tooltip) so one long value cannot blow
the table width out.
EnforcementReports: the per-entry detail modal capped at 640px, too narrow for
the 5-column table. Widen to min(1000px, 92vw) and let the Message column wrap
instead of forcing horizontal scroll inside the modal.
The lobby-display and screensaver slide manager was admin-only. Add a shared
slides.manage permission so a curator can manage both surfaces without full
admin. Admins keep access via the require_permission admin bypass.
Backend:
- plugins/slides/api/routes.py: all 5 management routes require slides.manage
- plugins/slides/plugin.py: declare it via get_permissions(); nav item carries
the permission so the frontend can gate visibility
- shopdb/core/api/auth.py: login response now returns the user's permissions
(matches /me) so the frontend authStore has them on fresh login
Frontend:
- stores/auth.js: hasPermission(name) getter (admin true, else granted list)
- router/index.js: guard supports requiresPermission
- views/AppLayout.vue: hide nav items whose permission the user lacks
- plugins/slides/frontend/routes.js: slide manager gated requiresPermission
Tests: no-perm user 403, curator role with the perm 200 (+ login advertises
it), admin 200 via bypass.
Deploy: run `flask seed permissions` to create the row, then grant it to a
role in Settings > Users & Roles.
Global search did a single ilike('%CSF Roles%'), so any query with more than one
word required the exact contiguous phrase and usually returned nothing. Add
_word_match: split the query into words and AND them (OR across the searched
columns per word), so 'CSF Roles' matches a record with both words in any field,
any order. Applied across every domain (assets, applications, KB, employees
[selfhosted + external HR], notifications, hostnames, IP, custom fields,
vendor/model/type). External HR path uses a parameterized per-word LIKE.
The dispatcher now derives its FQDN (F<BIOS serial>.<domain>) and asks
/api/dashboarddefaults/display-role for its role/path, so changing a display's
type/location in Settings > Dashboard Defaults takes effect with no reimage. If
there is no serial, no server mapping, or the lookup fails, it falls back to the
local display-type.txt map (offline-safe). VM-verified both paths.
Adds GET /api/computers/display-kiosks - the displays that reported in (Kiosk
type), each with its derived FQDN (F<serial>.<domain>). The Dashboard Defaults
form gets a kiosk dropdown that fills the FQDN so admins pick a display instead
of typing an IP; IP stays an optional manual field. Table shows FQDN or IP.
'Business Unit' label -> 'Location' on this page + the settings nav.
A display's DHCP IP can change; its FQDN (F<serial>.<domain>, domain from the
display_fqdn_domain setting) is stable and the collector already reports the
serial. Add a nullable unique fqdn column (varchar191 so the index fits utf8mb4
without innodb_large_prefix), make ipaddress nullable, and require fqdn OR ip.
visitor-location + display-role resolve by FQDN first, then IP; create/update
accept fqdn. Core migration 7d31, verified up/down/idempotent on MySQL 5.6.
'Business unit' wording -> 'location' in the validation messages.
replace_scope_draft deleted old draft entries with per-object db.session.delete
but left the deleted objects in scope.entries. On a re-publish a caller
(seed_display_scope) then matched a stale deleted entry via next() and
store_inline_payload attached a payload to its dead entryid, failing the
manifestpayloads->manifestentries FK on MySQL (1452); SQLite does not enforce
it so the idempotency test passed. Clear the collection via the delete-orphan
cascade instead, and flush pending inserts before the bulk payload delete so its
autoflush cannot interleave a half-built insert. Verified publish + re-publish
x3 on MySQL 5.6.
- notifications shopfloor feed: resolve the employee name live when the stored
value is a bare SSO (WJ notifications imported as SSOs, never converted), for
both single and split-per-employee cards
- employee name resolver: after a directory miss, fall back to the shopdb User
account (firstname/lastname, keyed by SSO username) so users from other
locations still show a name
- shopfloor dashboard: employee photo falls back to the GE monogram (own asset,
independent of the site_logo setting) with a loop-guarded onerror; recognition
+ recert tiles both covered
- shopfloor dashboard: 'All Business Units' filter label -> 'All Locations'
- geenforce display dispatcher: startup sweep also matches the imaging
installers' 'GE Aerospace Dashboard/Lobby' shortcuts by name
The prior sweep only matched '--kiosk'; the imaging installers (Inno
GEAerospaceDashboardSetup / lobby) create Startup shortcuts with single-dash
'-kiosk' pointing at /shopdb/shopfloor-dashboard, so they survived. Match any
msedge/chrome Startup .lnk whose args contain -kiosk (one or two dashes) OR a
shopdb kiosk URL (tsgwp00525 / /shopdb/ / shopfloor-dashboard). Unrelated
Startup items are left untouched (VM-verified).
- Resolve-ShopdbPayloads wrote an absolute local path into the entry, and the
engine resolves it as Join-Path InstallerRoot <field>, doubling it
(C:\...\payloads\C:\...\payloads\<sha>.ps1 -> PS1 not found). Write the leaf
filename instead; the runner already sets InstallerRoot to that payloads dir.
- display dispatcher now removes leftover kiosk launchers from prior installs
(any Startup .lnk that runs Edge --kiosk, plus .url to a shopdb kiosk page),
not just its own, so two kiosks do not fight.
- allowlist auth uses remote_addr, not the spoofable first X-Forwarded-For hop
(adds _trusted_client_ip + a regression test); rate-limit path unchanged
- client psm1: fix Set-StrictMode crashes reading absent keys in Get-ShopdbConfig
(token-less mode) and Resolve-ShopdbPayloads (no-payload entries); validate
the manifest response is JSON before overwriting the last-known-good cache
- runner: pass the engine its required -InstallerRoot/-LogFile; create the log
directory so enforce logging is not silently lost on a fresh kiosk
- display scope: dispatcher writes an all-users Startup shortcut instead of
Start-Process (SYSTEM cannot show a window in session 0), resolves the base
URL from HKLM, and adds an always-on power/no-lock entry; tests updated for
the 6-entry scope
Two instances run on the box (dev /ops + prod /shopdb); each needs its own
base-path build. Build both on every --dist so prod never ships a stale
frontend. tools/ is excluded from publication, so this is dev-tooling only.