3.3.1
Every date reads one way, a device's Edit sheet is the wide one, and an open sheet no longer shows a stray tooltip.
Changed
- Every day and moment reads one way. The thirty-one pages and panels that made their own date formatter or used the browser's default (which wrote "11/12/2026, 10:13:00 AM", seconds and all), and the nine that printed a stored
2026-10-01as it is, now write throughshared/date-format.ts: a stored day as the reader's locale writes it, and always that day ("Oct 1, 2026" wherever the reader is, never 30 September west of Greenwich); a moment as its day, or its day and time, where the reader is. Among them: a circuit's contract end, a model's end of sale and of support on the presets list, a run's test day on its page, its form, and the patch schedule, a tester reading, the photo timeline's days, "Kept offline", and "last synced". - A camera's or a tester's clock reads as it showed: "Taken Sep 3, 2026, 2:12 PM" rather than "Taken 2026-09-03 14:12", never moved into the reader's zone (
formatClock). A tester reading's time is to the minute, as a photo's is; it showed the seconds before. - The formatter takes milliseconds and a Date as well as ISO text, and refuses a day that does not exist ("2026-02-30") or a stored day asked for a time of day, rather than rolling it over or guessing midnight UTC.
- The Worker writes dates by one stated rule (
formatGeneratedAt,worker/report/html.ts). Having no reader, it has no locale or zone to write in, so what it writes for people uses ISO 8601: a stored day as stored, a moment in UTC to the minute and saying so ("2026-09-03 03:53 UTC"), and a device's clock as it read. The Word copy's "Generated" line printed the raw timestamp, and a new API token's audit line its raw expiry; both follow the rule now. The CSV, JSON, and Resources exports keep their stored values; no file format changed. - A device's Edit sheet is the wide one, as adding a device already was: its short fields pair up two to a row once the form has room (
EntityHeadertakeswideSheet, the switchSectionhas). Rooms, racks, properties, and clients keep the ordinary sheet: their forms hold 2 to 10 fields, against the device form's 21 and more, and none pairs.
Fixed
- No "Dismiss" tooltip over an open sheet's surroundings. The scrim around a sheet is a button named "Dismiss" with no words of its own, so the 3.3.0 tooltip layer treated it as an icon-only control. With the pointer anywhere outside the panel, a "Dismiss" label showed at the top of the screen. The scrim now opts out, as a control whose name its words already say does (found in the W21-D screenshot review at 3440 px).
3.3.0
Wave 20: Settings as a page, an account menu, tooltips, one rack face width and a new picture family, and a desktop-to-phone polish pass.
Added
- Settings is a page (owner, 2026-11-12). The gear in the top bar opens
/settings, which lists every section in groups (your account, the workspace, developers, data, help); on a settings page the sidebar lists the sections instead of the clients, and on a phone the page is the list. Every section keeps its address. The theme moved to Settings › Appearance. - The avatar opens an account menu with Profile and Sign out, on the staff bar and in the client portal.
- The views sit in the top bar: Resources, Racks, Stock, and Property map (Racks and Stock were inside the old Settings menu), behind one Views button on a phone.
- Tooltips for every icon-only control, on hover and on keyboard focus: dismissed with Escape, kept while the pointer moves onto them, never over the control, and none on touch (WCAG 1.4.13).
- A new rack picture family: 32 photoreal device fronts, one for each height a type is racked at (1U to 4U), with real rack ears and screw holes; a 2U to 4U blank is one plate, never two halves.
- More actions on a header, an accounts row, and a webhook endpoint's row: the everyday actions stay on the row, the rest behind one menu, Remove last and in red.
- A find box in the rack palette: type to narrow the device types and presets by name; every chip shows its full name, in a list that scrolls rather than pushing the rack down the page.
- Device type icons in search, Needs attention, and the command palette: a switch, an access point, or a camera wears its own glyph rather than one for every device.
Changed
- Every device in a rack is drawn the same width, a true 19-inch front for its height, with the same left and right edges row after row; a switch's or patch panel's ports sit centred on a plate that wide, so nothing reads wider than the rest of the rack.
- The top bar holds less: the unlabelled keyboard-shortcuts button and the Sign out button left it (press
?, or Settings › Help, for the shortcuts). - A desktop and ultrawide polish pass from a review of every screen at 393, 1280, 1920, and 3440 px in both themes: tables scroll sideways in their own box with sticky headers, long names whole; pages hold a readable centred column on a very wide screen; settings and editor forms sit at a readable width; the editors' controls share one toolbar row with Hide panels; the dashboard's charts fill whole rows, with grouped thousands; recent changes show ten at a time.
- Resources tables sort as people do: run 2 before run 10, addresses by their numbers, MAC addresses as written; a contact's number and address open the phone or mail app.
- The topology fits both ways, never below a readable size and enlarging a small map only so far; every room is boxed and named clear of the lines; parallel links draw as one line with a count; the key draws each medium's own line.
- Maps follow the theme: the property map picture uses the dark map on a dark page, at no more than its own pixels.
- The report reads on a phone and prints cleanly: tables scroll in their own box, the page holds a readable column on a wide screen, a QR code sits in its heading's row, and paper always gets the light palette.
- The client portal keeps the account in the avatar's menu and the theme at its foot; its tables show a Status column only when a device has one.
- Scanning without a camera decoder says so as news and takes the value typed.
- One way to write a range of rack units, such as
U33–32, on the drawing, the device page, labels, reservations, the history, and the report (a reservation wroteU33–U32). - Days read as your device writes them in a property's lists, a device's purchase, warranty, and support dates, a work order, the audit walk, and Needs attention, never as
2026-12-03, and a date never breaks across two lines. - The add-item sheet is wide on a desktop, in a room and in a rack, with the short fields two to a row and the groups drawn flat.
- Every section's help sits beside its title, including a device's notes, files, and photos and the rack list.
- The topology's Fit and Actual size sit in the editor's toolbar row, beside Hide panels.
- The report's and the share page's maps scroll at a readable size on a phone instead of shrinking to its width; paper still fits them to the page.
- Map pictures follow the dark theme everywhere: the property map, a property's place card, and the map of where photos were taken.
- Rows read alike: the contacts', VLANs', WLANs', and circuits' Edit and Remove are the app's row buttons, and every empty section says so in the same box.
- Dependabot's patch and minor updates merge themselves (owner, 2026-11-12) once every CI job passes on a branch up to date with
main: each job is now a required check of the "Protect main" ruleset (the owner's role bypasses it, so direct commits stay the workflow), and.github/workflows/dependabot-automerge.ymlswitches on auto-merge. Majors wait for the owner. The red drill now runs on pull requests too: a merge made with the workflow token starts no workflow, so the pull request's run is the only drill an update gets. - A failing weekly dependency audit opens an issue. The scheduled audit was red from 2026-10-12 to 2026-11-09 and nobody saw it; a red run now opens a
dependency-auditissue (or comments on the open one) with the report, and the next green run closes it. - Uploading CI evidence no longer decides a gate. The coverage report, the Playwright report, and the red-drill evidence upload with
continue-on-error: on 2026-11-11 GitHub's artifact service failed the quality job after every gate had passed.
Fixed
- The sidebar toggle's chevron is optically centred in both states.
- The phone search box no longer shows the browser's own clear cross beside its close button.
- A chart's middle figure keeps its unit on the same line.
- A 2U to 4U device picture in the rack is drawn in its true proportions (it was up to 17% too tall on a desktop and far taller on a phone).
- A property's checklist and work-order titles wrap rather than being cut off on a phone; an SSID and a Wi-Fi band stay on one line.
- The floor plan's key is lightened with its lines in the dark theme, and room boxes stand out from the ground.
- Selects in the ports table, a cassette's connector, and a WAN link's and a radio's settings have the same field box as every other select.
3.2.1
Security
- Source maps are no longer published. Since the first release the build wrote a source map beside every chunk and the deploy uploaded them, so
/assets/*.js.mapserved the client's original TypeScript, comments included, to anyone who asked (found reading the 3.2.0 deploy's upload list). Maps are now writtenhidden— no bundle names one — andpublic/.assetsignorekeeps every*.mapout of the upload; they stay on the machine that built them for decoding a stack trace. The Worker's code and every secret were never in them.scripts/public-assets.test.tspins both halves, with red-drill plants SEC4.
3.2.0
Added
- One-time secret links and credential requests (S15, migration 0102). "Send once" on a vault entry or a device's credentials makes a link that lives one to seven days; its page asks before it reveals the username and password, once, and is then spent (notes and one-time-code seeds are never sent; every refusal is the same 404; the page carries no script and is never cached). "Ask the client" puts a request on the client portal; the answer is sealed the moment it arrives and waits in the vault as "Submitted — review" until a manager accepts it or destroys it. Every step is an audit row; none of it is open to API tokens or kept offline.
- Accessibility statement and privacy notice (T3, T4).
/accessibilityand/privacy, readable signed out, linked from the sign-in page, the portal, the settings menu, and shared reports. - The user guide inside the app (T5).
/help: fourteen task pages built fromdocs/guide/at build time, with a filter and section links; eight Help popovers open the matching section, and the command palette opens the guide instead of the repository README. - What's new (T6).
/aboutshows the running release, whether the server runs the same one, and the latest three releases from this file. - API reference (T7).
GET /api/openapi.json(OpenAPI 3.1, built from the shared Zod schemas, held to the real routes by a drift test) and a readable/api-referencepage. - The export is complete (format 3). It now carries tester readings, pathways and their runs, port groups, address and rack reservations, switch stacks, and report branding, and the twenty-six columns it had missed — among them an account's client scope and portal flag. What never leaves is listed with its reason, and a schema test fails any new table that is in neither list. Webhooks, share links, API tokens, second factors, and stored secrets are set up again after a restore.
- Readings typed by hand leave history, as imported ones do.
- Import tester results (S1, migration 0101). The property importer reads Fluke LinkWare PC and LinkWare Live and NetAlly Link-Live CSVs (any other file is mapped by hand). "Check N rows" lists what will be recorded, what will not and why, and what is already there; "Record N readings" writes one reading per row on the run with that cable ID (the tester's own spelling, "cbl 7", is matched when it names one run). Dates are read month- or day-first as the file and the person settle, never guessed; Fluke's PASS* is kept as marginal; the tester's clock time marks a re-imported file; each run and the property get a history row.
- Download a property's photos and files (S13). "Download all" on the Photos and Files sections streams one stored zip from R2, foldered by the map (Room/Rack/Device/file) with a
Contents.csv, without holding it in the Worker's memory; refused past 2 GiB or 5,000 files with the figures; each download is an audit row. - Audit log as CSV (T1). "Download CSV" saves every entry matching the filters — managers only, formula-safe, at most 20,000 rows — and the export is itself an audit row.
- Credential exposure report (T2).
/users/:id/exposurelists every device credential, access code, and vault entry an account revealed that has not changed since, with where to change it; names only, never values. Vault reveals now record the entry they revealed. - Article review dates, folders, and drafts (S19, migration 0103). An article can carry a review-by date, with "Mark reviewed", a review log, and a Needs-attention row once it is past; one level of folders groups the Docs list; a draft is visible only to its author and managers — not listed, found, linked, or opened by anyone else.
- End of support on the model (S16, migration 0104). A preset's end-of-sale and end-of-support dates reach every device of that make and model unless the device has its own date, on the device page, the report, Resources, and the support-ending attention row.
- Read a label with the camera (U1). The scan sheet has Code, Text, and Tag modes. Text crops the label, reads it on the device with Tesseract.js 7 served from this app's own origin (
/ocr/tesseract-7.0.0/, lazy-loaded, cached by the service worker so it works offline; no third-party request), and matches what it read against serials, MACs, short IDs, and cable IDs with confusable-tolerant rules (shared/label-match.ts:O/0,I/1, any MAC separators,CBL 7=CBL-0007), throughGET /api/label-matchonline and the kept copy offline. A match must be unique in its tier (this property, this client, everything); two candidates are a pick list. The header scan button and the serial, MAC, and cable ID fields use it, and nothing is filled or opened until the technician presses Use. The CSP gains'wasm-unsafe-eval'for the WebAssembly engine and nothing else. - NFC tags (U2). "Write NFC tag" on device, rack, room, and run pages (Chrome on Android) reads the tag first, writes the page's URL and then its short ID, warns before replacing another record's tag, and offers a permanent lock as a separate step asked twice. The tag's serial number is a history row (
nfc_tag_written,nfc_tag_locked), queued when offline. "Tap a tag" in the scan sheet opens the tag's record; a URL that is not this app's is searched for as text, never followed. iPhones open the same tags without the app. - More label stock (S6). Avery 5160, L7160 and L7651 sheets, 12, 18, and 24 mm tape, and 2×1″ thermal rolls, each on a named
@pageof its exact size, with per-print toggles for the type, path, short ID, serial, IP, cable ID, and far end, remembered on the device. - More than one MAC per device (S20, migration 0093). Further MACs with a label each, checked like the first, refused when they repeat one already on the device or, on import, on the property; searchable by digits and shown in the item page, report, equipment CSV, Resources, export, and history.
- Checklist templates the account edits (S4, migration 0094). Settings → Checklist templates: steps that require a photo, allow N/A, or take a yes/no, number (with a unit), or text answer. The three built-in procedures became editable templates; existing checklists keep their steps. The Worker enforces every rule, N/A steps drop out of the percentage ("7 of 9 done · 1 N/A (77%)"), and the report prints the answers and photo thumbnails.
- Customer sign-off (S2, migration 0095). A checklist or work order takes the signer's name, role, and a drawn signature (or a typed name); it is then locked until a manager re-opens it with a reason, and the sign-off stays in the record, the report, the Word copy, the export, and the client portal.
- Documentation completeness (S9).
GET /api/properties/:id/completenessscores fourteen checks and the property page shows "NN% documented" with every gap linked; the report cover carries the same figure. - QR codes in the report (S10). Each room, rack, and device heading of the team's report and PDF carries an 18 mm code that opens the live record; share-link and portal copies carry none.
- Pinned flags (S3, migration 0096). A note has a level (info, warning, danger) and a pin; a pinned note is a banner on its node's page and on every page below it, prints first in the report, travels offline, and an open danger flag is a Needs-attention row.
PATCH /api/notes/:idchanges the level or pin, never the words. - Audit walk and "last verified" (S5, migration 0097). From a property's header: start a walk, then scan a label, tap a tag, or tick a row to stamp each room, rack, device, and run as seen; a live list of what is not yet seen shrinks by room, the whole walk works offline, and headers show "Last verified … by …". Devices unseen for twelve months at an audited property are a Needs-attention row.
- Favourites and recently viewed (S8). A star in every node header and the ten places last opened, per account on the device, lead the Ctrl/Cmd+K palette and a Pinned strip on the home page.
- Archive a client or property (S18, migration 0098). A manager archives or unarchives from the header; everything under it is read-only (the Worker refuses writes and says how to unarchive) and hidden from the lists, Needs attention, and Resources unless "Show archived" is on, while search, the export, and reports keep it.
- Bulk edit (S7). Resources' devices, runs, and links tables take row selection (the page or every match, up to 500) and a "Set…" sheet for status, a tag, the responsible person, or the room; each record goes through its own edit path with its rules and history, and the sheet ends with what was done, what is queued offline, and each refusal by name.
- Power draw and UPS/PDU load (S11). A draw in watts on every powered device and a capacity on a UPS, PDU, or strip (a VA rating is read at a 0.6 power factor, and the figure says so); the load through any PDU a source feeds is a gauge on the device, a warning on the feed form, a line in the report, and a Needs-attention row when over capacity.
- Transceivers per port (S12). An SFP-family port records its module (standard, wavelength, vendor, part number, serial), shown in the faceplate card and the report, kept in port history, and found by search.
- Racks side by side (S14). A room's racks drawn in a row at one U scale, with one Front/Back switch.
- Switch stacks (S17, migration 0100). Members, their positions, and the master, shown on each member's page and the rack drawing, and drawn on the topology as one switch.
- Top-down U numbering (S21, migration 0099). A per-rack choice; a top-down rack draws, reserves, labels, and prints its units from the top.
- Patch-cord length check (S22). A link inside one rack warns when its cord is shorter than the units between its ends plus slack.
- Links can be edited after they are made (W18).
PATCH /api/connections/:idchanges a link's label, status, notes, and — on a direct patch cord or an AV run — its cable, colour, length, and test. A link carried over a run refuses those fields (the run is the cable) and says which run to edit. An Edit button beside Trace and Unlink in the device page's Links list, and "Edit link…" on the ports screen's port menu, open the same sheet (LinkEditSheetaroundLinkDetailsForm). Each save is onelink_changedhistory row under both devices, field by field; a save that changed nothing records nothing. - Documentation and repository. A rewritten README with a banner, badges, and screenshots; a GitHub wiki (guides, API, operations, security, screenshots of every main screen);
LICENSE,CONTRIBUTING.md,CODEOWNERS, issue forms, and a pull-request template.npm run docs:seedbuilds a demo estate in its own state directory,npm run docs:shotscaptures the README and wiki screenshots from it, andnpm run qc:shotscaptures every route at 393, 1280, 1920, and 3440 pixels in both themes for visual review.
Fixed
- Editing a client, property, room, or rack renumbered it. Their edit forms opened with a blank short ID, and blank means "assign the next", so every save gave the node a new ID. The forms keep the ID now.
- The secrets script could make production unreadable.
secrets:generate --forcerewroteCREDENTIAL_KEYand droppedMAIL_FROMandMAPBOX_TOKEN, so pushing its output (the documented runbook) would have sealed nothing anyone could open. It now edits.dev.varsin place, rotates onlySESSION_SECRET, keeps every other line, and says what it changed;--new-loginreplaces the seed login, which only matters on a database with no accounts. - Key rotation skipped webhook secrets and the recycle bin's copies.
POST /api/credentials/rotatenow re-seals every sealed value and answers{ resealed, unreadable }. - Photos never recorded where they were taken. The Permissions-Policy blocked geolocation; it now allows it to this origin, asked only after photos are picked.
- The property map showed a limited account every client's properties, and refused it its own list. Both follow the account's scope now.
- Ownership could pass to a portal or client-limited account. Both are refused, and "Make owner" is not offered on their rows.
- The client portal showed technicians' step notes that the client copy of the report leaves out; the Worker drops them for portal accounts.
- A property's photos looked missing when they hung on its rooms and devices: the portal shows the property's whole photo timeline, and the staff page points to it. The timeline keeps the newest 500 photos.
- Revoked share and one-time links are dated by their revocation; a rack editor delete says it goes to the recycle bin; the files-zip audit note says "1 file"; Resources exports are "Export CSV" and "Export Excel" of every matching row.
- A preset library larger than 200 could not be imported back; the import sends it in parts.
- A full-depth device stood at different units on a rack's front and back. Both faces are now laid out from the rack's one order, so NAS-01 is U27–26 front and back, and units in use count each unit once.
- Patching a panel from its own page answered 500. The link indexes ignored a panel's front and rear; migration 0105 makes them side-aware, and a constraint the rules miss answers 409, never 500.
- Every device page asked for
/api/clients//contactsbefore its path loaded. - An address Mapbox only guessed at was pinned in another town. A low-confidence match is now left unplaced with a note, and lookups send the address in structured parts.
- A VLAN page listed other VLANs' devices as outside every subnet.
- Purging from the recycle bin left signatures, files, and photos no page could reach in storage. They are deleted with the entry now.
- A limited account's export and dashboard counted every client's service entries. The service log is narrowed to the account's client.
- Percent-escaped API paths are refused. The client scope, the archive guard, the portal allow list, and the bearer-token refusal list matched the raw path while the router matched the decoded one, so
/api/%63lients/<id>passed every guard (a security review found it; the token half predates this release). Any/apior/sharepath holding a%now answers 400 before a guard runs, and the guards read the router's own path. - Stacks and port groups respect an archived property. Their devices are named in the body, which the archive guard cannot see; both now refuse a device in an archived property, as links and feeds do.
- Uploaded images have a pixel ceiling. A header declaring more than 40 megapixels is refused (413), as SECURITY.md always said it would be.
- Search finds cable IDs and MACs in any format (T8). A run's cable ID is searched, and a query shaped like a MAC matches every stored MAC by its digits, so
AABBCCDDEEFFfindsaa:bb:cc:dd:ee:ff. - Labels name what they show (WCAG 2.5.3). axe-core 4.14 checks that a control's accessible name contains its visible words; the header's profile link (and the portal's), the accounts page's row actions, the recycle bin's and the ports table's buttons, photo tiles, and topology nodes now do.
- Links inside sentences are underlined on the About page and the guide's "no such page" line, so they are not told apart by colour alone (WCAG 1.4.1).
- A phone could not reach the settings menu's last entries. The menu hung below the screen's foot with no scroll, so Accounts, Webhooks, and Report branding were out of reach; it now stops at the screen's foot and scrolls.
- A tap below an open More panel was lost on a phone. Closing the panel returned focus to More and scrolled the page under the finger; a tap elsewhere now returns focus without scrolling (Escape still scrolls to More).
- Typing a page's name in the command palette hid it. Once two characters were typed the palette listed only search hits, so "guide" never offered "Open the user guide"; matching pages and actions now lead, then the map's hits.
- Watt totals printed as binary floats. Draws in tenths of a watt were added as floats, so the dashboard's PoE gauge read "56.099999999999994 of 2220 W". Every total of draws (a switch's PoE, the dashboard, the racks table, a UPS or PDU's load) is rounded to the tenth a port records.
- Long command palette hints pushed the list past the sheet's edge. The palette's column now stays the sheet's width and a hint is cut short with an ellipsis.
- A rack row's settings control sat over its last ports, and on a phone a tall device's type showed as one clipped letter. The faceplate and the kit's picture stop short of the control, and on a phone the type word leaves the label (the kind icon says it; screen readers still hear it).
- The recycle bin squeezed each entry into three columns on a phone, so where it was wrapped a word to a line. An entry's lines now stack, with Restore and Remove for good beside them or, on a phone, under them.
- A link made over a run fills the run's far end. Linking a device to a device in another room over a run set the link's run but left the run's own "Goes to" at the room, so the run page, the map peek, the report, and the floor plan disagreed with the ports and device pages. The connections route now gives the run the link's far device and port through the run's own edit path, judged by the run's rules before the link is written (
runEndsForLinkinshared/connections.ts), and the run-derived link matcher reads either way round, so the hand-made link stands as made and no second one is made. - A spare run carries no link. Nothing refused a link over a spare run. The form no longer offers one, and the Worker refuses it: "D-01 is a spare and carries no link; make it active first".
- A run's verdict is its readings' once it has any. The run form offered Tested on, Result, and Measured length beside the readings that overwrite them. With a reading recorded the form shows the verdict the latest reading gives and says to record a reading to change it; the Worker refuses a different verdict typed by hand.
- Restoring from the recycle bin makes and judges links again. A restored run makes its link again (the delete had taken it with the run). A restored device's links and power feeds are judged by the shared rules once back — a link whose far switch moved out of reach is removed and counted — and a row whose unique place a live row has since taken (a port another link holds, an outlet fed to another device) is left out and counted instead of failing the whole restore. The restore's summary and the page say "what they pointed at is gone, or their place has since been taken".
- The audit log shuffled entries written in the same millisecond. Ties were broken by the entries' random ids; they now keep the order they were written, in the log and in its CSV export.
Tests
- Shared rules: run ends from a link, the spare rule (
shared/connections.test.ts). Worker: the run adopting the link's end, editing a link, the spare refusal (test/connections.test.ts); the verdict lock (test/cabling.test.ts); a restored run re-linking, a link and a feed left out, a link the rules no longer allow (test/recycle-bin.test.ts). Client:LinkDetailsForm.test.tsx(new),DropForm.test.tsx,ItemPage.test.tsx,PortsPage.test.tsx,RecycleBinPage.test.tsx. Journeys:links.spec.ts(a link over a run between rooms, a status change seen from both ends),runs.spec.ts(a reading locks the verdict and reaches the panel schedule), and the newrecycle.spec.ts(a restored run links again).
Security
- Every reveal of a stored secret is an audit row: device credentials and access codes now write
credentials_revealedandaccess_revealed, as vault reveals already did. - Dependencies current;
npm auditclean again. After two idle months the audit reported 28 advisories (9 high) in zod, hono, undici (through wrangler and miniflare), vite 7.3.6, sharp (inside miniflare), and brace-expansion, minimatch, and glob under the lint tooling. Every package moved to its newest release its peers accept — React 19.3, Hono 4.13.17, Zod 4.6.7, Vite 8 with@vitejs/plugin-react6, Vitest 5, Playwright 1.65, jsdom 31, Stylelint 18, wrangler 4.160 — and the transitive fixes are scopedoverrides(sharp 0.35.5 under miniflare, brace-expansion 1.1.21/5.0.12, minimatch 11.0.7, glob 14.0.4). Held back, each with its blocker named in.github/dependabot.yml: ESLint 10, eslint-plugin-unicorn 66+, TypeScript 6.1+,@types/node25+. - Dependabot is back (owner, 2026-11-10): alerts, security-update pull requests, and weekly grouped version updates with a seven-day cooldown. CI runs on pull requests so those updates face the same gates; the hour-long red drill runs on the push that merges them.
mainis protected by a repository ruleset against deletion and force-push; direct commits stay the owner's workflow.- npm 11's install-script gate lists the three packages whose install scripts run (
allowScripts: workerd, esbuild, unrs-resolver).
Changed
- People are named by their display name in the audit log (with the username beside it, and username columns in its CSV), on share links, one-time links, presets, credential requests, and the exposure report.
- Node headers show Edit and the everyday actions, the rest under More; the property's access facts sit in its facts card beside the map.
- Phone header: search opens over the bar; breadcrumbs shorten with an ellipsis and keep the current page whole. On a desktop the view links sit beside the account controls.
- Wide screens: settings and list pages keep a readable width; headline tiles balance into even rows at every width; a row's tags and responsible person have a line of their own.
- Drawings: port faceplates wrap in banks of twelve and the ports page leads with the device on a phone; floor-plan words and markers keep their size on screen and give way rather than collide; the topology opens fitted to its frame (Fit / Actual size) with link labels clear of room names; a shelf's contents sit on its tray; amplifiers and AV receivers have photoreal fronts in the rack, and every other racked type with no ports recorded (a rackmount PC, a portless switch or patch panel, a splitter, "other") borrows a fitting one instead of a plain bezel; plan words and markers carry a screen-sized halo so run lines never read through a name.
- Offline page uses the shared heading and shows each kept property's real size.
- Repository description, homepage, and twenty topics set on GitHub.
- ROADMAP gains sections S (22 documentation gaps from a fresh read of the vendors' 2026 docs), T (trust, admin, and help items), and U (label OCR and NFC tags, moved out of section R by the owner).
3.1.0
Changed
- One control height, and a denser desktop (W17). Every button, field, chip, and tappable row used to be the 44px tap target at every width, so a desktop page read as a phone page stretched sideways.
tokens.cssnow carries three control heights (--control-h,--control-h-sm,--control-h-xs) that are the tap target on a touch screen and 36/32/28px where a pointer is fine and the screen is wide, a--control-fontthat is the page size on a phone and the small size on a desktop, and a desktop root size of 15px; the shared buttons, fields, and every stylesheet that said--tap-targetfor a control's height take the new tokens. Fields lost the vertical padding that made a one-line box 48px tall. - One toolbar and one segmented control.
Toolbar(withToolbarSearchandToolbarField) andSegmentedControlinsrc/components/replace the six ways the pages drew a row of controls. The tag and responsibility filters above every list, table, and card grid were two stacked full-width labelled fields (about 200px of every page); they are two compact search boxes on one row, and the list's view picker ("List / Cards", "Table / Cards") sits on the same row as a segmented control instead of a full-width select above the filters. The log filter bar, the topology page's four pictures, and the rack's Front/Back switch draw the same two pieces. - A page fills a wide screen. The shell centred every page in a 90rem box, which on a 3440px monitor left a third of the screen empty either side of it. The page now starts at the rail and runs to the edge, and the six node pages (client, property, room, rack, device, cable drop) lay themselves out through
PageLayout: from 100rem a second column carries what every node has alongside its data — the report, photos, files, notes, tags, the responsible person, relationships, history — sticky beside the main column; below that width the aside follows the main column in the order a phone always read. The at-a-glance tiles keep a tile's width rather than stretching across the column. - The rack drawn as a rack. The elevation is a cabinet: a dark interior between two rails punched three holes a unit, and every mounted device a charcoal chassis the height of its units with its label at the left (name, U range, height, type) and its ports drawn across it from the right — the ports were drawn under a text header that took the whole of a 1U block, so a switch's face was never visible.
DeviceFacedraws each port as its connector: an RJ45 jack with its opening, clip notch, and two link lights; an SFP cage with its bail; a duplex LC pair; a small jack. The state colour is the housing, as before. The ports page's faceplate sits on the same charcoal bezel and its squares are jacks with a notch; the palette above the rack is one scrolling strip of compact chips (presets first) instead of five rows of 44px pills. - One tab strip, and the last odd rows.
surfaces.tabs/surfaces.tabare the one pill strip; the Resources record types, the rack's Devices/Connections/VLANs, and the floor plan's storeys draw it. The Resources controls (scope, columns, saved questions, exports) and the ports page's edit-mode filter and search are the shared toolbar and segmented control. The Needs-attention lists sit two abreast from 100rem and three from 150rem. The Presets page's two paragraphs sit behind the help icon (ROADMAP P3). - Photoreal chassis art for port-less rack gear. A device that draws no port face — a UPS, PDU, power strip, NVR, server, shelf, cable manager, blank panel, drawer, or a router/firewall/controller/modem/converter whose ports are not recorded — shows a front elevation drawn for this app: ten WebP assets in
public/device-art/(8–32 KB each), generated with ChatGPT in one consistent charcoal style, cut out of their canvas and keyed to transparency (scripts/device-art/crop.py), and brought to the true 19-inch aspect for their height by content-aware column insertion so outlets and displays keep their shape (scripts/device-art/normalize.py, Python 3 + Pillow). A block that draws ports keeps the port face.
Fixed
- The ports page's patch targets (the panels within reach) ran out of the 16rem palette column and over the faceplate and its key; each target now scrolls its own plate sideways inside the column.
LogFilterBar.module.cssis gone; the bar is the shared toolbar.- A shelf's contents chips sit above the chassis art.
- The port hover card sets its own text colour: on the ports page's charcoal bezel it inherited the bezel's light text over its own light surface (1.13:1, caught by axe in the patch journey).
Tests
- New:
SegmentedControl.test.tsx,Toolbar.test.tsx,PageLayout.test.tsx. Changed: the view pickers are pressed buttons in a named group, and a search box is asearchbox, inAnnotations.test.tsx,ClientsPage.test.tsx,AttentionPage.test.tsx, ande2e/annotations.spec.ts(the whole suite green on Pixel 7, iPhone 14 WebKit, and desktop Chromium, and the touched gaps' 32 red drills verified; see PLAN §9, Wave 17).
3.0.0
Changed
- Thirty-one duplicated blocks are one implementation each. CI's duplication gate (jscpd at a zero threshold), which the consolidation gate had not run, found 31 clones across the waves. In product code the shared pieces are now:
usePopoverbesideuseDismiss(the settings menu, Help, the filter menu);sessionUserandsessionActorinsrc/auth/session.tsand oneSkipLinkcomponent (the app shell and the portal shell, whose skip link now wears the shell's pill rather than its own plain box); aDeleteSheetinConfirmDelete.tsx(the checklist and work-order pages and the node delete confirm); auseImportFilehook for both import wizards;MoveControlson the rack page;pinnedDropEndsin the plan-line lengths;centreLabelon the plan canvas;updateUnstampedinworker/db/sql.ts(checklists and work orders); the client-or-property history helpers inworker/routes/history.ts(articles and VLANs); the share routes' own refusal and view record; and.cell-link,.row-lines, and.row-actionsin the shared surfaces stylesheet (the presets page's own stylesheet held nothing else and is gone). In the tests:roomRoutesandunderPropertyRoutesinsrc/test-utils/page-routes.ts, the photo prepare stub,pasteAndContinuefor the import pages,ownerUserin the fixtures,addRoomintest/site.ts, and a local helper at the top of each remaining file. No test name, matcher, or expected value changed; one drill plant moved with the rule it mutates. - The two places that drew every client at once now draw a page of them (P3). With 1,378 clients in the local database, the dashboard's "All clients" table laid out 1,378 rows — or, in the cards view, 1,378 cards — and the desktop rail put 1,378 links beside every page of the app. On mobile WebKit an accessibility scan of that DOM ran past the journey's two-minute timeout, and on every browser the page was heavier than it had any reason to be. All three now draw the first page and put the rest behind the same "Show N more" the "Needs attention" lists have used since the last release: fifty rows or cards on the dashboard (
LOCATION_PAGE_ROWS), twenty-five in the rail (SIDEBAR_CLIENT_PAGE_ROWS), which is one narrow column. Filtering and sorting are unchanged and still run over every client before the page is taken, so a search that matches three clients shows three and a sort by devices can bring the last client the API sent onto the first page. The rail gains a filter box that matches a client's name or its short ID — the way to one of 1,400 without pressing a button fifty-five times — and keeps the client whose page is opening in view whichever page of the list it sorts onto. The paging itself moved into one place,src/components/ShowMore.tsx, rather than being written out four times.
Fixed
- A picked PDF or picture is no longer loaded into a media element to read a length it cannot have (L5). The upload sheet reads a memo's run time from the file the moment a video or audio kind is chosen, whatever the file. A file the browser already calls a document or an image never yields a length, and WebKit on Linux (CI's phone browser) stopped painting while it tried, which left the sheet half slid in and the e2e journey's tap on Upload waiting out its timeout. Only a file the browser calls audio, video, or nothing it recognised (the bytes decide those) goes to a media element now. The e2e helper that opens a section's add form also scrolls and checks the viewport together, and again if the check fails: on a slow runner the previous write's refetch drew the first item's filters and table above the next button after the helper's one scroll had found it in view. The photo journey's drag on the annotation canvas waits for the canvas to take the picture's size and for the dialog to settle before it reads where the canvas is; on the runner the drag had begun on a canvas still sized nothing.
- CI's lint step ran out of heap on the release commit. Type-aware ESLint over the whole program (about 850 TypeScript files across four tsconfigs) exhausted Node's default heap on the GitHub runner ("Ineffective mark-compacts near heap limit") where the workstation had headroom; the quality job now gives Node 8 GB of the runner's 16. The same run's secret scan flagged one made-up test passphrase (
test/moves.test.ts) whose entropy clears gitleaks' generic-api-key threshold where its siblings do not; the fingerprint is listed in.gitleaksignorewith its reason and in PLAN.md section 10. - The report card's description was squeezed into a ten-character column (P3). On a desktop the card gave its downloads a column of their own, which sized to the widest button and left the sentence saying what the report covers wrapping one or two words a line beside it. The description now takes a row of the card's own width under the heading, and the downloads flow as one grid beneath it — on the client and the property page alike, since it is one card.
- A node's short ID was printed twice in its header (P3). It sat under the breadcrumb as the short-ID path and again as a badge under the name, which on a client page — whose path is that one ID — read as the same word twice, forty pixels apart. The path now belongs to the header alone, under the name: the ancestors' IDs lead it and the node's own ends it, picked out in bold. The breadcrumb names the trail and nothing else.
- Some sections put their add button under the empty box, others in the heading row (P3). Contacts, Docs, and a device's credentials drew their own button beneath — or inside — the box that says there is nothing yet, while every other section's sits beside the heading. There is one rule now: a section's add action is in its heading row, wherever the control is owned, and the box below carries the one-line hint and nothing else.
- Photos, Files, and Notes each looked like a different product (P3). Photos led with a dashed "Add photo" tile in the grid, then said "No photos yet.", then a small line about formats; Files had a button beside a long sentence about which ones; Notes was a bare textarea with no box at all. All three now read like the sections above them: the action in the heading row ("Add photo", "Add file", "Write a note"), what the format or the shortcut is behind the same help icon the rest of the app uses, and one line in the same dashed box when there is nothing yet. The file picker, the caption panel, the resize before upload, and the offline queue are the paths they always were.
- Two labels printed over each other at the top of the topology map (H4). When every device on a map is unlinked, the row they are drawn in sits inside its room's boundary, and the boundary's name and the "Not linked" caption landed on the same line at the same left edge — an unreadable smear where the room's name should be. The caption now sits on the line above the boundary it would have overprinted; a boundary that starts higher up moves nothing.
- The topology key named every medium the product knows (P6). A map of nine devices and no links still listed eleven colours — Ethernet, fiber, mesh, VPN, HDMI, SDI, audio, speaker wire, RS-232, IR, relay — none of which were on the picture. The key now names the media the map actually draws, in the media's own order and with the dash each is drawn with, and says "No links to key yet" in one line when there are none. The wiring and signal-flow maps already worked this way; all three now read from the one key the page builds.
- "Devices per client" was eight identical bars over eight identical labels (P3). The card was one column wide, so every client's name ellipsised to "Import desk…", and when the top eight all held nine devices the bars were eight full-length blues that said nothing. The card now takes the width a name needs, the name leads the row and carries the full text as its tooltip, and when the ranked figures all tie the chart says so in a line — "All 8 tie on 9 devices." — and lists the names and figures without bars that would only be the same length.
- A tag pill wrapped over the client's own name (P3). In the clients table a client's tags and the person answerable for it were printed inside the name cell, so a long name, a coloured pill and "Responsible: …" ran together over three lines with the pill on top of the words. Tags and the responsible person are columns of their own now, beside the counts. On a narrow table they step aside with the rooms, the runs and the date; the card view beside the table carries both, as it already did.
- The Resources tables broke names over five lines and hid the last column (P3). Every cell was allowed to break at any character so the table could always be squeezed into the page, which turned a client called "Annotations desktop-chromium-1789530127516" into five stacked fragments and pushed the Responsible column off the right edge with no way to reach it. A text cell keeps one line now, up to a generous width, ellipsised past it with the whole value as its tooltip, and the table's own box scrolls sideways — which is what the box was there for.
- "Needs attention" was hundreds of cards, one per row (P3). Twenty gaps, each naming up to a hundred nodes, were all laid out at once as stacked cards — 831 of them on a real estate. Each list is now the dense table the rest of the app uses on a desktop (the node, what it is, and the path down to it) and keeps the 44px rows on a phone, and only the first fifty of a list are drawn: the rest wait behind "Show 50 more".
- A property page showed "Loading…" in six boxes a second after it had opened (P3). Every section is drawn inside the property's own answer, so its docs, share links, notes, tags, responsibility and relationships were not even asked for until that answer landed, and then all arrived at once. Those six need nothing but the property's id, which the address already carries, so they are asked for on the first render and land with the page. Nothing is cached that was not cached before. The records list still waits, and says why: it is read per client, and which client this property belongs to is only known once the property's answer names it.
- The phone header was five unlabelled glyphs (P3). The camera, the map pin, the avatar, the gear and the door were drawn without a word between them. Each already carried a name for a screen reader, but the map said "Property map" to a screen reader while showing "Map" to the eye; the link's own text is its name now, and it reads "Property map" both ways. The camera and the gear carry a tooltip, so a pointer learns what the screen reader was already told: the camera scans a code.
- A node's header ran the page off the right on a wide screen. The row of actions beside a node's name — edit, its own pages, duplicate, move, delete — was laid out on one line however many buttons were on it, so a device page at 1280px wide was 1369px of document with a horizontal scrollbar under everything. The row is now held to the width it has, and the buttons wrap the way they were always meant to. Found by the end-to-end suite.
- A property's page had two landmarks called "Floor plan". The Floor plan section and the drawing inside it both announced themselves under that one name, so a screen-reader user moving between landmarks was offered the same place twice with no way to tell which was which. The drawing is now the "Floor plan drawing", the way the rack's is the "Rack drawing". Found by the end-to-end suite's accessibility check.
- The inspector on a desktop editor covered the table beneath it (P1). The panel of settings beside the rack drawing, the floor plan, the ports faceplate and the topology map follows the drawing as you scroll — but it travelled past the foot of its own column and came to rest over the list across the bottom of the page, so the Move up and Move down buttons in a rack's contents table, and anything else under it, could not be clicked at all. It now stays in its column. Found by the end-to-end suite.
- A device could not be added with no signal. Adding a device while offline was refused with "Could not reach the server", with the form left open and nothing queued — on the one screen the app exists for, in the plant room where there is no signal. The queue builds the row it will show before the server answers, and that row has to be a whole device; it was built from the form's input alone, which carries neither the purchase fields (J1, optional on create) nor the stock place (J2, set through its own route), so it failed the device schema and the write was thrown away. Both are now filled in exactly as the Worker's own create fills them: blank purchase, no stock place. Devices added to a room, to a rack, and to a property's inventory are all affected. Found by the end-to-end suite.
- A room's change history spelled its kind of space in the stored word. Changing a room from an interior room to an external location was recorded as "Type: interior → external", the words the database keeps rather than the ones the form offers — while a property's type and a device's status had read as their own dropdowns said all along. It now reads "Type: Interior room → External location". Found by the end-to-end suite.
- A before-and-after pair on the photo timeline hid the captions that explain it (L2). The two compared figures read "Before · Server closet · Date unknown", with what the person had written about the photo left in the image's alt text alone — so a sighted reader saw where and when but never "Empty rack" against "Rack loaded", which is the whole point of setting the two side by side. The caption is now the first thing the figure says, before the place and the date, and the alt text is unchanged. Found by the end-to-end suite.
- The dashboard failed to load once any device sat at a half unit. A device mounted at a half unit (A12) makes the rack units in use a figure like 403.5, and the dashboard refused any figure that was not a whole number, so every estate with one such device saw "Could not load the overview" in place of its tiles and charts. The figure is now taken as it is, and the gauge reads "403.5 of 1200 rack units taken across 40 racks". Found by the end-to-end suite against a long-lived local database.
- The topology map was a white sheet in the dark theme (P8). The map drew its own background, boxes, room rings, and words in fixed light colours, so on a dark page it glared. It now follows the theme on screen while the SVG and PNG you download, and the map in a report, keep the light drawing they are meant to have. Speaker wire and RS-232 were drawn in colours too dark to see against a dark background; both are now readable on either. A pathway past its capacity is drawn in the same colour as anything else past its capacity.
- A patch panel's schedule read in the light theme's colours whatever theme you were in (P6). The dot beside each position's state was painted with a fixed colour rather than the app's own, so in the dark theme it did not match the faceplate it was describing. A marginal tester reading had the same fault, from a colour that was never defined.
Changed
- The topology controls are grouped rather than piled up (P1). "Pass through patch panels", "Colour by cable type", "Signal flow", "Layer 3 view" and "Reset layout" sat in one bar in five different widths over two rows, with the saved views and the filters above them, so which picture was on the screen was anyone's guess. The four pictures are now one segmented control — Topology · Wiring · Signal flow · Layer 3 — with exactly one on; the switches that change how a picture is drawn are a group of their own ("Map options"); and the saved views keep theirs. The wiring map is now called Wiring and the layer-3 map Layer 3.
- Every row list is a table on a desktop (P3). A room's items, racks, and runs, its sub-rooms, and a client's properties are drawn as a table when the screen is wide enough: a row per node, a column per fact — the device's type, model, and address; a rack's size and how full it is; a run's cable and length; a property's type, address, rooms, and devices. The name is the same link it always was. On a phone they stay the 44px tappable rows with the facts joined under the name. The journeys name a list rather than its layout, so the same test covers both shapes.
- A section is a card only when it groups more than one thing (P3). Every block on a page was drawn in the same box, so a heading with a single list under it read as heavily as a panel of facts and actions. A section whose body is one list or one table — a room's items, racks, runs and sub-rooms, a client's VLANs, WLANs and circuits, a property's VLANs and its site access — now keeps its heading and loses the frame. Everything that groups several things keeps its card, and a card nested inside a plain section keeps its own.
- A limited account's estate reads answer with its own client (K7). An account limited to one client now gets search results, the Resources tables, the global history, the dashboard and its "Needs attention" lists, the export, and the sync manifest for that client alone, where each was refused before. The accounts, webhooks, the audit log, key rotation, revoking every share, and creating a client stay refused.
Added
- The drawing pages are editors on a desktop (P1). The rack, the floor plan, the ports screen, and the topology map are now laid out the same way when there is room for it: the drawing in the middle, what you can place on the left, what you have selected on the right, and the page's own table or key along the bottom. Ctrl+H — or the "Hide panels" button above the board — folds the two side panels away so the drawing has the whole width, and the app remembers that on this device. On a phone the pages are the single column they have always been.
- Help is behind an icon, not in front of the drawing (P3). The paragraph that sat between the topology heading and the map, the note under its filters, the rack list's keyboard hint, and the explanation of a patch panel's two sides now open from a help icon beside what they explain. The words are still there — the icon names them and the panel closes on Escape — they just no longer cost a screen's height on every visit. On a desktop the rack's mounted devices are a table with type, model, and location as columns; on a phone they stay tappable rows.
- A row list tells you about a device or a run on hover (P5). Hovering or tabbing to a device or a cable-run row in a room's lists shows the same card the map, the rack, the plan, and the ports screen show. A room or rack row shows nothing new, because the card has nothing to say about one.
- The floor plan tells you about a pin without opening it (P5). Hovering a device or run marker, or a dashed run line, on a property's floor plan shows the same card the map, the rack, and the ports screen show. A marker that stands for a device or a run is also reachable from the keyboard now: Tab to it to read the card, Enter or Space to select it into the inspector. Room outlines, walls, and markups are unchanged.
- The ports faceplate uses the one hover card (P5). Each port's card was the faceplate's own markup with its own styles. It is now the same card the topology map and the rack drawing show, built from the same model: the port's state, its full path, what is on the far end, its connector, speed, duplex, VLAN with its name, the PoE it supplies, and what it has carried over time. It still appears on hover or keyboard focus, exactly where it did.
- The rack tells you about a device without leaving the rack (P5). Hovering or tabbing to a device on the rack drawing shows the same card the topology map shows: where it is, its address, its make and model, its serial, and how many of its ports are in use.
- Empty sections say what to do, in one line (P7). Thirty-three empty states were a sentence about what the feature is followed by a sentence about why it matters. Each is now one line naming the thing the section's own add button does — "No VLANs yet — add one per VLAN this client carries" — so there is one place to look and one button to press. The few with nothing to press point at where the action is instead.
- One way of showing a state (P6). A port's, a panel position's, a device's, a run's, a tester's verdict, how full something is, and what a change waiting to sync is doing are now all drawn the same way: a swatch in the state's colour and its own shape — filled, outlined, dashed, or hatched — with the state's word beside it. The shape means a state still reads printed in grey, or to someone who cannot separate the colours. The four keys that explain them are one component, so a colour means the same thing on the floor plan, the racks list, the ports screen, and a run's readings.
- A glyph of its own for every kind of device (P4). Ten types were drawn with five shared glyphs: a firewall and an intrusion panel were both a shield, a PDU and a wall outlet both a plug, a desktop and a screen both a monitor, an amplifier and an AV receiver both an amplifier, and a rack patch panel and a wall patch panel both a panel. Each now has its own, so a rack drawing and a map say what a box is before you read its label. A wall outlet is drawn the same way on the floor plan as in a list. Where a model has a product picture, the room's device list and the rack's mounted list now wear it too, as the map, the rack blocks, and the inventory already did.
- An empty rack is drawn too (P2). A rack with nothing mounted showed a "Nothing mounted yet" box where the picture of the rack should be, so there was nothing to drag a device onto. The elevation is now drawn from the first visit — the frame, the U numbers, the free space, and the palette — and the note about it being empty sits with the list below.
- Stock and movement (J2, migration 0085). The account now keeps stock places — the warehouse, the vans, an office shelf — and a device records which one it is physically sitting in while it waits to be installed. A stock place is not a container of the tree: the device keeps the property it is destined for, since a property's inventory already means "not placed at this property yet", so the journey warehouse → van → site → room is one place, then the next, then the place cleared as soon as a room or a rack is set. Every movement is a
stock_movedchange on the device naming the place it left and the place it reached, so the movement log is the history the device already has rather than a second log. A new Stock page lists each place with what is in it — every device linking to its own page with its client and property named — and the device page gained a "Stock" fact and a "Move to stock" action. Only an admin or the owner keeps the list of places; everyone reads it, an account limited to one client included, which sees the places but only its own client's devices among them and cannot change the list. Removing a place takes the devices in it out of stock rather than deleting them, and a device restored from the recycle bin comes back into the place it was in, or out of stock when that place has since gone. The places travel in the account export and are online-only; they are not in the search index. - A signal-flow diagram for AV work (H3). "Signal flow" on the topology map draws every link by the kind of signal it carries rather than by the cable it is made of: video (HDMI, SDI), audio (analogue audio, speaker wire), control (RS-232, IR, relay), and network, each with its own colour and its own dash so the drawing still reads printed in grey, with a key naming the families actually drawn. The same devices, rooms, and arrangement as the other maps, and it saves as SVG, PNG, and draw.io like them. A link medium that has not been put in a signal family yet is drawn plainly and listed as "Other" rather than shown as something it is not.
- A product picture on a device model (H5). A preset can now carry a photo of the model it describes: add one on the Presets page and every device of that make and model wears it instead of its line icon — on the topology map (and in the SVG and PNG you download from it), on its block in the rack, on the preset chips in the rack palette, in the rack's device list, in a property's inventory, and in the list of what sits on a shelf. A device whose model has no picture keeps its line icon, so nothing changes until a picture is added. Only whoever may change a preset may add, replace, or remove its picture, and the file is checked to be a real image before a byte of it is kept.
- Rooms drawn round their devices on the map (H4). The topology map now rings the devices of each room with a named boundary, so the picture says where a device is as well as what it links to, and an unmanaged device hanging off another device's port is ringed together with the device it hangs off. The boundaries are drawn behind everything, and they are in the SVG and PNG downloads, the draw.io file (as boxes behind the shapes, not containers, so a device still drags on its own), and the report's map. The layer-3 view keeps its subnet bands instead.
- Arrange the topology map by hand (H2). Drag a device on the topology map and it stays where you put it. The arrangement belongs to the saved view, so "Save as view" keeps it and choosing that view brings it back, and every way the map leaves the page — the SVG and PNG downloads, the draw.io file, and the report's inline map — draws it as arranged. A pill beside the filters says how many devices were placed by hand, and "Reset layout" puts them all back where the automatic layout drew them. The layer-3 view stays automatic: it draws each device in the subnet band its address falls in.
- Port duplex (C2). A port now records its duplex — auto, full, or half — beside its speed: a column on the ports table, a "Set duplex" choice in the range bulk edit, a word in the faceplate's spoken label and hover card, and a line in the change history when it changes. Blank by default, so every port recorded before it reads exactly as it did. This closes C2, whose duplex was the one field left unmodelled.
- Service history per device (J4, migration 0084). A device now carries the visits, inspections, repairs, and deficiencies recorded against it, each with the day the work was done — not the day it was typed — who did it, and what was found. A deficiency starts open and is the one kind that counts: the dashboard's "Needs attention" grows a row per device carrying one, listing what was found and when, and the device page has a Close button beside it. The report prints a device's history with its links, and leaves it out of a client's copy the way it leaves out the notes log. The entries go with the device, appear in the account export, and are placed under their client for a limited account.
- What a device cost and how long it is meant to work (J1, migration 0083). A device now records the day it went into service and the day it came out, what it cost, the purchase order it was bought on, and the vendor as one of the client's own contacts. The contact sits beside the free-text supplier rather than replacing it, so every device bought before this reads unchanged and a supplier nobody has made a contact of is still written down; removing the contact loosens the link rather than touching the device, and moving the device to another client clears it, with the move's preview counting it first. The cost is kept in whole cents, so a sum of costs cannot drift. The invoice needed nothing new — a file of the
invoicekind on the device — and the device page and the report now name it beside what the device cost, with the bytes where every file lives. - Security and life-safety devices, and zones (J7, migration 0082). Six more device types — access control panel, reader, door strike or lock, intrusion panel, fire panel, horn/strobe — each with the fields an installer writes on the drawing: doors driven, reader kind and protocol, the door's hardware and whether it fails safe or secure, zones served, the communicator and who monitors it, candela and notification circuit. Every one of them names the zone it belongs to, and a zone is now a record of the client: a named area with no expiry, so neither "Needs attention" list counts it. The keypad and sensor this list also asks for came with the AV types and are extended here rather than added a second time.
- AV and control links (J6, migrations 0080 and 0081). A link can now be HDMI, SDI, analogue audio, speaker wire, RS-232, IR, or a relay closure, between amplifiers, receivers, projectors, speakers, keypads, thermostats, sensors, screens, controllers, and the computers that feed them. An AV run keeps only the reach rule, in its weakest form — both devices at one property, because the wire has to get there — and takes no port, termination, fiber mode, or cable drop; it records its own cable instead, which is how a speaker or HDMI run is written down. Two runs between the same pair are allowed, unlike a mesh or VPN pair. The map draws each medium in its own colour and dash with a legend key, and the report names the medium on the device's link line. The signal-flow diagram mode (H3) is not part of this and stays open.
- Media converters and passive splitters in the path (J5). Two device types that mate two connector kinds: port 1 is the A side (a converter's fiber jack, a splitter's input) and every higher port a leg of the B side, each side declaring the connector it presents. What plugs into a side must mate with that side, so a fiber-to-copper pairing is allowed exactly where the conversion happens and refused everywhere else; a converter that has declared neither side is left unjudged rather than guessed at, which is how every modem/ONT recorded before this keeps reading. A trace now carries a run through one — the fiber run into the A side comes out of the first patched leg of the B side and continues to the device behind it — and a trace started at the converter reads both ways, as it does from a panel.
- The maker read off a MAC address (J9).
shared/oui.tscarries 241 vendor prefixes copied from the IEEE MA-L registry — the network, AV, security, print, and endpoint makers a site is built from — and the device form names the maker under the MAC field ("Looks like Ubiquiti") as soon as three octets are typed or scanned. It stays a hint: nothing is written into Make that the person did not type. The device import does fill it in, but only where the file's own make column is blank, and says on the row that the value came from the address. A randomised (locally administered) address and a prefix the table does not carry are named as nobody rather than guessed at. - How an access point is mounted (J3). A Wi-Fi device now records the height it hangs at with the unit the site is measured in, the antenna it uses, and which way that antenna faces, beside the mount location it already had. The device page and the report print the five together as one "Mounting" line — "Above reception · 3 m · Omni · Down (ceiling)" — rather than a fact per key, and the Word report gives the same line per device instead of a column blank on everything that has no radio. A height saved without its unit says the unit is missing rather than assuming feet or metres. Per-radio transmit power was already a column of the radios table and stays there.
- Laptops, screens, and 3D printers as device types (J8). Three more endpoint types in the registry, each with its own fields, icon, generated name, and short-ID prefix: a laptop carries the desktop's facts (the shape of the box is now the type, so it takes no form factor of its own), a screen its diagonal, resolution, mount, and inputs, and a 3D printer its process, build volume, materials, and queue. A desktop stays a
computer, as the roadmap asks. The device import tells them apart by the words a row carries — "3D printer" before "printer", a MacBook or a ThinkPad as a laptop, a display or signage panel as a screen — and a 3D printer counts among the devices expected to have an IP address. - A port's right-click menu, and Backspace to unlink (A4). On the ports page a right-click (or the menu key) on a port offers Connect, which opens the Link sheet for it, and Disconnect, which asks to unlink what holds it the way the table's Unlink button does; Backspace or Delete on a focused held port asks the same. Nothing is removed without the confirm.
- A recycle bin, and edits that can be reverted (M3). Deleting a client, property, room, rack, device, or cable run no longer loses it: everything the delete takes — the whole subtree, its links, notes, contacts, pins, and the photos and files it archives — is kept for 30 days in the recycle bin (Settings → Recycle bin), where it can be put back where it was in one step, or removed for good by a manager. A restore whose parent is gone says what to restore first; a row whose far end is gone for good is left out and counted. Every edit's history row now keeps the values it replaced, and a Revert button on the row puts them back through the same rules an edit is held to, recorded as its own change.
- Download the topology as a draw.io file (H1). Beside SVG and PNG, the topology page saves the laid-out map as a draw.io (diagrams.net) file: every device, ISP, and spare run as an editable shape where the map drew it, every link as a connector in the map's colour and dash, and on the layer-3 map every subnet band as a swimlane. draw.io opens it as is and can save it on as Visio.
- Move a room to another property, or a property to another client (E3). A "Move" action on the room and property pages previews the move before it happens: the links with one end left behind that the rules no longer allow, a short ID already used at the destination with a free one offered, cable IDs the destination property already uses with the IDs they will get, the VLANs the moved devices name that the destination lacks with a choice per VLAN (copy it, keep the bare tag, or clear it), and what else the boundary clears: run ends and port parents pointing across it, plan markers and room outlines on the old plans, runs in the old property's pathways, and across clients the power feeds, relationships, and responsible contacts with a foot on each side. The move is one atomic batch and one history row, filed under the new path and the old; the moved subtree's links, feeds, port groups, photos, records, articles, service requests, and saved views follow it to the new client; its tags are re-pointed at same-named tags there, the wireless networks its devices broadcast or join at same-SSID networks (copied when the client has none), and the circuits its WAN links name are copied.
- A layer-3 view on the topology (G7). A second mode on the map draws the subnets as swimlanes — one band per VLAN that records a subnet, in tag order — with every device placed in each band its addresses fall in, a route line between the two copies of a device that bridges two subnets, and a last band naming the addressed devices no subnet holds. It is the same drawing the page downloads and the report prints, which now carries the layer-3 map as its own section after the wiring diagram.
- ISP circuits as records of the client (G6, migration 0059). A
circuitstable records each service once — the provider, their circuit reference, the bandwidth each way, how it hands off, where each end lands, the support line, the account, and the contract dates — and a gateway's WAN link points at one by id while keeping its own address fields, so the provider and the account are no longer retyped on every link. A contract end feeds the same "Needs attention" lists a domain or a licence does, expiring within 90 days and already expired. Circuits are in the change history, the JSON export, per-client scope, and the client's report. - A subnet's addresses, its free space, and its DHCP range (G3, G4, G5, migration 0059). Every VLAN now has an address page: one block per family it records a subnet for, with the addresses already spoken for — the gateway, every device's address, every WAN link's, and the reservations — how much of the block is taken, and what is left. Nothing is enumerated: an IPv6 /64 is counted rather than listed, and every figure is exact. An address held by two things names both; an address inside the DHCP range is marked; an address that belongs to none of the VLAN's subnets is listed under "Outside every subnet" and raised on the dashboard's "Needs attention" beside the duplicate-IP row.
ip_reservationsrecords what cannot be derived — an address held for a device that is not installed yet, one a vendor pinned, one a router hands to a MAC (a DHCP reservation is that row with its MAC filled in). The DHCP range moved off the router onto the subnet it belongs to: the migration places each gateway's text on the one subnet it can mean and keeps the words as a note on the device either way. - WLANs as records of the client (G2, migration 0058). A
wlanstable per client records each wireless network once — the SSID, the band, how it is secured, the VLAN it lands on, whether it is hidden, and the notes beside it — and an access point now broadcasts those rows by reference instead of carrying a free-text SSID list. The migration turns every SSID typed on a device into a row of its client's table (one row per name, whatever case it was typed in) and replaces the text with the ids of the rows it became; a device model preset, which is a model rather than a site, loses its SSID cell. A camera, laptop, phone, or printer has a wireless port that joins one WLAN, and the topology draws it under an access point broadcasting that network, labelled with the SSID. Removing a WLAN first takes it off every device that named it, so nothing points at a network that is gone. A device import turns a scanned SSID into a WLAN of that client rather than a copy of the name. WLANs are in the change history, the search, the Resources tables, the JSON export, per-client scope, and the client's report. - VLANs defined once for a client (G1, migration 0057). A VLAN row now belongs to a client and may name one of its properties: a row with a property is that property's own, exactly as before, and a row without one is shared by every property of the client and offered on every port of its devices. Each row gains notes beside its purpose, and the columns the address work fills in (an IPv6 subnet and gateway, and the two ends of the subnet's DHCP range). A tag stays unique within its scope — a property cannot hold it twice, and cannot hold one its client already shares — and the refusal says which. Every VLAN has a page of its own listing every port across the client that carries the tag, as its native VLAN, one of a trunk's tagged VLANs, or a WAN link's. The client page gained a VLANs section; the property page's table now shows each row's scope; the history, search, the Resources table, the JSON export, per-client scope, and the report follow the same rows.
- Report branding and sections (N2). Settings → Report branding takes the company name, a logo, a cover line, and a footer line that every report carries, on the page, in the PDF, and in the Word document, and which sections a report includes by default. A download picks its own sections from the report card and the choice stays on the device; a share link and the portal keep leaving out what they always have. An unscoped admin or the owner changes the settings; anyone signed in reads them. The logo is checked as an image like a photo and served from the photos bucket.
- Capacity colouring (D11). One key — empty, room to spare, filling up, nearly full, over capacity — colours both the floor plan's rooms (by device count or port fullness) and the racks list's new "In use" and "PoE" columns, so a full rack and a full room read the same wherever they appear. A rack whose height was never set, or a room whose devices declare no ports, shows an em dash and takes no colour rather than being drawn as empty.
- Cable lengths offered from the plan (D4). A run whose two devices are pinned on a scaled floor is offered the straight-line length between them, on its own page and beside the empty length field in its form. Nothing is written until someone accepts it, and an accepted length gets a history row that says it came from the plan rather than from a tape.
- Floors placed on the map (D10, migration 0069). Tap two places on a floor plan, give each its coordinates, and the whole floor is placed: the property's map picture can then draw the devices pinned on it as markers beside the property's own pin. It is optional throughout — a floor with no alignment draws exactly as it did, and the toggle only appears once one has been placed.
- Plan revisions (D9, migration 0068). A plan image takes a new revision: the sheet that arrives becomes the current one, every pin is carried onto it at the same position, any floor that traced the old sheet follows to the new one, and the sheets it replaced stay listed underneath and still open. Only a plan takes a revision, a replaced sheet cannot take a second one, and deleting an old revision shortens the chain rather than touching what came after it.
- Markups and punch pins on the plan (D8, migration 0067). Freehand lines, arrows and revision clouds can be drawn on a floor beside the notes; each one says something about the plan, names no node, and prints in a layer group of its own with a key. A punch pin records a task or a fault where it is, may be tied to a step of one of the property's checklists, and takes a photo that files on the room it sits in — or on the property when it sits in none. Deleting a checklist unties its pins rather than erasing them. HTML print cannot make true PDF optional-content layers; the markups print as named, visually distinct groups instead.
- The site as a floor (D12, migration 0066). "Add site" on a property's floor strip raises one floor of kind
site: its underlay is the aerial or campus picture, and it then takes the same scale, outlines, markers, connection lines, and printed drawing every storey takes, so an outdoor camera on a pole is drawn, linked, and reported like anything indoors. - Floors are a level, and a room sits on one (D1, migration 0065). A room points at one floor of its own property instead of carrying a free-text storey label. The migration raises a floor for every distinct storey label a property used and had no floor for, matching case-insensitively, and links the rooms to it; a blank or over-long label is left unfiled and its text is kept, so nothing anyone typed is lost. The room form and the spreadsheet import choose a floor, the property page and the tree list rooms under one heading per floor, and deleting a floor unfiles its rooms rather than deleting them.
- Import a property's rooms, racks, cable runs, and links from a spreadsheet (O1). An "Import" page on every property takes one CSV per kind, maps its columns to the fields the form has (headers named like the fields map themselves), and sends the rows. Names in the file are matched to what the property already holds — a room, a device, a rack — and a name two nodes share is refused rather than guessed; choices are read by their label or key, numbers as numbers; every row goes through the form's own rules and gets its history row, a row that cannot be placed is skipped with the reason, and the property's history sums the import up.
- Overlapping rooms ask which, and a redrawn room re-checks its devices (D3). A device marker dropped or dragged to where two room outlines overlap asks which room its device files in, topmost first; the Worker refuses an answer the marker does not sit in. Redrawing or relinking a room outline re-checks every marker on the floor and moves devices into the rooms their markers now sit in, each with a history row, and says how many moved.
- Presets in the rack palette, and a click on the free space (A3). The saved device presets sit in the rack's palette beside the bare types; drag or tap one and the device is made from the preset's make, model, height, details, and face layout. The free space at the bottom of the elevation is a button that opens the same choices in a sheet.
- Pick and order the columns of any table download (N4). Every table download — the equipment list, the cable and patch schedules, the labels, and the Resources exports, as CSV or Excel — takes
?columns=a,b,cnaming header cells and carries only those columns in that order; a name the table lacks is refused by name rather than dropped. A column picker beside each table's links ticks and orders them, and the choice is kept on the device per table. The Resources exports carry the columns the table shows once some are hidden. - The client portal (M2, migration 0063). A client can be given a sign-in of its own: a
clientaccount, limited to that client on the accounts page, which gets the portal instead of the app. The portal shows the client's properties and what is installed in each room and rack, the people the team has on file, its checklists, its photos, and the report as a page, a PDF, or a Word document in the client's copy; the person keeps their own profile. The one thing a client account can do is raise a service request — what is needed, about which property, the details — which the team answers, closes, and reopens from the client page (or writes down there after a phone call), each step a history row. On the Worker the portal is an allowlist over the per-client scope: the record's reads, that one write, the account's own routes, and nothing else — not notes, files, access facts, secrets, records, history, the estate, or the accounts. A database trigger refuses a portal account with no client. - Word and Excel exports (N3). The equipment list of a client or property, and a property's cable schedule, patch schedule, and labels, download as
.xlsxbeside their CSV, and the Resources tables gain "Export all as Excel". A workbook holds the same rows as the CSV with text kept as text whatever it starts with (an Excel inline string is never a formula), numbers as numbers, and a bold, frozen header row with a filter. The report downloads as a Word document too ("Download Word" beside the PDF,GET …/report.docx): the same facts as the page, as headings, tables, and lists in Word's own named styles, so the navigation pane and a table of contents work and the document can be edited on. What the page only draws — the topology and wiring maps, the floor plans, the patch schedule — is named in a line, and photos are counted rather than embedded. The files are written in the Worker with no new dependency and no renderer: a stored-ZIP writer and the Office Open XML parts by hand. - Work orders with material lists (N5, migration 0062). A planned change on a property — fit these devices, pull these runs, walk this checklist — as one record with a state, a day, whoever is doing it, notes, and lines that each name a device, a cable run, or a checklist already on the map. The material list is worked out from the lines: devices by make and model within a type, runs by cable type with their lengths added up. Lines are ticked from the order's page and added from the property's own devices, runs, and checklists; Complete marks every line done and installs the planned devices as of today, with a history row on each device and on the property. Work orders are in the export and the internal report, and a limited account reaches only its own client's.
- Import from IT Glue and Hudu (O3). The device importer's picker offers IT Glue configurations (the CSV export or import template, or the API's JSON) and Hudu assets (an asset-layout CSV export, or the API's JSON with the layout's fields lifted into columns). Each goes through the same parse, preset mapping, review, and skip-with-reason path as a Fing or Meraki file; the tools' free-text kinds ("Managed Switch", "Wireless Access Point", "Workstation") become device types through the word table, and the organisation, asset tag, status, operating system, location, company, and archive state travel as extras.
- A preset library file, out and in (O2). The presets page downloads every preset as one JSON file — the inputs they were saved from, nothing that names an account — and imports such a file from another install, or a NetBox device-type file (manufacturer, model, height, interfaces) once the device type is chosen on the sheet, since NetBox carries none. The sheet shows what the file holds before anything is sent, and what was made and what was skipped (a name already in use, details that do not fit the type) after, each by name with its reason. A YAML file is refused with the reason; convert it to JSON first.
- Per-client scopes on accounts (M1). The owner or an admin can limit any non-owner account to one client from the accounts page: "Limited to" on the edit sheet, and a badge in the list. A limited account reaches that client's nodes and records and nothing else — not the other clients, the estate tables, search, export, the global history, the dashboard, or the accounts page — whatever its role; the Worker enforces it on every request (see the per-client scope entries under Security). The scope names a real client or the edit is refused, and lifting it is the same edit.
- Separate short IDs and naming schemes (B5, B6, E1). All six tree node kinds now have a short ID alongside the human name. IDs generate atomically within their parent, with configurable per-type prefixes, separator, digit padding and uppercase enforcement. Migration 0051 assigns existing nodes IDs without changing names, UUID links or cable IDs. The settings page includes naming guidance; shared ID fields, tree/header chips, readable paths through holders and ports, offline bundles, search, reports and exports carry the identifiers. Colliding edits or moves fail explicitly without changing the node. Shared settings writes require an unscoped admin or owner and an online connection. New offline nodes receive complete readable details from validated cached parents; ID/name edits also update cached descendant breadcrumbs. Offline list edits retain the full prior row and its version token, replace only their target, and leave unrelated rows intact. The session response exposes its actual client scope; missing scope in an older response grants no naming-edit permission. Invalid settings drafts are checked before formatting a live example, including out-of-range digit counts. The generic Resources tables, filters and CSV exports carry node short IDs and the device ID for labelled port rows.
- UUID create replay ownership. Create retries now compare explicit stored parent, client, node type and author scope before returning an existing row. Submitting another client's UUID through an allowed URL returns a conflict, and client-limited accounts cannot create or replay global clients. Inventory creates reuse the canonical device save path. Matching rack-device retries preserve generated names and bypass checks that apply only to a new placement; fresh devices still honor rack reservations. Checklist retries normalize the stored template name and keep steps bound to their original checklist. This governs the optional
ida create carries in its body; theIdempotency-Keyheader is the separate mechanism recorded under Security, and both apply to a retried create. - Custom asset types and property records (B8). Managers build typed field layouts for devices and non-device property records: text, number, date, choice, HTTP(S) URL, and boolean. The canonical item and record writers validate values against the selected layout; required fields and unknown keys are enforced. Layouts can be edited freely before assignment; later edits preserve existing field contracts and may add optional fields, change labels, relax required flags, or extend choices. Archive stops new assignments without removing existing values. Property records reuse the existing records table, history, forms, and search, support an optional expiry, and participate in private reports, export, and offline editing.
- Independent template copies (B7). Preset and layout editors now explain that changes affect future devices only. A newly created device retains its own front/back layout snapshot, drawn by the existing rack renderer and the report. Editing or deleting the source preset leaves deployed devices and their connections unchanged. Devices without a layout retain their generated faces.
- Tags, relationships and responsibility (B9, B10, E2). Every node has coloured tags and a responsible contact or account. Tags and responsibility are searchable and filterable in node lists, inventory and Resources. Typed relationships connect nodes, records, articles, files and credential metadata in both directions; authorized unscoped accounts can link across clients. Metadata references never reveal credentials. Deletion and file archival remove dangling relationships transactionally.
- Location cards (E4). Client and property lists offer a card view with addresses, responsible people, floor/room/device counts, tags and a notes indicator; the existing table/list mode remains available. Internal reports and full-data exports include annotation metadata with their existing audience and secret-exclusion boundaries.
- A camera on a device pin on the plan (D13). Selecting a pin on a room's floor plan, or a linked device marker on a property's floor-plan editor, now offers "Photo": the phone's camera opens and the picture is filed on the device the pin points at, not on the room and not on the plan. It takes the gallery's own path — the same browser-side resize, the same EXIF date read before the re-encode strips it, the same once-a-session position — so a photo taken from a pin is the same as one added on the device's page, is queued the same way with no signal, and can be captioned, tagged, or annotated afterwards in the viewer.
- Video and audio memos (L5). A short clip or a spoken note is now a file kind on any node, beside the manuals and the invoices. Each memo type is decided from its own magic bytes — RIFF/WAVE, an ID3 tag or an MPEG frame, an ISO base-media brand (which tells an M4A from an iPhone's QuickTime recording from an MP4), or EBML — so a memo is stored under the type it actually is, exactly as a photo is. Video and audio carry their own size ceilings (
VIDEO_MAX_BYTES,AUDIO_MAX_BYTES), and a kind whose bytes do not support it is refused in both directions: a PDF cannot be filed as a video memo, and a recording cannot be filed as a manual to borrow the wrong ceiling. The browser reads the run time before uploading, so the list says "Video memo · 0:42" (or "length unknown", never an invented figure), and the memo plays where it is listed with the browser's own controls, fetching nothing until someone presses play. Memo and file uploads are queued offline the way photos are. Migration 0061 relaxesattachments.kindcolumn-wise — the table is never dropped, so the floor-plan pins that reference it are untouched — and addsduration_seconds. - A photo gallery on the share link (L4). A share link now opens a read-only gallery of the property's photos at
/share/<secret>/photos, beside the report it already opened; the report links to it and the gallery links back. It is the same link, not a second one: one secret, redeemed through one shared check, with the same expiry, the same revocation, the same per-address guessing and viewing limits, the same view count, and the same document policy — nothing cached, nothing indexed, no referrer, and a page that can fetch nothing because every picture is a data URI. It carries nothing the client report does not (no notes log, no access facts, no Files lines, no checklists, no archived photos), and a gallery view is audited under its ownshare_gallery_viewedaction so it is told apart from a report view in the log. - Room and property QR labels (L3). The labels page now prints a sticker for the property itself and one per room, beside the racks, devices, panels, and runs it already printed. Rooms are selected by default, so a technician can scan the room's sticker by its door and then the device's, and file a photo or a note where they are standing. The property's own sticker is asked for rather than printed by default (one door needs one label, not a sheet) and is named by its address; a room says its floor when it has one, and a sub-room's label says which room it is inside. The labels CSV carries the same rows, and the header scan already opened a room or property code.
- A photo timeline per property (L2). A property's "Photo timeline" page (
/properties/:id/photos) lists every current photo under it in the order it was taken — the date the camera recorded, falling back to when it was filed for a file that carried none — grouped under a day heading, narrowed to one room or one tag, and with any two photos set side by side as a before and an after. Which is the before is decided by when they were taken, never by which was tapped first. The placed photos (L1) are drawn on the property's map with a numbered legend, and a photo opens in the same viewer the galleries use, so a caption is edited or a photo archived from here exactly as from its node. The page says how many older photos it left out pastPHOTO_TIMELINE_LIMITrather than quietly truncating. - Where a photo was taken (L1). A photo can now carry the position the browser reported at capture, beside the date the camera recorded. The app asks the Geolocation API once per session, with a hard timeout; a refusal, a browser without the API, and a fix that does not arrive all mean "no position" and the upload goes ahead regardless — the upload panel says which happened rather than leaving it blank. The reading is shown on the thumbnail strip and in the photo viewer with its accuracy (
±12 m) and links into the phone's own maps app, named in the change history, printed under the photo in the report, and carried in the JSON export. A property's map picture can draw its placed photos as numbered pins beside the property's own (GET /api/properties/:id/map?photos=1). Migration 0060 addslatitude,longitude, andaccuracy_mtophotos, each bounded by its own CHECK, plus an index on the time order the timeline reads; every photo taken before this reads unchanged with no position. - Multi-position rear ports and breakout cables (I2). A panel (or any ported device) port can be given several positions — an MPO/breakout port fanning to N legs — recorded as
positionson the port row. A link end may land on one leg (positionon the connection endpoint; migration 0049 addsa_position/b_positionand widens the per-port unique indexes so one port carries one link per leg while a plain port still allows one). The rules patch a breakout a leg at a time (choose a free leg, refuse a taken one, refuse a leg on a plain port), and the trace follows a run out of the matching leg — rear position K pairs with front position K. The faceplate names a port's legs, and the patch schedule lists a row per leg. A link recorded before this carries a null position (the whole port) and reads unchanged. - Modular patch panels and cassettes (I3). A patch panel can be built of cassettes — swappable modules, each with a name, port count, and connector — stored in the panel's
details.cassettes. The panel's capacity is its cassettes' ports together; cassettes are added, named, and removed on the ports screen (CassetteEditor), and the faceplate and the schedule name each port's cassette. A flat panel keeps its port count and reads unchanged. - Patch-panel numbering and layout (I4). A panel records how it numbers its ports — a start number, a step, whether it numbers within each cassette, and whether the rear mirrors the front. The faceplate square and its spoken label, the on-screen patch schedule, the report, and the CSV all follow the panel's own numbering, and per-cassette numbering labels a port under its cassette.
- Cable IDs defined once, everywhere (I7). Every run now carries a first-class cable ID that is unique within its property. It is generated from a running
CBL-0001sequence when left blank, or a technician can type their own (a jack number, a drawing reference); a typed ID that clashes with another run in the property is refused. The ID shows on the run page and header, the link, the panel schedule (as data the schedule reads), the report's drops table and its new "Cable IDs & labels" section, and the labels. Migration 0050 denormalisesproperty_idontocable_dropsand adds a partial unique index over(property_id, cable_id); existing runs are numbered per property in creation order. Generation is pure inshared/cable-id.ts. - Wire labels for both ends (I8). A run can be printed as two wire flags — a near-end and a far-end flag — each carrying the cable ID, a QR to the run, and where the other end lands. The labels page gains a "Wire flags (both ends)" option that lays them out on the same Avery sheet or 24 mm tape as the QR labels, and the report's cabling section lists the same near/far label text. Built in
shared/labels.ts(wireLabelRows). - Tester readings with a history per run (I9). A run keeps many tester readings (
cable_readings, migration 0050): the wiremap verdict, the measured length, and — for a certification tester — the NEXT margin and insertion loss in dB, with the tester and the day. The run page records and lists them; the latest reading rolls up into the run's own tested-on, result, and measured length, so the dashboard's tested and failed counts keep working. A new "Runs marginal on their last test" attention row flags an among-limits result. - Pathways (I10). Conduits, trays, J-hooks, sleeves, and risers are now records that runs pass through, per property, each with a capacity of how many cables it should hold (
pathwaysand thepathway_runsmembership, migration 0050). A property's pathways page lists them with their fill against capacity and the runs threaded through each, and lets a run be added or removed. A pathway over its capacity is flagged on the page, on the run, and as a new "Pathways over capacity" attention row on the dashboard. - Drag a device from the palette into the rack (A3). A device palette sits above the rack elevation with a drag chip for every item type (devices and rack-only fittings alike). Dragging a chip onto the elevation raises a new device of that type — through the same create path the "Add item" form uses, named for its type and the next free number — and mounts it at the drop position: before the block it landed on, pushing that block down, or at the bottom on a tap or keyboard press. A drop on a reserved span offers the same "Place anyway" override the add form uses. The "Add item" form, its presets, and drag-to-reorder are unchanged; the drop position maths (
insertedOrder) and the payload (droppedItemInput) are pure and tested. Mouse and touch. - Patch by dragging one port onto another (A4). On the ports faceplate each port is a drag handle, and the devices within a patch cord's reach (same rack or room, with numbered ports) are drawn beneath as drop-target faceplates. Dragging a port onto another port makes the link through the existing create-connection path, reusing the shared reach and validation rules; an illegal target (the same device, a port in use, connectors that do not mate, out of reach) is refused with the rule's own reason, and a pair only a cable run apart — or a fiber link — is left to the Link sheet, which stays for the keyboard and precision path. The drawn port states update on success. The reach-and-link maths is pure in
src/components/ports/drag-patch.ts. - Connection colours (A7). A link now shows its own colour everywhere it is drawn: a colour picker in the link form (already present), a swatch (
src/components/ColorSwatch.tsx, from the one shared palette inshared/cable.ts) beside every link row and on the port card, in the report, and as the stroke on the wiring map (shared/topology.ts) and the floor plan (shared/plan-lines.ts) — a link with its own colour overrides the cable-type colour there.viewConnectionresolves the colour, so a link carried over a run shows the run's. No migration:connections.colorexists from 0045. - Cable-length warnings (A13). Each cable type carries a recommended maximum length (
CABLE_MAX_LENGTHinshared/cable.ts— 100 m for twisted-pair copper, graded distances for fibre, none where reach varies too much to flag). A run longer than its type's ceiling is flagged on the link and counted as a new additive "Runs longer than the cable allows" row on the dashboard and the attention page; every existing gap and its tests are kept. The check (isOverLength) is unit-aware, so a length in feet is compared correctly. - Link aggregation and mirroring (C5, migration 0048). A device can carry LAG and mirror groups: a LAG bonds several ports into one logical link, a mirror copies one or more source ports to a monitor port. A
port_groupstable holds them (shared/schemas/port-group.ts); the shared rules and words live inshared/port-groups.ts; the device page manages them (src/components/PortGroups.tsx); and the trace and topology label a member link ("LAG ag0") and a mirror. The groups ride on the client tree. - Child devices off a port (C10, migration 0048). A device can hang off another device's port — an IP phone off a switch port, a PC behind that phone, a module in a chassis slot — recorded as
port_parent_item_idandport_parent_portonitems, independent of where the device is placed. The client index, the cable trace, and the topology walk through it (drawn as an "off port N" branch); the port card lists the child; andsrc/components/PortParent.tsxsets and clears the relationship. Removing the parent detaches the child (ON DELETE SET NULL) rather than deleting it. - Half-unit rack heights (A12, migration 0047). A mounted device's height may now be a half unit — 0.5U, 1U, 1.5U, 2U … — so half-height gear sits on a half-unit boundary. The height field offers half steps; the elevation draws a block on a half-unit grid (two grid rows per rack unit), and the U scale still reads whole units, labelling each block by the whole U it sits in. Overflow and free-space maths carry the halves; every existing whole-U rack is drawn exactly as before. Migration 0047 relaxes the
items.unitsanditem_presets.unitsCHECK column-wise (add, copy, drop, rename — never a table rebuild, so no cascade fires and no row is lost), proven inscripts/migrations.test.ts. - Port ranges and bulk edit (C8). On the ports screen a range of ports ("13–24") can be selected — Shift-click two ports on the faceplate, or set the first and last in the edit table — and a small form sets the VLAN, PoE mode, speed, or label across all of them in one action. The bulk edit is written into the same per-port draft a single edit uses, so Save ports and Discard changes are unchanged and every single-port path keeps working. The range maths and the field-by-field apply are pure in
src/components/ports/port-bulk.ts. - Connections drawn on the floor plan (D5). The property floor plan now draws the cabling between pinned devices: a dashed run for every cable drop whose two devices carry a marker on the floor, and a solid link for every direct patch/user cable (a wired connection carrying no run of its own), each coloured by cable type from the one wiring key (
shared/wiring-colors.ts) with a listed cable key beneath the canvas. Selecting a device overlays its whole connection chain and fades the rest. The lines are derived — never duplicated — from the connection/drop model and the markers' positions (planConnectionLines/chainFrominshared/plan-lines.ts), and a line draws only when both of its ends are placed on the floor. - Device palette that creates on drop (D7). The plan's device tool now offers a device type to add alongside the "link an existing device" picker. Dropping it on the plan raises a real device of that type in the room under the pointer and pins its marker in one move, with the id/name pre-filled — generalising the D6 outlet-on-drop into
withCreatedDevice(worker/routes/plan-floors.ts). It works for any placeable, non-rack-only type, touch and mouse alike, and keeps the existing marker tools; the route refuses a rack-only type or a create on a non-marker. No schema change: the device type is a request-only field, and the plan reusescreateItem. - Rack reservations (A11, migration 0046). A rack can hold a range of units ("U5–U7") for planned gear, with a label for its purpose and who it is for. Reservations are drawn as hatched, dashed bands on the elevation — distinct from a real device — and listed, edited, and released from a "Reserved units" section. A device may not be placed onto a reserved span: the add refuses with a 409 and a clear override ("Place anyway").
rack_reservationsis a new table off the rack; the geometry (landingSpan,rangesOverlap,reservationClash) is pure inshared/schemas/rack-reservation.ts. - Rack type and role (A12, migration 0046). A rack records its form factor (wall-mount, open-frame, two-post, cabinet) and an optional role on site (MDF, IDF, AV). Both show on the rack page header and as additive columns on the Racks table.
- A desktop rack inspector (A10). On a wide screen, selecting the rack or a device opens its settings in a right-hand panel beside the elevation, reusing the existing rack and item forms; the phone keeps the slide-out sheet.
- Full editor actions in the rack (A9). The undo/redo command stack (
shared/undo-stack.ts) is generalised from reorder-only to a generic, guarded history, and the editor gains copy (Ctrl+C), paste (Ctrl+V), and delete (Delete) of the selected device beside the existing Ctrl+Z / Ctrl+D; an undo never applies an order naming a device the rack no longer holds. The shortcut overlay lists the new bindings. - Unused-ports report (C7). Free ports, ports with a run landed but nothing linked, and ports never used are counted per switch and panel and across the estate — three new "Needs attention" lists and dashboard counts, and a count on the ports screen. Additive to the attention model; every existing gap is kept.
- The floor plan drawn in the report (N1). Each floor is now a section of the PDF/HTML report: a server-side SVG built from
plan_shapes(rooms, walls, markers, notes) in the floor's own unit space, with the scale bar and a small key, painted in the report's resolved light palette so the drawing matches the screen with no stylesheet. A floor with nothing drawn prints no empty sheet; every existing report section is untouched (worker/report/drawings.tsfloorPlanSvg, gathered indata.ts, rendered inhtml.ts). - Drag to create on the floor plan (ROADMAP D). A room, a wall, or a scale measurement can be rubber-banded out in one press-drag-release gesture — touch and mouse alike — as well as by the existing click-to-place taps; right-angle and endpoint snapping apply to the dragged end exactly as to a tapped one (
src/components/PlanCanvas.tsx). - A first-class
outletitem type (ROADMAP D6). A telecom outlet (wall plate) is now a real device type with a faceplate label and numbered jacks that a run lands on and a link consumes. Dropping an outlet marker inside a room outline, with no device of its own, raises a real outlet in that room and links the marker to it, so a plan outlet stands for a device rather than a free marker. - A device inherits the room it is drawn in (ROADMAP D3/D4, the plan side of E3/E4). A device or outlet marker that lands inside a room outline belongs to that room in the tree; dragging the marker into another room's outline moves the device there, recorded as one
movedhistory entry. Only a room-placed device is moved — a rack-mounted one keeps its rack — and only within the floor's own property. - Floors and plan shapes in the data export. The JSON export now carries the
floorsandplan_shapestables (each shape's geometry decoded to a point array), so a full export round-trips the drawn floor plans. - The rack drawn as a real elevation with device faceplates (A1, A2). The rack page no longer lists devices as text rows: each device is drawn as an SVG faceplate at its U span, its ports laid out odd-over-even and shaped by their connector (an RJ45 square, an SFP cage, a round fiber ferrule, a small console/USB/power jack). The geometry is pure (
shared/rack-svg.ts) so the drawing and its tests share one layout; the rack frame, U scale, and drag-to-reorder are unchanged. A Front/Back switcher appears when a rack has a rear or full-depth device and draws each face's devices (a full-depth device on both, shallow front and rear devices sharing a U). - One port-state colour key across the app (A6). Every port is coloured by its connection state — complete (both ends reach an active device), incomplete (ends at a passive hop), a landed run ("keystone in"), or free — computed from the trace in
shared/port-state.ts. One legend, drawn on the rack faces, the ports-screen faceplate, and its list, readable in both themes (tokens intokens.css). - Connection highlighting on the rack (A5). Hovering a port on the drawing lights every device port along its connection chain (the hovered port solid, the rest ringed, ports off the chain dimmed); an unconnected port never highlights.
- A Devices & Connections panel on the rack (A8). Three tabs — the devices in the rack, the connections to and from it (filterable by device), and the VLANs on its ports; clicking a VLAN lights its ports on the drawing, a pill clears it.
- The ports screen leads with the faceplate (C1). The switch face is the primary view — coloured by state, with link speed and a PoE marker on each port and a hover/focus card naming what is on it and its history — and the input table moved behind an "Edit ports" toggle; every existing edit path is kept.
- Per-port history (C6). A port's hover card shows what it has carried over time, read from the device's own change history (
shared/port-history.ts), with no new table. - Undo and redo in the rack editor (A9, partial). Ctrl+Z / Ctrl+Shift+Z and Undo/Redo buttons step a reorder back and forward through a small command stack (
shared/undo-stack.ts); Ctrl+D duplicates the rack. The new shortcuts are listed in the shortcut overlay. Add and remove are not yet themselves undoable (either resets the reorder history so an undo never applies a stale order). - Deeper links and the cabling drawings (I1, I5, I6, N1; migration 0045). A link now records what a real cable run does: a label (a cable ID or circuit number), its cable type (Cat6, OM4 …), colour, length, a life-cycle status (planned, connected, decommissioning), and a tester's verdict with the day it was taken. A link carried over a cable run reads its cable off that run — the run stays the one place its own cable is recorded — while a direct patch cord carries its own; the connection editor offers the fields for a direct link, and every link display carries them. New drawings hang off this depth: a wiring diagram (the topology map with patch panels shown as their own nodes and every run coloured by cable type, with a legend, toggled with "Colour by cable type" and carried in the report as its own section); a patch schedule per panel (a row per position — its state, the run landed at the rear and where it goes, the front patch, the cable, the tester's verdict — with a tested/untested filter and a count), shown on a panel's page, printed in the report, and downloadable at
GET /api/properties/:id/panels.csv; and each rack drawn as a front elevation in the report from the same U geometry the item table names. The whole physical path of a run (device → panel → panel → device) reads as one line in the report and stays the list trace on screen. - A floor-plan drawing surface for a property (ROADMAP D1-D3, D5-D8). A property's "Floor plan" section is now a drawing editor rather than a single image. A property has floors — add, name, reorder, and switch between them, each its own drawing (D1). On a floor the technician draws with a touch-first palette (44 px targets): outline rooms as rectangles or polygons and link each to a room node (D3); draw walls and cable-run lines that snap to right angles and to other walls' ends (D5); drop wall-outlet, camera, access-point, and device markers, each linked to the device or run it stands for and tapping through to its page (D6, D4); and add text notes that layer over the plan without touching the data (D8). Set the plan's scale by measuring a known distance and entering its real length (D2); a scale bar is then drawn and every line reads out in feet or metres. An optional underlay image (a traced plan photo, uploaded through the same sheet the Files section uses) sits under the shapes. Every shape is stored in the floor's own abstract unit space — never pixels — so it reads the same on a phone, a desktop, and a printed page, the way a plan pin is a fraction of its image; and every draw, move, and delete saves to the server on release, so the plan is never a local drawing that can be lost. A room or marker keeps only the linked node's id, so a renamed or moved device is renamed, or cleanly detached, on the plan when its shapes are next read. New tables
floorsandplan_shapes(migration 0044), each cascading with the property and floor it belongs to; an underlay's deletion clears the reference rather than the drawing. New API:GET|POST /api/properties/:id/floors,PUT /api/properties/:id/floors/order,PATCH|DELETE /api/floors/:id,GET|POST /api/floors/:id/shapes, andPATCH|DELETE /api/shapes/:id. The room page keeps its lighter floor image with device pins, unchanged. The editor is lazy-loaded with the rest of the authenticated app, out of the login bundle. - A knowledge base per client and per property (K1). A new "Docs" section on the client and property pages lists that node's articles; each opens a lazy editor with a title, a Markdown body typed into a textarea, a live preview, and a starting template (site summary, reboot order, ISP outage steps). The body is stored as portable Markdown text, never HTML, so the stored value is inert and is escaped on render exactly as the report builder escapes everything (a small, closed set of blocks and inline spans; no links, images, or raw HTML, and so no sanitiser dependency). An article belongs to a client, or to one of its properties, and cascades away with it. Every write is a change on the node; articles are found by title and body in search, carried in the full-data export, and exportable one at a time as a PDF (reusing the report's Browser Rendering renderer). Migration 0042.
- Document import into an article (K5). The editor's "Import from file" reads a .txt, .md, .csv, .json, or .log file — one picked directly — into the body; CSV and JSON go in fenced as code so their shape survives. A PDF is honest: its text cannot be extracted here, so nothing is imported and the editor says to attach the PDF to the node instead of storing a bad extraction.
- Expiring records (K4). A new "Records" section on the client page tracks things with a renewal date that are not devices — SSL certificates, domains, licences, contracts, warranties — with a kind, a name, an identifier, an expiry, and a renewal note. Records expiring within 90 days, and records already expired, each feed a "Needs attention" list on the dashboard and are found by search. Migration 0042.
- Config snapshots with a diff (K6). A device's Files section gains a "Config history" view: a timeline of its config uploads (files of kind Config, or any text file) and a line-by-line comparison of any two of them, computed in the browser with a small longest-common-subsequence diff (no dependency) that trims the shared prefix and suffix first.
- Client credential vault (ROADMAP K2). A vault on each client page for the logins that belong to the client rather than a device — the ISP portal, the registrar, DNS, the Microsoft 365 admin, the camera cloud. Each entry keeps a label, a category, and a username you can read at a glance, and a password, an optional one-time-code seed, and notes that are sealed on the server (AES-256-GCM under
CREDENTIAL_KEY, the row's own id bound in, the same way device credentials and property access codes are sealed). Reveal shows the password masked, and — when the entry has a seed — a rotating one-time code that ticks down; both leave the screen after a minute. The reveal is the only reply that carries a value, it writes avault_revealedaudit row that names the entry but never a value, and rotating the credential key re-seals these entries too. New migration 0043.
Changed
- Copy actions announce success and report denied or unavailable clipboard access through the existing accessible toast region, without including the value or the underlying exception. Two maintained red drills (
PR-CLIPBOARD,PR-CLIPBOARD-DENIED) prove the value never reaches the toast and a refused write is never announced as a success. - The integration gate now executes both
workerandworker-maps; coverage delegates to that canonical command. Map integration files no longer fall outside the normal quality chain. - Red drills require the exact named assertion and diagnostic, successful baseline and restored runs, and exact source restoration. Empty runs, startup failures, wrong assertions, and abnormal exits cannot count as proof. CI retains the structured evidence; the existing 99 mutations were observed and their expected failures reviewed, with misleading labels corrected. Security regressions have additional maintained plants.
- Only a manager may reveal a stored secret (ROADMAP K3). Revealing a device's credentials, a property's access codes, or a client vault entry is now the owner's or an admin's right. A plain user still sees that a secret exists and can still save one, but the reveal answers 403 and the app hides the reveal buttons. (This changes the earlier behaviour, where any signed-in user could reveal device credentials and access codes.)
Fixed
- Queued offline writes keep their order only where order matters. The queue used to stop at the first write that did not land, so one refused or backing-off write held every write behind it, however unrelated; and a drain that lost the connection part-way kept trying, spending every remaining write's attempts on the same silence. A write now waits only behind writes it depends on — the row it edits, or an id an earlier create will produce — and is sent past the rest; a send the browser could not make at all ends the round.
- A copy another account left on the device is removed at sign-in. Opening the offline copy only knew about the copy this page itself had opened, so a copy left by an account whose sign-out never ran here (the browser closed, the session ended while the phone was away) outlived the next account's sign-in on a shared device. The origin's databases are now listed on open and every other account's copy deleted.
- A sign-out that cannot reach the server says so. With no signal the Sign out button did nothing visible: the request failed, nothing was reported, and the person was left to wonder. It now reports "Could not reach the server" in the same toast every failed action uses, and changes nothing on the device — a sign-out the server never received would still open the app on the next visit, and the queued edits are kept to be sent.
- Floor-plan reads and mutations enforce the owning client's access. Shape links, enclosing room outlines, and underlays must belong to the floor's property before any device or drawing is created or moved. Geometry-only edits also check existing links, and old foreign links no longer expose node names in drawings or reports.
- An offline edit no longer hides the app. The session route is kept out of the offline copy (see Security below), and every write refetches every query, so the first edit made with no signal refetched the session, that refetch failed, and the session gate replaced the whole app with "Could not reach the server to check your session" — taking the offline queue and its banner with it. The gate now decides on the first session answer only; a later refetch that cannot reach the server leaves the app on the answer it has. A session that really ended is still signed out by the protected route that saw the 401. The four offline browser journeys (rename, add, note, runs, photo, sync; and a conflict settled both ways) pass again on desktop and phone Chromium.
Security
- A per-client scope now covers every record, not only the tree. An account limited to one client (ROADMAP K7) could reach another client's photos, files, notes, contacts, links, power feeds, port groups, pathways, VLANs, checklists and their steps, floors and their shapes, articles, records, topology views, rack reservations, share links, and vault entries by their own ids, and every estate-wide read. Each of those record kinds is now placed under its client (
worker/db/ownership.ts) and a foreign or missing one answers the same 404 a node does; the client list shows only the scope; and the account-wide routes — export, search, the Resources tables, the global history, the dashboard, the sync manifest, the audit log, accounts, webhooks, key rotation, revoking every share, and creating a client — answer 403 to a scoped account rather than leak. The device library, address lookup, the account's own profile, and other accounts' pictures stay open. A node's tags, responsible person and relationships are addressed by their record's kind and then its id, and are placed the same way, through the resolver the relationship endpoints already use. No account is scoped yet (the assignment UI is M1's), so nothing changes for the owner, admins, or the default user. - Deleting a client can no longer widen an account.
users.client_scopeis ON DELETE SET NULL, so removing the client an account was limited to left that account unscoped — every client. The delete now answers 409 while any account is limited to the client, and migration 0056 adds a trigger that refuses the same delete at the database, so no other write path can do it by accident. - An item edit is judged on its merged state. A PATCH could put an item in a shelf of another rack, give a room item a shelf, make an item its own holder, or hang a device off itself, a missing device, a device in another client, a port the device does not have, half a reference, or a loop. The edit is now checked the way a create or a move is, with the edit laid over the stored row.
- Secret reveals are excluded from the browser's offline cache and cannot be served while offline. Opening an existing copy removes legacy secret and account-administration reads while preserving map data, blobs, and pending edits. The Worker also excludes online-only responses from idempotency storage, refusing replay headers on those mutations before they execute; migration 0054 removes legacy sensitive replay rows.
- Personal API tokens are refused on property access-code and client-vault routes, including when issued to a manager. Session-based access retains its existing role checks.
- Keyed writes now claim execution atomically per account and bind their replies to request method, URL/query, bytes, precondition, role/client scope, session epoch, and bearer identity. Multipart retries compare the actual parts. Pending or uncertain outcomes refuse repeat execution; migration 0055 preserves legacy replies without trusting missing bindings.
- JSON and multipart limits now cancel oversized request streams before buffering the remainder, including missing or false Content-Length.
- Pin miniflare's transitive
sharpdependency to 0.35.4 for GHSA-rgj7-g3m4-5g8c. Upstream miniflare still pins 0.35.2; the scoped override passes real image conversion, invalid-image rejection, andnpm auditwith zero vulnerabilities. - Per-client account scope (ROADMAP K7, groundwork). An account can be limited to a single client through a new nullable
users.client_scopecolumn (migration 0043); a scoped account reaches only that client's records and gets a 404 for anything else, enforced in one read+write middleware. Owner, admin, and ordinary user accounts are unscoped by default, so nothing changes for them. The manager UI that assigns a scope, and filtering the client list for a scoped account, are deferred to the tenancy work. - The device/port/template data-model spine (ROADMAP A2, B1–B4, C2–C4, C9). The foundation the rack and port drawings will read.
- Ports carry more of what a switch face shows. Each port now records a physical connector (RJ45, SFP/SFP+/SFP28/QSFP, LC, SC, E2000, ST, MPO, console, USB, power — B2), a speed (100M–100G — C2), whether it is enabled (C2), a VLAN mode of access or trunk with the native tag kept as
vlanand a list of tagged VLANs for a trunk (C3), and a PoE mode (off, auto, 802.3af/at/bt — C4) beside the watts it draws. Every field is optional: a port recorded before them reads at its default (enabled, access, and the oldpoe:trueas theautoPoE mode), so nothing already stored changes. They live in the device'sdetailsJSON, so no migration carries them. The ports table edits them and the faceplate shows the connector, speed, and a dimmed square for a disabled port. - A link refuses connectors that cannot mate (B3). A cable between an RJ45 port and a fiber port (LC, SC …) is refused with a clear message, unless one end is a media converter (a modem/ONT) that bridges the media; an SFP cage mates with either side.
- The make field suggests brands already in use (B4).
GET /api/items/makeslists every manufacturer on a device or a preset, deduplicated without regard to case and sorted; the item form offers them through a datalist while still saving a make the list does not hold. - Presets become device templates with a face layout (B1). A preset can carry a
layout: a front and a back face, each a set of port groups (a connector, a name prefix, a counting direction, and a grid of rows × columns whose port numbers are computed), text fields, and icons, at a height of 1–8U. A visual editor at/presets/:id/layout(its own lazy chunk) lays it out and previews the computed ports; "start from preset" and "save as preset" are unchanged. Opt-in per template, port names can be enforced (uppercase, unique within the device, shown short with an ellipsis — C9). The rack renderer (a later wave) draws from this. - Rack items gain a face and a depth (A2). A device sits on the front or the back of the rack and may be full-depth, taking both faces; the move/add rules refuse a rear device the rear has no room for (a full-depth device blocks the rear), while front stacking is unchanged. Data only — the elevation is redrawn by a later wave.
- Ports carry more of what a switch face shows. Each port now records a physical connector (RJ45, SFP/SFP+/SFP28/QSFP, LC, SC, E2000, ST, MPO, console, USB, power — B2), a speed (100M–100G — C2), whether it is enabled (C2), a VLAN mode of access or trunk with the native tag kept as
- Migration 0041 adds the preset
layoutcolumn and the itemfaceandfull_depthcolumns, each nullable or defaulted so every existing row reads unchanged. - Resources: one filterable table per record type across every client (ROADMAP F1). A new
/resourcespage with a tab for each kind — clients, properties, rooms, racks, devices, ports, runs, links, contacts, VLANs, checklists, and files. Every tab is one table: pick which columns show, filter under any column header, narrow to a single client or property, sort by any column, click a name (or the client, property, room, or rack beside it) through to that node, and page through the rest. It is backed byGET /api/resources/:type, which scopes, filters, sorts, and pages on the Worker (worker/db/resources.ts, a read-only aggregate read model over the existing tables) so a phone downloads one page rather than the estate, and byGET /api/resources/:type/export.csv, which writes the current view or every matching row through the existing CSV writer (worker/csv.ts). - Saved questions on the Resources tables (ROADMAP F2). Save the current scope, filters, sort, and hidden columns under a name ("devices out of warranty", "ports on VLAN 30") and choose it again later. Saved questions are kept per account on the device (localStorage,
src/pages/saved-views.ts); a durable, cross-device version needs a table of its own and a migration, which a later item will add. - A global command palette (ROADMAP F3). Ctrl/⌘K opens a palette from anywhere: before typing, quick links to the main pages and a couple of actions (switch theme, open the README); as you type, it searches the map through the same API the search page uses. The arrow keys move the selection and Enter opens it. The header search box still works and "/" still jumps to it; Ctrl/⌘K now opens the palette instead of focusing the box.
- A quick Racks page (ROADMAP A15).
/rackslists every rack across every client with its client, property, room, height, and dates, with the same search, sort, click-through, and CSV as the Resources racks tab. - Filters on the activity logs (ROADMAP F4). The global change history and the security audit log each gained a filter bar: a date range, an action, a resource type (history only), and a user, combinable and cleared one at a time. The
/api/historyand/api/auditroutes take the matching optional query params; a request with none behaves exactly as before. - A keyboard-shortcut overlay (ROADMAP F5). A keyboard button in the header and the "?" key open a list of the app's shortcuts. Ctrl/⌘B was added to show or hide the tree sidebar, so every shortcut the overlay lists is a real binding.
- A red drill for each of the six features above (
scripts/red-drill.json, gaps F1–F5 and A15).
2.1.0
Added
- A red drill for every feature.
npm run drillplants one defect per market-report feature (scripts/red-drill.json: the file, the exact fragment, the broken replacement, and the tests that should catch it), runs those tests alone, restores the file from git, and fails when any plant leaves its tests green.scripts/red-drill.test.tskeeps every plant pointing at a fragment that still exists, on everynpm test, and CI runs the drill as its own job. The 2026-09-07 drill of all 24 gaps is recorded in PLAN.md §9. - Offline editing with sync. The app already installed and opened without a signal; it now works there. "Keep offline" on a property (or on a client, for every property under it) fetches the whole property in one request and holds it on the device, with each photo's thumbnail and each file's bytes up to 5 MB apiece and 100 MB per property — anything larger is named as skipped rather than quietly missing. From then on every read of that property, and of anything visited while there was a signal, is answered from the copy when the server cannot be reached, and every edit to the site record — clients, properties, rooms, racks, devices, runs, links, power, notes, contacts, checklists, VLANs, presets, photos and files — is queued instead of failing. The page shows the edit at once and the toast says "Saved offline; will sync". The queue drains oldest first when the connection returns and stops at the first write that did not land, so a device added to a room made offline never arrives before its room; a refusal the server will repeat is marked failed with the server's own words, while a 429, a 5xx, or a lost connection waits and tries again, doubling from 2 seconds to 5 minutes for eight attempts. Settings → Offline (a lazy chunk, so the login path is untouched) lists what is kept with its size, the queue with each change's state, and buttons to sync, retry, discard, or settle a conflict; the banner counts what is waiting and what is in conflict.
Three server rules make a queued write safe to send twice. Every write on a map node accepts If-Match: "<updatedAt>" — the version its GET reported in an ETag — and one naming a version the row no longer has is refused 412 stale with the row as it now stands, the check folded into the UPDATE's own WHERE clause so nothing can land between reading and writing. Every create accepts the id the client generated, so replaying it finds its own row (200 with it) instead of making a second one, and an id that names something else is 409 id_taken. Everything else — a move, a reorder, a duplicate, an upload — may carry Idempotency-Key, whose reply is stored for 48 hours (migration 0040's idempotency_keys, swept by the existing cron handler) and replayed rather than re-run, under the account that minted it and never across accounts.
Two reads keep the copy honest: GET /api/sync/manifest?since= says which rows moved, which nodes were deleted, and which were touched by something that moves no row of its own (a note, a photo, a file, a link, a power feed); GET /api/properties/:id/offline answers one property whole. A conflict is never settled automatically — both sides are somebody's record of what is on a wall — so it waits on the Offline page with what you changed beside what the server has, and "Keep mine" or "Take theirs".
A batch of runs made from a pattern works with no signal too. The route is still what a connected device sends; without one, the pattern is worked out on the device by the same dropBatchRows the route and the form's preview use, and queued as one ordinary "Add run" per row — in order, each with the id this device generated and its own idempotency key — so the runs appear at once and land exactly once through the single-run route. The label check the batch route makes before it writes anything is made first against the room in the copy, in the route's own words, and refuses the whole batch with nothing queued; the toast says how many are waiting ("Saved offline; will sync (24 runs)").
A photo taken with no signal shows its own picture, in the gallery and full size, rather than a broken image until it syncs. The upload's bytes are already in the queue for the replay, so usePhotoSource — one hook the gallery's thumbnail and the viewer share — reads them through an object URL while the upload waits, revoked when the element goes or the upload lands, after which the server's own routes serve it unchanged.
What still needs a connection says so in words: account, security, token, webhook, and share-link operations, device credentials and access codes, importing devices from a file, reports, PDFs, CSVs, the map picture, and search across the server (search offline looks through the kept copy by name). The copy is one IndexedDB database per signed-in account holding no credential, access code, token, or secret, and it is deleted on sign-out, on the 401 that follows a revoked session — so signing an account out everywhere clears it on the device's next contact — and when another account signs in on the device. The service worker is unchanged in what it caches, and now leaves /share/* alone as well.
- Read-only share links to a property's report. An admin or the owner can open the HTML report of one property to someone with no account: the Share section on the property page makes a link with a label ("For the landlord") and a lifetime of at most 30 days, and shows its full address once, with a warning that it is not shown again. The section lists the live links with their prefix, expiry, view count, and last view, folds the expired and revoked ones away underneath, and revokes one behind a confirm; the Accounts page adds "Revoke every share link", which closes every live link on every property at once (the README runbook's answer to a URL that went astray). The link itself is
/share/<secret>: 256 random bits of which only a SHA-256 is stored, the same way a password-reset token is kept, so a copy of the database is not a copy of the links. Opening it answers the property's report with a "Shared read-only copy · expires …" banner and without the notes log, the access facts (where to park, how to get in, when), the Files lines, or the checklists — every one of them is either how a technician gets into the building or free text a technician wrote on site. Contacts, photos, rooms, racks, devices, runs, power, and the topology stay, because they are what the client is being shown. The route is not under/apiand reads neither the session cookie nor anAuthorizationheader, so it cannot become a way into the API; it serves HTML and nothing else — never a client report, never the JSON API, and never a PDF, whose renderer time is billed. An unknown, expired, or revoked secret answers one plain 404 page that never says which of the three it was, anonymous use is capped per source address (60 reports opened an hour, 20 refused secrets an hour, then 429), and every link made, revoked, viewed, and refused is an audit entry naming the link by its eight-character prefix and the address it came from — never by its secret. Migration 0036 addsshare_links; a link outlives the account that made it (with the username copied on) and goes with its property. - Device model presets. The typing one device model always needs — its type, make, model, height in rack units, typed details (a switch's port count and PoE budget, an access point's radios), and extra fields — is saved once under a name and offered the next time (migration 0034,
item_presets). The item form's "Start from preset" select appears at the top while a device is being added and fills the model in, leaving the name, serial, addresses, and firmware exactly as typed; a device's page saves itself as a preset ("Save as preset"), taking only what a model has, so no serial, address, MAC, name, or credential can travel with it; and Settings → Presets (/presets, its own lazy chunk) lists them all with the model line and who made each, with an edit sheet that renders the same details fieldset the item form does. Anyone may save one — it is the typing a technician keeps repeating — and whoever saved it or a manager may change or remove it; names are unique whatever the case, so a second "UniFi USW-24" is a 409 rather than a duplicate in the picker. A preset is a template rather than a node of the map, so it writes no change history:preset_created,preset_edited, andpreset_removedgo to the security audit log by name. The table is in the JSON export (additive, no format bump). From the market report's gap 15, approved 2026-09-06. - A rack elevation names its positions. Every mounted block now carries the U range it covers ("U42–41"), counted from the bottom the way a rack is labelled: the top block starts at the rack's height and each one below it follows. The scale beside the elevation prints a number per unit, or every second one past 24 rows, the caption counts the units free at the bottom, and a device inside a shelf takes the shelf's units, since that is where it sits. The same range is the item page's "Rack position" fact and a fact on every racked device in the report. A rack whose height was never set is numbered from the top starting at the sum of its heights, and the caption says so once. The arithmetic is pure functions in
shared/rack-layout.ts; nothing is stored, the ordered-list model and its drag-and-drop reordering are untouched, and the owner's "rack U-position elevation view" non-goal is reversed (2026-09-06, market report gap 21). - A floor image with device pins. A room and a property page each carry a "Floor plan" section. Upload a picture of the floor — the section's "Add a plan" button opens the Files upload sheet preset to the
plankind — and the picture is drawn in the column with a marker wherever a device, rack, or cable run has been put on it. Each marker wears the device's own icon, names itself on hover and focus, and is a link to that node's page. "Edit pins" turns the markers into handles: choose a node from the picker of everything under the plan's node that is not pinned yet and tap the plan to place it (activated by keyboard the pin starts in the middle), drag a marker with a pointer, nudge a focused one by 0.01 of the image per arrow key, and remove the selected pin. Every position is saved on release, so the plan is never a local drawing that can be lost. Positions are fractions of the image's width and height, never pixels, so the same plan reads on a phone column, a desktop panel, and a printed page. A node may be pinned once per plan, and only when it lies under the node the plan hangs off — a room's plan reaches that room and its sub-rooms, a property's plan the whole property — enforced by the same subtree definition the delete cascade uses. Pinning, moving, and unpinning are recorded on the pinned node's own change history, naming the plan. New tableplan_pins(migration 0038) withON DELETE CASCADEfrom the attachment, so deleting a plan for good takes its pins; deleting a node takes its pins in the same batch that deletes the node. New API:GET /api/attachments/:id/pins,PUT|DELETE /api/attachments/:id/pins/:nodeType/:nodeId. The HTML and PDF reports print each room's and property's plan with the image embedded as a data URI, numbered markers drawn over it as an inline SVG overlay, and a legend ("1 Core switch · 2 AP Lobby"); a plan file over 1 MB is named instead ("Plan: Ground floor (too large to print)") so the document stays printable. This is the light floor plan every comparable product ships — a raster image with x/y positions — not the drawing canvas the owner held back on 2026-09-03; the owner approved this version on 2026-09-06. - A VLAN and subnet table on every property. A VLAN used to be free text on a router ("VLANs: 10 staff, 20 cameras") and a bare tag on each switch port and WAN link, so the same VLAN was typed once per device and nothing said what tag 20 was for or which subnet it carried. A property now has a "VLANs and subnets" section: one row per VLAN with its 802.1Q tag, a name, the subnet in CIDR form, the gateway, and what it is for (migration 0037, table
vlans,GET/POST /api/properties/:id/vlansandPATCH/DELETE /api/vlans/:id). A tag is unique within its property — a second VLAN 20 is a 409, from a create or from an edit that moves a tag — while the same tag on another property is another VLAN entirely. The subnet must be an address and a prefix its family allows and the gateway a bare address, both checked by one pure rule (isCidrinshared/network.ts) that the API and the form share. Every write is a change-history entry on the property (vlan_added,vlan_editedfield by field,vlan_removed); search finds a property by a VLAN's name, subnet, or purpose and by "VLAN 20"; the export has avlanstable and the report a VLANs table under each property; the rows go with their property. Where a tag is typed — every port on a switch's ports screen and every WAN link of a gateway — the field stays a text input and now offers the property's tags as suggestions ("20 · Cameras"), so a tag the table has never heard of is still recorded: the map takes what is really installed. The ports table and the faceplate name the VLAN beside the tag when the table knows it. Two counts join the dashboard's "Needs attention", each with its list: devices of one property recorded on the same IP address (the row names the other device), and devices carrying a tag their property's table does not list ("VLAN 30 on port 4, not in the table"), counted only where the property has a table at all. This is documentation, not address management: no DHCP pools, no reservations, no scanning (owner reversal of the "no IP address management" non-goal, 2026-09-06). - Devices import into a room from a file. "Import devices" beside a room's Add item opens a three-step page (
/rooms/:id/import, its own lazy chunk): pick the source and the file, or paste its text — a Fing scan (CSV or JSON), a Meraki organization inventory (CSV), a UniFi device list (the Network application's or the Site Manager API's JSON), a WiFi Explorer scan (CSV, every BSSID an access point with its SSID and channel in the Wi-Fi details), or any spreadsheet; check the mapping of columns to fields, which the source's preset fills in and a select per column adjusts (two columns on one field keep the first with a value; a column can be kept as a key/value extra), and the type guessed for each row from the source's own words (UniFi'suap/usw/udm, Meraki's model prefix) or a small word table (switch, camera, printer, phone, NAS, router, UPS, …), changed per row; then import in one request (POST /api/rooms/:id/import, at most 500 rows, 20 imports an hour per account) and read what was added, each a link, and what was skipped with its reason. The file is parsed in the browser by the app's own RFC 4180 reader (quoted cells, doubled quotes, CRLF, a byte-order mark, semicolon or tab delimiters, ragged rows); MAC addresses are stored as lower-case colon pairs and an IP that is not one is left blank with a note. On the Worker each row goes through the same path as a device added by hand (the per-type details check, a generated name when blank, an "added" history row each); a row whose MAC or serial is already on a device anywhere in the property, or earlier in the same file, is skipped naming that device, as is a row that fails validation; the room's history gets oneimport_completedentry ("12 devices from a Fing export; 2 rows skipped"). This is a one-time seed of a room, not discovery: nothing is polled, and only the finished rows leave the browser. The owner reversed the 2026-09-03 "no import" decision on 2026-09-06. - An item has a life. Every item now carries a status (planned, installed, spare, retired), the dates it was purchased and installed and when its warranty and vendor support end, and the supplier and order reference it was bought on (migration 0029; every existing item is installed with nothing else known). The item form has a Lifecycle group with the device's own date picker; the item page shows the dates and supplier among its facts; the report prints them. A status other than installed is worn as a small chip beside the name on the item page, in a room's item list, in the sidebar tree, and in the report; installed is the norm and stays quiet. Edits are recorded field by field ("Warranty ends: → 2028-03-01", "Status: Installed → Spare"), the supplier and order reference are searchable, the export carries the columns, and a copy keeps the lifecycle as it stands.
- Warranties and support ends on the dashboard. "Needs attention" adds the devices whose warranty ends within 90 days or has ended, and the devices past their end of support (retired ones are left out), each with a page listing them soonest first, naming the date and whether it has passed (
GET /api/overviewgapswarrantyEndingSoonandpastSupportEnd;GET /api/attentionlists of the same names). - PoE budget and draw. A switch declares its PoE budget in watts, each port records what it draws ("PoE W" in the port map, spoken by the faceplate, listed in the port sheet and the report), and the device page says "PoE: 45 of 370 W" beside its port summary whenever a budget or a draw is set. The dashboard adds a third gauge, "PoE in use", across every switch (
GET /api/overviewpoeWatts); a gauge past its track fills the whole ring and says by how much. - One row per radio. An access point (and a router with Wi-Fi) records each radio's band (2.4, 5, or 6 GHz), channel, width (20–320 MHz), and transmit power in a small table instead of one "channel" field; migration 0029 moves a recorded channel into the first radio. The item page lists the radios, the report tables them, the change history reads "Radio 1 channel: 36 → 44", and each radio's cells are searchable.
- ISP account on a WAN link. A gateway's WAN link records the ISP account number and the support phone number; the link's summary line ends with the account, the support number is dialled from the WAN list, and the report tables both.
- QR label sheets. A property page and a rack page each offer Labels (
/properties/:id/labels,/racks/:id/labels): one label per rack, device, patch panel, or run (chosen with checkboxes; racks, devices, and panels to begin with), each a QR code of the node's own address drawn on the device beside its name, its type or cable type, and where it sits (property › room › rack). Two layouts: a Letter sheet of Avery 5160 labels (three by ten, 2.625 × 1 in, printed without a printer margin so the grid lands on the die cuts; the geometry isLABEL_SHEETinsrc/constants.ts) or 24 mm tape, one label per line for a Brother or DYMO labeler. Print leaves only the labels on the page: the app shell and the controls go (AppShell.module.css,LabelsPage.module.css). - CSV downloads beside the report links, from
worker/routes/csv.tsthrough a small RFC 4180 writer (worker/csv.ts) that quotes and escapes every cell and prefixes one a spreadsheet would run as a formula (=,+,-,@, a tab) with an apostrophe; each file starts with a byte-order mark so Excel reads the UTF-8. Equipment (GET /api/{clients,properties}/:id/equipment.csv: property, type, name, make, model, serial, IP, MAC, firmware, room, rack; rack parts and unplaced inventory included), the cable schedule (GET /api/properties/:id/drops.csv: label, room, from, to, port, cable type, termination, length, unit, colour, status), and the labels (GET /api/properties/:id/labels.csv: kind, name, type, path, url — the labels page's rows, the URL built from the request's origin, for a Brother P-touch import or any label tool). - Runs from a pattern. "Add runs from a pattern" beside "Add drop" on a room page opens a form with a label pattern (
{n}counts from a start number, padded to 0–3 digits;{port}is the panel port each run lands on), how many (1–96), and the fields every run shares (cable type, termination, length unit, status, colour, where it comes from and the first port), with a live preview of the first three labels and the last.POST /api/rooms/:id/drops/batch(schemashared/schemas/drop-batch.ts, the rows worked out byshared/drop-batch.ts) makes every run through the same checks and save as a single "Add drop" (assertNewDrop,saveDropinworker/routes/rooms.ts), one history entry each, the port advancing by one per run; the whole batch is judged first, so a label already in the room (409, naming it), a pattern that repeats a label, or a port already in use makes nothing. - A map of every property.
/map(a pin in the header, and the first entry of the client-list rail) draws every placed property across every client on one Mapbox picture (GET /api/map: apin-sper point, the view fitted to them all with room around the edge, or street level for a single one; up toMAP_OVERVIEW_MAX_PINS, 200; the same hourly map allowance and browser cache as a property's own map), and lists every property (GET /api/map/properties,shared/schemas/map.ts) with its client, address, whether the map could place it, and directions, narrowed to one client by a select. WithoutMAPBOX_TOKENthe picture route answers 503 like the property map and the page shows the list with a note. - Page paths in
shared/navigation.ts.nodeHrefandnodeSegmentmoved down fromsrc/navigation.ts(which re-exports them) so the Worker writes the same addresses into the labels CSV that the app links to. - Checklists and procedures on a property. A property carries any number of checklists — a commissioning procedure, a handover, the annual service, or a list written from scratch — made from a template in
shared/checklists.tswhose steps are copied onto the property (migration 0032:checklistsandchecklist_items, both going with the property). The property page lists each one with its progress ("7 of 10 done", a tick when complete); the checklist's own page at/checklists/:idis built for a phone: a row per step whose whole label is the tap target, ticked the moment it is touched, with who ticked it and when shown underneath, a note edited in place, a field for the next step, and rename and delete in the page actions. The Worker records who and when on every tick, clears them on an untick, and sets the list'scompleted_atthe moment the last step is done (cleared when a step is unticked or a new one added). Every tick, untick, completion, and list added or removed is a change-history entry on the property. Whoever made a list or a manager may remove it. Reports print each property's checklists with the tick, who, when, and note per step; search finds a property by a checklist name or a step; the export carries both tables; the delete confirm counts the lists a property or client delete takes with it. - Who to call, and how to get in. A client and a property each carry contacts (
GET/POST /api/{clients,properties}/:id/contacts,PATCH/DELETE /api/contacts/:id; migration 0030): a name, what they are to the site, a phone number the page dials, and an email address it writes to, with one of them starred as the person to try first (starring another moves the star). Every add, edit (field by field), and removal is a change-history entry on the node; search finds a node by a contact's name, phone, or email ("Contact Sam Lee · phone 555-0199"); the export has acontactstable; the report tables the client's contacts under its heading and each property's under its own. Contacts go with their node when it is deleted. - A property says where to park, how to get in, and when. Three plain fields on the property (
parking,entry,hours) in an "Access" group of the property form, shown as an Access section on the property page when any is set, named in the history, in the export, and in the report. - A property's access codes, sealed. Alarm, gate, and lockbox codes and a free access note are stored exactly like device credentials — one AES-256-GCM value per property under
CREDENTIAL_KEYwith the property id as associated data (property_access) — and leave the Worker only throughGET /api/properties/:id/access, which the history records asaccess_revealed;PUTrecordsaccess_setwith the changed field labels only,DELETE(or saving everything blank)access_removed. No property, tree, search, export, report, or history body carries a code. The property page's "Access codes" section is the credentials section with different fields: reveal for a minute, copy, edit from the revealed values, remove after a confirm; the form types the codes hidden withnew-passwordautocomplete.POST /api/credentials/rotatere-seals these rows with the rest, and the rotation drill proves it. - Files on any node. A client, property, room, rack, item, or cable drop carries files beside its photos: the switch's manual, a config export, the tester's PDF, the plan, the invoice, or anything else, each with a kind and an optional title, who added it and when, and its size (
GET/POST /api/{clients,…,drops}/:id/attachments, migration 0031). The Worker decides what a file is from its bytes and its name — a PDF by its signature, an image as photos are, a text, CSV, JSON, or log file when it is valid UTF-8, and a tester or config export (.flw,.cfg,.conf) by extension alone, stored as plain bytes — and refuses everything else (415) and anything over 10 MB (413). Bytes are served only throughGET /api/attachments/:id/filewith the session, inline for a PDF or an image and as a named download otherwise, withnosniff. A file is archived with a reason rather than deleted (/archive,/restore), deleting a node archives its files with its photos in the same transaction, and an admin or the owner deletes an archived file for good. Every step is a history entry, the export lists the rows (never the bytes), search finds a node by a file's title or name, the report names each node's files with their kind, and uploads are capped at 120 an hour per account. Each node page gains a Files section: the list, an upload panel (file, kind, title), a file sheet to edit or archive, and the archived list behind a toggle. - Photos on cable drops and clients. A run keeps the picture of its jack as terminated or the tester's screen, and a client its signed contract or site survey, through the same gallery, archive, and restore as every other node; the report shows a run's photos under the drops table headed by its label and a client's in the header. Deleting a drop archives its photos and files with the reason
Cable drop "D-01" deleted. - A cable run's test result. A drop records the day it was tested, the result (pass, fail, marginal, or not recorded), and the length the tester measured in the run's own unit (
testedOn,testResult,measuredLength; migration 0031), entered in a Test group of the drop form, stated on the drop page ("Tested 2026-09-01 · Pass · measured 42 ft"), in a Test column of the report's drops table, in the history field by field, and in the export. The dashboard's "Needs attention" list and page add two rows: active runs never tested, and runs that failed their last test. - Scripts can sign in with a token. Every account mints personal API tokens on a new API page (Settings → API): a name, read-only or read-and-write access, and an expiry of 30, 90, or 365 days or never. The secret (
pm_and 32 random bytes) is shown once; only its SHA-256 is stored, and the list shows the first eight characters, the scope, the expiry, and when it was last used. A request that carriesAuthorization: Bearer pm_…is served as the account, without a cookie and without the same-origin check (there is no cookie to forge); one that carries both is refused as ambiguous. A read-only token is refused every write (403token_scope); an unknown, revoked, or expired token answers 401invalid_token, is counted against the address (20 in 15 minutes locks it), and leaves atoken_rejectedaudit row naming only the prefix. Tokens never open sign-in, sessions, the password, the second factor, device credentials, or the token routes themselves (403token_not_allowed), and an account that must still set its own password is held with a token as with a session. Up to ten live tokens per account; a revoked one keeps its row. Managers see and revoke anyone's tokens from the Accounts page, except the owner's. Migration 0033 (api_tokens). - Webhooks tell other systems what changed. Managers add https webhooks (Settings → Webhooks) subscribed to map changes, audit events, or both. Every change written to the history and every audit row fans out, after the response, to one delivery per subscribed webhook: a JSON POST with
X-ProjectMap-Event,X-ProjectMap-Delivery,X-ProjectMap-Timestamp, andX-ProjectMap-Signature(sha256=and the hex HMAC-SHA256 oftimestamp.bodyunder the webhook's secret, sealed at rest underCREDENTIAL_KEYand shown once at creation or when re-issued). A delivery waits ten seconds, follows no redirect, and counts as delivered on 2xx; otherwise it records the status and error and is retried 1, 5, 30, 120, and 600 minutes later by a cron trigger every five minutes. Twenty failed attempts in a row turn a webhook off with awebhook_disabledaudit row in the app's own name; turning it on again starts the count over. A URL that is not https, carries credentials, or names this host or a private, link-local, or loopback address is refused at creation. The page lists each webhook's events, state, and last delivery, with a test delivery (ping), a deliveries drawer (status, attempts, next attempt, error), and re-keying. WithoutCREDENTIAL_KEYthe routes answer 503 and the page says so. Migration 0033 (webhooks,webhook_deliveries). - Power, outlet by outlet. A UPS, PDU, or power strip now records what is plugged into each of its outlets (
power_feeds, migration 0035): one feed per outlet and one per device, both at the same property, the same way switch ports are consumed. The item page adds a Power section — an outlet table with what each outlet feeds and a Connect sheet per free outlet for a source, and "Fed by UPS 1 outlet 3" with Disconnect for every other device — overGET/POST /api/clients/:id/power,GET /api/items/:id/power, andDELETE /api/power/:id(400 with the rule that refused it, 409 when the outlet or the device is already taken). Both devices carry the change in their history (power_connected,power_disconnected), the export carries the table, and editing a source to have fewer outlets, or to be something other than a source, pulls the feeds it can no longer carry and counts them in the reply besidebrokenLinks(brokenFeeds). The report gains an Outlets table under each source, a "Power: UPS 1, outlet 3" fact on every fed device, and, under a UPS, "If this UPS fails: …" naming every device it powers directly or through a PDU or strip it feeds. A device with redundant power supplies is out of scope: one feed is recorded per device. - Serials and MAC addresses are scanned, not typed. The serial and MAC fields on the device form carry a camera button; it opens a panel with a live view from the rear camera that reads Code 128, Code 39, QR, Data Matrix, and EAN-13 through the browser's own Barcode Detection API — no library, and a lazily loaded chunk, so a form that is only typed into costs nothing. What is read is shown for a person to confirm: only "Use" writes it into the field, a MAC arriving in any of the forms a label prints it in becomes
00:1A:2B:3C:4D:5E, and "Scan again" starts over. Where the browser has no decoder, or the camera is refused, the panel says so in words and offers nothing else. On a phone, a camera button beside the header search reads one of this app's own QR labels and opens that node's page; any other code is searched for as text. - Photos carry the date they were taken, and can be drawn on. The browser reads EXIF DateTimeOriginal out of a picked file before the re-encode strips every other scrap of metadata, so a photo now says "Taken 2026-09-03 14:12" (or "Date unknown") in the gallery, on its own page, and under its thumbnail in the report — with the location and the device details still discarded as they always were. Each photo also takes up to eight short tags, typed or picked from "before", "after", "rack front", "rack rear", "wall plate", "label", and "damage"; search finds a node by a tag on one of its current photos, the change history names the tags a photo was filed with, and the JSON export carries both columns. Before uploading, a photo can be annotated on a canvas — arrow, box, text, undo, clear — and the marks are burned into the uploaded image and its thumbnail alike. A photo already filed can be annotated too: the marked-up copy is filed as a new photo with the same caption, tags, and date, and the original is archived with the reason "Annotated copy replaces it", so the picture as it came off the camera is never overwritten.
- The dashboard draws the map. The home page adds the figures across every client in one request (
GET /api/overview) and draws them: a wheel of properties by kind (business and residential), a wheel of devices by type with the long tail gathered, a wheel of cable drops by cable type with the total length in the middle and the runs' state (active, spare, without a length) under the title, gauges of switch ports and rack units in use against what is declared, the last 30 days of changes as columns (the busiest day marked), the clients ranked by devices, and a "Needs attention" list: network devices with no IP address, active runs with no far end, runs without a length, properties without an address, and addresses the map could not place. Every drawing carries its numbers as words beside it. The headline tiles now count business and residential properties apart, and add links and photos. - A page behind every "Needs attention" count. Each line on the dashboard opens the Needs attention page at its own list (
GET /api/attention): the devices without an IP address, the active runs with no far end, the runs without a length, the properties without an address, and the addresses the map could not place, each named, with what it is and the path down to where it sits, linked to the page where the detail can be added. Lists are the first 100 by name with the total; the lists and the counts come from one set of rules, so they agree. - Routers, firewalls, and modems have ports. Each records its LAN ports (a count with one-tap picks, and the per-port table), so a link lands on a numbered port there as it does on a switch; left blank, the device links without a port as before.
- A node on the topology map peeks and opens in place. The pointer (or focus) on a device or a spare run shows a popover with its type, where it is, its IP address, and how many links it has; a click slides out a panel with every fact, the links one per line, and the way to the node's own page, instead of leaving the map.
- The tree side panel can be hidden. A circled chevron astride the panel's edge hides and shows the tree on a wide screen, pointing the way the panel will go; the choice is kept on the device.
Changed
- One data table, not three. The scrolling box and the table inside it are now
surfaces.module.css(scroll,table): the port map of a switch, the outlet table of a UPS, and the VLAN table of a property had written the same rules under three names. Each keeps only the width it needs and its own column widths. - A bounded number is a whole one. A port count or a wattage with a cap in the item type registry is now checked as an integer within its range by the shared schema, not only clamped by the form's stepper.
- A property patch leaves what it does not name alone. The property patch schema is a partial of the plain fields, like the item, rack, and drop patches, so a
PATCHwithoutkindno longer resets the property to a business. - The app has its own address.
projects.blowmoney.netis a Workers custom domain on the owner's zone (routesinwrangler.jsonc), with its DNS record and certificate made by the deploy; theworkers.devURL is off, so the app has one origin. The same subdomain is the mail sender's domain once it is onboarded to Email Service (README "Mail"), and aMAIL_FROMwith a display name,Cablefolio <no-reply@…>, is sent as name and address. - Password-reset mail goes through Cloudflare Email Service. The Worker sends with its
EMAILbinding (send_emailinwrangler.jsonc) instead of Resend's API, so there is no third-party key:MAIL_FROMalone turns mail on, and it must name an address on a domain onboarded to Email Service (README "Mail").RESEND_API_KEYis gone. The account moved to the Workers Paid plan on 2026-09-05, which Email Service needs; the same plan lifts Browser Rendering's daily cap, so the PDF quota message no longer talks of a daily allowance, and D1 Time Travel keeps 30 days. - A run is consumed where it lands. A run that lands on a device port (a panel's B side, a switch port) must be linked from that device at that port; a link from elsewhere over the run is refused with the reason, and the link form no longer offers a far device over such a run to anyone but the device it lands on. The rest of the route (the patch on the panel's A side) is its own link, so every port along the route is held. The run's page now says what it carries.
- A panel's sides are A and B. The side facing the devices and the uplink is the A side; the side where runs land is the B side, one run per port (a second run on a taken B port is refused, as before). The link form names the side on each port choice, the trace and the unlink confirmation say A or B, and the ports page explains the two.
- The activity chart counts by your days. The dashboard's changes per day are the days of the browser's own zone, not UTC: the API counts changes per half hour and the page adds them up into local days, so a late-evening change lands on the day it was made.
- The page uses the width. The content column grows to 90rem and the inline back button stays on a wide screen too.
- The client list is a dashboard. The home page opens with the figures across every client (clients, properties, homes, rooms, devices and parts, cable drops), then the client table with the newest changes beside it on a wide screen. The table hides its optional columns by its own width rather than the screen's, so it fits beside the changes.
- Filters live in the table. A filter row under the column headers replaces the search box above the table: a search under the client name, and under each count a pick from the values the list holds, with a Clear button while any filter is set.
- The tree lists devices in hierarchy order. Within a rack or a room the tree shows an ONT before a router before a switch, then panels, access points, and endpoints, the order the topology map uses; devices of one kind keep their order.
- Tree guide lines are elbows. Each level draws a dashed line from its parent's toggle with an elbow into every child, and the line ends at the last child instead of running on; the indent is wider so the elbows read.
- The map sits beside the address. The property page draws the map picture and the directions buttons in the header card next to the address, instead of in a separate Location section that repeated the address. The facts stack beside the map, the buttons sit centred under it, and a property's kind is labelled Type.
- Password and email are on the profile page. The settings menu is the theme and the way to Profile, Security, and (for managers) Accounts; changing one's password is a section of Profile, where the email already was. The menu no longer opens sheets of its own.
Fixed
- No stray button on a phone. The tree panel's show/hide control had no phone rule after it moved out of the header, so it drew as a bare button down the left edge on a narrow screen. It only exists on a wide screen now, as the panel does.
- Properties from before 2.0.0 now get their map. Only a save or an address edit looked an address up, so every property that existed before migration 0025 stayed unplaced and its page said the address could not be placed. Reading a property now looks its address up when the point is missing, keeps the answer, and (migration 0028,
placed_at) waits an hour before retrying a lookup that found nothing. The map picture opens the directions too. - A run within one room or rack links its ends. A cable drop from a wall panel to a device in the same room, or a panel to the switch beside it, was refused with "A cable drop is only used between rooms" and the run was not saved. The link over a run now accepts the run whose own two ends are the devices, wherever they are; between rooms the drop must still be one that joins them.
- The topology drawing sits centred in its card when narrower than it, and the saved-view picker lines up with the button beside it.
- Buttons no longer sit flush against other controls: the "Add note" button has room under its field, a long kind chip no longer squeezes a page title into one letter per line (the actions wrap under the name), and a wall-mount patch panel's header reads as one line.
- The device-credentials form no longer fills the person's own Cablefolio sign-in into the admin login: browsers ignore
autocomplete=offon a username/password pair, so the secret fields arenew-password. - Form buttons only stick to the bottom inside a sheet; on a page (Profile) they follow the fields.
- The migration test refuses any migration that drops a table other tables reference: on D1 that cascades whatever the file says about foreign keys, which cost production one
connectionsrow at the 2.0.0 deploy (restored from the backup; PLAN.md §9).
2.0.0
The enterprise release. Sign-in changes for everyone: existing sessions end at deploy (sign in once), an account added by an admin must set its own password at first sign-in, and passwords follow a policy beyond length. The API is a major step: POST /api/auth/login may answer 200 { mfaRequired }, POST /api/users defaults mustChangePassword to true, the free-text notes fields are gone in favour of notes routes, DELETE /api/photos/:id only removes an archived photo, and the export is format 2. Migrations 0016 to 0027 apply.
Added
- Notes are a running log. Anyone signed in adds a note to a client, property, room, rack, item, or cable run; each records who (the display name at the time) and when, listed newest first; the author or a manager can remove one; every note is also a change-history entry, so it survives the node's deletion. Migration 0026 turns each existing notes field into one unauthored note ("earlier note") dated the row's last update, then drops the columns.
- Photos are archived, never quietly deleted. Archiving records who, when, and a required reason, and keeps the node's name and path so the record reads after the node is gone. Deleting a node (or a client) archives every photo beneath it (reason
Item "Core switch" deleted); the confirm says "N photos will be archived, not deleted". Galleries show current photos with a "Show archived (N)" toggle; the client page has an Archived photos section with Restore; only an admin or the owner can delete an archived photo for good (migration 0027;photo_archivedandphoto_restoredin the history). - Guide lines in the tree. Each level of nesting draws a thin vertical line under its parent's toggle, so a device several rooms deep can be traced back by eye.
- Profiles. Every account has a display name, a title, a phone number, and a picture (Settings → Profile). The picture is cropped square and shrunk on the device before upload, stored in the photos bucket, and served only with a session; the header shows it beside the name, and the Accounts page shows it with each account. Deleting an account removes its picture; the export carries the profile fields.
- Photos on properties. A property carries photos like a room does (the building, the street front, the meter cupboard); they appear in the property's report and go with the property (migration 0025 opens the photo table's node-type list to the registry).
- Where a property is. With
MAPBOX_TOKENset, a property's address is placed as it is saved and the property page shows a map picture (Mapbox Static Images, proxied by the Worker so the token stays there) with the required attribution; without the token the page says so. "Open in Google Maps" and "Open in Apple Maps" give directions from the point when known and from the address otherwise. Map pictures count 300 an hour per account. - The code step at sign-in. After a right password, an account with a second factor is asked in the same form for the six-digit code from its authenticator app (
inputmode=numeric,autocomplete=one-time-code, focus on the field, the prompt in a live region), with "Use a recovery code instead" and "Start over". A wrong code or a lockout shows the Worker's words; an expired challenge returns the form to the password step with the reason. - Set your own password. A session whose password someone else set sees only a "Set your own password" screen (username, current + new + confirm, the policy hint) until it picks one; then the app opens where the person was going.
- Password policy in every form. The change-password sheet, the reset page, and the first-sign-in screen run
passwordProblemwith the account's username before sending; the Worker's answer stays the fallback. - Security page (
/security, Settings → Security, every role): signed-in devices (browser and device, address, signed in, last active, "This device"), "End session" per other device and "Sign out everywhere else", both behind a confirm and reported in a toast with the count; the second factor: enrol with a QR code and the secret (copy button), the first code, the ten recovery codes shown once behind "I have saved these" (copy all), "On · N recovery codes left", new recovery codes for a code, "Turn off" for the password and a code. WithoutCREDENTIAL_KEYthe section says the key is needed instead of offering to enrol. scripts/totp-code.ts <secret> [--step n]prints the code an authenticator app would show, using the Worker's own TOTP code, for trying the second factor without a phone; the e2e journey uses it.- Accounts page security controls. Pills for a second factor and a pending password; the add-account form's "Must set their own password at first sign-in" (on by default); Sign out everywhere (confirm, toast with the count) and Remove second factor (confirm) for managers on anyone but the owner and themselves; Export data downloads
/api/export. - Audit log on the Accounts page, newest first as sentences with note, time, address, and browser; failed sign-ins, lockouts, and wrong codes carry a Warning pill; Older pages back.
- Request references. A 5xx failure reads "… (Reference <id>)" in failed-page states, the error boundary, and failure toasts; 4xx never shows one.
- A weekly dependency audit (
.github/workflows/audit.yml) runsnpm auditevery Monday and on demand, so a new advisory is a red workflow within a week even when nothing was pushed. - Business or home. A property is a business or a residence (the form asks; existing ones are businesses). A home is drawn as a house wherever properties are listed, and a client whose every property is a home is drawn as a house too; the report and the property page name the kind (migration 0023).
- Installable app.
public/manifest.webmanifest(standalone, icons 192/512 rasterised fromfavicon.svg) and a hand-written service worker (public/sw.mjs, registered fromsrc/main.tsxin production builds only) cache the app shell (network-first, cachedindex.htmlwhen offline) and the hashed/assets/*files (cache-first). Nothing under/api/is ever cached; old caches are dropped on activate;_headersserves the worker and manifest withCache-Control: no-cache. - Toasts. Every write says what it did ("Saved", "Client added", "Deleted", "Moved", "Copied", "Unlinked"…) in a polite live region that dismisses itself; the few actions with no form or confirm to report into (rack reorder, duplicate, unlink) toast their failures too.
- Error boundary at the app root and around the routed page: a "Something went wrong" panel with Reload and Go home; an
ApiErrormessage is shown, any other exception is kept to the console. - Ended sessions are handled. A 401
unauthenticatedfrom any protected route signs the app out at once and the login page says "Your session ended. Sign in again." with the return path kept. - Offline banner ("You're offline. Changes can't be saved until you're back online.") and a thin activity bar (
role="progressbar", still under reduced motion) while any read or write is in flight. Banner, bar, and toasts live in one "App status" landmark at the app root, so axe's landmark rule holds on every page. - Reads retry only when the server could not be reached (twice, backing off 1 s → 2 s, capped at 4 s); a page that failed to load shows the server's own words and a Try again button.
- Unsaved-changes guard. A sheet whose form has edits asks inline ("You have unsaved changes." — Discard / Keep editing) on Escape, the scrim, Close, or the phone's back button; every entity form (client, property, room, rack, item, drop, link) reports its edits. The browser also asks before the tab closes while a write is in flight.
- Back button closes a sheet. Each open sheet pushes a history entry; Back pops it and closes the sheet; closing any other way pops the entry without leaving the page. Navigation started while a pop is in flight waits for it, so a tap right after a form closes is never undone.
- Keyboard: a "Skip to content" link is the shell's first tab stop; focus moves to the main landmark (and the page scrolls to the top) after every navigation unless a sheet is open; Ctrl/Cmd+K (or
/outside a field) focuses the header search; the tree row menu opens from Shift+F10 or the menu key, arrows walk its items, Escape returns to the row. - Client table sorting by any column (name, properties, rooms, devices, runs, updated) with
aria-sortand an arrow; the API order stays until a column is chosen. - Document titles: every page sets "<page> · Cablefolio".
- Port unlinking asks first on the ports page, like every other unlink.
- Sessions you can see and end. A session is now a server-side row (migration 0019): signing out ends it for every copy of the cookie, a session ends after 7 days without use or 30 days after sign-in, the account lists its signed-in devices (device, address, first and last use) and ends any of them or all the others, a password change ends the other sessions, and an admin or the owner can sign any account out everywhere (
DELETE /api/users/:id/sessions). Everyone signs in again once after this release: older cookies name no session. - A second factor at sign-in. Any account can enrol an authenticator app (TOTP, migration 0022): scan the code or type the secret, confirm with the first six-digit code, and keep the ten one-time recovery codes shown once. From then on the password opens only a five-minute challenge; a code or a recovery code opens the session. Codes cannot be replayed, wrong ones count toward the account lockout, and the secret and recovery codes live sealed under
CREDENTIAL_KEY(and are re-sealed with it). Turning it off takes the password and a code; an admin or the owner can remove someone's factor (DELETE /api/users/:id/mfa), which the audit log records. - Quotas on the costly routes. Each account may render 20 PDFs, upload 300 photos, look up 120 addresses, and rotate the credential key 5 times an hour; past that the route answers 429 with
Retry-After. Multipart bodies over 12 MB are refused before they are read. - Per-account lockout and reset limits. Five failed sign-ins against one account within 15 minutes, from anywhere, lock that account for 15 minutes; ten from one address lock the address. The count across every address, which let twenty bad guesses lock everyone out, is gone. Reset mails are capped per account and per address, and wrong reset tokens per address (migration 0021).
- Handover passwords must be replaced. An account whose password an admin set (on creation or later) can sign in but do nothing else until it sets its own; the session reports
passwordChangeRequiredand every other route answers 403. An admin can turn this off when adding an account. - A password policy beyond length. A password must not contain the username, be digits alone, use fewer than four different characters, or contain a common fragment such as
passwordorqwerty; changing one's own password to the same one is refused. The rule lives inshared/password-policy.tsfor the form and the Worker alike. - A security audit log. Sign-ins and failed attempts (with the username tried), lockouts, sign-outs, session revocations, password changes, sets, and resets, account creation, changes, and removal, ownership transfers, credential-key rotations, and exports each leave an
audit_eventsrow (migration 0020) with who, whom, address, browser, and a short note, plus asecuritylog line. Admins and the owner read it newest first atGET /api/auditand on the Accounts page. - A JSON export of everything.
GET /api/exportdownloads the whole map as one file (project-map-export-<date>.json) for admins and the owner: every table with JSON columns decoded and rows in creation order, so two exports of unchanged data are identical. Never included: password hashes, sessions, reset tokens, login attempts, device credentials, or photo bytes (photos are metadata with their R2 keys). - Request ids and structured logs. Every API reply carries an
X-Request-Idheader and every error body the samerequestId; the Worker writes one JSON log line per request (method, path, status, duration, ray, user) and one per unhandled error with its stack, never a body, cookie, query string, or secret.GET /api/healthnow reports the release version and a database round trip, answering 503degradedwhen the database does not answer. - The client list is a table: each client's properties, rooms, devices, and runs and when it last changed, with the name (and the first line of its notes) as the link. Rooms, runs, and the date step aside on a phone. The counts come with
GET /api/clients. - The side panel lists every client on the client list page, so any client is one tap away from the home page as well as from inside a client.
- Captions when adding photos. Picked photos wait in a panel with an optional caption each before they upload; a caption can still be added or changed later from the photo.
Changed
- A property save waits at most 4 seconds for the address to be placed, then saves it unplaced; a toast no longer fades in (half-strength text failed the contrast check for a moment); the end-to-end suite waits for entrance animations before its accessibility checks and gives a single expectation 15 seconds.
- Entity forms and the client table no longer have a Notes field or line; the report prints each node's notes log; search matches note bodies ("Note …"); duplicating a node does not copy its notes;
GET /api/exportis format 2 (anotestable, nonotescolumns, the photo archive columns). CopyButtonis a shared component (src/components/CopyButton.tsx); the card status row (surfaces.status/surfaces.status-actions) is shared by the credentials and second-factor cards.- Audit entries and session entries carry a server-rendered
device("Safari on iPhone") and audit entries alabel(the verb phrase), so the app shows them without parsing user agents. - Confirm labels are consistent: the confirm button is the verb ("Delete", "Remove", "Unlink", "Clear all", "Transfer ownership", "Move") and the way out is always "Cancel" (was "Yes, delete" / "Keep it" and variants). The item links confirm now uses the shared
ConfirmSheet. - "Signed in as <name>" in the header is read by screen readers (it was
aria-hidden); visually unchanged. - Client-only tunables live in
src/constants.ts(toast duration, retry limit and delays, sheet history state, search shortcut keys). - Five e2e specs brought back in line with
main's recent UI changes (they failed identically on a cleanmain):tree.spec.ts(rack Height select, exact device link now that the inventory lists it too),photos.spec.ts(the caption panel's "Upload photo" step),runs.spec.ts(run destination labels now name the room),devices.spec.tsandtopology.spec.ts(the run-state and link filters live in the "Runs"/"Links" filter menus). - Hardened response headers. The app document and assets now send
Cross-Origin-Opener-Policy: same-originandCross-Origin-Resource-Policy: same-origin, and API replies carry HSTS as well as the assets, so a first contact through an API URL pins HTTPS too. - Every dialog is a slide-out side panel. Forms, confirms, the photo viewer, and the port detail open from the right edge over the dimmed page, full height, and slide back out: the whole width of a phone, a column beside the page on a wider screen. The bottom sheet and the centred card are gone.
- Hidden device credentials. Any item can carry an admin username and password plus SSH details (username, port, password, key) and private notes, stored encrypted (AES-256-GCM under the new
CREDENTIAL_KEYWorker secret, migration 0018) and shown only when revealed on the device page. Revealed values stay masked until their eye is pressed, copy to the clipboard, and hide again after a minute. Every reveal, save, and removal is in the change history by who and when; the history names changed fields, never values. Credentials never appear in the tree, inventory, search, reports, or duplicated devices. Without the key the section says the feature is off. Key rotation is a runbook in the README, backed byCREDENTIAL_KEY_PREVIOUSandPOST /api/credentials/rotate. - The change history's action list now lives in code rather than a database CHECK (migration 0018 rebuilds
changes), like item types since 0016. - Multi-WAN with VLAN tags. A router or firewall records any number of WAN links, each with a label, ISP, address mode (DHCP, static, PPPoE), WAN IP, gateway, 802.1Q VLAN tag, and interface, so a dual-ISP or fibre-plus-LTE gateway is documented per link and the tag its handoff needs sits beside the address it applies to. The tag is checked (1–4094, blank for untagged) in the form before the round trip. The item page lists the links, the report tables them, search finds a link by its ISP or address, and history records each link's changes by name. Migration 0017 turns the old free-text WAN field into the first link's label.
- The item form now checks the typed details against the type's own schema before sending, so a bad value is flagged on its field at once.
- Migrations are tested against a populated SQLite database (
scripts/migrations.test.ts): the items rebuild keeps links and photos, and the WAN carry-over leaves other details alone. - Three endpoint item types — computer (form factor, hostname, OS, who it is assigned to, wired or Wi-Fi), phone (extension, PBX/SIP server, assigned to, PoE), and printer (kind, colour, connection, queue/share name) — so a run can end at the desk it serves.
- A wall-mount patch panel: a patch panel that hangs in a room or sub-room instead of a rack, with the same numbered ports, front and rear sides, ports screen, and run landings as a racked panel.
- The items table no longer repeats the list of item types in a CHECK (migration 0016 rebuilds it, same foreign-keys-off recipe as 0015), so a new type is one registry entry rather than a table rebuild. The rack-only rule stays in the database.
- Each property has an inventory: every item that belongs to it, whether or not it is placed. An item can be added straight to inventory and left unassigned, then assigned to a room or rack when it is installed; anything added to a room shows in the inventory too. The property page lists the inventory, flags what is unassigned, and assigns it in place. Items now belong to a property directly (migration 0015 adds
property_idand lets an item sit in neither a room nor a rack); the rebuild keeps foreign keys off around the drop so connections and photos are preserved. - A space can be an external location — a pole, pedestal, or wall mount outside the building — instead of an interior room (migration 0014). It holds racks, items, and drops exactly like a room; the type only changes how it reads and draws: its own icon in the tree, an "External location" label on its page, and an "(external location)" note in the report. Existing rooms stay interior; the room form chooses the type.
- Every row in the topology tree has a ⋯ actions menu: duplicate a room, rack, or device, or delete any node you may delete, without leaving the tree. The delete confirm (with its cascade count) opens over the tree; editing, adding, and swapping stay on the node's own page, and moving is still a drag.
- On a device page, tapping a port on the faceplate opens a slide-out with that port's label, VLAN, PoE, and what it connects to, with links to the far device and to the full port manager.
- A rack records its physical depth (a dropdown of standard inch sizes) and its mount type (wall, floor-standing, or ceiling), shown on the map, the room list, the rack page, and the report (migration 0012). This replaces the free-text "size" that duplicated the height.
Changed
- Topology filters are now a filter bar of dropdown menus (Rooms, Links, Runs) with a count on each and an applied-filter pill row that removes a filter in one tap or clears them all, replacing the always-open checkbox boxes.
- A switch always has ports: the port-count picker defaults to the smallest size and no longer offers "Not set".
- A device's Links list gained a Clear all control, and both it and a single Unlink now ask before removing anything (unlinking used to be instant and irreversible).
- A switch no longer has a free-text Uplink field. An uplink is a connected device, recorded as a normal link in the Links section (which offers a device dropdown, port, and run). Any stored uplink text is removed from existing switches (migration 0013).
Fixed
- Modal panels (change password, forms) are no longer clipped to the header bar. The sheet is portalled to the document body so the app header's
backdrop-filtercan no longer act as its containing block. - The worker test suite pins
MAPBOX_TOKENto empty so a real token in a developer's.dev.varscannot leak in and make the address-lookup tests call Mapbox for real. - A cable run with a device at both ends can now reach a device in any room of the property, not only the room the drop is filed under, so linking (for example) a rack switch to an access point in another room over a run works. The drop form's "Goes to" offers every device in the property, and the reach rules count both structured ends of a run when deciding which rooms it joins.
1.13.0
Address autocomplete on the property form (milestone M19).
Added
- Address autocomplete. Typing into a property's "Address line 1" now offers real address suggestions from Mapbox; picking one fills line 1, city, region, and postal code in a single tap. The field is an accessible combobox (arrow keys move the highlight, Enter picks, Escape closes) and stays a plain text input when the lookup is unavailable.
GET /api/geocode/address?q=…on the Worker proxies the lookup to Mapbox so the token stays a server secret. It needs a session, wants at least three characters, and answers503 address_lookup_unavailablewhen noMAPBOX_TOKENis configured — which the client reads as "leave the field a plain input" rather than an error.MAPBOX_TOKENWorker secret (optional). A publicpk.…token with default scopes is enough; see.dev.vars.exampleand the README. Absent by default, so the feature ships off until a token is set.
1.12.0
Devices that change, runs with colour and state, and the port faceplate (milestone M18, three requests built as one).
Added
- Swap device. A device page has "Swap device": the form opens with the make, model, serial, MAC, and firmware cleared and everything else kept, and saving keeps the device's place, name, port labels, and links.
- Changing a device keeps the links that still fit. A switch changed to fewer ports keeps the links on the ports that remain and drops only the ones beyond the new count; a device changed to a type that cannot carry a link loses that link; a run landed on a port the device no longer has lets go of that end. Each removal is in the change history, and the edit's reply says how many links went (
brokenLinks). - Run colour and state. A drop has a colour for the map (a palette; blue stays the default when none is chosen), drawn on the run's line, and a status: active, or spare. A spare is pulled and labelled but links nothing however its ends are set; it needs only where it comes from and goes to (a room, a rack, a device, or free text). Spare runs sit in a row of their own on the map, each opening its page. The map filters by run state (active links, spare runs), and saved views keep that filter. Migration 0011.
- The port faceplate. A device with ports shows them as its faceplate: odd ports along the top row and even ports beneath, the way the switch is laid out, each a numbered square coloured by what holds it (linked, one side free on a panel, a run landed with no link, free), with the device at the far end shown above or below its port and a legend. On the ports screen a free port opens the link form and a held one opens its trace; the device page shows the compact faceplate beside "Manage ports". The table beneath keeps to what the map records: port, label, VLAN, what it connects to, PoE.
Fixed
- An edit that left a field out no longer resets it: the drop, item, rack, and view patch schemas were partials of the input schemas, whose defaults (a drop's destination, an item's height and holder, a rack's height, a view's pass-through) filled in for the missing field. Patches are now partials of the plain fields.
1.11.0
Runs that link (milestone M17).
Added
- A cable drop now says where it goes as well as where it comes from: "Goes to" offers the devices in the room the run lands in, and "Reaches port" the free ports of the one chosen. With a device at both ends the run is the cable: the Worker makes the link between the two over that run (terminated as the run is: keystone, plug, LC, or SC; fiber runs make fiber links), replaces it when an end changes, and removes it when an end is cleared or the run is deleted. Each of those is recorded in the change history ("the run joins them", "the run changed", "the run was removed"). A run whose link a rule would refuse (a taken port, a device out of the run's reach, the same device at both ends) is refused with that rule, and nothing is saved.
- The drop page and the report's drop table show both ends with their ports; the ports screen shows a run arriving at a port.
- Migration 0010 (
to_node_type,to_node_id,to_porton drops).
Changed
- On the topology map, lines leave a device below its name and address instead of crossing the text, and the label of a link that travels over a run opens that run's page.
- Accounts page polish: the role is a pill (it was drawn with the square count badge, which squeezed the word), the email sits beside it and wraps on its own, the name column takes the room so the buttons sit beside it on wide screens and wrap beneath it on phones.
- On phones the header shows the app mark alone, so the search box keeps its width instead of clipping its placeholder.
1.10.0
The topology map redrawn as a hierarchy (milestone M16, from the owner's picture of what the map should look like).
Changed
- The map is a top-down tree the way a network controller draws one: the internet (the modem's ISP) at the top, then the modem, gateway, switches, patch panels, access points, and every device beneath the one it links to, joined by a bus line from each parent. Wired links are solid, fiber, mesh, and VPN links dashed in their own colours. Each device shows its icon, its name, and its IP address (or its room when no address is set). Devices that link to nothing sit in a row of their own at the bottom. Rack furniture (shelves, drawers, cable managers, power strips, blank panels) is not drawn.
- Cable links say which run they travel over and how long it is ("3 → 1 · D-01 60 ft").
- Every device on the map is a link to its own page, in the app, in the report, and in the saved SVG.
Added
- Pass-through mode ("Pass through patch panels"): patch panels are hidden and the run behind each panel port is drawn as one link from device to device, with its length. Saved views keep the mode (migration 0009).