Commit Graph

53 Commits

Author SHA1 Message Date
cproudlock
784964c907 Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
2026-08-28 16:17:17 -04:00
cproudlock
4e59fe30b2 A nav item says which permission its page needs
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m43s
The sidebar hides what the caller cannot reach - that is what makes it useful
to a guest, and what stops a member with a sparse role being offered links that
can only answer 403. It reads the permission off the nav item to decide.

Only four items ever declared one. Machines, PCs, Printers, Network, USB,
Knowledge Base, Notifications, Calendar, Dashboard, Map, Applications and
Reports declared nothing, so the filter had nothing to go on: granting the
guest role machines.view opened the API and left the sidebar empty, which reads
as the grant not working.

Every item now names its read permission - in the plugins, in the core
navigation endpoint, and in the frontend's fallback list, which is what renders
when the endpoint is unreachable and would otherwise disagree with it.
2026-08-27 14:50:52 -04:00
cproudlock
afce24379c Reading anything needs a permission
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m42s
A sweep of the URL map found 165 GET routes that answered anybody, with no
token at all: the whole asset inventory, every machine, PC, printer and network
device, the USB checkout history and who is holding which drive, the staff
directory with its photographs, every core report. Guest access had been argued
about one feature-flag at a time while the front door stood open.

Each of those now carries the read permission it belongs to. That also makes
the guest role mean something: a site can open exactly the reads it chooses,
and nothing follows by accident.

Thirty-three endpoints stay public, and each is named with its reason rather
than left to be inferred: the app shell and its bootstrap, the settings
allowlist (the handler already filters an anonymous caller down to branding and
a few keys), images fetched by <img src> which carries no Authorization header,
what a display kiosk renders before anybody signs in, the parts and USB kiosks,
the client scripts that run as SYSTEM before a user session exists, and a
secure-send link whose unguessable token IS the authorisation.

tests/test_core/test_public_read_surface.py is the standing gate, the companion
to the mutating-route sweep that has guarded writes for months. A new public
read fails CI until somebody adds it to that list in front of a reviewer. Four
further reads need a login but no permission, because the handler decides from
who you are - your own user record, your own API tokens, the role catalogue the
grid renders - and the same test proves each still refuses an anonymous caller.
Verified not vacuous by ungating one route and watching the sweep name it.

The employees plugin gained employees.view; the directory is names, SSOs,
photographs and reporting lines, and it had no permission of any kind.

Around ninety tests asserted payload shapes against endpoints that answered
anybody. They are about what the answer says, not about who may ask, so they
are signed in now rather than the gate being loosened. The ones that assert an
anonymous caller is refused were left exactly as they were.
2026-08-27 14:26:06 -04:00
cproudlock
151d7c4f44 Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one
feature at a time: a securesend_allow_guests setting, an enable flag per kiosk,
and very nearly a third for guides - each with its own settings page, its own
vocabulary and its own hand-written branch inside a route. Every one of them
answered a permissions question, and this app already administers permissions
per role.

So a caller with no token is not nobody: they hold the seeded `guest` role.
require_permission consults it when there is no identity, which is the entire
mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a
site opens one by ticking a box in the same grid it uses for every other role,
and closes it the same way. Revocation is read on every request rather than
cached, because taking guest access away is what somebody does the moment they
realise what they opened, and it has to bite immediately.

The redundant @jwt_required() above require_permission is gone from 184 routes.
It was belt-and-braces before; now it is actively wrong, because it refuses a
request with no token before the permission check can have an opinion, so a
grant would appear to do nothing.

What a guest may hold is bounded. Any .view or .read permission is grantable.
A WRITE has to be blessed by whoever owns it, through the new
get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the
only case that exists: sending a password to a vendor is the one write this
product means to offer somebody who never signs in. Everything else is refused
by the API and greyed out in the grid rather than warned about - a checkbox
that quietly makes deleting an asset anonymous is not a warning anybody reads
twice. A guest write records no creator: the trail says when and from where,
never who, and that is the reason for the bar.

GE-Enforce and Slides gained read permissions so they can be opened to guests
without handing out the manage right. Their read routes accept either through
require_any_permission, so nothing an existing manager could reach has moved.

The navigation endpoint now returns only what the caller can actually reach.
Without that a signed-out visitor gets a sidebar of the whole app where every
link bounces off the login page, which reads as a broken site rather than as a
closed door. A nav item may name several permissions; holding any one is
enough, which is what GE-Enforce and Slides need now that a viewer and a
manager hold different strings.

Two things this found on the way: current_user is a LocalProxy, so
`current_user is None` is False for a request with nobody behind it - harmless
under a required JWT, an AttributeError the moment verification became
optional. And an image pasted into a guide before its first save was never
attached to it, so it was visible to editors and invisible to every reader;
saving now attaches it, and `flask guides fix-images` repairs what is stored.

The guides guest setting written earlier today is gone. Granting guides.view to
the guest role replaces it, which is the point.
2026-08-27 12:51:58 -04:00
cproudlock
21e66c6749 Network devices: manage buttons where every other asset keeps them
Machines, PCs, printers and measuring tools all carry Print Label / Edit /
Retire / Back to List in a page header. A network device had no header at all:
Edit was tucked inside the hero title row, and Print Label and Delete sat in a
bar at the very bottom of the page, past every card. Managing one meant looking
somewhere different from every other asset type.

They move to the standard header. The bottom bar is removed rather than left as
a second copy, and the two delete handlers collapse into one - the route is a
soft delete, so it is called Retire here as it is everywhere else, and the
confirm says what is kept.
2026-08-27 09:25:45 -04:00
cproudlock
2ad2bd1b90 Sidebar sections are DECLARED, not guessed from a position number
The section headings were derived from position ranges: 10-29 became Assets,
30-49 became Information. A number is for ORDER; it was never a statement about
meaning, and using it as one meant a plugin landed in whichever bucket its
number happened to fall in.

That is how USB Devices - an asset - came to sit under Information beside
GE-Enforce and Tech Tools, and 3D Parts with it. Nobody chose that. It also
meant two plugins picking the same number silently reordered each other:
Warranties and Guides both held 8, the tie broke on plugin load order, and
Guides rendered under Warranties rather than under Slides where it was asked to
go. Warranties moves to 9.

Each nav item now names its section and the renderer groups by what was
declared. An item with no section stays in the unlabelled group at the top,
which is the safe default: it appears, it is simply not filed.

Assets gains USB Devices and 3D Parts. GE-Enforce and Tech Tools move to a new
Fleet section - they are neither assets nor reference material, and calling them
Information was only ever an artefact of their position numbers.

Also: sorting used `position || 99`, and 0 is falsy in JavaScript, so Dashboard -
the only item that uses 0 - sorted to the end of its own group.
2026-08-27 09:25:27 -04:00
cproudlock
ed5d64fa3e Name the record in the browser tab, not just the section
Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two
different machines were indistinguishable, a bookmark said nothing about what
it pointed at, and a link shared into Teams previewed as the section rather
than the thing.

The router cannot fix this alone: afterEach runs before the view has fetched
anything, so it only ever knows the KIND of page. So a detail view names its
record once the record arrives, and both writers go through
composables/pageTitle.js.

THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build
the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that
fetched quickly and would overwrite the good title with the generic one. The
route's fullPath is claimed before that await, and the generic write stands
down when a record title already belongs to the route being landed on. The
reverse case is handled too: a view that names its record BEFORE the facility
name arrives composed against the placeholder prefix, so the late resolve
rewrites it rather than leaving "ShopDB - CSF16" on a site called West
Jefferson. Both are pinned by tests, because both fail only under timing.

Covers machines, PCs, printers, network devices, subnets, measuring tools, USB
devices, printed parts, applications, employees, knowledge base articles and
guides. Printers reuse the page's own displayTitle, which already discards the
literal name "NONE" a discovered queue can carry, so the tab and the heading
agree.

A record with no usable name, or one that failed to load, keeps the section
name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs.
Long names are cut at a word boundary - knowledge base articles are named with
a whole sentence of description, which a browser otherwise truncates mid-word.

Reports gained meta.title, which the router already supported and nothing used.
That also fixes "Pc Relationships", which the name-derived spelling produced.
2026-08-26 13:13:30 -04:00
cproudlock
5001aedcf9 Offer a spelling on every list, not just the global search
Some checks failed
CI / backend (push) Failing after 7m15s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
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.
2026-08-21 11:14:56 -04:00
cproudlock
875fde9f48 Fix what the last round of device fixes broke, and two dips it missed
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m14s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled
A review of d60ed60 and 8b9b936 found five things. Three were introduced by
those commits.

REFUSING A NAMED DEVICE MADE A MARKER PC CLAIM ITS OPERATION. _sync_partmarker
returned [] both for "not a marker PC" and for "a marker PC that linked
nothing", and the caller reads [] as the first - so a bay whose asset-id.txt
named something unresolvable fell through to the ordinary machine link, and for
a marker PC the machine number IS the operation. It took an active link to a
record that can only have one holder while several markers share it, and the
warning said "not linked". The previous behaviour minted a twin; this traded
that for a contested operation. None now means "not a marker PC" and is the only
answer that lets the machine link run; the response normalises it away so the
API shape is unchanged.

A DORMANT CHALLENGER WAS PROMOTED BY DELETING A FILE. Recording a challenger
dormant leaves a row that the next cycle finds as `reuse` and reactivated with
no incumbent check - so a second PC took a live device by its enrollment file
becoming unreadable. Both reuse branches re-check incumbency now, which is what
_sync_machine_link always did.

AN INCUMBENT UNDER ANOTHER COLLECTOR LABEL WAS INVISIBLE. Incumbency was queried
on our own label, but on a CMM the instrument IS the reported bay, so the
incumbent's link is the machine sync's row. A second PC naming that instrument
found no incumbent and linked actively: two live holders of one instrument, each
invisible to the other. Incumbency now counts any collector-owned label. A row
made BY HAND carries none of them and is still excluded - a person's link is not
the collector's to archive.

THE NETWORK FORM LOCKED OUT THE ROWS IT NEEDED TO FIX. Asset number is disabled
while editing, correctly, but the payload is built in script so the blank was
still sent - and the new server-side guard rejects it. A device with no asset
number could not be saved at all, and the field could not be typed into. It now
unlocks only for a record that loaded without one, with a hint saying why.

"2 in 4.0d" WAS THE LABEL LYING. Replacements are counted across the whole
history window; basisdays is only how long the current cartridge has been in.
Joining them with "in" claimed two changes inside four days - the exact shape
reported as unbelievable, except here the data was right. Now "2, this one 4.0d".

Two toner dips the same review found:

A MULTI-POLL OUTAGE STILL MINTED A PHANTOM SWAP. Only single readings were
dropped, so 90, 0, 0, 90 survived and 0 -> 90 scored as a change. Dips of any
length are handled now. One bad sample stays a candidate whatever the polling
cadence, because a reading is an instant; several consecutive low ones only
count as one outage when they are close together, since days at zero is a real
empty period. That time bound also separates an outage from a swap, ordinary
consumption, and a second swap, which have the same shape in levels alone.

THE DIP FILTER ATE REAL SWAPS OF NEARLY-FULL CARTRIDGES. Recovery was tested
with an absolute difference, so 95 then 5 then 100 read as a recovery because
100 and 95 are close, and the swap evidence was deleted. Toner only falls: a
recovery comes back at or BELOW where it left, a new cartridge comes back
higher.

The review also proved by reverting each feature that the previous tests did not
pin the median burn rate or the near-full rule - both passed with the bug
restored. Verified by the same method that all four toner behaviours now fail
when reverted, and the burst assertion is tight enough to tell 0.2 from 0.88.
2026-08-21 08:49:38 -04:00
cproudlock
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.
2026-08-20 16:11:08 -04:00
cproudlock
85931db0fa Network devices: filter models by vendor, and style the hero like every other page
Some checks failed
CI / backend (push) Failing after 7m9s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m18s
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.
2026-08-20 15:10:01 -04:00
cproudlock
62f4a42210 fix: page past the 100-row cap in batch label sheets and asset pickers
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
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.
2026-08-17 14:10:08 -04:00
cproudlock
aede5ac621 Match the optional identifiers in every list's search box
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-17 13:16:22 -04:00
cproudlock
3324dbd91e Buildings and levels for the floor map, and make every identifier searchable
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
The 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.
2026-08-17 12:55:51 -04:00
cproudlock
ab301df9ac network: generate a device's asset number instead of asking twice
Every network device on this fleet already follows one convention, applied by
hand: AP-<name>, SW-<name>, SVR-<name>, IDF-<name>. 45 records, no exceptions.
The create form demanded the asset number anyway, so the same value was typed
twice and the convention held only as long as everyone remembered it.

The prefix now lives on the device type, and a blank asset number is generated
as <PREFIX>-<name>. Left explicit, an asset number always wins: a device
carrying a real identifier of its own - a vendor tag, a controller name, a
serial - keeps it. That is the platform rule, adopt where an identifier exists
and derive only where none does.

The prefix is NOT derived from the type name. "Access Point" and "Access Panel"
both initialise to AP, and assetnumber is unique, so the second type would
collide with the first on every device it created. It is nullable, so a type
that wants no prefix generates the bare name rather than needing one invented.

Names are sanitised before they reach a business key - the existing data
already shows why, with IDF-Telco-Demarc-#1 carrying a '#' into an identifier.
An existing prefix is never stacked: IDF-03 under type IDF stays IDF-03.
2026-08-14 13:45:51 -04:00
cproudlock
27f76ee964 frontend: declare the empty toplevel export the codegen reads
routes.gen.js spreads `.toplevel` for every plugin uniformly, but only a
few own full-screen routes, so Rollup warned on eleven of them every
build. The `|| []` guard was always doing its job - the warning was noise,
and noise in a build log is where a real warning goes to hide.
2026-08-12 11:43:57 -04:00
cproudlock
5eb84873e8 dashboard: convert the last dead widgets, and delete the one that had nothing
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Three plugins still declared widgets naming Vue components nobody wrote.
Converting them honestly meant three different answers, not one.

notifications gets a real card: the active notifications themselves, not a
count. "4 active" tells an admin nothing; knowing WHICH message the shop is
looking at is the point, and it is how a stale one gets noticed and taken down.

machines gets machines out of service - anything not In Use, excluding
Inventory, because a spare on a shelf is stock rather than a problem. Someone is
supposed to be chasing each of those and today they are visible only to whoever
thinks to filter the list by status.

network gets NOTHING, and its declaration is deleted rather than converted.
Network devices carry no live status - no polling, no reachability check,
nothing that can be wrong - so the only possible card is a count of how many
exist, which is precisely the always-true number this dashboard exists to get
away from. A comment records that, so the next person does not re-add it. If
reachability is ever collected, that is the card.

Also adds a contract test over every declared card: no component names, a valid
renderer and severity, and - the one that matters - the endpoint must be a REAL
route. A declaration pointing at a route nobody wrote is exactly how the old
widgets rotted unnoticed for months, and now it fails the build instead.
2026-08-11 14:27:29 -04:00
cproudlock
ed9c91c47d Give a network device somewhere to put its IP
The network device form had no IP field, so the one thing people look up a
switch for could not be entered. The API had accepted an ipaddress since the
legacy import work - create, update and every read already carried it - but
nothing in the UI ever sent one, which also left the device off the "Devices on
this network" list, since that matches on the IP a device does not have.

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

Clearing it now clears the stored address. The upsert helper returned early on a
blank value despite a docstring promising it cleared - harmless while no form
could submit one, wrong the moment this field existed.
2026-08-07 10:30:38 -04:00
cproudlock
512d5fafac Stop an empty box appearing after Notes on the network device form
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
CustomFieldsInputs renders nothing when a site has defined no custom fields,
which is the normal state. The network form was the only one wrapping it in a
fieldset, and a fieldset draws its border whether or not anything is inside, so
an empty bordered box sat below Notes on every new network device.

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

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

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

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

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

The menu also warns when the server is not fully provisioned and names the key
that fixes it, instead of reporting it as ordinary status lines that read as
normal unless you already knew what to look for. That check is cached for the
session because it shells out to flask twice and the answer does not change
while somebody reads the screen.
2026-08-05 13:42:20 -04:00
cproudlock
53c1f6476c Pick a network device's map position, and stop hardcoding one site's label prefix
Two things a second site ran into.

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

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

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

Not to be confused with printedparts_code_prefix, which mints item codes like
3DP0042 and was already configurable. That is the code we generate; this is the
tag already printed on the label.
2026-08-05 13:16:41 -04:00
cproudlock
85ff25462e Reset to page one when a filter changes, and let the catalog carry a real type
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two unrelated things found while looking at blank printer types.

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

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

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

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

Verified on the development database: 24 printer models need a decision, 22 got
a sensible suggestion, applying them let all 42 printers match a printertype by
name, and the transaction rolled back cleanly.
2026-08-05 11:26:23 -04:00
cproudlock
cb18d170cf Say whose type it is
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two fields on the same page were both labelled "Type": the asset's own, and the
catalog model's. Only one of them was vague. "Model type" already says exactly
what it is; the bare "Type" did not say whose.

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

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

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

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

So the row now appears only when the two disagree, which is the case worth
seeing: a model catalogued as one thing fitted to an asset recorded as another.
When they agree it says nothing and is hidden. Applied to machines, PCs,
printers and network devices, each compared against its own type table.
2026-08-05 10:16:21 -04:00
cproudlock
3f320fcc8b Derive an asset's vendor from its catalog model, and show the model's own type
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
An asset that carries a model but no vendor was showing a blank the database
could already answer: the model records its vendor, and both sides reference the
same vendors table. Machines, PCs, printers and network devices now fall back to
it.

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

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

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

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

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

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

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

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

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

Deploying this needs `flask db upgrade` and `flask plugin upgrade-all` on the
server, not just a file copy.
2026-08-05 09:08:40 -04:00
cproudlock
b16f143467 search: asset lists search the type column they display
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.
2026-07-31 09:02:51 -04:00
cproudlock
b63690996a fix: location dropdowns rendered blank (wrong field) + require printer model
Some checks failed
CI / backend (push) Failing after 1m52s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
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.
2026-07-29 09:07:43 -04:00
cproudlock
31139267d1 Fix model images 404 under a subpath mount (withBase)
Model image URLs are root-relative (/api/models/image/...), so on an /ops
subpath deploy the raw <img src> resolved to the server root and 404'd. Wrap
every model-image src in withBase(): machine/printer/PC/network detail heroes,
the models settings preview, and the machine-badge / asset-label print pages.
withBase leaves external http(s)/data URLs untouched.
2026-07-20 11:14:00 -04:00
cproudlock
592ff49abe ADR-013 Phase 4: extract the 11 plugin routes embedded in core.js
core.js still routed plugin-owned pages directly. Extracted all 11 into the
owning plugin's route file + moved their views into plugins/<name>/frontend/:
- computers: reports/pc-relationships, settings/pctypemapping
- printers: reports/toner, settings/printertypes, settings/zabbix (toner/supply
  monitoring)
- machines: settings/machinetypes
- network: settings/networktypes
- warranty: settings/dellwarranty
- slides: settings/slides (its route file gains a default export; it was
  toplevel-only)
- employees: NEW plugin frontend (employees/:sso + settings/employeedirectory) -
  employees had no route file before; its pages lived only in core.js.

core.js now holds only core routes; all 14 bundled plugins are self-contained
under plugins/<name>/frontend/. Verified live: the extracted Machine Types
settings page renders in the settings rail from the machines plugin frontend.
Build + 58 vitest + naming green.
2026-07-19 00:02:32 -04:00
cproudlock
ebca0b00b0 ADR-013 Phase 4: relocate the remaining 9 plugin frontends (all 13 done)
Relocate warranty, measuringtools, network, printers, usb, notifications,
computers, and slides into plugins/<name>/frontend/. Each plugin's views are
pulled from wherever they lived (own dir, plus the shared views/settings/,
views/reports/, views/print/ dirs, and top-level views) into the plugin's
frontend/views/, and its route file becomes the self-contained routes.js.

Handled the messy cases:
- computers: name mismatch (its views live in views/pcs/) - moved by following
  the route file's own imports, so the dir name did not matter. Its OS/access-
  protocol/PC-type settings views move with it (only computers.js routed them).
- network: NetworkHub's sibling sub-views (NetworkDevicesList, SubnetsBrowse,
  not directly routed) moved too so its `./` imports resolve.
- printers: the qrLogo helper is SHARED with core AssetLabel, so it stays in
  views/print/ and PrinterQR imports it via @/views/print/qrLogo.
- slides: route file is toplevel-only (TVDashboard); SlideManager stays core
  (core.js routes /settings/slides).

frontend/src/views/ now holds only core views; frontend/src/router/routes/ holds
only core.js. All 13 plugins are self-contained under plugins/<name>/frontend/.
Verified live: Network (hub + moved sub-views), Computers (name mismatch),
GE-Enforce (helper), printedparts all render from their staged frontends. Build +
58 vitest + naming green.
2026-07-18 23:56:07 -04:00
cproudlock
8528617037 Network: consolidate into one tabbed hub; subnet devices span all asset types
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Replaces the two flat "Network Devices" + "View Networks" nav entries with a
single "Network" entry opening a tabbed hub: Devices | Networks | VLANs
(NetworkHub renders the existing device list, the subnet browse, and the VLAN
list; VLANs is now reachable outside Settings). /network -> hub; /networks
redirects to the Networks tab; subnet detail stays at /networks/:id.

Subnet "Devices on this network" now matches ANY asset whose primary IP falls in
the CIDR (PCs, printers, machines, measuring tools - not just network devices),
computed on the core Communication + Asset tables; each row links to its typed
detail (extension id resolved lazily/guarded per plugin). Fixes the empty list -
printers and PCs carry IPs and now appear (e.g. 35 devices on 10.80.92.0/24).

Also: subnet-browse search uses the standard form-control styling; dropped the
redundant per-tab page header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:15:51 -04:00
cproudlock
dd541fba0a Add "View Networks": front-facing subnet browse + detail with attached devices
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Subnets previously lived only under Settings, easily confused with the Network
Devices asset list. Add a front-facing browse + detail:

- Nav: rename "Network" -> "Network Devices"; add "View Networks" (subnets), both
  under Assets (network plugin get_navigation_items; frontend fallback matched).
- /networks (SubnetsBrowse): all subnets with name / CIDR / type / VLAN / notes,
  searchable, row-click to detail.
- /networks/:id (SubnetDetail): the subnet (CIDR, network address, type, VLAN,
  gateway, notes) plus the network devices whose primary IP falls inside its
  CIDR - get_subnet now computes that membership in Python (a device's IP lives
  in a Communication row, so it is not a plain SQL join).

Verified on the import DB: 37 networks list (real WJ subnets), detail renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:06:47 -04:00
cproudlock
be1ea29403 Import-API hardening: network device IP endpoint + communicationtypes seeder
The legacy-import surface (docs/IMPORT-API.md) is the schema-agnostic contract
every adopting site targets; these close two gaps found while mapping the WJ
classic import.

Network device IP: POST/PUT /api/network now accept an `ipaddress` and
materialize a primary Communication (mirroring the printer route), and GET
(list + detail + create/update result) surface it. Previously a network
device's IP - which lives in the communications table, not on the extension -
had no HTTP import path at all.

communicationtypes seed: `flask seed reference-data` now seeds the eight
canonical communication types (IP/Serial/Network_Interface/USB/Parallel/VNC/
FTP/DNC), which IMPORT-API.md already documents as a prerequisite. The IP type
must exist before any asset import so printer/network routes can attach an IP.
There is no CRUD endpoint for these, so seeding is the only path.

Tests: network create/update IP upsert + GET surfacing + seed creates IP type.
204 targeted tests pass; naming + pyflakes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:11:01 -04:00
cproudlock
1c6c7ba14b DB review fixes: drop redundant indexes + dead column, add CI MySQL-upgrade job
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
From the database review (verdict: sound-with-minor-issues). Applies the
actionable findings.

Redundant indexes: five non-unique secondary indexes duplicated a named idx_*
or a unique index on the same column - ix_communications_assetid,
ix_computers_hostname, ix_networkdevices_hostname, ix_printers_hostname (each
shadowing an idx_*), and idx_usb_serial (shadowing the serialnumber unique
index). Removed the redundant index source from the models (column index=True /
the extra db.Index) and added core migration 7d25 dropping the live duplicates.
The unique ix_*_assetid indexes are kept (they enforce assetid uniqueness).

Dead column: usbcheckouts.machineid was a NOT NULL soft-ref to the retired
machines table storing sentinel 0 (ADR-001). Dropped from the model + the
machineid=0 literal in selfhosted checkout; usb plugin migration 0002 drops it
live (downgrade restores it default 0).

Index: notifications.businessunitid (filtered by the shopfloor feed) was
unindexed; added index=True + notifications migration 0002.

CI: new migrations-mysql job proves the real multi-site deploy path - fresh
`flask db upgrade` + per-plugin install on utf8mb4 MySQL from empty, asserting
table count + charset and a clean second-run no-op. The pytest suite only
exercises SQLite create_all(), so a regression in the Alembic chain on MySQL
would otherwise ship undetected.

Verified: fresh core upgrade on a scratch utf8mb4 MySQL builds clean + no-op on
rerun (redundant indexes absent, unique assetid kept); plugin migrations applied
+ verified on the dev DB (machineid gone, bu index present). 953 backend tests
pass; naming + pyflakes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:29:45 -04:00
cproudlock
cd353b6432 Review safe-polish: docs accuracy, dead imports, no-emoji, geenforce robustness
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
From the full multi-agent review (0 high, 7 medium, 17 low findings). Applies
the mechanical, low-risk items; design/policy findings left for a decision.

Docs accuracy: CLAUDE.md contract 0.10.0 -> 0.11.0 and both stale Alembic head
citations -> 7d24_customfield_searchable / 31 migrations; Dockerfile bundled-
plugin comment fixed (drop nonexistent "equipment", add machines +
measuringtools, count eleven).

Style/naming (LOCKED rules): remove a CSS-escaped pushpin emoji before location
search results (no-emoji policy); rename ManifestEditor shareRoot -> shareroot
(variable mirrors the API field verbatim).

Dead code: remove confirmed-unused imports across ~20 modules (require_role/
require_permission scaffold residue, stray db/Vendor/Model/current_user/Optional/
error_response); drop unused build_scope import + a stale GEENFORCE_API_KEY
docstring clause in geenforce. Migration files left untouched.

Correctness: geenforce ingest robustness - record_enforcement_report now 400s
on a non-dict counts / non-list results instead of 500; _apply_app_link ignores
a non-numeric appid per its docstring instead of 500. Regression tests added.

Backend query.get sweep finished: auth.py refresh -> db.session.get (last one).

910 backend tests pass; pyflakes clean; naming green; frontend build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:02:43 -04:00
cproudlock
7dfbe7bf8a Add the get_permissions plugin hook (contract 0.10.0)
All checks were successful
CI / backend (push) Successful in 1m20s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Plugins declare their own RBAC permissions instead of core accumulating
them: 36 permissions moved out of the core catalog into the 9 owning
plugins (core keeps the 19 its own blueprints enforce). The catalog is
resolved dynamically (core + enabled plugins) and feeds the roles grid,
the token scope picker and ceiling, and flask seed permissions;
installing or enabling a plugin seeds its permissions automatically. A
disabled plugin drops out of the assignable catalog while existing role
links keep working. New plugins - bundled or external - now bring their
permissions with zero core edits.

781 tests pass; live-verified with a machines.edit-scoped token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 09:29:55 -04:00
cproudlock
46e50c07ff Add the API import surface for legacy migrations (contract 0.8.0)
All checks were successful
CI / backend (push) Successful in 1m4s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Goal: an LLM or script can migrate an entire legacy database using only
the HTTP API - original history preserved, safely re-runnable.

- X-Import-Mode header (admin only): create/update endpoints across 15
  timestamped entity types accept original createddate/modifieddate;
  helper exposed via shopdb.api (contract 0.7.0 -> 0.8.0).
- Exact-match natural-key lookup filters on 13 list endpoints for the
  lookup-then-upsert recipe.
- Selfhosted USB checkout/checkin accept backdated event times in
  import mode.
- docs/IMPORT-API.md: operator manual grounded in the real legacy
  schema - order of operations, full table-by-table mapping including
  the machines fan-out, idempotent Python importer with dry-run, parity
  checks, and decided dispositions for unmigrated tables (DNC config
  stays live-fed via the collector; supportteams/appowners map to the
  upcoming supportteams model).

635 tests pass; naming green; frontend untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:10:46 -04:00
cproudlock
22e623c1f6 Plugin framework maturation, reports overhaul, theming, and USB frontend repair
Framework:
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin
  carries its own chain with a stamp-only anchor at the ownership cutover;
  new plugin schema lands in plugins/<name>/migrations/, never the core
  chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the
  shared alembic template (engine URL resolution) and taught the metadata
  filter to include FK-referenced core tables.
- Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin;
  a disabled plugin's pages redirect to the dashboard via a cached,
  fail-open check against the new public GET /api/plugins/enabled.
- get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute
  report cards; warranty and toner cards moved off the hardcoded list.

Reports:
- Hub grouped by category with search; inline reports render at the top,
  are URL-backed (?report=id, back-button and deep links work), expose
  their server-side filter params as controls, and export CSV. Warranty
  and Toner pages gained CSV export.
- Deleted the dead legacy Warranty Status report (always-zero buckets
  from a retired column).

Theming and fonts:
- Inter (variable) bundled locally via @fontsource, replacing the Google
  Fonts Roboto import - air-gapped installs now render correctly; tables
  use tabular numerals.
- Optional brand_primary_dark_color, brand_accent_color,
  brand_sidebar_color settings applied to CSS vars at bootstrap.

USB frontend repair (views were reading a dead legacy shape):
- List/detail/form and the employee profile USB panels remapped to the
  real API shape (device_id/device_desc/checkinoutlog); employee panels
  now use /usb/checkouts endpoints; external-mode /usb/checkouts/active
  honors the badge filter; dead client methods pruned.

Also: warranties list page no longer requires login (matches app
convention); collector doc rewritten with a GE-Enforce integration guide
and paste-ready PowerShell reporter; ADR index and CHANGELOG updated.

Verified: 323 tests pass, naming/style green, frontend builds, plugin
migration dry-run green on scratch MySQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:01:47 -04:00
cproudlock
b8c22244a1 Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:07 -04:00
cproudlock
78a0ee8d83 Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:37:21 -04:00
cproudlock
f663cc5bbe Enforce plugin contract purity: single import surface via shopdb.api
Plugins were reaching into internal core paths (shopdb.core.models.*,
shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and
violating the ADR-001 contract. Consolidate onto one versioned surface.

- shopdb.api: expand from 2 helpers to the full plugin import surface -
  db, cache; BaseModel, AuditMixin; core models (Asset, AssetType,
  AssetStatus, Vendor, Model, Communication, CommunicationType, Location,
  Setting, AuditLog, Application, AppVersion, OperatingSystem); response +
  pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md.
- Migrate all 22 plugin source files to import only from shopdb.api (plus
  shopdb.plugins.base for the ABC).
- Drop the printers plugin's legacy MachineType dependency: remove
  _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup
  (Model.machinetypeid is nullable; printers carry type via PrinterType).
- Guard test test_plugins_only_import_contract_surface scans plugin source
  and fails on any core import outside shopdb.api / shopdb.plugins.base.
- Scaffold templates updated so generated plugins are contract-pure.
- Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion;
  manifests pin <1.0.0 so they still satisfy).

145 tests pass, naming/style green, app factory boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:45:06 -04:00
cproudlock
37ffb4add5 Make gauge/maintenance identifiers per-asset-type and extend to all types
Optional asset identifiers (gauge lab reference, maintenance reference, FQDN)
were global per-identifier and only surfaced on equipment. Now they are
toggleable per asset type and rendered on every asset type.

- settings: replace 3 global identifier toggles with a per-type matrix. New
  keys identifier_<name>_<assettype>_enabled (3 identifiers x 4 types).
  IDENTIFIER_LABELS / IDENTIFIER_ASSETTYPES constants drive the seed (API seed
  and CLI seed settings).
- composable: identifierSettings now exposes isEnabled(name, assettype),
  per-type flag winning over the legacy global key, defaulting on.
- backend writes: computers, network, printers asset create + update now
  accept gaugelabreference and maintenancereference (equipment already did).
  Reads already flowed through Asset.to_dict.
- frontend: Settings page renders an identifier x asset-type toggle matrix.
  Equipment, PC, printer, network forms and detail pages show gauge/maintenance
  (and FQDN where applicable) gated by isEnabled(name, type).

Legacy global identifier_<name>_enabled keys are still honored as a fallback
for older installs. SystemSettings toggles upsert (create on 404) so a deploy
that has not re-seeded still works on first toggle.

144 tests pass, naming/style check green, frontend builds. Verified live:
matrix renders, PC form shows the fields, PUT persists gauge/maintenance on a
PC and reads back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:34:50 -04:00
cproudlock
4f1638bda0 Consolidate bundled plugin schema into the core migration chain
The core chain already owns and reproduces the full bundled schema (deploys run
`flask db upgrade` only). The per-plugin Alembic baselines duplicated those
tables, so `flask plugin upgrade-all` would conflict - a footgun. Remove the 6
bundled plugin migration dirs; the per-plugin Alembic helpers
(alembic_template, PluginMigrationRunner) remain for external/filesystem
plugins. upgrade-all now cleanly no-ops for bundled plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:48:07 -04:00
cproudlock
fc93506af3 Fix list filters + align API params to the concatenated convention
Backends read snake_case filter params (type_id, vendor_id, location_id,
businessunit_id, status_id, os_id) while the frontend sends the concatenated
form (typeid, vendorid, ...), so the network/notifications/printers list
filters silently did nothing. Backends now read the concatenated name with a
snake_case fallback (no breakage), matching the locked naming convention.

Verified: network type filter now narrows results (45 -> 25 for Access Points).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:36:47 -04:00
cproudlock
0436e8b0af Asset-side printer save, status CRUD, real printer types, network fix
- Printers save through the asset blueprint (PUT /printers) instead of the
  legacy machines API; restrict supply-model picker to printer models.
- Asset statuses get full CRUD (PUT/DELETE with in-use guard); canonical set.
- Printer types reseeded to a real classification set + list filter.
- Equipment accepts gauge/maintenance references.
- Fix network list emitting network_device instead of networkdevice (View 404).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:35:32 -04:00
cproudlock
42ae985d72 Phase 7B: per-plugin Alembic chains for bundled plugins
Each of the six bundled plugins (computers, equipment, network,
notifications, printers, usb) now has its own Alembic chain with a
baseline migration. Sister sites adopting one of these plugins can
manage its schema via `flask plugin migrate <name>` instead of relying
on db.create_all to bootstrap everything.

Existing single-site deploys that bootstrap via db.create_all continue
to work unchanged. The chains coexist; the bootstrap path stays the
operator's choice.

Framework
- shopdb/plugins/alembic_template.py: shared env.py logic + helpers.
  PLUGIN_TABLE_OWNERS pins which tables belong to which plugin (explicit
  registry, not import-side-effect). _get_plugin_metadata filters
  db.metadata to only the named plugin's tables. create_plugin_tables /
  drop_plugin_tables emit DDL via SQLAlchemy CreateTable so the table
  definitions stay sourced from the models, not duplicated.
- shopdb/plugins/__init__.py: PluginManager.upgrade_all_plugins() runs
  pending migrations across every discovered plugin and returns a status
  dict. Idempotent (Alembic skips applied revisions).

CLI
- `flask plugin upgrade-all` runs pending migrations for every plugin.
  Used on a fresh deploy after the core schema is in place.

Per-plugin scaffolding
- plugins/{computers,equipment,network,notifications,printers,usb}/
  migrations/{alembic.ini, env.py, script.py.mako, versions/0001_baseline.py}
- Each env.py is a 5-line shim that sets PLUGIN_NAME and delegates to
  the shared template. Each 0001_baseline calls create_plugin_tables(name)
  / drop_plugin_tables(name); no duplication of column definitions.

Tests
- tests/test_plugin_migrations.py (18 cases): every bundled plugin has
  an entry in PLUGIN_TABLE_OWNERS, has the on-disk Alembic scaffolding,
  and the filtered MetaData contains every owned table (catches drift
  between the template's table list and what the models declare).
- 129 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:20:07 -04:00
cproudlock
40686ab0d2 Phase 7A: wire ADR-001 asset position contract surface
Lock the position-resolution columns from ADR-001 in code so
resolve_asset_position's relationship walk activates.

Schema
- Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy
- Location.mapx / Location.mapy added (fallback for priority 3 of the
  ADR-001 resolution chain)
- AssetRelationship.label (free-text nuance per ADR-001)
- AssetRelationship.inheritsposition (bool, server_default true, controls
  whether the resolved-position walk follows the edge)
- RelationshipType.propagatesthroughid (self-FK; sibling-propagation rail)

Seeds
- Three canonical ADR-001 relationship types created idempotently:
  partof, controls, connectedto
- controls.propagatesthroughid wired to partof (partof + connectedto stay
  null per ADR-001 table). Both via Alembic migration AND CLI seed command
  so a fresh test fixture and a sister-site deploy both end up correct.
- Legacy connection types (Serial Cable, Direct Ethernet, USB, WiFi,
  Dualpath) retained for backward compat with pre-1.0 relationship rows.

Resolver
- shopdb.api.resolve_asset_position now walks inheritsposition=true edges
  of type partof (then controls), recursively, depth-capped at 3 with
  visited-set cycle protection. Inactive edges + non-inheritable types
  are skipped. Falls through to the existing location fallback when the
  walk yields nothing.

Tests
- 11 new test_api_namespace cases cover: partof walk, controls-after-
  partof ordering, connectedto skipped, inheritsposition=false skipped,
  recursion, cycle break, depth-3 cap, self-beats-related, related-beats-
  location, inactive-edge skip.
- 111 tests pass. Naming/style check green.

Migration
- migrations/versions/7a01_adr001_position_contract.py:
  - alter_column renames on assets (no data loss)
  - add_column on locations + relationshiptypes + assetrelationships
  - idempotent seed of three ADR types + propagation FK wire-up
  - downgrade reverses + best-effort deletion of seeded types that have
    no FK refs

Backend rename (mapleft/maptop -> mapx/mapy)
- shopdb/core/api/assets.py
- plugins/{computers,equipment,network,printers}/api/...
- scripts/migration/migrate_assets.py
- Legacy Machine model + machines API + import_from_mysql.py UNCHANGED
  (per ADR-001 Machine retires; not part of the asset contract)

Frontend rename
- frontend/src/components/ShopFloorMap.vue
- frontend/src/views/{MapEditor.vue, pcs/{PCDetail,PCForm}.vue,
  printers/{PrinterDetail,PrinterForm}.vue,
  machines/{MachineDetail,MachineForm}.vue,
  network/NetworkDeviceForm.vue}
- Form field labels + v-model bindings + computed flags switched in
  lockstep with the backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:14:22 -04:00
cproudlock
9d3482fa21 Phase 3 (part 1): manifest-first loader, shopdb.api namespace, auto-register
Hardens the plugin framework so sister-site adoption is safe.

Loader rewrite (shopdb/plugins/loader.py):
- Reads manifest.json directly. Dependency sort and version checks
  no longer instantiate plugin classes (avoids __init__ side effects).
- Fail-loud policy: in dev/test (DEBUG or TESTING true), plugin
  errors re-raise. In production, errors log with full context and
  the plugin is excluded from registration. Framework keeps booting.
- Contract-version range check via packaging.SpecifierSet. Plugin's
  manifest.core_version must include the framework's
  __contract_version__ or load fails per the policy above.
- Manifest validation: required fields (name, version, description),
  name matches directory, JSON parseable.

Exceptions (shopdb/exceptions.py):
- PluginNotFoundError, PluginContractError, PluginVersionError,
  PluginDependencyError. Specific types replace generic Exception
  swallowing.

Auto-register core blueprints (shopdb/__init__.py):
- CORE_BLUEPRINT_NAMES tuple drives registration. Adding a core
  resource is one entry, not three lines (import + register call).
- Replaces 27 hand-coded register_blueprint calls.
- Asserts each blueprint is exported by shopdb.core.api at boot.

Public API namespace (shopdb/api/__init__.py):
- audit_log: thin wrapper over AuditLog.log() with stable signature.
- resolve_asset_position: implements ADR-001 position resolution
  (asset > related > location). Asset.mapx/mapy and
  AssetRelationship.inheritsposition columns are part of the locked
  contract surface but not yet in models; helper degrades gracefully
  to location-only fallback until the migration lands.

BasePlugin helpers (shopdb/plugins/base.py):
- get_setting(key, default), set_setting(key, value, ...). Settings
  namespaced as plugin.<pluginname>.<key> so two plugins can use the
  same key without colliding.

Manifest version compatibility (plugins/*/manifest.json):
- Bumped core_version from ">=1.0.0" to ">=0.1.0,<1.0.0" so all
  bundled plugins satisfy the new range check.

Contract version bump (shopdb/__init__.py):
- 0.1.0 -> 0.2.0. Additive surface change (Setting helpers,
  shopdb.api namespace) per ADR-002 minor-bump rules.

Tests (tests/test_plugin_loader.py, tests/test_api_namespace.py):
- 13 loader tests: manifest validation failures, version range
  checks, plugin.py import errors, strict-vs-isolate behavior under
  TESTING vs production-like config, manifest-first dependency sort.
- 8 api-namespace tests: audit_log roundtrip, resolve position
  fallback chain, plugin.get_setting/set_setting roundtrip with
  per-plugin namespacing.

Test count: 66 -> 87 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:15:28 -04:00
cproudlock
2c36c0c873 Add system settings, audit logging, user management, and dark mode fixes
System Settings:
- Add SystemSettings.vue with Zabbix integration, SMTP/email config, SAML SSO settings
- Add Setting model with key-value storage and typed values
- Add settings API with caching

Audit Logging:
- Add AuditLog model tracking user, IP, action, entity changes
- Add comprehensive audit logging to all CRUD operations:
  - Machines, Computers, Equipment, Network devices, VLANs, Subnets
  - Printers, USB devices (including checkout/checkin)
  - Applications, Settings, Users/Roles
- Track old/new values for all field changes
- Mask sensitive values (passwords, tokens) in logs

User Management:
- Add UsersList.vue with full user CRUD
- Add Role management with granular permissions
- Add 41 predefined permissions across 10 categories
- Add users API with roles and permissions endpoints

Reports:
- Add TonerReport.vue for printer supply monitoring

Dark Mode Fixes:
- Fix map position section in PCForm, PrinterForm
- Fix alert-warning in KnowledgeBaseDetail
- All components now use CSS variables for theming

CLI Commands:
- Add flask seed permissions
- Add flask seed settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:16:56 -05:00