Close the UI-review follow-ups: names typeahead, fleet form, overflow flip.
Some checks are pending
offline / test (push) Waiting to run
Staff pages typeahead customers by name (Ada, not cust_1). Add-machine is two rows with a wide identity path and filename picker. Overflow menus flip up near the viewport edge. Customer list no longer leaks password hashes. UI-REVIEW.pdf remaining list is the three leftover items.
|
|
@ -1,44 +1,36 @@
|
||||||
# UI issues still to consider
|
# 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).
|
Follow-up to the 2026-09-11 review PDF. Items the review listed as open are now implemented except the three left below.
|
||||||
|
|
||||||
## Fixed in this pass
|
## Resolved from the review list
|
||||||
|
|
||||||
| Item | Result |
|
| Severity | Issue | What shipped |
|
||||||
|------|--------|
|
|----------|-------|----------------|
|
||||||
| CS/sales/accounting/access-staff unstyled cents forms | Indigo shell, USD, agent/customer labels, empty illustrations |
|
| High | Staff headings showed `cust_1` | Ledger stores names (`customer.put`). Staff UIs typeahead customers from edge (`GET /customers`) and review by name. |
|
||||||
| Fleet pause/resume/restart/on/off/pause crowding | One ⋯ menu per replica and per service; Power is status |
|
| High | CS/sales/accounting HTML had no login | `verae-staff-session` :3027. Set `STAFF_AUTH=1` and `STAFF_SESSION_URL` on department servers. |
|
||||||
| Docs tab bullet dump | Cards matching Fleet |
|
| Med | Catalog was a bibliography | Catalog index is cards (Start here / Architecture / Modules). |
|
||||||
| Simulator cream/orange brand | Indigo; orange = fault inject only |
|
| Med | Fleet identity field truncated | Two-row add-machine form; identity path is a wide field plus a filename picker. |
|
||||||
| Header LOOPBACK vs 0.0.0.0 bind | `operator LAN · binds 0.0.0.0:3850` |
|
| Med | Disabled machines looked like faults | Intentionally disabled cards are grey (`disabled`), not degraded yellow. |
|
||||||
| Add machine truncated roles field | Chips |
|
| Low | Portal statement empty cells in a table | Empty lists use the same card empty state as invoices. |
|
||||||
| Admin customers inline-edit table | List + edit drawer |
|
| Low | Overflow menus off-screen | Menu flips upward when near the bottom of the viewport. |
|
||||||
| Portal API key always visible | Masked + Reveal/Copy/Regenerate |
|
| Low | Simulator empty console was a black void | Placeholder: “Run a step to fill this trace.” |
|
||||||
| 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
|
## Still to consider
|
||||||
|
|
||||||
| Severity | Issue | Suggestion |
|
| 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 |
|
| Med | Swagger `/docs` remains stock | Keep it for integrators; do not skin. Portal already labels it as stock OpenAPI explorer. |
|
||||||
| High | CS/sales/accounting HTML has no login (LAN trust) | Staff SSO or shared cookie before any non-lab bind |
|
| Low | access-staff and CS HTML are twins | Share one template file if those packages ever merge. |
|
||||||
| Med | Catalog is still a bibliography | Card index like console Docs |
|
| Low | Staff cookie is host-scoped (`127.0.0.1`) | Fine on one operator host. For multiple DNS names, put a reverse proxy in front. |
|
||||||
| 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
|
## How to use each surface
|
||||||
|
|
||||||
- **Appealing:** staff and console now match portal/admin; simulator no longer looks like a second product.
|
- **Operator** http://127.0.0.1:3850/ — Fleet (replicas, overflow, grey disabled machines), Trace, Docs.
|
||||||
- **Obvious:** Review account, Apply credit, Edit, Reveal/Copy, Run step, ⋯.
|
- **Staff CS** :3011 — type a customer name, Review, credit in USD. `STAFF_AUTH=1` redirects to :3027.
|
||||||
- **Comprehensive:** review, credit, quote, export, fleet power, trace, docs, portal billing, admin customers — covered. Missing: staff identity join and catalog IA (above).
|
- **Sales** :3012 — review / quote by name.
|
||||||
|
- **Accounting** :3013 — review + QuickBooks IIF / CSV.
|
||||||
|
- **Staff plane** :3025 — same review/credit after authz.
|
||||||
|
- **Customers** /portal — masked API key, empty invoice/statement cards.
|
||||||
|
- **Ops** /admin — customer list + edit drawer.
|
||||||
|
- **Lab** simulator :3847.
|
||||||
|
- **Catalog** https://zapier.georgelambert.org/ — card index, system-ui.
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 394 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 948 KiB After Width: | Height: | Size: 1 MiB |
|
|
@ -117,11 +117,11 @@ def main():
|
||||||
story.append(p("What was fixed in this pass", s["h1"]))
|
story.append(p("What was fixed in this pass", s["h1"]))
|
||||||
items = [
|
items = [
|
||||||
"CS / sales / accounting / access-staff restyled to the portal indigo system. Dollars and names, not cents forms.",
|
"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.",
|
"Fleet replica and service actions collapsed to one overflow menu (flips up near the viewport edge). Power is status. Disabled machines are grey.",
|
||||||
"Add-machine roles are chips. Header copy is operator LAN · binds 0.0.0.0:3850 (NATS stays private).",
|
"Add-machine is two rows: identity path is wide plus a filename picker; roles are chips. Header: operator LAN · binds 0.0.0.0:3850.",
|
||||||
"Docs tab is cards. Trace hop grid wraps. Skip link and focus rings on staff and console.",
|
"Docs tab is cards. Trace hop grid wraps. Skip link and focus rings on staff and console.",
|
||||||
"Simulator indigo; orange only on Inject faults.",
|
"Simulator indigo; orange only on Inject faults.",
|
||||||
"Portal API key masked with Reveal / Copy / Regenerate. Invoice empty-state illustration.",
|
"Portal API key masked. Invoice and statement empty states are cards, not table rows. Staff typeahead uses customer names. STAFF_AUTH=1 uses verae-staff-session.",
|
||||||
"Admin customers: list vs edit drawer. Invoice empty state.",
|
"Admin customers: list vs edit drawer. Invoice empty state.",
|
||||||
"Catalog body font is system-ui. Swagger /docs labeled stock.",
|
"Catalog body font is system-ui. Swagger /docs labeled stock.",
|
||||||
]
|
]
|
||||||
|
|
@ -134,16 +134,9 @@ def main():
|
||||||
story.append(p("Issues still to consider", s["h1"]))
|
story.append(p("Issues still to consider", s["h1"]))
|
||||||
remaining = [
|
remaining = [
|
||||||
["Severity", "Issue", "Suggestion"],
|
["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."],
|
["Med", "Swagger /docs remains stock.", "Keep it for integrators; do not skin it. Portal already labels it stock OpenAPI explorer."],
|
||||||
["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."],
|
["Low", "access-staff and CS HTML are twins.", "Share one template if those packages ever merge."],
|
||||||
["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."],
|
["Low", "Staff cookie is host-scoped (127.0.0.1).", "Fine on one operator host. Multiple DNS names need a reverse proxy."],
|
||||||
["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 = getSampleStyleSheet()["BodyText"]
|
||||||
body.fontSize = 8
|
body.fontSize = 8
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
{
|
{
|
||||||
"capturedAt": "2026-09-11T21:58:47.138Z",
|
"capturedAt": "2026-09-11T22:17:57.807Z",
|
||||||
"errors": [],
|
"errors": [],
|
||||||
"all": [
|
"all": [
|
||||||
{
|
{
|
||||||
"page": "catalog",
|
"page": "staff",
|
||||||
"type": "console",
|
"type": "console",
|
||||||
"text": "Failed to load resource: the server responded with a status of 404 (File not found)"
|
"text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "staff",
|
||||||
|
"type": "console",
|
||||||
|
"text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "staff",
|
||||||
|
"type": "console",
|
||||||
|
"text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -53,8 +53,9 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
<label for="id">Customer id or name</label>
|
<label for="id">Customer</label>
|
||||||
<input id="id" value="cust_1" autocomplete="off" />
|
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
|
||||||
|
<datalist id="cust-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick="review()">Review account</button>
|
<button type="button" onclick="review()">Review account</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,6 +94,18 @@
|
||||||
}).join('') + '</tr>').join('');
|
}).join('') + '</tr>').join('');
|
||||||
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||||
}
|
}
|
||||||
|
async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const { customers } = await (await fetch('/customers')).json();
|
||||||
|
document.getElementById('cust-list').innerHTML = (customers || [])
|
||||||
|
.map((c) => `<option value="${c.name}">${c.id}</option>`)
|
||||||
|
.join('');
|
||||||
|
if (customers && customers[0] && !document.getElementById('id').value) {
|
||||||
|
document.getElementById('id').value = customers[0].name;
|
||||||
|
}
|
||||||
|
} catch { /* edge optional */ }
|
||||||
|
}
|
||||||
|
loadCustomers();
|
||||||
async function review() {
|
async function review() {
|
||||||
const id = document.getElementById('id').value.trim();
|
const id = document.getElementById('id').value.trim();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
/** Join ledger ids to zappier-edge customer display names. */
|
/** Join ledger ids to zappier-edge customer display names. */
|
||||||
|
export async function listCustomers(edge, key) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||||
|
headers: { 'x-admin-key': key },
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function withCustomerName(st, idOrName, edge, key) {
|
export async function withCustomerName(st, idOrName, edge, key) {
|
||||||
const out = { ...(st || {}) };
|
const out = { ...(st || {}) };
|
||||||
if (out.name && out.customerId) return out;
|
if (out.name && out.customerId) return out;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { withCustomerName } from './names.js';
|
import { listCustomers, withCustomerName } from './names.js';
|
||||||
|
|
||||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||||
const PORT = Number(process.env.PORT || 3025);
|
const PORT = Number(process.env.PORT || 3025);
|
||||||
|
|
@ -47,6 +47,9 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (req.method === 'GET' && url.pathname === '/health') {
|
if (req.method === 'GET' && url.pathname === '/health') {
|
||||||
return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE });
|
return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE });
|
||||||
}
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/customers') {
|
||||||
|
return json(200, { customers: await listCustomers(EDGE, KEY) });
|
||||||
|
}
|
||||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||||
if (req.method === 'GET' && review) {
|
if (req.method === 'GET' && review) {
|
||||||
const id = decodeURIComponent(review[1]);
|
const id = decodeURIComponent(review[1]);
|
||||||
|
|
|
||||||
|
|
@ -390,9 +390,10 @@ class="sourceCode bash"><code class="sourceCode bash"><span id="cb1-1"><a href="
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Fleet</strong></td>
|
<td><strong>Fleet</strong></td>
|
||||||
<td>Machines (SSH/local), replica floors, green/yellow/red, RTT
|
<td>Machines (SSH/local), replica floors, green/yellow/red, RTT spark.
|
||||||
min/avg/p50/p90 spark, pause/restart/off with confirm, ssh-check
|
Replica and service actions live in one <strong>⋯</strong> overflow
|
||||||
toast</td>
|
menu. Power column shows on/paused/off as status, not toggles.
|
||||||
|
Add-machine roles are chips.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Trace</strong></td>
|
<td><strong>Trace</strong></td>
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,10 @@
|
||||||
tr.down { background:var(--err-bg); }
|
tr.down { background:var(--err-bg); }
|
||||||
.inst { font:12px ui-monospace,Menlo,monospace; margin:.2rem 0; display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; }
|
.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 { display:flex; flex-wrap:wrap; gap:.4rem; margin:.4rem 0 1rem; align-items:end; }
|
||||||
form.add .roles-field { flex: 1 0 100%; }
|
form.add .row1, form.add .row2 { display:flex; flex-wrap:wrap; gap:.4rem; align-items:end; width:100%; }
|
||||||
|
form.add .identity-field { flex: 1 1 22rem; }
|
||||||
|
form.add .identity-field input[name="identityFile"] { min-width:18rem; width:100%; }
|
||||||
|
form.add .roles-field { flex: 1 1 100%; }
|
||||||
form.add label { font-size:11px; color:var(--muted); display:flex; flex-direction:column; gap:.15rem; }
|
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 {
|
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;
|
padding:.4rem .5rem; border:1px solid var(--line); border-radius:8px; font:13px ui-monospace,Menlo,monospace;
|
||||||
|
|
@ -104,6 +107,7 @@
|
||||||
box-shadow:var(--shadow); min-width:11rem; padding:.25rem; z-index:40;
|
box-shadow:var(--shadow); min-width:11rem; padding:.25rem; z-index:40;
|
||||||
}
|
}
|
||||||
.more.open .more-pop { display:block; }
|
.more.open .more-pop { display:block; }
|
||||||
|
.more.open.up .more-pop { top:auto; bottom:calc(100% + 4px); }
|
||||||
.more-pop button {
|
.more-pop button {
|
||||||
display:block; width:100%; text-align:left; border:0; background:none;
|
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);
|
padding:.45rem .6rem; border-radius:8px; font:650 12px system-ui; cursor:pointer; color:var(--ink);
|
||||||
|
|
@ -134,13 +138,21 @@
|
||||||
<p id="meta" class="muted"></p>
|
<p id="meta" class="muted"></p>
|
||||||
<h2>Machines</h2>
|
<h2>Machines</h2>
|
||||||
<form class="add" id="addMachine">
|
<form class="add" id="addMachine">
|
||||||
|
<div class="row1">
|
||||||
<label>id <input name="id" required placeholder="ns1-b"/></label>
|
<label>id <input name="id" required placeholder="ns1-b"/></label>
|
||||||
<label>host <input name="host" required placeholder="70.88.205.138"/></label>
|
<label>host <input name="host" required placeholder="70.88.205.138"/></label>
|
||||||
<label>kind <select name="kind"><option value="local">local</option><option value="ssh" selected>ssh</option><option value="agent">agent</option></select></label>
|
<label>kind <select name="kind"><option value="local">local</option><option value="ssh" selected>ssh</option><option value="agent">agent</option></select></label>
|
||||||
<label>user <input name="user" value="marchon"/></label>
|
<label>user <input name="user" value="marchon"/></label>
|
||||||
<label>ssh port <input name="sshPort" type="number" value="22"/></label>
|
<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>capacity <input name="capacity" type="number" min="1" value="8"/></label>
|
||||||
|
</div>
|
||||||
|
<div class="row2">
|
||||||
|
<label class="identity-field">identity file (path on this host)
|
||||||
|
<span style="display:flex;gap:.35rem;align-items:center">
|
||||||
|
<input name="identityFile" value="~/.ssh/id_ed25519"/>
|
||||||
|
<input type="file" id="identityPick" title="Use filename only; SSH keys stay on disk" style="max-width:11rem"/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label class="roles-field">roles
|
<label class="roles-field">roles
|
||||||
<div class="roles" id="roleChips">
|
<div class="roles" id="roleChips">
|
||||||
<label><input type="checkbox" name="role" value="*"/> *</label>
|
<label><input type="checkbox" name="role" value="*"/> *</label>
|
||||||
|
|
@ -154,6 +166,7 @@
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button class="act primary" type="submit">Add machine</button>
|
<button class="act primary" type="submit">Add machine</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="cards" id="machines"></div>
|
<div class="cards" id="machines"></div>
|
||||||
<h2>Services</h2>
|
<h2>Services</h2>
|
||||||
|
|
@ -290,6 +303,11 @@ document.addEventListener('click', (e) => {
|
||||||
const wrap = btn.closest('.more');
|
const wrap = btn.closest('.more');
|
||||||
document.querySelectorAll('.more.open').forEach((m) => { if (m !== wrap) m.classList.remove('open'); });
|
document.querySelectorAll('.more.open').forEach((m) => { if (m !== wrap) m.classList.remove('open'); });
|
||||||
wrap.classList.toggle('open');
|
wrap.classList.toggle('open');
|
||||||
|
if (wrap.classList.contains('open')) {
|
||||||
|
const pop = wrap.querySelector('.more-pop');
|
||||||
|
const space = window.innerHeight - wrap.getBoundingClientRect().bottom;
|
||||||
|
wrap.classList.toggle('up', space < (pop?.offsetHeight || 140) + 16);
|
||||||
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -357,6 +375,11 @@ async function drawFleet() {
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('') + '</tbody></table></div>';
|
}).join('') + '</tbody></table></div>';
|
||||||
}
|
}
|
||||||
|
$('identityPick')?.addEventListener('change', (e) => {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
document.querySelector('input[name="identityFile"]').value = '~/.ssh/' + f.name;
|
||||||
|
});
|
||||||
$('addMachine').addEventListener('submit', async (e) => {
|
$('addMachine').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const f = e.target;
|
const f = e.target;
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
<h2 style="margin-top:0">Response to Zapier</h2>
|
<h2 style="margin-top:0">Response to Zapier</h2>
|
||||||
<pre id="response">Run a step to see output.</pre>
|
<pre id="response">Run a step to see output.</pre>
|
||||||
</section>
|
</section>
|
||||||
<section class="console" id="console" aria-label="trace console"></section>
|
<section class="console" id="console" aria-label="trace console">Run a step to fill this trace.</section>
|
||||||
<section class="monitors">
|
<section class="monitors">
|
||||||
<h2 style="margin-top:0">Monitors & suggested modifications</h2>
|
<h2 style="margin-top:0">Monitors & suggested modifications</h2>
|
||||||
<div id="monitors"></div>
|
<div id="monitors"></div>
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,9 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
<label for="id">Customer id or name</label>
|
<label for="id">Customer</label>
|
||||||
<input id="id" value="cust_1" autocomplete="off" />
|
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
|
||||||
|
<datalist id="cust-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick="review()">Review account</button>
|
<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/quickbooks.iif">QuickBooks IIF</a>
|
||||||
|
|
@ -77,6 +78,18 @@
|
||||||
}).join('') + '</tr>').join('');
|
}).join('') + '</tr>').join('');
|
||||||
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||||
}
|
}
|
||||||
|
async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const { customers } = await (await fetch('/customers')).json();
|
||||||
|
document.getElementById('cust-list').innerHTML = (customers || [])
|
||||||
|
.map((c) => `<option value="${c.name}">${c.id}</option>`)
|
||||||
|
.join('');
|
||||||
|
if (customers && customers[0] && !document.getElementById('id').value) {
|
||||||
|
document.getElementById('id').value = customers[0].name;
|
||||||
|
}
|
||||||
|
} catch { /* edge optional */ }
|
||||||
|
}
|
||||||
|
loadCustomers();
|
||||||
async function review() {
|
async function review() {
|
||||||
const id = document.getElementById('id').value.trim();
|
const id = document.getElementById('id').value.trim();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
/** Join ledger ids to zappier-edge customer display names. */
|
/** Join ledger ids to zappier-edge customer display names. */
|
||||||
|
export async function listCustomers(edge, key) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||||
|
headers: { 'x-admin-key': key },
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function withCustomerName(st, idOrName, edge, key) {
|
export async function withCustomerName(st, idOrName, edge, key) {
|
||||||
const out = { ...(st || {}) };
|
const out = { ...(st || {}) };
|
||||||
if (out.name && out.customerId) return out;
|
if (out.name && out.customerId) return out;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||||
import { withCustomerName } from './names.js';
|
import { listCustomers, withCustomerName } from './names.js';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3013);
|
const PORT = Number(process.env.PORT || 3013);
|
||||||
const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
|
const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
|
||||||
|
|
@ -41,6 +41,9 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (req.method === 'GET' && url.pathname === '/health') {
|
if (req.method === 'GET' && url.pathname === '/health') {
|
||||||
return json(200, { ok: true, role: 'zappier-accounting-export' });
|
return json(200, { ok: true, role: 'zappier-accounting-export' });
|
||||||
}
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/customers') {
|
||||||
|
return json(200, { customers: await listCustomers(EDGE, KEY) });
|
||||||
|
}
|
||||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||||
if (req.method === 'GET' && review) {
|
if (req.method === 'GET' && review) {
|
||||||
const id = decodeURIComponent(review[1]);
|
const id = decodeURIComponent(review[1]);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ curl -X POST http://127.0.0.1:3011/credits -H 'content-type: application/json' \
|
||||||
|
|
||||||
| Route | Job |
|
| Route | Job |
|
||||||
|-------|-----|
|
|-------|-----|
|
||||||
| `GET /` | Staff review UI |
|
| `GET /` | Staff review UI (typeahead names). `STAFF_AUTH=1` redirects to `verae-staff-session`. |
|
||||||
|
| `GET /customers` | `{ customers }` from zappier-edge |
|
||||||
| `GET /health` | `{ ok, role }` |
|
| `GET /health` | `{ ok, role }` |
|
||||||
| `POST /credits` | Add/subtract prepaid cents (NATS `balance.adjust`) |
|
| `POST /credits` | Add/subtract prepaid cents (NATS `balance.adjust`) |
|
||||||
| `GET /credits?customerId=` | Ledger |
|
| `GET /credits?customerId=` | Ledger |
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,9 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
<label for="id">Customer id or name</label>
|
<label for="id">Customer</label>
|
||||||
<input id="id" value="cust_1" autocomplete="off" />
|
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
|
||||||
|
<datalist id="cust-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick="review()">Review account</button>
|
<button type="button" onclick="review()">Review account</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -112,6 +113,18 @@
|
||||||
<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>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>`;
|
<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 loadCustomers() {
|
||||||
|
try {
|
||||||
|
const { customers } = await (await fetch('/customers')).json();
|
||||||
|
document.getElementById('cust-list').innerHTML = (customers || [])
|
||||||
|
.map((c) => `<option value="${c.name}">${c.id}</option>`)
|
||||||
|
.join('');
|
||||||
|
if (customers && customers[0] && !document.getElementById('id').value) {
|
||||||
|
document.getElementById('id').value = customers[0].name;
|
||||||
|
}
|
||||||
|
} catch { /* edge optional */ }
|
||||||
|
}
|
||||||
|
loadCustomers();
|
||||||
async function review() {
|
async function review() {
|
||||||
const id = document.getElementById('id').value.trim();
|
const id = document.getElementById('id').value.trim();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
/** Join ledger ids to zappier-edge customer display names. */
|
/** Join ledger ids to zappier-edge customer display names. */
|
||||||
|
export async function listCustomers(edge, key) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||||
|
headers: { 'x-admin-key': key },
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function withCustomerName(st, idOrName, edge, key) {
|
export async function withCustomerName(st, idOrName, edge, key) {
|
||||||
const out = { ...(st || {}) };
|
const out = { ...(st || {}) };
|
||||||
if (out.name && out.customerId) return out;
|
if (out.name && out.customerId) return out;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||||
import { withCustomerName } from './names.js';
|
import { listCustomers, withCustomerName } from './names.js';
|
||||||
|
|
||||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||||
|
|
||||||
|
|
@ -50,6 +50,9 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (req.method === 'GET' && url.pathname === '/health') {
|
if (req.method === 'GET' && url.pathname === '/health') {
|
||||||
return json(200, { ok: true, role: 'zappier-customer-service', nats: Boolean(process.env.NATS_URL) });
|
return json(200, { ok: true, role: 'zappier-customer-service', nats: Boolean(process.env.NATS_URL) });
|
||||||
}
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/customers') {
|
||||||
|
return json(200, { customers: await listCustomers(EDGE, KEY) });
|
||||||
|
}
|
||||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||||
if (req.method === 'GET' && review) {
|
if (req.method === 'GET' && review) {
|
||||||
const id = decodeURIComponent(review[1]);
|
const id = decodeURIComponent(review[1]);
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,9 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
<label for="id">Customer id or name</label>
|
<label for="id">Customer</label>
|
||||||
<input id="id" value="cust_2" autocomplete="off" />
|
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
|
||||||
|
<datalist id="cust-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick="review()">Review account</button>
|
<button type="button" onclick="review()">Review account</button>
|
||||||
<button type="button" class="ghost" onclick="quote()">Show quote</button>
|
<button type="button" class="ghost" onclick="quote()">Show quote</button>
|
||||||
|
|
@ -92,6 +93,18 @@
|
||||||
<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>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>`;
|
<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 loadCustomers() {
|
||||||
|
try {
|
||||||
|
const { customers } = await (await fetch('/customers')).json();
|
||||||
|
document.getElementById('cust-list').innerHTML = (customers || [])
|
||||||
|
.map((c) => `<option value="${c.name}">${c.id}</option>`)
|
||||||
|
.join('');
|
||||||
|
if (customers && customers[0] && !document.getElementById('id').value) {
|
||||||
|
document.getElementById('id').value = customers[0].name;
|
||||||
|
}
|
||||||
|
} catch { /* edge optional */ }
|
||||||
|
}
|
||||||
|
loadCustomers();
|
||||||
async function review() {
|
async function review() {
|
||||||
const id = document.getElementById('id').value.trim();
|
const id = document.getElementById('id').value.trim();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
/** Join ledger ids to zappier-edge customer display names. */
|
/** Join ledger ids to zappier-edge customer display names. */
|
||||||
|
export async function listCustomers(edge, key) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||||
|
headers: { 'x-admin-key': key },
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function withCustomerName(st, idOrName, edge, key) {
|
export async function withCustomerName(st, idOrName, edge, key) {
|
||||||
const out = { ...(st || {}) };
|
const out = { ...(st || {}) };
|
||||||
if (out.name && out.customerId) return out;
|
if (out.name && out.customerId) return out;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||||
import { withCustomerName } from './names.js';
|
import { listCustomers, withCustomerName } from './names.js';
|
||||||
|
|
||||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||||
|
|
||||||
|
|
@ -53,6 +53,9 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (req.method === 'GET' && url.pathname === '/health') {
|
if (req.method === 'GET' && url.pathname === '/health') {
|
||||||
return json(200, { ok: true, role: 'zappier-sales-pricing' });
|
return json(200, { ok: true, role: 'zappier-sales-pricing' });
|
||||||
}
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/customers') {
|
||||||
|
return json(200, { customers: await listCustomers(EDGE, KEY) });
|
||||||
|
}
|
||||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||||
if (req.method === 'GET' && review) {
|
if (req.method === 'GET' && review) {
|
||||||
const id = decodeURIComponent(review[1]);
|
const id = decodeURIComponent(review[1]);
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,9 @@ export function adminRouter(
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/customers', (req, res) => {
|
router.get('/customers', (req, res) => {
|
||||||
res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) });
|
res.json({
|
||||||
|
customers: customers.list().map((c) => ({ ...c, apiKey: undefined, passwordHash: undefined, totpSecret: undefined })),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/customers', (req, res) => {
|
router.post('/customers', (req, res) => {
|
||||||
|
|
|
||||||