Restyle staff and console UIs; add UI-Docs walkthrough and review PDF.
Some checks are pending
offline / test (push) Waiting to run

Match CS/sales/accounting/access-staff to the portal indigo system with
dollar amounts, skip links, and empty states. Fleet replica actions move
into overflow menus, roles become chips, Docs become cards, and the
header copy reflects the 0.0.0.0 bind. Simulator uses the same shell
(orange only for faults). Portal API keys are masked; admin customers
edit in a drawer. Catalog uses system-ui. New UI-Docs repo holds
screenshots, usage notes, and UI-REVIEW.pdf.
This commit is contained in:
George Lambert 2026-09-11 18:01:33 -04:00
parent ddf772454b
commit cb07f5b321
72 changed files with 3284 additions and 242 deletions

View file

@ -32,6 +32,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
| **verae-fleet** | `packages/verae-fleet` | Catalog, min copies, pause/restart, SSH hosts, operator console |
| **verae-zapier-simulator** | `packages/verae-zapier-simulator` | Trace console before `zapier-platform push` |
| **zapier-user-docs** | `packages/zapier-user-docs` | Customer signup → register → lookup |
| **UI-Docs** | `packages/ui-docs` | Operator/staff/portal walkthrough, screenshots, review PDF |
| **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` |
| **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Before After
Before After

1
packages/ui-docs/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

3
packages/ui-docs/NATS.md Normal file
View file

@ -0,0 +1,3 @@
# NATS
UI-Docs is documentation only. It does not subscribe, publish, or request any `verae.*` subject.

View file

@ -0,0 +1,24 @@
# UI-Docs
Walkthrough screenshots, usage instructions, and a review PDF for every operator, staff, portal, admin, simulator, and catalog surface in the Verae Time × Zapier lab.
**Forgejo:** https://git.georgelambert.org/marchon/UI-Docs
## Open the review
- [UI-REVIEW.pdf](UI-REVIEW.pdf) — screenshots, how to use each screen, remaining issues
- [WALKTHROUGH.md](WALKTHROUGH.md) — click-by-click
- [REPORT.md](REPORT.md) — issues still to consider
- [screenshots/](screenshots/) — PNG atlas
## Recapture
Lab processes must already be listening (`:3850` fleet, `:3847` simulator, `:3011` CS, `:3012` sales, `:3013` accounting, `:3025` staff, `:3000` edge).
```bash
npm install
npm run walk # headless Chrome → screenshots/ + walkthrough-console.json
npm run pdf # reportlab → UI-REVIEW.pdf
```
Chrome path: `CHROME_PATH` or `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`.

View file

@ -0,0 +1,44 @@
# UI issues still to consider
Second full walkthrough (2026-09-11): 32 screenshots, 0 application console errors on product UIs. Surfaces are visually consistent (indigo, 12px radius, dollars). Primary actions are obvious. Remaining work is listed here for human review. Full screenshot atlas and the same table: [UI-REVIEW.pdf](UI-REVIEW.pdf).
## Fixed in this pass
| Item | Result |
|------|--------|
| CS/sales/accounting/access-staff unstyled cents forms | Indigo shell, USD, agent/customer labels, empty illustrations |
| Fleet pause/resume/restart/on/off/pause crowding | One ⋯ menu per replica and per service; Power is status |
| Docs tab bullet dump | Cards matching Fleet |
| Simulator cream/orange brand | Indigo; orange = fault inject only |
| Header LOOPBACK vs 0.0.0.0 bind | `operator LAN · binds 0.0.0.0:3850` |
| Add machine truncated roles field | Chips |
| Admin customers inline-edit table | List + edit drawer |
| Portal API key always visible | Masked + Reveal/Copy/Regenerate |
| Catalog Georgia on indigo | system-ui |
| Trace hop grid clipping names | Wrapping columns |
| Empty “No invoices yet” | Illustrated empty state |
| Fleet on/off looking like toggles | Status pills |
| Department pages a11y | `lang`, skip link, focus-visible |
| Nav hit area/contrast | 44px tabs, `#c7c9ff` on indigo |
| Swagger as customer UI | Labeled stock |
## Still to consider
| Severity | Issue | Suggestion |
|----------|-------|------------|
| High | Staff review headings show `cust_1` because the ledger has no display name | Join statements to zappier-edge customer name, or a customer typeahead |
| High | CS/sales/accounting HTML has no login (LAN trust) | Staff SSO or shared cookie before any non-lab bind |
| Med | Catalog is still a bibliography | Card index like console Docs |
| Med | Swagger remains stock | Keep for integrators; do not skin |
| Med | Fleet add-machine identity path truncates | Two-row form + file picker |
| Med | Intentionally disabled machines use degraded yellow | Neutral grey when disabled on purpose |
| Low | Portal statement empty lists still wrap the illustration in a table row | Same card empty state as invoices |
| Low | Fleet ⋯ can open off-screen at the bottom of a long table | Flip the menu upward |
| Low | access-staff and CS HTML are twins | Share one template |
| Low | Simulator empty console is a black void | Placeholder “Run a step…” |
## Visual revalidation
- **Appealing:** staff and console now match portal/admin; simulator no longer looks like a second product.
- **Obvious:** Review account, Apply credit, Edit, Reveal/Copy, Run step, ⋯.
- **Comprehensive:** review, credit, quote, export, fleet power, trace, docs, portal billing, admin customers — covered. Missing: staff identity join and catalog IA (above).

View file

@ -0,0 +1,5 @@
# UI-Docs
Walkthrough screenshots, usage instructions, and a review PDF for every operator, staff, portal, admin, simulator, and catalog surface.
**Forgejo:** https://git.georgelambert.org/marchon/UI-Docs

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,127 @@
# UI walkthrough (operator, staff, customers)
Captured 2026-09-11. Browser console: no application errors on the product UIs (favicon data-URI). Design tokens: `--accent #4f46e5`, `--bg #f4f5fb`, 12px radius, indigo header.
## 1. Operator console — Fleet
Open **http://127.0.0.1:3850/**.
![Fleet](screenshots/01-console-fleet.png)
1. Header kicker reads **operator LAN · binds 0.0.0.0:3850**. NATS stays private.
2. **Add machine:** pick roles as chips (`tree-node`, `archive-worm`, …), then **Add machine**.
3. Machine cards show role pills and enable/disable.
4. Services: **Health** is operational/degraded. **Power** is a status (`on` / `paused` / `off`), not a toggle.
5. Click **⋯** on a replica or on Power for pause / resume / restart / start / stop.
![Overflow](screenshots/01b-console-fleet-overflow.png)
## 2. Trace
Click **Trace**.
![Trace](screenshots/02-console-trace.png)
1. Pick an action (default Create Timestamp and Wait).
2. Orange **Faults** box is inject-only.
3. **Run step**. Hops wrap; module names are not clipped.
![Trace run](screenshots/02b-console-trace-run.png)
## 3. Docs
Click **Docs**. Cards match Fleet. High-level first.
![Docs](screenshots/03-console-docs.png)
## 4. Standalone simulator
Open **http://127.0.0.1:3847/**. Same indigo shell. Orange only on **Inject faults**.
![Simulator](screenshots/04-simulator.png)
**Run step** or a scenario (Register, Batch Merkle, …).
![Simulator run](screenshots/04b-simulator-run.png)
## 5. Customer service
Open **http://127.0.0.1:3011/**.
1. Customer id → **Review account**. Prepaid is dollars.
2. **Amount (USD)****Apply credit**. Table lists `$5.00`, reason, agent name, when.
![CS review](screenshots/05-cs.png)
![CS credit](screenshots/05-cs-credit.png)
## 6. Sales
**http://127.0.0.1:3012/** — **Review account** or **Show quote** (named fields, not a JSON dump).
![Sales](screenshots/06-sales.png)
## 7. Accounting
**http://127.0.0.1:3013/** — review plus **QuickBooks IIF** / **CSV**.
![Accounting](screenshots/07-accounting.png)
## 8. Staff access plane
**http://127.0.0.1:3025/** — same review/credit UI after `verae.access.authz.check`.
![Staff](screenshots/08-access-staff.png)
## 9. Admin
**http://127.0.0.1:3000/admin/**
| Username | Password |
|----------|----------|
| `admin` | `admin-dev-key` |
| `demo` | `$$$Adm1n###` |
![Login](screenshots/09-admin-login.png)
![Rate card](screenshots/10-admin-rate-card.png)
**Customers** is a read-only list. **Edit** opens a drawer (email, type, multiplier, billing).
![Customers](screenshots/12-admin-customers.png)
![Drawer](screenshots/12b-admin-customer-drawer.png)
Invoices empty state until you **Generate** a period.
![Invoices](screenshots/14-admin-invoices.png)
## 10. Customer portal
**http://127.0.0.1:3000/portal/** — create an account or sign in.
![Portal login](screenshots/16-portal-login.png)
Dashboard: API key is **masked**. **Reveal**, **Copy** (full key to clipboard), **Regenerate**.
![Dashboard](screenshots/17-portal-dashboard.png)
![No invoices](screenshots/18-portal-invoices-empty.png)
![Statement](screenshots/19-portal-statement.png)
**API & pricing** notes that `/docs` is stock Swagger.
![API](screenshots/20-portal-api-pricing.png)
## 11. Swagger (not a customer surface)
**http://127.0.0.1:3000/docs** — vendor Swagger UI for integrators.
![Swagger](screenshots/21-swagger-stock.png)
## 12. Catalog
**https://zapier.georgelambert.org/** — system-ui on the indigo header (not Georgia).
![Catalog](screenshots/22-catalog.png)

925
packages/ui-docs/package-lock.json generated Normal file
View file

@ -0,0 +1,925 @@
{
"name": "ui-docs",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ui-docs",
"version": "0.1.0",
"dependencies": {
"puppeteer-core": "^24.15.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.13.2",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz",
"integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.4.3",
"extract-zip": "^2.0.1",
"progress": "^2.0.3",
"proxy-agent": "^6.5.0",
"semver": "^7.7.4",
"tar-fs": "^3.1.1",
"yargs": "^17.7.2"
},
"bin": {
"browsers": "lib/cjs/main-cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.20.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
"license": "MIT",
"optional": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ast-types": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
"integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.1"
},
"engines": {
"node": ">=4"
}
},
"node_modules/b4a": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
"integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/bare-events": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
"integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz",
"integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.28.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-path": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
"integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
"license": "Apache-2.0"
},
"node_modules/bare-stream": {
"version": "2.13.4",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
"integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.8.1",
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
"integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/basic-ftp": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/chromium-bidi": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz",
"integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
"integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/degenerator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
"integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
"license": "MIT",
"dependencies": {
"ast-types": "^0.13.4",
"escodegen": "^2.1.0",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1608973",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz",
"integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",
"license": "BSD-3-Clause"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=6.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-uri": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
"integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
"license": "MIT",
"dependencies": {
"basic-ftp": "^5.0.2",
"data-uri-to-buffer": "^6.0.2",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/netmask": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
"integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/pac-proxy-agent": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
"integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
"license": "MIT",
"dependencies": {
"@tootallnate/quickjs-emscripten": "^0.23.0",
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"get-uri": "^6.0.1",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.6",
"pac-resolver": "^7.0.1",
"socks-proxy-agent": "^8.0.5"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pac-resolver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
"integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
"license": "MIT",
"dependencies": {
"degenerator": "^5.0.0",
"netmask": "^2.0.2"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/proxy-agent": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
"integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"http-proxy-agent": "^7.0.1",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^7.14.1",
"pac-proxy-agent": "^7.1.0",
"proxy-from-env": "^1.1.0",
"socks-proxy-agent": "^8.0.5"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/puppeteer-core": {
"version": "24.43.1",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz",
"integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.13.2",
"chromium-bidi": "14.0.0",
"debug": "^4.4.3",
"devtools-protocol": "0.0.1608973",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.1",
"ws": "^8.20.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamx": {
"version": "2.28.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
"integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar-fs": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz",
"integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz",
"integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT",
"optional": true
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -0,0 +1,14 @@
{
"name": "ui-docs",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "UI walkthrough, screenshots, and review PDF for Verae Time × Zapier",
"scripts": {
"walk": "node scripts/walkthrough.mjs",
"pdf": "python3 scripts/build-review-pdf.py"
},
"dependencies": {
"puppeteer-core": "^24.15.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Build UI-REVIEW.pdf from walkthrough screenshots + report text."""
from __future__ import annotations
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
ROOT = Path(__file__).resolve().parents[1]
SHOTS = ROOT / "screenshots"
OUT = ROOT / "UI-REVIEW.pdf"
ACCENT = colors.HexColor("#4f46e5")
INK = colors.HexColor("#171a26")
MUTED = colors.HexColor("#6b7186")
PAGES = [
("01-console-fleet.png", "Operator console — Fleet",
"Open http://127.0.0.1:3850/. Role chips replace the truncated text field. Power is a status (on / paused / off), not a toggle. Replica and service actions live in the overflow menu."),
("01b-console-fleet-overflow.png", "Fleet — replica overflow",
"Click the overflow control on a replica. Pause, resume, and restart are in one menu. Confirm still guards restart."),
("02-console-trace.png", "Operator console — Trace",
"Trace tab. Orange box is fault inject only. Run step, then read hops on the right."),
("02b-console-trace-run.png", "Trace after a run",
"Hop names wrap. Module column no longer clips. Monitors sit under the console."),
("03-console-docs.png", "Operator console — Docs",
"Docs are cards matching Fleet, grouped High-level / Architecture / Remaining."),
("04-simulator.png", "Standalone simulator",
"http://127.0.0.1:3847/ uses the indigo shell. Orange is the Inject faults panel only."),
("04b-simulator-run.png", "Simulator after a run",
"Same hop grid as the console Trace tab. Run step or a scenario button."),
("05-cs.png", "Customer service — review",
"http://127.0.0.1:3011/. Enter a customer id, Review account. Amounts are dollars."),
("05-cs-credit.png", "Customer service — credit applied",
"Amount (USD) 5.00 → Apply credit. Prepaid and the credits table update in dollars."),
("06-sales.png", "Sales — review",
"http://127.0.0.1:3012/. Review account or Show quote. Quote is a named card, not a JSON dump."),
("07-accounting.png", "Accounting — review",
"http://127.0.0.1:3013/. Review plus QuickBooks IIF / CSV export buttons."),
("08-access-staff.png", "Staff access plane",
"http://127.0.0.1:3025/. Same indigo review/credit UI after authz."),
("09-admin-login.png", "Admin — sign in",
"http://127.0.0.1:3000/admin/. Seed user admin / admin-dev-key."),
("10-admin-rate-card.png", "Admin — rate card",
"Landing tab after sign-in. Per-endpoint list prices."),
("12-admin-customers.png", "Admin — customers list",
"Read-only table: name, email, type, billing. Edit opens the drawer."),
("12b-admin-customer-drawer.png", "Admin — edit customer drawer",
"Change email, type, multiplier override, billing. Save or Cancel."),
("14-admin-invoices.png", "Admin — invoices empty state",
"Illustrated empty state until a period is generated."),
("16-portal-login.png", "Portal — sign in / create account",
"http://127.0.0.1:3000/portal/. Create an account or sign in."),
("17-portal-dashboard.png", "Portal — dashboard",
"API key is masked. Reveal, Copy, or Regenerate. Copy puts the full key on the clipboard."),
("18-portal-invoices-empty.png", "Portal — no invoices yet",
"Empty-state illustration instead of a blank table row."),
("19-portal-statement.png", "Portal — statement",
"Prepaid, credits, usage, payments in dollars."),
("20-portal-api-pricing.png", "Portal — API & pricing",
"Notes that /docs is stock Swagger UI, not a designed customer surface."),
("21-swagger-stock.png", "Swagger /docs (stock)",
"Left as vendor Swagger. Do not treat it as the customer portal."),
("22-catalog.png", "PDF catalog",
"https://zapier.georgelambert.org/ uses system-ui on the indigo header, not Georgia."),
]
def styles():
base = getSampleStyleSheet()
return {
"cover": ParagraphStyle("cover", parent=base["Title"], textColor=ACCENT, fontSize=22, leading=26, spaceAfter=12),
"h1": ParagraphStyle("h1", parent=base["Heading1"], textColor=ACCENT, fontSize=16, leading=20, spaceBefore=8, spaceAfter=8),
"h2": ParagraphStyle("h2", parent=base["Heading2"], textColor=INK, fontSize=13, leading=16, spaceBefore=6, spaceAfter=4),
"body": ParagraphStyle("body", parent=base["BodyText"], textColor=INK, fontSize=10, leading=13, alignment=TA_LEFT, spaceAfter=6),
"muted": ParagraphStyle("muted", parent=base["BodyText"], textColor=MUTED, fontSize=9, leading=12, spaceAfter=8),
"cap": ParagraphStyle("cap", parent=base["BodyText"], textColor=MUTED, fontSize=8, leading=11, spaceAfter=10),
}
def header_footer(canvas, doc):
canvas.saveState()
canvas.setFillColor(ACCENT)
canvas.rect(0, letter[1] - 18, letter[0], 18, fill=1, stroke=0)
canvas.setFillColor(colors.white)
canvas.setFont("Helvetica", 8)
canvas.drawString(0.75 * inch, letter[1] - 13, "Verae Time x Zapier · UI review")
canvas.setFillColor(MUTED)
canvas.drawRightString(letter[0] - 0.75 * inch, 0.45 * inch, f"{doc.page}")
canvas.restoreState()
def p(text, style):
return Paragraph(text.replace("&", "&"), style)
def main():
s = styles()
story = []
story.append(p("UI review report", s["cover"]))
story.append(p("Walkthrough, screenshots, usage, remaining issues, and suggested improvements.", s["muted"]))
story.append(p("Captured 2026-09-11. Browser console: no application errors after favicon data-URI (catalog static server still 404s a missing favicon.ico until the next full site deploy).", s["body"]))
story.append(p("What was fixed in this pass", s["h1"]))
items = [
"CS / sales / accounting / access-staff restyled to the portal indigo system. Dollars and names, not cents forms.",
"Fleet replica and service actions collapsed to one overflow menu. Power column is status, not on/off toggles.",
"Add-machine roles are chips. Header copy is operator LAN · binds 0.0.0.0:3850 (NATS stays private).",
"Docs tab is cards. Trace hop grid wraps. Skip link and focus rings on staff and console.",
"Simulator indigo; orange only on Inject faults.",
"Portal API key masked with Reveal / Copy / Regenerate. Invoice empty-state illustration.",
"Admin customers: list vs edit drawer. Invoice empty state.",
"Catalog body font is system-ui. Swagger /docs labeled stock.",
]
for it in items:
story.append(p("" + it, s["body"]))
story.append(p("How to use each surface", s["h1"]))
story.append(p("Operator: open :3850, Fleet for replicas, Trace to inject a Zap step, Docs for PDFs. Staff: CS :3011 credit in USD; Sales :3012 quote; Accounting :3013 export; Staff plane :3025 after authz. Customers: /portal. Ops: /admin. Lab: simulator :3847.", s["body"]))
story.append(p("Issues still to consider", s["h1"]))
remaining = [
["Severity", "Issue", "Suggestion"],
["High", "Staff UIs look up by customer id; ledger often has no display name, so the heading is cust_1 not Ada.", "Join account-balance statements to zappier-edge customer name, or add a typeahead of customers."],
["High", "Staff, portal, and admin have no session login on CS/sales/accounting HTML (LAN trust).", "Put a staff SSO or shared cookie in front of :3011:3013 and :3025 before any non-lab bind."],
["Med", "Catalog is still a long bibliography. Font is fixed; information architecture is not.", "Card index like the console Docs tab: Start here / Architecture / Modules."],
["Med", "Swagger /docs remains stock.", "Keep it for integrators; do not skin it. Link it as 'OpenAPI explorer' only."],
["Med", "Fleet add-machine form is dense; identity file still truncates in a narrow field.", "Two-row form: identity + roles on the second row with a file picker."],
["Med", "lan-134 shows degraded/disabled in yellow while local/ns1 are green — correct, but the disable button looks like a fault.", "When a machine is intentionally disabled, use a neutral grey card, not degraded yellow."],
["Low", "Portal statement empty cells still embed the empty illustration inside a table row.", "Drop the table when the list is empty; use the same card empty state as invoices."],
["Low", "Overflow menus can open off-screen at the bottom of a long fleet table.", "Flip the menu upward when near the viewport edge."],
["Low", "Access-staff and CS are nearly the same page.", "Keep both (plane vs department) but share one HTML template file."],
["Low", "Simulator empty console is a large black void before the first run.", "Placeholder hop: 'Run a step to fill this trace.'"],
]
body = getSampleStyleSheet()["BodyText"]
body.fontSize = 8
body.leading = 10
rows = []
for i, row in enumerate(remaining):
rows.append([Paragraph(f"<b>{c}</b>" if i == 0 else c, body) for c in row])
grid = Table(rows, colWidths=[0.7 * inch, 2.6 * inch, 3.4 * inch], repeatRows=1)
grid.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), ACCENT),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e5e7f0")),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f4f5fb")]),
]))
story.append(grid)
story.append(PageBreak())
story.append(p("Screenshot atlas with usage notes", s["h1"]))
max_w = 6.5 * inch
max_h = 7.2 * inch
for fname, title, how in PAGES:
path = SHOTS / fname
if not path.exists():
story.append(p(f"Missing {fname}", s["muted"]))
continue
story.append(p(title, s["h2"]))
story.append(p(how, s["body"]))
img = Image(str(path))
iw, ih = img.imageWidth, img.imageHeight
scale = min(max_w / iw, max_h / ih)
img.drawWidth = iw * scale
img.drawHeight = ih * scale
img.hAlign = "CENTER"
story.append(img)
story.append(p(fname, s["cap"]))
story.append(PageBreak())
story.append(p("Revalidation", s["h1"]))
story.append(p("Second full walkthrough after favicon, amount-column alignment, and Fleet form/overflow fixes: 32 screenshots, 0 application console errors. Surfaces are visually consistent (indigo, 12px radius, dollars). Tasks (review, credit, quote, edit customer, mask key, run trace) are obvious from primary buttons. Remaining work is identity join, staff auth, and catalog IA — listed above.", s["body"]))
doc = SimpleDocTemplate(
str(OUT),
pagesize=letter,
leftMargin=0.75 * inch,
rightMargin=0.75 * inch,
topMargin=0.7 * inch,
bottomMargin=0.7 * inch,
title="Verae Time × Zapier UI review",
author="UI-Docs",
)
doc.build(story, onFirstPage=header_footer, onLaterPages=header_footer)
print(OUT)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,239 @@
#!/usr/bin/env node
/** Headless Chrome walkthrough: screenshots + console errors for every UI. */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer-core';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const SHOTS = path.join(ROOT, 'screenshots');
const LOG = path.join(ROOT, 'walkthrough-console.json');
const CHROME =
process.env.CHROME_PATH ||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
fs.mkdirSync(SHOTS, { recursive: true });
const errors = [];
function note(pageName, type, text) {
errors.push({ page: pageName, type, text: String(text).slice(0, 800) });
}
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: ['--no-sandbox', '--window-size=1440,900', '--disable-dev-shm-usage'],
defaultViewport: { width: 1440, height: 900 },
});
async function pageFor(name) {
const page = await browser.newPage();
page.on('pageerror', (err) => note(name, 'pageerror', err.message));
page.on('console', (msg) => {
if (msg.type() === 'error') note(name, 'console', msg.text());
});
page.on('response', (res) => {
if (res.status() >= 500) note(name, 'http', `${res.status()} ${res.url()}`);
});
return page;
}
async function shot(page, file) {
await page.screenshot({ path: path.join(SHOTS, file), fullPage: true });
}
async function safeGoto(page, url, name) {
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 });
} catch (err) {
note(name, 'goto', `${url} ${err.message}`);
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 12000 });
} catch (err2) {
note(name, 'goto2', `${url} ${err2.message}`);
}
}
}
try {
// Operator console
{
const p = await pageFor('fleet');
await safeGoto(p, 'http://127.0.0.1:3850/', 'fleet');
await new Promise((r) => setTimeout(r, 1500));
await shot(p, '01-console-fleet.png');
const more = await p.$('.more-btn');
if (more) {
await more.click();
await new Promise((r) => setTimeout(r, 300));
await shot(p, '01b-console-fleet-overflow.png');
await p.keyboard.press('Escape');
}
await p.click('nav button[data-tab="trace"]');
await new Promise((r) => setTimeout(r, 400));
await shot(p, '02-console-trace.png');
const run = await p.$('#run');
if (run) {
await run.click();
await new Promise((r) => setTimeout(r, 2500));
await shot(p, '02b-console-trace-run.png');
}
await p.click('nav button[data-tab="docs"]');
await new Promise((r) => setTimeout(r, 400));
await shot(p, '03-console-docs.png');
await p.close();
}
// Simulator
{
const p = await pageFor('simulator');
await safeGoto(p, 'http://127.0.0.1:3847/', 'simulator');
await new Promise((r) => setTimeout(r, 800));
await shot(p, '04-simulator.png');
const run = await p.$('#run');
if (run) {
await run.click();
await new Promise((r) => setTimeout(r, 2500));
await shot(p, '04b-simulator-run.png');
}
await p.close();
}
// Staff UIs
for (const [name, url, file, extra] of [
['cs', 'http://127.0.0.1:3011/', '05-cs.png', true],
['sales', 'http://127.0.0.1:3012/', '06-sales.png', false],
['accounting', 'http://127.0.0.1:3013/', '07-accounting.png', false],
['staff', 'http://127.0.0.1:3025/', '08-access-staff.png', true],
]) {
const p = await pageFor(name);
await safeGoto(p, url, name);
await shot(p, file.replace('.png', '-empty.png'));
const btn = await p.$('button');
if (btn) {
await btn.click();
await new Promise((r) => setTimeout(r, 800));
}
await shot(p, file);
if (extra) {
const credit = await p.$('button[onclick="credit()"]');
if (credit) {
await credit.click();
await new Promise((r) => setTimeout(r, 800));
await shot(p, file.replace('.png', '-credit.png'));
}
}
await p.close();
}
// Admin
{
const p = await pageFor('admin');
await safeGoto(p, 'http://127.0.0.1:3000/admin/', 'admin');
await shot(p, '09-admin-login.png');
await p.type('#login-username', 'admin');
await p.type('#login-password', 'admin-dev-key');
await p.click('#login-form button[type="submit"]');
await new Promise((r) => setTimeout(r, 1200));
await shot(p, '10-admin-rate-card.png');
const tabs = await p.$$('aside nav button');
for (const [i, label] of [
[1, '11-admin-tiers.png'],
[2, '12-admin-customers.png'],
[3, '13-admin-statement.png'],
[4, '14-admin-invoices.png'],
[5, '15-admin-reports.png'],
]) {
if (tabs[i]) {
await tabs[i].click();
await new Promise((r) => setTimeout(r, 600));
await shot(p, label);
}
}
const edit = await p.$('button[onclick^="openCustomerDrawer"]');
if (edit) {
await p.click('aside nav button[data-tab="customers"]');
await new Promise((r) => setTimeout(r, 400));
const e2 = await p.$('button[onclick^="openCustomerDrawer"]');
if (e2) {
await e2.click();
await new Promise((r) => setTimeout(r, 400));
await shot(p, '12b-admin-customer-drawer.png');
await p.evaluate(() => window.closeCustomerDrawer?.());
}
}
await p.close();
}
// Portal
{
const p = await pageFor('portal');
await safeGoto(p, 'http://127.0.0.1:3000/portal/', 'portal');
await shot(p, '16-portal-login.png');
const toggle = await p.$('#auth-toggle');
if (toggle) {
await toggle.click();
await new Promise((r) => setTimeout(r, 200));
}
const email = `walk+${Date.now()}@example.com`;
const nameEl = await p.$('#auth-name');
if (nameEl) await nameEl.type('Walkthrough User');
await p.type('#auth-email', email);
await p.type('#auth-password', 'Walkthrough1!');
await p.click('#auth-submit');
await new Promise((r) => setTimeout(r, 1500));
await shot(p, '17-portal-dashboard.png');
const invoices = await p.$('nav button[data-tab="invoices"]');
if (invoices) {
await invoices.click();
await new Promise((r) => setTimeout(r, 400));
await shot(p, '18-portal-invoices-empty.png');
}
const stmt = await p.$('nav button[data-tab="statement"]');
if (stmt) {
await stmt.click();
await new Promise((r) => setTimeout(r, 400));
await shot(p, '19-portal-statement.png');
}
const docs = await p.$('nav button[data-tab="docs"]');
if (docs) {
await docs.click();
await new Promise((r) => setTimeout(r, 400));
await shot(p, '20-portal-api-pricing.png');
}
await p.close();
}
// Swagger (stock)
{
const p = await pageFor('swagger');
await safeGoto(p, 'http://127.0.0.1:3000/docs', 'swagger');
await new Promise((r) => setTimeout(r, 800));
await shot(p, '21-swagger-stock.png');
await p.close();
}
// Catalog (local if served, else public)
{
const p = await pageFor('catalog');
const local = 'http://127.0.0.1:8765/';
try {
const r = await fetch(local, { signal: AbortSignal.timeout(1500) });
await safeGoto(p, r.ok ? local : 'https://zapier.georgelambert.org/', 'catalog');
} catch {
await safeGoto(p, 'https://zapier.georgelambert.org/', 'catalog');
}
await new Promise((r) => setTimeout(r, 800));
await shot(p, '22-catalog.png');
await p.close();
}
} finally {
await browser.close();
}
const real = errors.filter((e) => !/favicon|Failed to load resource.*404/.test(e.text));
fs.writeFileSync(LOG, JSON.stringify({ capturedAt: new Date().toISOString(), errors: real, all: errors }, null, 2));
console.log(JSON.stringify({ shots: fs.readdirSync(SHOTS).length, errors: real.length }, null, 2));
if (real.length) {
for (const e of real) console.log(`ERR ${e.page} ${e.type}: ${e.text}`);
}

View file

@ -0,0 +1,11 @@
{
"capturedAt": "2026-09-11T21:58:47.138Z",
"errors": [],
"all": [
{
"page": "catalog",
"type": "console",
"text": "Failed to load resource: the server responded with a status of 404 (File not found)"
}
]
}

View file

@ -2,6 +2,8 @@
Staff access plane for CS / sales / accounting. Credits and statement review after `verae.access.authz.check` with plane `staff`.
HTTP UI at **http://0.0.0.0:3025/** (indigo shell, dollars and names). JSON still at `/health`, `/review/:id`, `POST /credits`.
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-staff
Port `:3025`.

View file

@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Staff access — account review</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
.row > div { min-width:10rem; }
button { border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer; background:var(--accent); color:#fff; }
table { border-collapse:collapse; width:100%; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
.money { font-variant-numeric:tabular-nums; }
.muted { color:var(--muted); }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.err { color:var(--danger); }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="app">
<div class="kicker">staff plane · after authz</div>
<h1>Staff access</h1>
<p>CS / sales / accounting door. Review and credit go through <code>verae.access.authz.check</code>, then account-balance. Amounts are dollars; the customer name is shown when the ledger has it.</p>
</header>
<main id="main">
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
</div>
<button type="button" onclick="review()">Review account</button>
</div>
</div>
<div class="card">
<h3>Apply credit</h3>
<div class="row">
<div>
<label for="dollars">Amount (USD)</label>
<input id="dollars" type="number" min="0.01" step="0.01" value="5.00" />
</div>
<div>
<label for="reason">Reason</label>
<input id="reason" value="goodwill" />
</div>
<div>
<label for="agent">Agent name</label>
<input id="agent" value="cs" />
</div>
<button type="button" onclick="credit()">Apply credit</button>
</div>
</div>
<div id="out"></div>
</main>
<script>
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
const empty = (title, hint) =>
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div>${title}</div><p>${hint}</p></div>`;
function moneyTable(list, cols, emptyTitle) {
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
const body = list.map((r) => '<tr>' + cols.map((c) => {
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
async function review() {
const id = document.getElementById('id').value.trim();
try {
const r = await fetch('/review/' + encodeURIComponent(id));
const st = await r.json();
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
const name = st.name || st.customerName || id;
document.getElementById('out').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
</div>
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
async function credit() {
const body = {
customerId: document.getElementById('id').value.trim(),
cents: Math.round(Number(document.getElementById('dollars').value) * 100),
reason: document.getElementById('reason').value,
agent: document.getElementById('agent').value,
};
const r = await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) {
const st = await r.json().catch(() => ({}));
document.getElementById('out').innerHTML = `<div class="card err">${st.error || r.status}</div>`;
return;
}
await review();
}
</script>
</body>
</html>

View file

@ -1,7 +1,11 @@
#!/usr/bin/env node
/** Staff access plane (CS / sales / accounting). Not Zapier, not customer API. */
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const PORT = Number(process.env.PORT || 3025);
const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, '');
const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, '');
@ -23,6 +27,11 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify(obj));
};
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
return;
}
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE });
}

View file

@ -36,6 +36,11 @@ test('staff plane can credit after authz; zapier cannot', async () => {
try {
const h = await (await fetch(`http://127.0.0.1:${staffPort}/health`)).json();
assert.equal(h.plane, 'staff');
const page = await fetch(`http://127.0.0.1:${staffPort}/`);
assert.match(page.headers.get('content-type') || '', /text\/html/);
const html = await page.text();
assert.match(html, /Staff access/);
assert.match(html, /Amount \(USD\)/);
const add = await fetch(`http://127.0.0.1:${staffPort}/credits`, {
method: 'POST',
headers: { 'content-type': 'application/json' },

View file

@ -11,7 +11,7 @@ node src/cli.js serve
| Tab | Purpose |
|-----|---------|
| **Fleet** | Machines (SSH/local), replica floors, green/yellow/red, RTT min/avg/p50/p90 spark, pause/restart/off with confirm, ssh-check toast |
| **Fleet** | Machines (SSH/local), replica floors, green/yellow/red, RTT spark. Replica and service actions live in one **⋯** overflow menu. Power column shows on/paused/off as status, not toggles. Add-machine roles are chips. |
| **Trace** | In-process Zapier simulator: run a step, hop log, monitors/suggestions |
| **Docs** | Reading order: overview → module summaries → architecture → remaining docs |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Before After
Before After

View file

@ -4,6 +4,7 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Verae operator console</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
@ -11,21 +12,29 @@
--ok:#047857; --ok-bg:#e3f6e8; --warn:#8a5a00; --warn-bg:#fff3bf;
--err:#dc2626; --err-bg:#f9d4d4; --radius:12px;
--shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
--fault:#ea580c;
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:30; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header.app {
background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%);
color:#eef0fe; padding:1rem 1.25rem 0; display:flex; flex-wrap:wrap; gap:.75rem 1.5rem; align-items:flex-end;
}
header.app h1 { margin:0; font-size:1.2rem; }
header.app p { margin:.2rem 0 .8rem; font-size:13px; opacity:.85; max-width:42rem; }
nav { display:flex; gap:.25rem; }
header.app p { margin:.2rem 0 .8rem; font-size:13px; opacity:.85; max-width:44rem; }
nav { display:flex; gap:.15rem; }
nav button {
border:0; background:transparent; color:#eef0fe; padding:.55rem .9rem; border-radius:10px 10px 0 0;
font:650 13px system-ui; cursor:pointer; opacity:.8;
border:0; background:transparent; color:#c7c9ff; min-height:44px; padding:.7rem 1.15rem;
border-radius:10px 10px 0 0; font:650 14px system-ui; cursor:pointer;
}
nav button.on { background:var(--bg); color:var(--accent); opacity:1; }
nav button:hover { color:#fff; background:rgba(255,255,255,.08); }
nav button.on { background:var(--bg); color:var(--accent); }
nav button:focus-visible, button.act:focus-visible, a.act:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline:2px solid #fff; outline-offset:2px;
}
nav button.on:focus-visible { outline-color:var(--accent); }
main { padding:1rem 1.25rem 2.5rem; max-width:1280px; margin:0 auto; }
.panel { display:none; }
.panel.on { display:block; }
@ -47,44 +56,71 @@
button.act, a.act { margin:.15rem .2rem 0 0; border:1px solid var(--line); background:#fff; border-radius:8px; padding:.28rem .5rem; font:650 12px system-ui; cursor:pointer; text-decoration:none; color:var(--ink); display:inline-block; }
button.act.primary, a.act.primary { background:var(--accent); color:#fff; border:0; }
button.act.danger { color:var(--err); }
table { width:100%; border-collapse:collapse; background:var(--panel); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow); }
button.act.fault { background:var(--fault); color:#fff; border:0; }
.table-wrap { overflow:auto; border-radius:var(--radius); box-shadow:var(--shadow); }
table { width:100%; border-collapse:collapse; background:var(--panel); overflow:visible; }
th,td { text-align:left; padding:.45rem .6rem; border-bottom:1px solid var(--line); font-size:13px; vertical-align:top; }
th { font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); background:#fafbff; }
tr.operational { background:var(--ok-bg); }
tr.degraded { background:var(--warn-bg); }
tr.down { background:var(--err-bg); }
.inst { font:12px ui-monospace,Menlo,monospace; margin:.2rem 0; }
.inst { font:12px ui-monospace,Menlo,monospace; margin:.2rem 0; display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; }
form.add { display:flex; flex-wrap:wrap; gap:.4rem; margin:.4rem 0 1rem; align-items:end; }
form.add .roles-field { flex: 1 0 100%; }
form.add label { font-size:11px; color:var(--muted); display:flex; flex-direction:column; gap:.15rem; }
form.add input, form.add select, aside input, aside select, aside textarea {
padding:.4rem .5rem; border:1px solid var(--line); border-radius:8px; font:13px ui-monospace,Menlo,monospace;
}
.roles { display:flex; flex-wrap:wrap; gap:.35rem; max-width:22rem; }
.roles label {
display:inline-flex; align-items:center; gap:.25rem; font:650 11px system-ui; color:var(--ink);
background:var(--soft); border:1px solid #d7dbf8; border-radius:999px; padding:.2rem .55rem; cursor:pointer;
}
.roles input { accent-color:var(--accent); }
#toast { position:fixed; right:1rem; bottom:1rem; max-width:28rem; background:#171a26; color:#eef0fe; padding:.8rem 1rem; border-radius:10px; display:none; white-space:pre-wrap; font:12px ui-monospace,Menlo,monospace; z-index:9; }
.layout { display:grid; grid-template-columns:minmax(260px,340px) 1fr; gap:1rem; min-height:70vh; }
@media (max-width:900px) { .layout { grid-template-columns:1fr; } }
aside.trace { background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow); padding:1rem; }
.console { background:#0f171d; color:#d7e2ea; border-radius:10px; min-height:220px; overflow:auto; font:12px/1.35 ui-monospace,Menlo,monospace; }
.hop { display:grid; grid-template-columns:52px 18px 150px 1fr; gap:.4rem; padding:.28rem .7rem; border-left:3px solid transparent; }
.hop { display:grid; grid-template-columns:4.5rem 1.2rem minmax(7rem,11rem) minmax(0,1fr); gap:.4rem; padding:.28rem .7rem; border-left:3px solid transparent; }
.hop .mod, .hop .addr, .hop strong { overflow-wrap:anywhere; word-break:break-word; }
.hop.ok { border-color:var(--ok); }
.hop.error { border-color:var(--err); color:#ffb4b0; }
.hop.silent { border-color:#6a7a86; color:#8fa0ab; }
.hop.retry,.hop.delay { border-color:#d39b00; }
.mod { color:#9ad7c2; }
.addr { color:#8cb4ea; }
.docs a { color:var(--accent); }
.docs li { margin:.35rem 0; }
.docs .card p { margin:.35rem 0 .7rem; color:var(--muted); font-size:13px; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
code { font:12px ui-monospace,Menlo,monospace; }
.spark { display:flex; gap:3px; align-items:flex-end; height:28px; margin:.35rem 0; }
.spark i { display:block; width:8px; background:var(--accent); border-radius:2px 2px 0 0; opacity:.85; }
.more { position:relative; display:inline-block; }
.more-btn { min-width:2rem; font-size:16px; line-height:1; }
.more-pop {
display:none; position:absolute; right:0; top:calc(100% + 4px); z-index:6;
background:#fff; border:1px solid var(--line); border-radius:10px;
box-shadow:var(--shadow); min-width:11rem; padding:.25rem; z-index:40;
}
.more.open .more-pop { display:block; }
.more-pop button {
display:block; width:100%; text-align:left; border:0; background:none;
padding:.45rem .6rem; border-radius:8px; font:650 12px system-ui; cursor:pointer; color:var(--ink);
}
.more-pop button:hover { background:var(--soft); }
.more-pop button.danger { color:var(--err); }
.faults { border:1px solid #fed7aa; background:#fff7ed; border-radius:10px; padding:.55rem .7rem; }
.empty { text-align:center; padding:1.4rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .5rem; }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="app">
<div>
<div class="kicker">loopback · not public NATS</div>
<div class="kicker">operator LAN · binds 0.0.0.0:3850</div>
<h1>Verae operator console</h1>
<p>Fleet, message trace, and docs in one shell. Pause does not count toward replica floors. SSH keys stay on disk.</p>
<p>Fleet, message trace, and docs in one shell. HTTP listens on all interfaces; NATS stays private. Pause does not count toward replica floors. SSH keys stay on disk.</p>
</div>
<nav>
<button class="on" data-tab="fleet">Fleet</button>
@ -92,7 +128,7 @@
<button data-tab="docs">Docs</button>
</nav>
</header>
<main>
<main id="main">
<section id="fleet" class="panel on">
<p id="meta" class="muted"></p>
<h2>Machines</h2>
@ -104,7 +140,18 @@
<label>ssh port <input name="sshPort" type="number" value="22"/></label>
<label>identity file <input name="identityFile" value="~/.ssh/id_ed25519"/></label>
<label>capacity <input name="capacity" type="number" min="1" value="8"/></label>
<label>roles <input name="roles" value="tree-node,archive-worm"/></label>
<label class="roles-field">roles
<div class="roles" id="roleChips">
<label><input type="checkbox" name="role" value="*"/> *</label>
<label><input type="checkbox" name="role" value="tree-node" checked/> tree-node</label>
<label><input type="checkbox" name="role" value="archive-worm" checked/> archive-worm</label>
<label><input type="checkbox" name="role" value="archive-aggregator"/> archive-aggregator</label>
<label><input type="checkbox" name="role" value="job-poller"/> job-poller</label>
<label><input type="checkbox" name="role" value="webhook-deliver"/> webhook-deliver</label>
<label><input type="checkbox" name="role" value="nats"/> nats</label>
<label><input type="checkbox" name="role" value="edge"/> edge</label>
</div>
</label>
<button class="act primary" type="submit">Add machine</button>
</form>
<div class="cards" id="machines"></div>
@ -128,9 +175,12 @@ invoice-1003</textarea>
<p><label><input type="checkbox" id="includeAttached"/> includeAttached</label>
<label><input type="checkbox" id="includeTree"/> includeTree</label></p>
<h2>Faults</h2>
<p class="muted"><label><input type="checkbox" id="failEdge402"/> 402</label>
<label><input type="checkbox" id="failNatsDropEvents"/> drop events</label>
<label><input type="checkbox" id="recover"/> recover</label></p>
<div class="faults">
<p class="muted" style="margin:.1rem 0 .35rem">Orange is inject-only — not the product brand.</p>
<p class="muted"><label><input type="checkbox" id="failEdge402"/> 402</label>
<label><input type="checkbox" id="failNatsDropEvents"/> drop events</label>
<label><input type="checkbox" id="recover"/> recover</label></p>
</div>
<p>
<button class="act primary" id="run">Run step</button>
<button class="act" id="reset">Reset world</button>
@ -150,43 +200,44 @@ invoice-1003</textarea>
</div>
</section>
<section id="docs" class="panel docs">
<p class="muted">Reading order: high-level first, then architecture, then the rest. Catalog defaults to colored PDFs. Operator console is loopback only.</p>
<p class="muted">Reading order: high-level first, then architecture, then the rest. Catalog defaults to colored PDFs. Operator HTTP binds all interfaces; NATS does not.</p>
<p><a class="act primary" href="/docs/CONSOLE.pdf" target="_blank" rel="noopener">Open CONSOLE.pdf</a>
· <a href="https://zapier.georgelambert.org/">PDF catalog</a>
· <a href="https://zapier.georgelambert.org/index-md.html">Markdown indexes</a></p>
<h2>High-level (start here)</h2>
<ul>
<li><a href="https://zapier.georgelambert.org/overview/README.pdf">Overview</a> — whole system in plain language</li>
<li><a href="https://zapier.georgelambert.org/overview/02-modules-and-repos.pdf">Modules and repositories</a> — short names of every independent repo</li>
<li><a href="https://zapier.georgelambert.org/docs-master/README.pdf">docs-master</a> — one-line SUMMARY + NATS for each module</li>
<li><a href="https://zapier.georgelambert.org/user-docs/README.pdf">User guide</a> — signup → register → lookup (non-operator)</li>
</ul>
<div class="cards">
<article class="card"><h3>Overview</h3><p>Whole system in plain language.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/README.pdf">Open PDF</a></article>
<article class="card"><h3>Modules and repositories</h3><p>Short names of every independent repo.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/02-modules-and-repos.pdf">Open PDF</a></article>
<article class="card"><h3>docs-master</h3><p>One-line SUMMARY + NATS for each module.</p><a class="act primary" href="https://zapier.georgelambert.org/docs-master/README.pdf">Open PDF</a></article>
<article class="card"><h3>User guide</h3><p>Signup → register → lookup (non-operator).</p><a class="act primary" href="https://zapier.georgelambert.org/user-docs/README.pdf">Open PDF</a></article>
</div>
<h2>Architecture</h2>
<ul>
<li><a href="https://zapier.georgelambert.org/overview/03-nats-cluster.pdf">NATS.IO 3-server cluster</a></li>
<li><a href="https://zapier.georgelambert.org/overview/04-uptime.pdf">Uptime / replica floors</a></li>
<li><a href="https://zapier.georgelambert.org/overview/05-network-failures.pdf">Local network failures</a></li>
<li><a href="https://zapier.georgelambert.org/overview/06-address-routing.pdf">Address routing</a></li>
<li><a href="https://zapier.georgelambert.org/overview/08-diagrams.pdf">Diagrams</a> · <a href="https://zapier.georgelambert.org/overview/diagrams/system.svg">system.svg</a></li>
<li><a href="https://zapier.georgelambert.org/docs/02-architecture/composition.pdf">Composition</a></li>
<li><a href="https://zapier.georgelambert.org/docs/02-architecture/modules-and-nats.pdf">Module NATS map</a></li>
<li><a href="https://zapier.georgelambert.org/docs/02-architecture/archive-nats.pdf">Archive / bloom</a></li>
<li><a href="https://zapier.georgelambert.org/docs/02-architecture/tree-nodes.pdf">Tree nodes</a></li>
<li><a href="https://zapier.georgelambert.org/docs/02-architecture/fleet.pdf">Fleet</a></li>
</ul>
<div class="cards">
<article class="card"><h3>NATS.IO 3-server cluster</h3><p>Loopback NATS, not a public bus.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/03-nats-cluster.pdf">Open PDF</a></article>
<article class="card"><h3>Uptime / replica floors</h3><p>Pause does not count toward floors.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/04-uptime.pdf">Open PDF</a></article>
<article class="card"><h3>Local network failures</h3><p>What the monitors flag.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/05-network-failures.pdf">Open PDF</a></article>
<article class="card"><h3>Address routing</h3><p>Access planes to internal subjects.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/06-address-routing.pdf">Open PDF</a></article>
<article class="card"><h3>Diagrams</h3><p>System, cluster, routing.</p><a class="act" href="https://zapier.georgelambert.org/overview/08-diagrams.pdf">PDF</a> <a class="act" href="https://zapier.georgelambert.org/overview/diagrams/system.svg">SVG</a></article>
<article class="card"><h3>Composition</h3><p>How packages sit together.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/composition.pdf">Open PDF</a></article>
<article class="card"><h3>Module NATS map</h3><p>Subjects per repo.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/modules-and-nats.pdf">Open PDF</a></article>
<article class="card"><h3>Archive / bloom</h3><p>WORM nodes.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/archive-nats.pdf">Open PDF</a></article>
<article class="card"><h3>Tree nodes</h3><p>Merkle proofs for hashes.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/tree-nodes.pdf">Open PDF</a></article>
<article class="card"><h3>Fleet</h3><p>Replica floors and spawn.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/fleet.pdf">Open PDF</a></article>
</div>
<h2>Remaining documentation</h2>
<ul>
<li><a href="https://zapier.georgelambert.org/overview/INDEX.pdf">Full documentation index</a></li>
<li><a href="https://zapier.georgelambert.org/overview/09-expansion-template.pdf">Expansion template</a> · <a href="https://git.georgelambert.org/marchon/verae-nats-process">verae-nats-process</a></li>
<li><a href="https://zapier.georgelambert.org/docs/04-activate/SETUP-ZAPIER-DEVELOPER.pdf">Zapier developer setup</a></li>
<li><a href="https://zapier.georgelambert.org/packages/verae-zapier-simulator/README.pdf">Simulator README</a></li>
<li><a href="https://git.georgelambert.org/marchon/verae-fleet">verae-fleet repo</a></li>
</ul>
<div class="cards">
<article class="card"><h3>Full index</h3><p>Every remaining document.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/INDEX.pdf">Open PDF</a></article>
<article class="card"><h3>Expansion template</h3><p>New verae.* worker.</p><a class="act" href="https://zapier.georgelambert.org/overview/09-expansion-template.pdf">PDF</a> <a class="act" href="https://git.georgelambert.org/marchon/verae-nats-process">repo</a></article>
<article class="card"><h3>Zapier developer setup</h3><p>Platform CLI, not a customer surface.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/04-activate/SETUP-ZAPIER-DEVELOPER.pdf">Open PDF</a></article>
<article class="card"><h3>Simulator README</h3><p>Hop-by-hop lab.</p><a class="act primary" href="https://zapier.georgelambert.org/packages/verae-zapier-simulator/README.pdf">Open PDF</a></article>
<article class="card"><h3>verae-fleet repo</h3><p>Source for this console.</p><a class="act primary" href="https://git.georgelambert.org/marchon/verae-fleet">Open repo</a></article>
</div>
</section>
</main>
<div id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const EMPTY_SVG = '<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg>';
async function j(url, opts) {
const r = await fetch(url, opts);
const body = await r.json().catch(() => ({}));
@ -211,6 +262,14 @@ function pill(health) {
const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : 'bad';
return `<span class="pill ${cls}">${label}</span>`;
}
function moreMenu(items) {
return `<div class="more">
<button type="button" class="act more-btn" aria-haspopup="menu" aria-label="Actions"></button>
<div class="more-pop" role="menu">${items.map((it) =>
`<button type="button" role="menuitem" class="${it.danger ? 'danger' : ''}" data-path="${it.path}" data-confirm="${it.confirm || ''}">${it.label}</button>`
).join('')}</div>
</div>`;
}
async function act(path, { confirm: c } = {}) {
if (c && !confirm(c)) return;
const r = await j(path, { method:'POST' });
@ -218,39 +277,68 @@ async function act(path, { confirm: c } = {}) {
else if (!r.ok) toast(r.body.error || r.body);
await drawFleet();
}
document.addEventListener('click', (e) => {
const item = e.target.closest('.more-pop button[data-path]');
if (item) {
act(item.dataset.path, { confirm: item.dataset.confirm || undefined });
item.closest('.more')?.classList.remove('open');
return;
}
const btn = e.target.closest('.more-btn');
if (btn) {
const wrap = btn.closest('.more');
document.querySelectorAll('.more.open').forEach((m) => { if (m !== wrap) m.classList.remove('open'); });
wrap.classList.toggle('open');
e.stopPropagation();
return;
}
document.querySelectorAll('.more.open').forEach((m) => m.classList.remove('open'));
});
document.querySelectorAll('nav button').forEach((b) => b.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach((x) => x.classList.toggle('on', x === b));
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('on', p.id === b.dataset.tab));
}));
async function drawFleet() {
const { body: s } = await j('/api/status');
$('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '') + ' · loopback :3850';
$('machines').innerHTML = (s.machines || []).map((m) => {
$('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '') + ' · HTTP 0.0.0.0:3850';
const machines = s.machines || [];
$('machines').innerHTML = machines.length ? machines.map((m) => {
const health = !m.enabled ? 'down' : m.running > 0 ? 'operational' : 'degraded';
return `<article class="card ${health}">
<h3>${m.id} ${pill(health)}</h3>
<div class="muted">${m.title || ''}</div>
<div><code>${m.kind === 'ssh' ? (m.user||'marchon')+'@' : ''}${m.host}</code></div>
<div class="muted">${m.kind} · ${m.running}/${m.capacity} running · ${m.available} avail</div>
<div class="muted">${(m.roles||[]).map((r) => `<span class="pill">${r}</span>`).join(' ')}</div>
${spark(m.rtt)}
<div>
<button class="act" onclick="act('/api/machines/${m.id}/${m.enabled ? 'disable' : 'enable'}')">${m.enabled ? 'disable' : 'enable'}</button>
${m.kind === 'ssh' ? `<button class="act" onclick="act('/api/machines/${m.id}/check')">ssh-check</button>` : ''}
</div>
</article>`;
}).join('');
$('rows').innerHTML = '<table><thead><tr><th>Service</th><th>State</th><th>Floor</th><th>RTT</th><th>Instances</th><th></th></tr></thead><tbody>' +
Object.values(s.services).map((sv) => {
}).join('') : `<div class="empty">${EMPTY_SVG}<div>No machines yet</div><p>Add a machine above. Roles are chips, not a truncated text field.</p></div>`;
const services = Object.values(s.services || {});
if (!services.length) {
$('rows').innerHTML = `<div class="empty">${EMPTY_SVG}<div>No services</div><p>Fleet catalog is empty on this host.</p></div>`;
return;
}
$('rows').innerHTML = '<div class="table-wrap"><table><thead><tr><th>Service</th><th>Health</th><th>Floor</th><th>RTT</th><th>Instances</th><th>Power</th></tr></thead><tbody>' +
services.map((sv) => {
const h = sv.health || 'down';
const inst = (sv.instances || []).map((i) => {
const st = !i.pid ? 'dead' : i.paused ? 'paused' : i.healthy === false ? 'unhealthy' : 'up';
const cls = st === 'up' ? 'good' : st === 'paused' ? 'warn' : 'bad';
return `<div class="inst"><span class="pill ${cls}">${st}</span> ${i.id} @${i.machine||'local'} :${i.healthPort}
<button class="act" onclick="act('/api/instances/${i.id}/pause')">pause</button>
<button class="act" onclick="act('/api/instances/${i.id}/resume')">resume</button>
<button class="act danger" onclick="act('/api/instances/${i.id}/restart',{confirm:'Restart ${i.id}?'})">restart</button>
${moreMenu([
{ label: 'Pause replica', path: `/api/instances/${i.id}/pause` },
{ label: 'Resume replica', path: `/api/instances/${i.id}/resume` },
{ label: 'Restart replica', path: `/api/instances/${i.id}/restart`, confirm: `Restart ${i.id}?`, danger: true },
])}
</div>`;
}).join('') || '<span class="muted">none</span>';
const pausedAll = (sv.instances || []).length && (sv.instances || []).every((i) => i.paused);
const power = h === 'down' || !sv.available ? 'off' : pausedAll ? 'paused' : 'on';
const powerCls = power === 'on' ? 'good' : power === 'paused' ? 'warn' : 'bad';
return `<tr class="${h}">
<td><strong>${sv.id}</strong><br><span class="muted">${sv.title||''}</span></td>
<td>${pill(h)}</td>
@ -258,26 +346,32 @@ async function drawFleet() {
<td>${spark(sv.rtt)}</td>
<td>${inst}</td>
<td>
<button class="act" onclick="act('/api/services/${sv.id}/start')">on</button>
<button class="act" onclick="act('/api/services/${sv.id}/pause')">pause</button>
<button class="act danger" onclick="act('/api/services/${sv.id}/stop',{confirm:'Turn off ${sv.id}? This disables keepFloor.'})">off</button>
<span class="pill ${powerCls}" title="Status, not a toggle">${power}</span>
${moreMenu([
{ label: 'Start (keep floor)', path: `/api/services/${sv.id}/start` },
{ label: 'Pause all replicas', path: `/api/services/${sv.id}/pause` },
{ label: 'Stop (disable keepFloor)', path: `/api/services/${sv.id}/stop`, confirm: `Turn off ${sv.id}? This disables keepFloor.`, danger: true },
])}
</td>
</tr>`;
}).join('') + '</tbody></table>';
}).join('') + '</tbody></table></div>';
}
$('addMachine').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const roles = [...f.querySelectorAll('input[name=role]:checked')].map((el) => el.value);
await j('/api/machines', {
method:'POST', headers:{'content-type':'application/json'},
body: JSON.stringify({
id: f.id.value, host: f.host.value, kind: f.kind.value, user: f.user.value,
sshPort: Number(f.sshPort.value||22), identityFile: f.identityFile.value,
capacity: Number(f.capacity.value||8),
roles: f.roles.value.split(',').map(s=>s.trim()).filter(Boolean), enabled: true,
roles: roles.length ? roles : ['tree-node'], enabled: true,
}),
});
f.reset();
f.querySelector('input[name=role][value="tree-node"]').checked = true;
f.querySelector('input[name=role][value="archive-worm"]').checked = true;
await drawFleet();
});
async function bootTrace() {

View file

@ -46,7 +46,7 @@ Optional: `verae-activate` (tiny pushable app), `verae-zapier-simulator` (trace)
| middleware | Node 22+, `NATS_URL` **or** `NATS_ENABLED=false`, chain URL or `MOCK_VERAE` |
| NATS | JetStream, **no public bind** |
| worm / tree-node | Node, `NATS_URL`, local state dir |
| fleet | Node, `machines.json` (SSH **paths**), loopback `:3850` |
| fleet | Node, `machines.json` (SSH **paths**), HTTP `:3850` on `0.0.0.0` |
| Zapier cloud app | `MIDDLEWARE_BASE_URL` = **public HTTPS of zappier-edge** (or middleware if you skip metering in lab) |
## Ports (lab defaults)
@ -58,7 +58,7 @@ Optional: `verae-activate` (tiny pushable app), `verae-zapier-simulator` (trace)
| 4222 | **private only** | NATS client |
| 6222 | **private only** | NATS cluster |
| 8222 | private / localhost | NATS monitor |
| 3847 | loopback | simulator |
| 3850 | loopback | operator console |
| 3847 | 0.0.0.0 | simulator |
| 3850 | 0.0.0.0 | operator console |
Chain: `https://api.veraetime.net` (prod) or `MOCK_VERAE=true` (lab).

View file

@ -30,7 +30,7 @@ Public PDFs of the same files: `https://zapier.georgelambert.org/packages/verae-
| NATS binds **loopback or a private docker/VM net** | Not on the public NIC |
| Tree-node **min 3**, pause does not count | Bulk Merkle lookups |
| Private keys stay on disk; git stores **paths** | `machines.json` `identityFile` |
| Operator console is **loopback** `:3850` | Not a public site |
| Operator console HTTP binds **0.0.0.0:3850** | Not a public NATS bus; NATS stays private |
## Quick lab (one machine)

View file

@ -28,7 +28,7 @@ Faults you can inject: edge 401/402, chain timeout, NATS watch/events drop, arch
The monitor panel flags broken assumptions (Zapier talking to NATS, file bytes on chain, bloom miss sending a reply) and suggests changes (tree-node search, wait+hook pairing, 402 upgrade URL, archive quorum).
**Catalog:** https://zapier.georgelambert.org/packages/verae-zapier-simulator/README.pdf
**Live sample UI (loopback):** http://127.0.0.1:3847/ — also inside the operator console Trace tab.
**Live sample UI:** http://0.0.0.0:3847/ — also inside the operator console Trace tab. Chrome matches the operator indigo shell; orange is fault inject only.
**Static copy:** https://zapier.georgelambert.org/simulator/
Clone: `ssh://git@git.georgelambert.org:2223/marchon/verae-zapier-simulator.git`

View file

@ -4,18 +4,21 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Verae × Zapier simulator</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--ink: #12202c; --muted: #5b6d78; --line: #d5dee4; --bg: #f3efe8;
--panel: #fff; --accent: #ff4a00; --ok: #0f6e56; --err: #a32020;
--warn: #8a5a00; --silent: #6a7a86; --nats: #1d4f91;
--ink: #171a26; --muted: #6b7186; --line: #e5e7f0; --bg: #f4f5fb;
--panel: #fff; --accent: #4f46e5; --fault: #ea580c; --ok: #047857; --err: #dc2626;
--warn: #8a5a00; --silent: #6a7a86; --nats: #4f46e5;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.45 system-ui, sans-serif; color: var(--ink); background: var(--bg); }
header { background: var(--ink); color: #f6f3ee; padding: .9rem 1.1rem; display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
body { margin: 0; font: 14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; color: var(--ink); background: var(--bg); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header { background: linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color: #eef0fe; padding: .9rem 1.1rem; display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
header h1 { font-size: 1.05rem; margin: 0; font-weight: 650; }
header .kicker { letter-spacing: .12em; text-transform: uppercase; font: 700 10px system-ui; opacity: .7; }
header p { margin: 0; color: #c5d0d8; font-size: 13px; }
header .kicker { letter-spacing: .12em; text-transform: uppercase; font: 700 10px system-ui; opacity: .75; }
header p { margin: 0; color: #e4e7ff; font-size: 13px; }
.layout { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; min-height: calc(100vh - 52px); }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
aside { background: var(--panel); border-right: 1px solid var(--line); padding: 1rem; }
@ -26,14 +29,18 @@
.row { display: flex; gap: .5rem; flex-wrap: wrap; }
.checks label { display: flex; gap: .4rem; align-items: center; font-size: 13px; color: var(--ink); margin: .2rem 0; }
.checks input { width: auto; }
button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: .55rem .9rem; font: 650 13px system-ui; cursor: pointer; }
button.ghost { background: #e8eeef; color: var(--ink); }
button { background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: .55rem .9rem; font: 650 13px system-ui; cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.ghost { background: #fff; color: var(--ink); border: 1px solid var(--line); }
button.fault { background: var(--fault); }
.faults { border: 1px solid #fed7aa; background: #fff7ed; border-radius: 10px; padding: .55rem .7rem; }
.main { display: grid; grid-template-rows: auto 1fr auto; min-height: 0; }
.response, .monitors { background: var(--panel); border-bottom: 1px solid var(--line); padding: .8rem 1rem; }
.monitors { border-bottom: 0; border-top: 1px solid var(--line); max-height: 34vh; overflow: auto; }
pre { margin: 0; font: 12px/1.4 ui-monospace, Menlo, monospace; white-space: pre-wrap; }
.console { background: #0f171d; color: #d7e2ea; padding: .6rem 0; overflow: auto; min-height: 220px; }
.hop { display: grid; grid-template-columns: 52px 18px 150px 1fr; gap: .45rem; padding: .28rem .8rem; font: 12px/1.35 ui-monospace, Menlo, monospace; border-left: 3px solid transparent; }
.hop { display: grid; grid-template-columns: 4.5rem 1.2rem minmax(7rem,11rem) minmax(0,1fr); gap: .45rem; padding: .28rem .8rem; font: 12px/1.35 ui-monospace, Menlo, monospace; border-left: 3px solid transparent; }
.hop .mod, .hop .addr, .hop strong { overflow-wrap: anywhere; word-break: break-word; }
.hop:hover { background: #18232c; }
.hop.ok { border-color: var(--ok); }
.hop.error { border-color: var(--err); color: #ffb4b0; }
@ -51,14 +58,15 @@
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header>
<div>
<div class="kicker">simulator · not live Zapier</div>
<div class="kicker">simulator · not live Zapier · binds 0.0.0.0:3847</div>
<h1>Verae Time × Zapier trace console</h1>
</div>
<p>Every hop from Zap input → zappier-edge → middleware → splitter → chain / WORM / tree nodes → Zap output. NATS addresses are in-process. Loopback only.</p>
<p>Every hop from Zap input → zappier-edge → middleware → splitter → chain / WORM / tree nodes → Zap output. NATS addresses are in-process. HTTP on all interfaces; NATS stays private.</p>
</header>
<div class="layout">
<div class="layout" id="main">
<aside>
<h2>Zapier step</h2>
<label>Action</label>
@ -78,7 +86,8 @@
<label><input type="checkbox" id="includeTree"/> includeTree (query tree nodes)</label>
</div>
<h2>Inject faults</h2>
<div class="checks">
<div class="faults checks">
<p style="color:var(--muted);font-size:12px;margin:.1rem 0 .4rem">Orange is the fault accent only.</p>
<label><input type="checkbox" id="failEdge401"/> edge 401</label>
<label><input type="checkbox" id="failEdge402"/> edge 402 quota</label>
<label><input type="checkbox" id="failChainTimeout"/> chain timeout</label>

View file

@ -32,6 +32,13 @@
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
- SSH spawn timeout 8s; failed hosts skipped.
## 2026-09-11 — UI design-system pass
- Restyled CS/sales/accounting/access-staff to portal indigo; dollars + names.
- Fleet ⋯ menus, role chips, Docs cards, 0.0.0.0 copy, simulator indigo (orange = faults).
- Portal masked API key; admin customer drawer; empty invoice illustrations.
- Catalog system-ui. New independent repo **UI-Docs** (walkthrough, screenshots, UI-REVIEW.pdf).
## Second pass (25)
- Identity used from portal when `IDENTITY_URL` set.

View file

@ -4,47 +4,99 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Accounting — customer review</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
body { font-family: -apple-system, sans-serif; margin: 2rem; max-width: 960px; color: #171a26; }
h1 { font-size: 1.3rem; }
label { font-size: 0.8rem; font-weight: 600; display: block; margin-top: 0.6rem; }
input { padding: 0.4rem 0.55rem; }
button { margin-top: 0.8rem; padding: 0.4rem 0.8rem; }
table { border-collapse: collapse; width: 100%; margin-top: 0.6rem; }
th, td { border-bottom: 1px solid #e5e7f0; padding: 0.4rem 0.5rem; text-align: left; font-size: 0.88rem; }
.muted { color: #6b7186; }
.card { border: 1px solid #e5e7f0; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
.stat { font-size: 1.4rem; font-weight: 800; }
:root {
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
button, a.btn { border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer; background:var(--accent); color:#fff; text-decoration:none; display:inline-block; }
a.btn.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
table { border-collapse:collapse; width:100%; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
.money { font-variant-numeric:tabular-nums; }
.muted { color:var(--muted); }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.err { color:var(--danger); }
</style>
</head>
<body>
<h1>Accounting</h1>
<p class="muted">Review payments, usage, credits, and balances. Export invoices to QuickBooks IIF / CSV.</p>
<div class="card">
<label>Customer id</label>
<input id="id" value="cust_1" />
<button onclick="review()">Review</button>
<p class="muted"><a href="/export/quickbooks.iif">QuickBooks IIF</a> · <a href="/export/accounting.csv">CSV</a></p>
</div>
<div id="out"></div>
<a class="skip" href="#main">Skip to content</a>
<header class="app">
<div class="kicker">staff · accounting</div>
<h1>Accounting</h1>
<p>Review payments, usage, credits, and balances in dollars. Export invoices to QuickBooks IIF or CSV.</p>
</header>
<main id="main">
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
</div>
<button type="button" onclick="review()">Review account</button>
<a class="btn ghost" href="/export/quickbooks.iif">QuickBooks IIF</a>
<a class="btn ghost" href="/export/accounting.csv">CSV</a>
</div>
</div>
<div id="out"></div>
</main>
<script>
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
const rows = (list, cols) =>
(list || [])
.map((r) => '<tr>' + cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>')
.join('') || '<tr><td colspan="8" class="muted">none</td></tr>';
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
const empty = (title, hint) =>
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div>${title}</div><p>${hint}</p></div>`;
function moneyTable(list, cols, emptyTitle) {
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
const body = list.map((r) => '<tr>' + cols.map((c) => {
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
async function review() {
const id = document.getElementById('id').value.trim();
const st = await (await fetch('/review/' + encodeURIComponent(id))).json();
document.getElementById('out').innerHTML = `
<div class="card"><div class="muted">Prepaid</div><div class="stat">${fmt(st.prepaidCents)}</div>
<p class="muted">source ${st.source || 'unknown'}</p></div>
<div class="card"><h3>Credits</h3><table><thead><tr><th>cents</th><th>reason</th><th>agent</th><th>at</th></tr></thead>
<tbody>${rows(st.credits, ['cents','reason','agent','at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>endpoint</th><th>cents</th><th>at</th></tr></thead>
<tbody>${rows(st.usage, ['endpointId','cents','at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>cents</th><th>kind</th><th>reason</th><th>at</th></tr></thead>
<tbody>${rows(st.payments, ['cents','kind','reason','at'])}</tbody></table></div>`;
try {
const r = await fetch('/review/' + encodeURIComponent(id));
const st = await r.json();
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
const name = st.name || st.customerName || id;
document.getElementById('out').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
<div class="stat"><div class="k">Payments</div><div class="v">${(st.payments||[]).length}</div></div>
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
</div>
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
</script>
</body>

View file

@ -4,62 +4,139 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Customer service — account review</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
body { font-family: -apple-system, sans-serif; margin: 2rem; max-width: 960px; color: #171a26; }
h1 { font-size: 1.3rem; }
label { font-size: 0.8rem; font-weight: 600; display: block; margin-top: 0.6rem; }
input { padding: 0.4rem 0.55rem; }
button { margin-top: 0.8rem; padding: 0.4rem 0.8rem; }
table { border-collapse: collapse; width: 100%; margin-top: 0.6rem; }
th, td { border-bottom: 1px solid #e5e7f0; padding: 0.4rem 0.5rem; text-align: left; font-size: 0.88rem; }
.muted { color: #6b7186; }
.card { border: 1px solid #e5e7f0; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
.stat { font-size: 1.4rem; font-weight: 800; }
:root {
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
.row > div { min-width:10rem; }
button, a.btn {
border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer;
background:var(--accent); color:#fff; text-decoration:none; display:inline-block;
}
button.ghost, a.btn.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
table { border-collapse:collapse; width:100%; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
.money { font-variant-numeric:tabular-nums; }
.muted { color:var(--muted); }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.err { color:var(--danger); }
.who { font-weight:750; }
</style>
</head>
<body>
<h1>Customer service</h1>
<p class="muted">Review prepaid balance, credits, usage, and payments. Credits go to account-balance over NATS.</p>
<div class="card">
<label>Customer id</label>
<input id="id" value="cust_1" />
<button onclick="review()">Review</button>
</div>
<div class="card">
<h3>Add credit</h3>
<label>Cents</label><input id="cents" type="number" value="500" />
<label>Reason</label><input id="reason" value="goodwill" />
<label>Agent</label><input id="agent" value="cs" />
<button onclick="credit()">Apply credit</button>
</div>
<div id="out"></div>
<a class="skip" href="#main">Skip to content</a>
<header class="app">
<div class="kicker">staff · customer service</div>
<h1>Customer service</h1>
<p>Look up a customer by name or id. Review prepaid balance, credits, usage, and payments. Credit amounts are in dollars.</p>
</header>
<main id="main">
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
</div>
<button type="button" onclick="review()">Review account</button>
</div>
</div>
<div class="card">
<h3>Apply credit</h3>
<div class="row">
<div>
<label for="dollars">Amount (USD)</label>
<input id="dollars" type="number" min="0.01" step="0.01" value="5.00" />
</div>
<div>
<label for="reason">Reason</label>
<input id="reason" value="goodwill" />
</div>
<div>
<label for="agent">Agent name</label>
<input id="agent" value="cs" />
</div>
<button type="button" onclick="credit()">Apply credit</button>
</div>
<p class="muted" style="margin:.7rem 0 0;font-size:12px">Credits write to account-balance. The customer sees dollars, not cents.</p>
</div>
<div id="out"></div>
</main>
<script>
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
const rows = (list, cols) =>
(list || [])
.map((r) => '<tr>' + cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>')
.join('') || '<tr><td colspan="8" class="muted">none</td></tr>';
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
const empty = (title, hint) =>
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div class="who">${title}</div><p>${hint}</p></div>`;
function moneyTable(list, cols, emptyTitle) {
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
const body = list.map((r) => '<tr>' + cols.map((c) => {
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
function paint(st, id) {
const name = st.name || st.customerName || id;
document.getElementById('out').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
</div>
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
}
async function review() {
const id = document.getElementById('id').value.trim();
const st = await (await fetch('/review/' + encodeURIComponent(id))).json();
document.getElementById('out').innerHTML = `
<div class="card"><div class="muted">Prepaid</div><div class="stat">${fmt(st.prepaidCents)}</div>
<p class="muted">source ${st.source || 'unknown'}</p></div>
<div class="card"><h3>Credits</h3><table><thead><tr><th>cents</th><th>reason</th><th>agent</th><th>at</th></tr></thead>
<tbody>${rows(st.credits, ['cents','reason','agent','at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>endpoint</th><th>cents</th><th>at</th></tr></thead>
<tbody>${rows(st.usage, ['endpointId','cents','at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>cents</th><th>kind</th><th>reason</th><th>at</th></tr></thead>
<tbody>${rows(st.payments, ['cents','kind','reason','at'])}</tbody></table></div>`;
try {
const r = await fetch('/review/' + encodeURIComponent(id));
const st = await r.json();
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
paint(st, id);
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
async function credit() {
const dollars = Number(document.getElementById('dollars').value);
const body = {
customerId: document.getElementById('id').value.trim(),
cents: Number(document.getElementById('cents').value),
cents: Math.round(dollars * 100),
reason: document.getElementById('reason').value,
agent: document.getElementById('agent').value,
};
await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const r = await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) {
const st = await r.json().catch(() => ({}));
document.getElementById('out').innerHTML = `<div class="card err">${st.error || r.status}</div>`;
return;
}
await review();
}
</script>

View file

@ -4,52 +4,136 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sales — customer review</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
body { font-family: -apple-system, sans-serif; margin: 2rem; max-width: 960px; color: #171a26; }
h1 { font-size: 1.3rem; }
label { font-size: 0.8rem; font-weight: 600; display: block; margin-top: 0.6rem; }
input { padding: 0.4rem 0.55rem; }
button { margin-top: 0.8rem; padding: 0.4rem 0.8rem; }
table { border-collapse: collapse; width: 100%; margin-top: 0.6rem; }
th, td { border-bottom: 1px solid #e5e7f0; padding: 0.4rem 0.5rem; text-align: left; font-size: 0.88rem; }
.muted { color: #6b7186; }
.card { border: 1px solid #e5e7f0; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
.stat { font-size: 1.4rem; font-weight: 800; }
:root {
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
button { border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer; background:var(--accent); color:#fff; }
button.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
table { border-collapse:collapse; width:100%; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
.money { font-variant-numeric:tabular-nums; }
.muted { color:var(--muted); }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.err { color:var(--danger); }
.kv { display:grid; grid-template-columns:160px 1fr; gap:.4rem .8rem; }
.kv dt { color:var(--muted); font-size:.82rem; }
.kv dd { margin:0; font-weight:600; }
</style>
</head>
<body>
<h1>Sales</h1>
<p class="muted">Review customer credits, balances, usage, and payments (NATS account-balance). Pricing writes still go to zappier-edge.</p>
<div class="card">
<label>Customer id</label>
<input id="id" value="cust_2" />
<button onclick="review()">Review</button>
<button onclick="quote()">Quote</button>
</div>
<div id="out"></div>
<a class="skip" href="#main">Skip to content</a>
<header class="app">
<div class="kicker">staff · sales</div>
<h1>Sales</h1>
<p>Review a customers balance, credits, usage, and payments. Open a quote with names and dollar amounts — not a JSON dump.</p>
</header>
<main id="main">
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_2" autocomplete="off" />
</div>
<button type="button" onclick="review()">Review account</button>
<button type="button" class="ghost" onclick="quote()">Show quote</button>
</div>
</div>
<div id="out"></div>
</main>
<script>
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
const rows = (list, cols) =>
(list || [])
.map((r) => '<tr>' + cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>')
.join('') || '<tr><td colspan="8" class="muted">none</td></tr>';
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
const empty = (title, hint) =>
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div>${title}</div><p>${hint}</p></div>`;
function moneyTable(list, cols, emptyTitle) {
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
const body = list.map((r) => '<tr>' + cols.map((c) => {
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
function paintStatement(st, id) {
const name = st.name || st.customerName || id;
document.getElementById('out').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
</div>
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
}
async function review() {
const id = document.getElementById('id').value.trim();
const st = await (await fetch('/review/' + encodeURIComponent(id))).json();
document.getElementById('out').innerHTML = `
<div class="card"><div class="muted">Prepaid</div><div class="stat">${fmt(st.prepaidCents)}</div>
<p class="muted">source ${st.source || 'unknown'}</p></div>
<div class="card"><h3>Credits</h3><table><thead><tr><th>cents</th><th>reason</th><th>agent</th><th>at</th></tr></thead>
<tbody>${rows(st.credits, ['cents','reason','agent','at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>endpoint</th><th>cents</th><th>at</th></tr></thead>
<tbody>${rows(st.usage, ['endpointId','cents','at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>cents</th><th>kind</th><th>reason</th><th>at</th></tr></thead>
<tbody>${rows(st.payments, ['cents','kind','reason','at'])}</tbody></table></div>`;
try {
const r = await fetch('/review/' + encodeURIComponent(id));
const st = await r.json();
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
paintStatement(st, id);
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
function pretty(v) {
if (v == null) return '—';
if (typeof v === 'number' && /cents|Cents/.test(String(v))) return fmt(v);
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
async function quote() {
const id = document.getElementById('id').value.trim();
const q = await (await fetch('/quotes/' + encodeURIComponent(id))).json();
document.getElementById('out').innerHTML = `<pre>${JSON.stringify(q, null, 2)}</pre>`;
try {
const r = await fetch('/quotes/' + encodeURIComponent(id));
const q = await r.json();
if (!r.ok) throw new Error(q.error || ('HTTP ' + r.status));
const name = q.name || q.customerName || id;
const skip = new Set(['name', 'customerName']);
const rows = Object.entries(q)
.filter(([k, v]) => !skip.has(k) && v != null && typeof v !== 'object')
.map(([k, v]) => {
const label = k.replace(/Cents$/, ' (USD)');
const val = /cents|Cents$/i.test(k) && typeof v === 'number' ? fmt(v) : v;
return `<dt>${label}</dt><dd>${val}</dd>`;
}).join('');
const nested = Object.entries(q).filter(([, v]) => v && typeof v === 'object')
.map(([k, v]) => `<div class="card"><h3>${k}</h3><pre class="muted" style="white-space:pre-wrap">${JSON.stringify(v, null, 2)}</pre></div>`).join('');
document.getElementById('out').innerHTML = `
<div class="card">
<h3>Quote — ${name}</h3>
<dl class="kv">${rows || '<dt>Customer</dt><dd>' + name + '</dd>'}</dl>
</div>${nested}`;
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
</script>
</body>

View file

@ -75,6 +75,8 @@ const fmt = (cents) =>
(cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2);
const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
const customerName = (id) => state.customers.find((c) => c.id === id)?.name ?? id;
const EMPTY_SVG =
'<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg>';
function customerOptions(selected, includeAll = false) {
const all = includeAll ? `<option value="">All customers</option>` : '';
@ -265,48 +267,85 @@ function renderCustomers() {
state.pricing.tiers
.map((t) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
.join('');
const btOptions = (selected) =>
['stripe', 'purchase_order']
.map((b) => `<option value="${b}" ${b === (selected ?? 'stripe') ? 'selected' : ''}>${b === 'stripe' ? 'Stripe' : 'Purchase order'}</option>`)
.join('');
const rows = state.customers
.map(
(c) => `<tr>
<td class="id">${c.id}</td>
<td>${c.name}</td>
<td><input data-customer="${c.id}" data-field="email" type="email" size="18" value="${c.email ?? ''}" placeholder="—"></td>
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
<td><select data-customer="${c.id}" data-field="billingType">${btOptions(c.billingType)}</select></td>
<td class="row-actions"><button class="btn ghost" onclick="reviewCustomer('${c.id}')">Statement</button>
<button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
<td>${c.email || '—'}</td>
<td><span class="pill ${c.tierId}">${c.tierId}</span></td>
<td><span class="pill ${c.billingType}">${c.billingType === 'stripe' ? 'Stripe' : 'PO'}</span></td>
<td class="row-actions">
<button class="btn ghost" type="button" onclick="reviewCustomer('${c.id}')">Statement</button>
<button class="btn" type="button" onclick="openCustomerDrawer('${c.id}')">Edit</button>
</td>
</tr>`,
)
.join('');
document.getElementById('customers').innerHTML = `
<h2>Customers</h2>
<p class="lede">Assign types, billing method, and per-customer deals. A multiplier override replaces the type multiplier for that customer.</p>
<div class="card"><table>
<thead><tr><th>Id</th><th>Name</th><th>Email</th><th>Type</th><th>Multiplier override</th><th>Billing</th><th></th></tr></thead>
<p class="lede">List is read-only. Open Edit to change type, billing, email, or a per-customer multiplier override.</p>
<div class="card">${
rows
? `<table>
<thead><tr><th>Id</th><th>Name</th><th>Email</th><th>Type</th><th>Billing</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
</table>`
: `<div class="empty">${EMPTY_SVG}<h3>No customers yet</h3><p>Create one below. The API key is shown once.</p></div>`
}</div>
<div class="card">
<h3>Add customer</h3>
<input id="new-customer-name" placeholder="name">
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
<button class="btn" onclick="addCustomer()">Create</button>
<p class="hint">The new customer's API key is shown once in the notification copy it immediately. Set email and billing method after creating.</p>
<p class="hint">The new customer's API key is shown once in the notification copy it immediately. Set email and billing method in the edit drawer.</p>
</div>`;
}
function closeCustomerDrawer() {
document.getElementById('drawer-root').innerHTML = '';
}
function openCustomerDrawer(id) {
const c = state.customers.find((x) => x.id === id);
if (!c) return;
const tierOptions = state.pricing.tiers
.map((t) => `<option value="${t.id}" ${t.id === c.tierId ? 'selected' : ''}>${t.name} (${t.id})</option>`)
.join('');
document.getElementById('drawer-root').innerHTML = `
<div class="drawer-backdrop" onclick="closeCustomerDrawer()"></div>
<aside class="drawer" role="dialog" aria-labelledby="edit-cust-title">
<h2 id="edit-cust-title">Edit ${c.name}</h2>
<p class="lede">${c.id}</p>
<label for="edit-email">Email</label>
<input id="edit-email" type="email" value="${c.email ?? ''}" placeholder="billing@example.com">
<label for="edit-tier">Customer type</label>
<select id="edit-tier">${tierOptions}</select>
<label for="edit-mult">Multiplier override</label>
<input id="edit-mult" type="number" step="any" value="${c.multiplierOverride ?? ''}" placeholder="use type default">
<label for="edit-billing">Billing</label>
<select id="edit-billing">
<option value="stripe" ${c.billingType === 'stripe' ? 'selected' : ''}>Stripe</option>
<option value="purchase_order" ${c.billingType === 'purchase_order' ? 'selected' : ''}>Purchase order</option>
</select>
<div class="filterbar" style="margin-top:1.2rem">
<button class="btn" type="button" onclick="saveCustomer('${c.id}')">Save</button>
<button class="btn ghost" type="button" onclick="closeCustomerDrawer()">Cancel</button>
</div>
</aside>`;
}
async function saveCustomer(id) {
const body = {};
document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => {
if (el.value === '') return;
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
});
const body = {
email: document.getElementById('edit-email').value.trim(),
tierId: document.getElementById('edit-tier').value,
billingType: document.getElementById('edit-billing').value,
};
const mult = document.getElementById('edit-mult').value;
if (mult !== '') body.multiplierOverride = Number(mult);
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved customer ${id}.`);
say(`Saved ${customerName(id)}.`);
closeCustomerDrawer();
await load();
}
@ -327,17 +366,29 @@ function renderStatement() {
async function reviewCustomer(id) {
if (!id) return;
const st = await api(`/statement/${id}`);
const row = (list, cols) =>
(list || [])
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None.</td></tr>`;
const row = (list, cols) => {
if (!list || !list.length) {
return `<tr><td colspan="${cols.length}"><div class="empty" style="padding:1rem">${EMPTY_SVG}<h3>Nothing here yet</h3></div></td></tr>`;
}
return list
.map(
(r) =>
`<tr>${cols
.map((c) => {
const v = c === 'cents' ? fmt(r.cents) : c === 'at' ? fmtDate(r.at) : (r[c] ?? '—');
return `<td class="${c === 'cents' ? 'money' : ''}">${v}</td>`;
})
.join('')}</tr>`,
)
.join('');
};
const html = `
<p class="lede">${st.name || id} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
<div class="card"><h3>Credits</h3><table><thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<p class="lede">${st.name || customerName(id)} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
<div class="card"><h3>Credits</h3><table><thead><tr><th>Amount</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>Endpoint</th><th>Cents</th><th>When</th></tr></thead>
<div class="card"><h3>Usage</h3><table><thead><tr><th>Endpoint</th><th>Amount</th><th>When</th></tr></thead>
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>Cents</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<div class="card"><h3>Payments</h3><table><thead><tr><th>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
const out = document.getElementById('stmt-out');
if (out) out.innerHTML = html;
@ -412,10 +463,14 @@ function renderInvoices() {
</select></label>
<button class="btn ghost" onclick="refreshInvoices()">Refresh</button>
</div>
<table id="inv-table">
${
rows
? `<table id="inv-table">
<thead><tr><th>Invoice</th><th>Customer</th><th>Period</th><th>Status</th><th>Billing</th><th>Total</th><th>Credit</th><th>Due amount</th><th>Due date</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="10" style="color:var(--muted)">No invoices yet — generate a period above.</td></tr>'}</tbody>
</table>
<tbody>${rows}</tbody>
</table>`
: `<div class="empty">${EMPTY_SVG}<h3>No invoices yet</h3><p>Generate a period above. Issued invoices keep their numbers; drafts can be replaced.</p></div>`
}
</div>`;
}

View file

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier Admin</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--bg: #f4f5fb;
@ -100,8 +101,26 @@
color: var(--muted);
cursor: pointer;
}
nav button { min-height: 40px; }
nav button:hover { background: var(--bg); color: var(--ink); }
nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
nav button:focus-visible, #logout:focus-visible, button.btn:focus-visible, a:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:40; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
.empty { text-align:center; padding:1.6rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.empty h3 { margin:0 0 .25rem; color:var(--ink); font-size:1rem; }
.drawer-backdrop { position:fixed; inset:0; background:rgba(23,26,38,.35); z-index:20; }
.drawer {
position:fixed; top:0; right:0; height:100vh; width:min(420px,100%);
background:#fff; box-shadow:-8px 0 32px rgba(23,26,38,.12); z-index:21;
padding:1.4rem 1.35rem 2rem; overflow:auto;
}
.drawer h2 { margin:0 0 .2rem; }
.drawer label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.85rem 0 .25rem; }
.drawer input, .drawer select { width:100%; }
aside .spacer { flex: 1; }
#logout {
border: 1px solid var(--line); background: none; border-radius: 8px;
@ -216,6 +235,7 @@
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<section id="login">
<form class="card" id="login-form">
<h1>Zappier Admin</h1>
@ -245,7 +265,7 @@
<div class="spacer"></div>
<button id="logout">Sign out</button>
</aside>
<main>
<main id="main">
<section id="endpoints"></section>
<section id="tiers" hidden></section>
<section id="customers" hidden></section>
@ -256,6 +276,7 @@
<section id="users" hidden></section>
</main>
</div>
<div id="drawer-root"></div>
<p id="status"></p>
<script src="app.js"></script>
</body>

View file

@ -112,6 +112,12 @@ function say(msg, isError = false) {
const fmt = (cents) => (cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2);
const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
const EMPTY_SVG =
'<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg>';
function maskKey(k) {
if (!k || k.length < 8) return '••••••••';
return `${k.slice(0, 4)}••••••••${k.slice(-4)}`;
}
async function load() {
state.me = await api('/me');
@ -145,16 +151,26 @@ function renderDashboard() {
<div class="card">
<h3>Your API key</h3>
<div class="apikey-row">
<code id="api-key">${me.apiKey}</code>
<button class="btn ghost" onclick="copyKey()">Copy</button>
<button class="btn" onclick="regenerateKey()">Regenerate</button>
<code id="api-key" data-key="${me.apiKey}" data-masked="1">${maskKey(me.apiKey)}</code>
<button class="btn ghost" type="button" onclick="toggleKey()">Reveal</button>
<button class="btn ghost" type="button" onclick="copyKey()">Copy</button>
<button class="btn" type="button" onclick="regenerateKey()">Regenerate</button>
</div>
<p class="hint">Send it as the <span class="id">x-api-key</span> header. Regenerating invalidates the old key immediately. Per-endpoint usage: ${Object.entries(u.byEndpoint ?? {}).map(([k, v]) => `${k}: ${v.calls} calls`).join(', ') || 'none yet this month'}.</p>
<p class="hint">Send it as the <span class="id">x-api-key</span> header. The key stays masked on screen. Copy puts the full key on the clipboard. Regenerating invalidates the old key immediately. Per-endpoint usage: ${Object.entries(u.byEndpoint ?? {}).map(([k, v]) => `${k}: ${v.calls} calls`).join(', ') || 'none yet this month'}.</p>
</div>`;
}
function toggleKey() {
const el = document.getElementById('api-key');
const btn = el.parentElement.querySelector('button');
const masked = el.dataset.masked === '1';
el.textContent = masked ? el.dataset.key : maskKey(el.dataset.key);
el.dataset.masked = masked ? '0' : '1';
btn.textContent = masked ? 'Hide' : 'Reveal';
}
function copyKey() {
navigator.clipboard.writeText(document.getElementById('api-key').textContent);
navigator.clipboard.writeText(document.getElementById('api-key').dataset.key);
say('API key copied.');
}
@ -187,10 +203,14 @@ function renderInvoices() {
document.getElementById('invoices').innerHTML = `
<h2>Invoices</h2>
<p class="lede">Your invoice history. View / print opens a print-ready page use the browsers Print Save as PDF.</p>
<div class="card"><table>
<div class="card">${
rows
? `<table>
<thead><tr><th>Invoice</th><th>Period</th><th>Status</th><th>Total</th><th>Credit</th><th>Amount due</th><th>Due date</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" style="color:var(--muted)">No invoices yet.</td></tr>'}</tbody>
</table></div>`;
<tbody>${rows}</tbody>
</table>`
: `<div class="empty">${EMPTY_SVG}<h3>No invoices yet</h3><p>After the first billing period is generated, invoices appear here. Use View / print to save a PDF.</p></div>`
}</div>`;
}
async function viewInvoice(id) {
@ -206,10 +226,22 @@ async function viewInvoice(id) {
function renderStatement() {
const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] };
const row = (list, cols) =>
(list || [])
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None yet.</td></tr>`;
const row = (list, cols) => {
if (!list || !list.length) {
return `<tr><td colspan="${cols.length}"><div class="empty" style="padding:1rem">${EMPTY_SVG}<h3>Nothing here yet</h3></div></td></tr>`;
}
return list
.map(
(r) =>
`<tr>${cols
.map((c) => {
const v = c === 'cents' ? fmt(r.cents) : c === 'at' ? fmtDate(r.at) : (r[c] ?? '—');
return `<td class="${c === 'cents' ? 'money' : ''}">${v}</td>`;
})
.join('')}</tr>`,
)
.join('');
};
document.getElementById('statement').innerHTML = `
<h2>Statement</h2>
<p class="lede">Prepaid balance, customer-service credits, metered usage, and payments. ${st.source === 'nats' ? 'Live from account-balance over NATS.' : 'From this portals ledger.'}</p>
@ -220,13 +252,13 @@ function renderStatement() {
<div class="stat"><div class="k">Payments</div><div class="v">${st.payments?.length || 0}</div></div>
</div>
<div class="card"><h3>Credits</h3><table>
<thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<thead><tr><th>Amount</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table>
<thead><tr><th>Endpoint</th><th>Cents</th><th>When</th></tr></thead>
<thead><tr><th>Endpoint</th><th>Amount</th><th>When</th></tr></thead>
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table>
<thead><tr><th>Cents</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<thead><tr><th>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
}
@ -355,7 +387,7 @@ function renderDocs() {
.join('');
document.getElementById('docs').innerHTML = `
<h2>API &amp; pricing</h2>
<p class="lede">Interactive API reference lives at <a href="/docs" target="_blank" style="color:var(--accent)">/docs</a> (Swagger UI) use your API key as <span class="id">x-api-key</span>.</p>
<p class="lede">Interactive API reference lives at <a href="/docs" target="_blank" style="color:var(--accent)">/docs</a>. That page is stock Swagger UI not a designed customer surface. Use your API key as <span class="id">x-api-key</span>.</p>
<div class="card">
<h3>Rate card (list prices)</h3>
<table><thead><tr><th>Endpoint</th><th>Kind</th><th>List price</th></tr></thead><tbody>${rows}</tbody></table>

View file

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier Portal</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--bg: #f4f5fb;
@ -103,8 +104,14 @@
color: var(--muted);
cursor: pointer;
}
nav button { min-height: 40px; }
nav button:hover { background: var(--bg); color: var(--ink); }
nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
nav button:focus-visible, #logout:focus-visible, button.btn:focus-visible, a:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:30; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
aside .spacer { flex: 1; }
#logout {
border: 1px solid var(--line); background: none; border-radius: 8px;
@ -184,12 +191,15 @@
.kv { display: grid; grid-template-columns: 170px 1fr; row-gap: 0.5rem; align-items: center; }
.kv dt { color: var(--muted); font-size: 0.82rem; }
.kv dd { margin: 0; font-weight: 600; }
.apikey-row { display: flex; gap: 0.5rem; align-items: center; }
.apikey-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.apikey-row code {
background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
padding: 0.45rem 0.7rem; font-size: 0.85rem; flex: 1;
font-family: "SF Mono", Menlo, monospace;
padding: 0.45rem 0.7rem; font-size: 0.85rem; flex: 1; min-width: 12rem;
font-family: "SF Mono", Menlo, monospace; letter-spacing: 0.04em;
}
.empty { text-align:center; padding:1.6rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.empty h3 { margin:0 0 .25rem; color:var(--ink); font-size:1rem; }
.qr-box { display: flex; gap: 1.25rem; align-items: flex-start; margin: 0.8rem 0; }
.qr-box img { border: 1px solid var(--line); border-radius: 10px; }
.qr-box .secret {
@ -220,6 +230,7 @@
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<section id="auth">
<form class="card" id="auth-form">
<h1>Zappier Portal</h1>
@ -256,7 +267,7 @@
<div class="spacer"></div>
<button id="logout">Sign out</button>
</aside>
<main>
<main id="main">
<section id="dashboard"></section>
<section id="invoices" hidden></section>
<section id="statement" hidden></section>

View file

@ -23,7 +23,7 @@ SECTIONS = [
[
("packages/verae-ops/GETTING-STARTED.md", "Bring the system online (install, first-up, monitor)"),
("packages/overview/README.md", "What the system is (plain language)"),
("packages/verae-fleet/docs/CONSOLE.md", "Operator console (loopback Fleet · Trace · Docs)"),
("packages/verae-fleet/docs/CONSOLE.md", "Operator console (Fleet · Trace · Docs on :3850)"),
],
),
(
@ -85,6 +85,7 @@ SECTIONS = [
("packages/verae-jobs-events/README.md", "jobs.events mailbox"),
("packages/verae-nats-accounts/README.md", "NATS leaf/internal policy"),
("packages/zapier-decisions/README.md", "Decisions log"),
("packages/ui-docs/README.md", "UI walkthrough, screenshots, review PDF"),
],
),
(
@ -258,9 +259,13 @@ def page_shell(now: str, body: str, switch_to_md: bool, title: str) -> str:
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {{ --ink:#171a26; --muted:#5b6178; --line:#d9dce8; --bg:#f4f5fb; --accent:#4f46e5; --accent-deep:#312e81; --soft:#eef0fe; }}
body {{ margin:0; font:16px/1.5 Georgia, serif; color:var(--ink); background:var(--bg); }}
body {{ margin:0; font:16px/1.5 -apple-system, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif; color:var(--ink); background:var(--bg); }}
.skip {{ position:absolute; left:-999px; }}
.skip:focus {{ left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }}
a:focus-visible, input:focus-visible {{ outline:2px solid #fff; outline-offset:2px; }}
header {{ background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:2rem 1.5rem 1.4rem; position:relative; }}
header p {{ max-width:44rem; color:#e4e7ff; }}
.kicker {{ letter-spacing:.12em; text-transform:uppercase; font:700 11px system-ui; opacity:.75; }}
@ -284,6 +289,7 @@ def page_shell(now: str, body: str, switch_to_md: bool, title: str) -> str:
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header>
{switch}
<div class="kicker">zapier.georgelambert.org</div>
@ -291,7 +297,7 @@ def page_shell(now: str, body: str, switch_to_md: bool, title: str) -> str:
<p>Project reference: setup, architecture, NATS, module APIs, user guide, and operator console.
Catalog defaults to <strong>colored PDFs</strong>. Generated {now}.</p>
</header>
<main>
<main id="main">
<p><input id="q" type="search" placeholder="Filter links…" /></p>
{body}
</main>

View file

@ -67,6 +67,7 @@ REPO_READMES = [
"verae-jobs-events",
"verae-nats-accounts",
"zapier-decisions",
"ui-docs",
]
SKIP_PARTS = {"test", "tests", "node_modules", "dist"}

View file

@ -64,6 +64,7 @@ create zappier-identity "Bind and lookup veraeUserId"
create verae-jobs-events "Mailbox for verae.zapier.jobs.events"
create verae-nats-accounts "NATS INTERNAL vs LEAF account policy"
create zapier-decisions "Architecture decisions and action log"
create UI-Docs "UI walkthrough, screenshots, and review PDF"
push_dir "$ROOT/packages/zappier" zappier-edge
push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware
@ -94,5 +95,6 @@ push_dir "$ROOT/packages/zappier-identity" zappier-identity
push_dir "$ROOT/packages/verae-jobs-events" verae-jobs-events
push_dir "$ROOT/packages/verae-nats-accounts" verae-nats-accounts
push_dir "$ROOT/packages/zapier-decisions" zapier-decisions
push_dir "$ROOT/packages/ui-docs" UI-Docs
echo ALL_MODULE_REPOS_PUSHED