Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35aa8c693 | ||
|
|
129599fe9e | ||
|
|
a28b0e796d | ||
|
|
a1a13cdc25 | ||
|
|
60b906b448 | ||
|
|
49d11bb9bc | ||
|
|
2b5786685d | ||
|
|
a33acca87f | ||
|
|
ff9d42e0ba | ||
|
|
a70a66b1cc | ||
|
|
344bb1cfc2 | ||
|
|
4d17b91b31 | ||
|
|
7b97b6752b | ||
|
|
139d7fc6f9 | ||
|
|
f6df104ec8 | ||
|
|
db164c8470 | ||
|
|
6976badf4e | ||
|
|
85b7e6accb | ||
|
|
f464508249 | ||
|
|
d37dc23580 | ||
|
|
38eb16f391 | ||
|
|
e1ff150012 | ||
|
|
a47de37ff5 |
+17
-1
@@ -33,6 +33,18 @@ ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# identically to one served from inside cloud.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
# The publishable ingest key, for SIGNED-OUT views only. A signed-in visitor is
|
||||
# still attributed by their own IAM bearer -- src/lib/event.ts feeds this through
|
||||
# getToken as `token ?? key`, never as `ingestKey`, so it can only fill the gap
|
||||
# where there is no token and can never displace one.
|
||||
#
|
||||
# PUBLISHABLE_KEY is the name in KMS (org hanzo, path deploy, env prod) and on the
|
||||
# --build-arg; NEXT_PUBLIC_ is what makes the bundler inline it and is a property
|
||||
# of THIS build, so it is applied here and the secret store keeps the one plain
|
||||
# name. No default: absent means signed-out views report nothing, exactly as
|
||||
# before, which is a degradation and not a break.
|
||||
ARG PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
|
||||
COPY . .
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
|
||||
@@ -44,7 +56,11 @@ RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
|
||||
# cannot change under a rebuild. (The console's OWN release is named by semver in
|
||||
# the values file — that is the version a human reads.)
|
||||
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
|
||||
#
|
||||
# v0.5.7: serves a directory's index IN PLACE. The prior pin 301'd `/` to
|
||||
# `/index.html`, so the address bar carried the internal filename and the
|
||||
# console's breadcrumb dutifully read "Home > index.html".
|
||||
FROM ghcr.io/hanzoai/static@sha256:46b9a9b359b24377e228d39fb3d4e485af594d55bf1034dcc7b7a1e858a0bba6
|
||||
COPY --from=build /console/out/ /srv/
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/static"]
|
||||
|
||||
@@ -3921,3 +3921,113 @@ One placement note that cost a debug cycle: the quickstart branch must return BE
|
||||
reading the ones that exist, and the moments you most need the quickstart — no agents
|
||||
yet, or the registry not answering — are exactly the ones those early returns swallow
|
||||
it in.
|
||||
|
||||
## The rail shows ONE level, and the catalog is not an allow-list (fix/console-six)
|
||||
|
||||
Five defects the owner hit, and all five were one thing twice.
|
||||
|
||||
**Level 2 replaced level 1 in principle and was added to it in practice.** A product's
|
||||
pages expanded INDENTED beneath its row while the whole catalog stayed painted below,
|
||||
so two levels shared the screen. Worse, a PINNED product appeared twice — once under
|
||||
Pinned carrying the pages, once in its category carrying nothing — and the `owns` rule
|
||||
that picked which copy got the sub-list is the tell: a rule to decide which of two
|
||||
identical rows is the real one means there should have been one row.
|
||||
|
||||
The level is a REPLACEMENT now and it is a function of the ROUTE alone. Inside a
|
||||
product the rail is: the category (the way back up), the product as a HEADING, its
|
||||
pages FLUSH, then `More in <category>` — its siblings, so sideways is still one click.
|
||||
Nothing is remembered, so a reload, a deep link and Back cannot disagree with it.
|
||||
Deleted: `NAV_PRODUCT_OPEN_PREF`, `productIsOpen`, `toggleProduct`, the `owns` rule, the
|
||||
per-row expand chevron, and `SubRows`' inert accordion. `nav-accordion.ts` → `nav.ts`
|
||||
(it holds the rail's view model, not one accordion).
|
||||
|
||||
**`SubRow` is now the only level-2 row.** The product-shell face had its own copy plus a
|
||||
private `BILLING_SUBPAGE_ICON` map that was a strict subset of the shared `subpageIcon`.
|
||||
Both gone.
|
||||
|
||||
**"Many products are missing" was a hard-coded list of 13 ids.** `filterBeta` kept only
|
||||
`LAUNCH_PRODUCTS` for anyone who was not a superadmin — a deny-everything-not-named list
|
||||
wearing the word "beta" (zero entries were actually stamped `beta: true`). A hanzo-org
|
||||
user saw 13 of 185 products in 6 of 14 categories. Deleted whole: `LAUNCH_PRODUCTS`,
|
||||
`isLaunchProduct`, `filterBeta`, `isBetaEntry`, `CatalogEntry.beta`, `useAppsBeta`
|
||||
(`lib/products/beta.ts`), `beta-gate.test.ts`, and the `showBeta` argument threaded
|
||||
through `visibleCatalog`, `searchDestinations`, the rail, the palette and the panel.
|
||||
|
||||
That left TWO answers to "may I see this product": the All-products panel said every
|
||||
product is always available (`visibleCatalogByCategory(showAdmin, null)`) and the rail
|
||||
still filtered by the org's entitlement set. Same catalog, two sizes, depending which
|
||||
you asked. The `navCatalog` preference settles it: ON (default) the rail is everything
|
||||
the viewer may SEE — permission only; OFF it narrows to the org's enabled set plus pins
|
||||
and wherever you are. **Measured**: an org whose entitlement plane answers
|
||||
`{enabled: []}` — which silently collapsed the rail to the always-on essentials — now
|
||||
shows all 11 categories.
|
||||
|
||||
Permission (admin surfaces, brand scope) still holds in both states, so nothing on the
|
||||
rail is a surface the viewer cannot open.
|
||||
|
||||
**One search.** The rail had its own text filter that narrowed only the rows it had
|
||||
already drawn — a second search answering a smaller question, next to the header's ⌘K
|
||||
palette and a third hand-rolled copy in the mobile drawer. All three are
|
||||
`CommandSearchBox` now (`height` + `onOpen` are the only differences), so the rail's box
|
||||
opens the one palette, which asks the WHOLE catalog from either level. `filtering` left
|
||||
the rail entirely, and with it `categoryIsOpen`'s `ctx` argument.
|
||||
|
||||
**The two ends of the rail are peers.** `@hanzo/ui@8.0.56` ships an `OrgSwitcher` +
|
||||
`UserMenu` pair built as peers, and it was surveyed: `OrgSwitcher` is better than ours
|
||||
(debounced search, real paging — ours hard-codes page 0, so an admin could never reach
|
||||
org #21 — and race protection), but its trigger is org-only and cannot say `Org /
|
||||
project`; `UserMenu` hard-codes `placement: "bottom-end"` with no prop to change it, so
|
||||
it CANNOT mount at the foot of a rail. Adopting one and not the other is not
|
||||
convergence, so both are local and both are now ONE component: `components/ui/Menu.tsx`
|
||||
— trigger, sheet and rows, worn by `ContextSwitcher` and `AccountMenu` alike.
|
||||
|
||||
- **The org name was replaced by the org's logo.** The trigger branched on `org.logo`
|
||||
and rendered the image INSTEAD of the name; the name survived only in the aria-label.
|
||||
The logo is the MARK now (`OrgMark` resolves logo-else-monogram), the name is the
|
||||
label, always.
|
||||
- `AccountMenu` was `@hanzo/iam`'s `UserMenu` — a second rendering system inside one
|
||||
rail: raw `createElement`, an injected global `hz-iam-*` stylesheet, its own portal, a
|
||||
28px CIRCLE with a one-letter initial against the org's 20px rounded square with two.
|
||||
It is @hanzo/gui + `MenuRow` + `paper` now. Identity still comes from IAM
|
||||
(`useSession`, `signOut`); only the drawing changed.
|
||||
- **The balance row is gone from the menu** — `SidebarWallet` sits one row below it,
|
||||
reads the same `useCloudBalance`/`spendableCents`, links the same `config.payUrl`, and
|
||||
adds the trial/prepaid split. The same number twice, the second time behind a click.
|
||||
- ONE naming rule: `orgLabel(org)` in `account/org-state.ts`. The trigger titled the
|
||||
slug and the rows printed it raw, so the control could read "Acme" over an active row
|
||||
reading "acme". `SidebarBrand` used the raw slug too.
|
||||
- `SidebarBrand` said "the tenant leads the chrome … never the house mark" and then
|
||||
rendered `BrandMark` for any org without a logo — so one org wore the Hanzo glyph in
|
||||
the collapsed rail and its own monogram in the expanded one. It matches its own rule.
|
||||
|
||||
**Two bugs `paper` and the ladder had been hiding.** `paper` declared `bordered: true`,
|
||||
which leaves `borderWidth` at 0 — every anchored sheet in the console met the page with
|
||||
no edge. And the menu took no z-layer, so on a phone (where it opens from inside the
|
||||
account SHEET at `Z.modal`) it was measurable and unclickable; it is `Z.popover` now,
|
||||
which is the layer the ladder documents for exactly this. `up` is a preference rather
|
||||
than a promise (`allowFlip`/`stayInFrame`): at the foot of a desktop rail upward is
|
||||
right, and 365px off the top of a phone it is not.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean · `next build` clean · `vitest` **3340 passed /
|
||||
8 skipped** (267 files) · Playwright **22/22** across `e2e/rail.spec.ts` (new, 6),
|
||||
`account-menu.spec.ts` (4), `level-2-nav.spec.ts` (5) and `find-and-do.spec.ts` (7).
|
||||
`rail.spec.ts` measures what only a browser can: that the level-2 pages sit FLUSH with
|
||||
the row naming the level (x within 2px, the indentation defect), that nothing from
|
||||
another category is painted beside them, that the product is NAMED once and linked
|
||||
zero times, that both triggers share height/left-edge/type/weight and both carry a
|
||||
chevron, and that the sheet is opaque at opacity 1 and fully on screen.
|
||||
|
||||
Two traps worth keeping: `getByLabel('Find an organization')` resolves to the
|
||||
`role="search"` DIV wrapping `SearchInput` (which takes no `aria-label`), so `.fill()`
|
||||
throws — use the placeholder. And typing at the PAGE (`keyboard.type`) puts text in a
|
||||
React-Native-Web `Input`'s DOM without ever raising `onChangeText`: the box reads
|
||||
"vector" while the list is still the unfiltered browse view, so a spec that then looks
|
||||
for "Vector" finds it in the browse list and passes without the search having run. Fill
|
||||
the input and assert where ENTER lands.
|
||||
|
||||
**Found, not fixed — for the palette lane.** The palette's visible list disagrees with
|
||||
the list it acts on. Type `vector` and commit: Enter correctly opens `/vector`
|
||||
(`items[sel]` comes from the ranked `destResults`), while the rows on screen are the
|
||||
unfiltered `browseGroups` in catalog order with `Overview` first and `#cmdk-active` on
|
||||
it. Reproduces via ⌘K, so it predates this work and is not caused by the rail's new
|
||||
search box. `find-and-do.spec.ts` passes because it probes Enter, never DOM order.
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* against is a menu that is present in the DOM and unreadable — a library that
|
||||
* paints with utility class names renders exactly that in this app, because
|
||||
* Tailwind never scanned node_modules. An `expect(locator).toBeVisible()` would
|
||||
* have passed on the broken build.
|
||||
* have passed on the broken build. That is also why the menu is now the console's
|
||||
* OWN `Menu` on @hanzo/gui rather than a second rendering system injecting its own
|
||||
* global stylesheet: the rows are `MenuRow`, so they are located by ARIA role.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
@@ -152,7 +154,7 @@ test.describe('account control', () => {
|
||||
expect(onTop).toBe(true)
|
||||
|
||||
// Rows are padded, tall enough to hit, and readable.
|
||||
const rows = await menu.locator('.hz-iam-row').evaluateAll((els) =>
|
||||
const rows = await menu.locator('[role=menuitem], [role=radio]').evaluateAll((els) =>
|
||||
els.map((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { pl: s.paddingLeft, h: el.getBoundingClientRect().height, color: s.color, text: (el.textContent ?? '').trim() }
|
||||
@@ -166,7 +168,7 @@ test.describe('account control', () => {
|
||||
}
|
||||
|
||||
// Hover is a real state — the switch-that-rendered-identical class of bug.
|
||||
const first = menu.locator('.hz-iam-row').first()
|
||||
const first = menu.locator('[role=menuitem], [role=radio]').first()
|
||||
const atRest = await first.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
await first.hover()
|
||||
expect(await first.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(atRest)
|
||||
@@ -200,7 +202,7 @@ test.describe('account control', () => {
|
||||
|
||||
// Acme is nobody's membership — it exists only in the cross-tenant list an
|
||||
// admin may search. A memberships-only switcher could not offer it at all.
|
||||
await page.getByLabel('Find an organization').fill('acme')
|
||||
await page.getByPlaceholder('Find an organization').fill('acme')
|
||||
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
|
||||
// React Native's a11y set, which carries `option` but NOT `listbox`.
|
||||
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
|
||||
|
||||
+27
-21
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* e2e: ONE level-2 nav.
|
||||
*
|
||||
* Clicking into a product must reveal ITS options rather than replacing the screen,
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
|
||||
* competing tab strip in the content, which is what `/models` used to do (eight items
|
||||
* in the rail, four in the content, disagreeing on the index's own name).
|
||||
* Clicking into a product must reveal ITS options, and there must be exactly ONE
|
||||
* such nav on screen — not the sidebar's level 2 AND a competing tab strip in the
|
||||
* content, which is what `/models` used to do (eight items in the rail, four in the
|
||||
* content, disagreeing on the index's own name).
|
||||
*
|
||||
* "Rather than replacing the screen" is now literal on both axes: the product's
|
||||
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
|
||||
* used to swap itself for the product's sub-nav behind a "Back to all products"
|
||||
* button, so these specs assert the other products are still there — that is the
|
||||
* whole point of the change, and the part a future drill would silently undo.
|
||||
* The RAIL owns level 2: inside a product it lists that product's pages, flush, with
|
||||
* the rest of its category beneath them and the category itself as the way back up.
|
||||
* It does not swap itself for a bare sub-nav behind a "Back to all products" button
|
||||
* (nothing to move sideways to), and it does not indent the pages under the product's
|
||||
* row while the whole catalog stays painted below (two levels at once, and the same
|
||||
* product listed twice). These specs pin both failures shut.
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED style and
|
||||
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
|
||||
@@ -82,11 +83,9 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The rail expanded Models in place — and did NOT swap itself for it.
|
||||
// No dead-end drill: the way out is the category and the full catalog, both named.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
// The rest of the catalog is still there — "All products" sits at the FOOT of the
|
||||
// product list, so its presence proves the list was never swapped away. This is the
|
||||
// assertion the drill could not have passed.
|
||||
await expect(page.getByRole('button', { name: 'Back to AI' }).first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
|
||||
|
||||
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
|
||||
@@ -119,7 +118,12 @@ test('phone: the strip carries level 2 where the sidebar is a drawer', async ({
|
||||
const labels = await strip(page, 'models').getByRole('button').allInnerTexts()
|
||||
// Routing is admin-only and this account is an ORG admin, not a global one — the
|
||||
// one nav gates it, so a customer is never offered a surface they cannot open.
|
||||
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics'])
|
||||
//
|
||||
// The tail reads raw → summary: Logs, then Metrics, then Status LAST (the
|
||||
// live-health verdict comes after the signals it is derived from). f6df104ec8
|
||||
// reordered BASE_SUBPAGES and updated match-core.test.ts but not this spec, so it
|
||||
// asserted the retired order and failed against every build from 8.5.75 on.
|
||||
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Logs', 'Metrics', 'Status'])
|
||||
|
||||
// The strip wraps rather than pushing the page sideways.
|
||||
const scrolls = await page.evaluate(
|
||||
@@ -176,8 +180,8 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/models')
|
||||
|
||||
// Models is still expanded with the same options — browser Back moved the ROUTE,
|
||||
// and the rail followed it without collapsing what the user was looking at.
|
||||
// Models still owns the rail with the same options — browser Back moved the ROUTE,
|
||||
// and the rail is a function of the route, so it followed without losing the level.
|
||||
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
|
||||
@@ -187,10 +191,9 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
|
||||
/**
|
||||
* Every product that used to carry its own `const TABS` — the whole conversion, in
|
||||
* one sweep. For each: the page renders, the rail expands it in place, and the
|
||||
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
|
||||
* invariant, and it is the thing that regresses the moment someone adds a tab bar
|
||||
* back.
|
||||
* one sweep. For each: the page renders, the rail carries its pages, and the content
|
||||
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
|
||||
* and it is the thing that regresses the moment someone adds a tab bar back.
|
||||
*/
|
||||
const CONVERTED = [
|
||||
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
|
||||
@@ -199,6 +202,9 @@ const CONVERTED = [
|
||||
] as const
|
||||
|
||||
test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
// Eighteen full page loads in one test — it lands within a hair of the 60s
|
||||
// default and fails on the wrong side of it about as often as the right one.
|
||||
test.slow()
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
@@ -211,7 +217,7 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
).toBe('none')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back to all products' }),
|
||||
`${id}: the rail expands in place — it must never swap itself for one product`,
|
||||
`${id}: the rail keeps a way out — never a bare drill behind one button`,
|
||||
).toHaveCount(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* e2e: the rail — its two levels, its two ends, and the one search.
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED geometry and
|
||||
* counts, because every defect here is a LAYOUT defect: a level that renders while
|
||||
* the level above it is still painted, a name replaced by a picture, two triggers
|
||||
* that are meant to be peers and are not. `toBeVisible()` resolves to display /
|
||||
* visibility / box-size, which is the only honest test of "is this on screen".
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4123 npx playwright test rail
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|org|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
/** A tenant that HAS uploaded a logo — the case that used to erase the org's name. */
|
||||
const LOGO = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Crect width="24" height="24" fill="%237c5cff"/%3E%3C/svg%3E'
|
||||
|
||||
/**
|
||||
* Every backend answers 401 except the org identity, which answers a real org
|
||||
* WITH a logo and a display name. This spec is about the RAIL, not data, and an
|
||||
* unauthorized read is the state every module already handles honestly.
|
||||
*/
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const json = (body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (url.pathname.startsWith('/auth/')) return json({ ok: true })
|
||||
if (url.href.includes('get-organization')) {
|
||||
return json({ status: 'ok', data: { name: 'hanzo', displayName: 'Hanzo AI', logo: LOGO } })
|
||||
}
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return json({ error: 'Sign in to use Hanzo Cloud.' }, 401)
|
||||
}
|
||||
|
||||
/** A hanzo-org user — an admin of their OWN org, NOT a platform super admin.
|
||||
* This is the identity the whole catalog must be available to. */
|
||||
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
|
||||
|
||||
async function open(page: Page, path: string) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('nav[aria-label="Products"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
/** The persistent rail (the shell mounts the same nav three times; this is the
|
||||
* first in document order, and every geometry check proves it is the painted one). */
|
||||
const rail = (page: Page) => page.locator('nav[aria-label="Products"]').first()
|
||||
const railRow = (page: Page, name: string) =>
|
||||
rail(page).getByRole('button', { name, exact: true }).filter({ visible: true })
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('level 1 is the whole catalog — a hanzo-org user is not shown a dozen products', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/')
|
||||
|
||||
// The catalog used to be filtered to a hard-coded 13-id launch list, so a
|
||||
// customer saw six categories. Every category the brand admits is here now.
|
||||
const text = await rail(page).innerText()
|
||||
for (const category of ['AI', 'Compute', 'Data', 'Network', 'Security', 'Observe', 'Platform', 'Dev', 'Web3', 'Apps']) {
|
||||
expect(text, `${category} is a section of the catalog`).toContain(category)
|
||||
}
|
||||
// Products from categories the launch list erased entirely.
|
||||
for (const label of ['Vector', 'Functions', 'Gateway', 'Fine-tuning', 'Projects']) {
|
||||
await expect(railRow(page, label).first(), `${label} is reachable`).toBeVisible()
|
||||
}
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-level-1.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('level 2 REPLACES level 1 — the pages are flush and the catalog is not underneath', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The product's own pages are the list.
|
||||
for (const label of ['Catalog', 'Leaderboard', 'Blend']) {
|
||||
await expect(railRow(page, label).first(), `${label} is a rail row`).toBeVisible()
|
||||
}
|
||||
// Its category siblings follow them, so moving sideways is one click.
|
||||
await expect(rail(page)).toContainText('More in AI')
|
||||
await expect(railRow(page, 'Playground').first()).toBeVisible()
|
||||
|
||||
// …and NOTHING from another category is painted: level 1 is gone, not pushed down.
|
||||
for (const label of ['Vector', 'Functions', 'Gateway']) {
|
||||
expect(await railRow(page, label).count(), `${label} belongs to level 1`).toBe(0)
|
||||
}
|
||||
|
||||
// Exactly ONE mention of the product. It used to appear TWICE — once under
|
||||
// Pinned carrying the indented pages, once in its category carrying nothing —
|
||||
// so the rail showed the same product in two places, one of them dead.
|
||||
const lines = (await rail(page).innerText()).split('\n').map((l) => l.trim())
|
||||
expect(lines.filter((l) => l === 'Models'), 'Models is named once').toHaveLength(1)
|
||||
// …and it is a HEADING, not a link: its index page is the first row beneath it,
|
||||
// so a second way to the same page would be the duplication this level removes.
|
||||
expect(await railRow(page, 'Models').count(), 'the name is not a second link').toBe(0)
|
||||
|
||||
// The pages are FLUSH with the row that names the level, not indented under it.
|
||||
const pages = await Promise.all(
|
||||
['Catalog', 'Leaderboard', 'Blend'].map(async (l) => (await railRow(page, l).first().boundingBox())!.x),
|
||||
)
|
||||
const back = (await railRow(page, 'Back to AI').first().boundingBox())!.x
|
||||
for (const [i, x] of pages.entries()) {
|
||||
expect(Math.abs(x - back), `page ${i} is flush with the level, not indented`).toBeLessThanOrEqual(2)
|
||||
}
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-level-2.png') })
|
||||
|
||||
// The way back up is the category the product sits in.
|
||||
await railRow(page, 'Back to AI').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/category/ai')
|
||||
// …and the rail is level 1 again.
|
||||
await expect(railRow(page, 'Vector').first()).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the org switcher shows the org NAME even when the org has a logo', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/')
|
||||
|
||||
const org = page.getByTestId('switcher-context').first()
|
||||
// The logo took the name's slot; the name survived only in the aria-label.
|
||||
await expect(org).toContainText('Hanzo AI')
|
||||
// The logo is the MARK beside it, not a replacement for it.
|
||||
await expect(org.locator('img')).toBeVisible()
|
||||
|
||||
await org.click()
|
||||
await page.locator('[role=menu]').first().waitFor()
|
||||
await page.waitForTimeout(600)
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-org-name.png'), animations: 'disabled' })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the two ends of the rail are peers — same box, same mark, same type', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/')
|
||||
|
||||
const measure = (id: string) =>
|
||||
page.getByTestId(id).first().evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
const mark = el.querySelector('img, [class*="OrgMark"], span, div')
|
||||
return {
|
||||
height: el.getBoundingClientRect().height,
|
||||
x: el.getBoundingClientRect().x,
|
||||
font: s.fontSize,
|
||||
weight: s.fontWeight,
|
||||
// The chevron: both must carry one, or one reads as a caption.
|
||||
svgs: el.querySelectorAll('svg').length,
|
||||
markH: mark ? Math.round(mark.getBoundingClientRect().height) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const org = await measure('switcher-context')
|
||||
const account = await measure('nav-user')
|
||||
|
||||
expect(account.height, 'same height').toBe(org.height)
|
||||
expect(account.x, 'same left edge').toBe(org.x)
|
||||
expect(account.font, 'same type size').toBe(org.font)
|
||||
expect(account.weight, 'same type weight').toBe(org.weight)
|
||||
expect(account.svgs > 0 && org.svgs > 0, 'both carry a chevron').toBe(true)
|
||||
|
||||
// The account is at the FOOT, the org at the HEAD — peers, not a stack.
|
||||
const orgY = (await page.getByTestId('switcher-context').first().boundingBox())!.y
|
||||
const accY = (await page.getByTestId('nav-user').first().boundingBox())!.y
|
||||
expect(accY).toBeGreaterThan(orgY)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-switchers.png') })
|
||||
|
||||
// Both open onto the same sheet, and the account's opens UPWARD so it stays
|
||||
// on screen from the foot of the rail.
|
||||
await page.getByTestId('nav-user').first().click()
|
||||
const menu = page.locator('[role=menu]').first()
|
||||
await menu.waitFor()
|
||||
await page.waitForTimeout(600)
|
||||
const box = (await menu.boundingBox())!
|
||||
expect(box.y, 'the account sheet opens upward, fully on screen').toBeGreaterThanOrEqual(0)
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(1001)
|
||||
// It PAINTS: an opaque sheet, not a transparent stack the rail reads through.
|
||||
const paint = await menu.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { bg: s.backgroundColor, opacity: s.opacity }
|
||||
})
|
||||
expect(paint.bg).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(Number(paint.opacity)).toBe(1)
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-account-menu.png'), animations: 'disabled' })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('search from the rail reaches a product the rail is not currently showing', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
// Inside a product, so the rail is level 2 and the catalog is put away.
|
||||
await open(page, '/models')
|
||||
expect(await railRow(page, 'Vector').count(), 'Vector is not on the rail here').toBe(0)
|
||||
|
||||
// The rail's search box is the SAME palette the header opens — one search.
|
||||
await rail(page).getByText('Search or jump to…').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
// Fill the palette's OWN input, then commit — where the query TAKES you is the
|
||||
// honest probe. Merely finding the word "Vector" on screen proves nothing: with an
|
||||
// empty query the palette browses the whole catalog, so "Vector" is already there
|
||||
// and a spec that looks for it passes without the search ever having run.
|
||||
const box = page.getByPlaceholder(/Search apps and commands/i).filter({ visible: true }).first()
|
||||
await box.fill('vector')
|
||||
await expect(page.locator('#cmdk-active').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-search.png'), animations: 'disabled' })
|
||||
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => new URL(page.url()).pathname, { timeout: 15_000 }).toBe('/vector')
|
||||
|
||||
// …and the rail followed: it is now Vector's level 2, from a product that was
|
||||
// nowhere on the rail a moment ago.
|
||||
await page.waitForTimeout(1500)
|
||||
await expect(rail(page)).toContainText('More in Data')
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-search-landed.png'), animations: 'disabled' })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the settings toggle decides whether the rail lists the catalog', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/profile')
|
||||
|
||||
const label = page.getByText('Show every product').first()
|
||||
await expect(label).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-toggle.png') })
|
||||
|
||||
// ON by default: the rail carries the catalog — here, the rest of this
|
||||
// product's category beneath its own pages.
|
||||
await expect(rail(page)).toContainText('More in Settings')
|
||||
await expect(railRow(page, 'Members').first()).toBeVisible()
|
||||
|
||||
// Turn it off — the rail keeps this product's pages and the pins, nothing else.
|
||||
const toggle = page.getByRole('switch').first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click()
|
||||
await page.waitForTimeout(900)
|
||||
await expect(rail(page), 'the catalog is put away').not.toContainText('More in Settings')
|
||||
await expect(railRow(page, 'Account').first(), 'this product keeps its pages').toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'rail-toggle-off.png') })
|
||||
|
||||
// Back at level 1 the catalog is put away there too — the pins remain.
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1500)
|
||||
expect(await railRow(page, 'Vector').count(), 'no catalog at level 1 either').toBe(0)
|
||||
await expect(railRow(page, 'Models').first(), 'the pins stay').toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -41,3 +41,7 @@ images:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/console
|
||||
# Fetched from KMS (deploy/PUBLISHABLE_KEY, env prod) and passed as
|
||||
# --build-arg PUBLISHABLE_KEY. Signed-out views need it to be admitted at all;
|
||||
# signed-in ones keep their own bearer. Same name the rest of the estate uses.
|
||||
build_secrets: [PUBLISHABLE_KEY]
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.63",
|
||||
"version": "8.5.83",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
|
||||
+104
-58
@@ -2,83 +2,129 @@
|
||||
|
||||
/**
|
||||
* The account control — WHO you are: identity, your team, your personal
|
||||
* settings, what you have left to spend, and the way out. ONE control, at the
|
||||
* foot of the rail.
|
||||
* settings, the theme, and the way out. ONE control, at the foot of the rail.
|
||||
*
|
||||
* It deliberately does NOT switch tenant. Org and project are one question —
|
||||
* WHERE you are — and they are answered together by `ContextSwitcher` at the
|
||||
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
|
||||
* would put the org in two corners again, which is the exact confusion the
|
||||
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
|
||||
* and the single `org-scope.switchOrg` money seam all moved there intact; there
|
||||
* is still exactly one org switch in the app.
|
||||
* top-left, beside the tenant's own mark. Handing this menu an org too would put
|
||||
* the org in two corners again, which is the exact confusion the condensed
|
||||
* switcher removes. The cross-tenant reach, the admin-gated org list and the
|
||||
* single `org-scope.switchOrg` money seam all live there; there is still exactly
|
||||
* one org switch in the app.
|
||||
*
|
||||
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
|
||||
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
|
||||
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
|
||||
* everything the console knows that the SDK does not:
|
||||
* It is the SAME `Menu` the context switcher wears — same height, same mark, same
|
||||
* type, same chevron, same sheet — so the two ends of the rail are peers. It used
|
||||
* to be `@hanzo/iam`'s `UserMenu`: a second rendering system inside one rail,
|
||||
* drawing raw DOM through an injected global stylesheet into its own portal, with
|
||||
* a 28px circle and a one-letter initial against the org's rounded-square,
|
||||
* two-letter mark. Identity still comes from IAM (`useSession`, `signOut`) — what
|
||||
* changed is that the console draws its own rail with its own primitives, once.
|
||||
*
|
||||
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
|
||||
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
|
||||
* own theme hook beside it — one theme system, not two.
|
||||
*
|
||||
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
|
||||
* paint a Hanzo mark on a Lux or Zoo console.
|
||||
* The BALANCE is not here. `SidebarWallet` sits one row below this control and
|
||||
* shows the same number from the same hook, plus the trial/prepaid split and a
|
||||
* top-up button — so the copy in here was the same fact twice, the second time
|
||||
* behind a click.
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Text, YStack } from '@hanzo/gui'
|
||||
import { BookOpen, LogOut, Receipt, Users, UserRound } from '@hanzogui/lucide-icons-2'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { Menu, MenuLabel, MenuRule } from '~/components/ui/Menu'
|
||||
|
||||
/** `system` is a real choice, and the console's provider already understands it. */
|
||||
const THEMES = [
|
||||
{ mode: 'light', label: 'Light' },
|
||||
{ mode: 'dark', label: 'Dark' },
|
||||
{ mode: 'system', label: 'Sync with system' },
|
||||
] as const
|
||||
|
||||
export function AccountMenu() {
|
||||
const router = useRouter()
|
||||
const { account, signOut } = useSession()
|
||||
const { balance } = useCloudBalance()
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
|
||||
// `system` is a real choice, and the console's provider already understands it.
|
||||
const theme: UserTheme = useMemo(
|
||||
() => ({
|
||||
mode: (current === 'light' || current === 'dark' ? current : 'system') as UserTheme['mode'],
|
||||
resolved: (resolvedTheme ?? current) === 'light' ? 'light' : 'dark',
|
||||
setMode: (mode) => set(mode),
|
||||
}),
|
||||
[current, resolvedTheme, set],
|
||||
)
|
||||
const { current, set } = useThemeSetting()
|
||||
|
||||
if (!account) return null
|
||||
|
||||
const cents = spendableCents(balance)
|
||||
// Never a fabricated "User" — a name nobody chose reads as a bug to the person
|
||||
// it names. The email's local part is a real name; the account name is a real name.
|
||||
const name = account.displayName?.trim() || account.name
|
||||
const mode = current === 'light' || current === 'dark' ? current : 'system'
|
||||
|
||||
return (
|
||||
<UserMenu
|
||||
align="up"
|
||||
identity={{
|
||||
name,
|
||||
email: account.email ?? null,
|
||||
initials: (name || '?').slice(0, 1).toUpperCase(),
|
||||
avatarUrl: account.avatar || null,
|
||||
<Menu
|
||||
mark={{ name, logo: account.avatar || undefined }}
|
||||
label={name}
|
||||
aria={`Account — ${name}`}
|
||||
testId="nav-user"
|
||||
up
|
||||
>
|
||||
{(close) => {
|
||||
const go = (href: string) => () => {
|
||||
close()
|
||||
router.push(href)
|
||||
}
|
||||
return (
|
||||
<YStack gap="$0.5">
|
||||
<YStack px="$2" py="$1.5">
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{account.email ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{account.email}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuRow label="Profile" icon={<UserRound size={14} />} onPress={go('/profile')} />
|
||||
<MenuRow label="Billing & usage" icon={<Receipt size={14} />} onPress={go('/billing')} />
|
||||
{/* Your people, beside your own settings — the other half of "who am I". */}
|
||||
<MenuRow label="Members" icon={<Users size={14} />} onPress={go('/team')} />
|
||||
<MenuRow
|
||||
label="Documentation"
|
||||
icon={<BookOpen size={14} />}
|
||||
onPress={() => {
|
||||
close()
|
||||
window.open(config.docsUrl, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
/>
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuLabel>Theme</MenuLabel>
|
||||
<YStack role="radiogroup" aria-label="Theme" gap="$0.5">
|
||||
{THEMES.map((t) => (
|
||||
<MenuRow
|
||||
key={t.mode}
|
||||
label={t.label}
|
||||
active={mode === t.mode}
|
||||
onPress={() => {
|
||||
close()
|
||||
set(t.mode)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuRow
|
||||
label="Sign out"
|
||||
icon={<LogOut size={14} />}
|
||||
onPress={() => {
|
||||
close()
|
||||
void signOut()
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}}
|
||||
isAuthenticated
|
||||
isLoading={false}
|
||||
onSignOut={() => void signOut()}
|
||||
theme={theme}
|
||||
settingsUrl="/profile"
|
||||
usageUrl="/billing"
|
||||
usageLabel="Billing & usage"
|
||||
// Only shown when the backend actually reported a balance — never a fabricated $0.
|
||||
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
|
||||
items={[
|
||||
// Your people, beside your own settings — the other half of "who am I".
|
||||
// Choosing a DIFFERENT tenant is a different question and lives in the
|
||||
// top-left context switcher, so this menu never re-scopes the console.
|
||||
{ label: 'Members', href: '/team' },
|
||||
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
|
||||
]}
|
||||
brand={{ name: config.brandName }}
|
||||
/>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
@@ -190,8 +189,7 @@ export function AddProductPanel() {
|
||||
|
||||
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
|
||||
// appear), grouped by category.
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
|
||||
|
||||
// Literal, case-insensitive substring match over label/description/id — NOT a
|
||||
// compiled RegExp of user input.
|
||||
|
||||
@@ -77,7 +77,6 @@ import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } fr
|
||||
import { searchDestinations, type Destination } from '~/lib/products/search'
|
||||
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
@@ -214,6 +213,7 @@ function CatalogRow({
|
||||
return (
|
||||
<XStack
|
||||
className="hz-row-pin"
|
||||
data-testid="palette-hit"
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
@@ -279,6 +279,7 @@ function DestinationRow({
|
||||
const Icon = subpage.icon ?? entry.icon
|
||||
return (
|
||||
<XStack
|
||||
data-testid="palette-hit"
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
@@ -365,7 +366,6 @@ function PaletteDialog({
|
||||
const router = useRouter()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
const { colorOf } = useProductColors()
|
||||
const pins = usePins()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
@@ -441,7 +441,7 @@ function PaletteDialog({
|
||||
// a search, so the ranked branch is left strictly alone.
|
||||
const destResults = useMemo(() => {
|
||||
if (mode !== 'catalog') return []
|
||||
const found = searchDestinations(query, showAdmin, null, showBeta)
|
||||
const found = searchDestinations(query, showAdmin, null)
|
||||
if (sub) return found.slice(0, 50)
|
||||
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
|
||||
}, [mode, query, sub, showAdmin, pins.pinnedIds])
|
||||
@@ -822,17 +822,26 @@ export function Palette({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Header trigger — a search box that opens the palette. */
|
||||
export function CommandSearchBox() {
|
||||
/**
|
||||
* THE search box — the one control that opens the palette, wherever a surface wants
|
||||
* to offer search. The header, the sidebar rail and the mobile drawer all mount this
|
||||
* one component; each used to draw its own box, which is three searches for one
|
||||
* question. `onOpen` lets a surface that is itself dismissible (the drawer) step out
|
||||
* of the way first.
|
||||
*/
|
||||
export function CommandSearchBox({ height = 36, onOpen }: { height?: number; onOpen?: () => void } = {}) {
|
||||
const { open } = useCommandPalette()
|
||||
return (
|
||||
<XStack
|
||||
onPress={open}
|
||||
onPress={() => {
|
||||
onOpen?.()
|
||||
open()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$3"
|
||||
height={36}
|
||||
height={height}
|
||||
flex={1}
|
||||
maxW={420}
|
||||
bg="$color2"
|
||||
|
||||
+102
-136
@@ -10,10 +10,14 @@
|
||||
* project are one question — which tenant, and which slice of it — so they are
|
||||
* one control, and it sits with the org mark that already anchors the top-left.
|
||||
*
|
||||
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
|
||||
* settings, the way out) at the foot of the rail. The NETWORK stays its own
|
||||
* control in the top-right, because it is a global MODE rather than a place —
|
||||
* and its tier dot is a destructive-environment guard, not decoration.
|
||||
* The ACCOUNT keeps the other question ("who am I") at the foot of the rail, and
|
||||
* wears the SAME `Menu` — same height, same mark, same type, same chevron — so the
|
||||
* two ends of the rail read as two halves of one identity.
|
||||
*
|
||||
* The org's NAME always shows. It used to be replaced by the org's logo whenever
|
||||
* IAM carried one, which left a tenant with a logo looking at a picture and no
|
||||
* name at all — the name survived only in the aria-label. The logo is the MARK now
|
||||
* (that is what `OrgMark` is for), and the name is the label, always.
|
||||
*
|
||||
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
|
||||
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
|
||||
@@ -26,8 +30,8 @@
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
|
||||
import { Text, YStack } from '@hanzo/gui'
|
||||
import { FolderGit2, Plus, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
@@ -35,10 +39,10 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
|
||||
import { contextLabel, orgLabel, scopedOrgRow } from '~/lib/account/org-state'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { SearchInput } from '@hanzo/ui/product'
|
||||
import { Menu, MenuLabel, MenuRule } from '~/components/ui/Menu'
|
||||
import { OrgMark, SearchInput } from '@hanzo/ui/product'
|
||||
|
||||
export function ContextSwitcher() {
|
||||
const router = useRouter()
|
||||
@@ -46,13 +50,12 @@ export function ContextSwitcher() {
|
||||
const scoped = currentOrg()
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [orgs, setOrgs] = useState<Organization[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// IAM's display name when it has one; otherwise the slug, titled the same way
|
||||
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
|
||||
const orgLabel = org.displayName || titleCase(org.name || scoped)
|
||||
// ONE naming rule — `orgLabel` — so the trigger and the row for the very same
|
||||
// org can never read differently ("Acme" above, "acme" below).
|
||||
const name = orgLabel(org.displayName ? org : { ...org, name: org.name || scoped })
|
||||
|
||||
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
|
||||
// it, so they are never asked to — their own org is the honest answer. An admin
|
||||
@@ -67,14 +70,6 @@ export function ContextSwitcher() {
|
||||
[isSuperAdmin, scoped],
|
||||
)
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next && orgs === null) void loadOrgs('')
|
||||
},
|
||||
[orgs, loadOrgs],
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q)
|
||||
@@ -83,134 +78,105 @@ export function ContextSwitcher() {
|
||||
[loadOrgs],
|
||||
)
|
||||
|
||||
const pick = useCallback(
|
||||
(fn: () => void) => () => {
|
||||
setOpen(false)
|
||||
fn()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const orgRows = useMemo(() => orgs ?? [], [orgs])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
px="$2"
|
||||
data-testid="switcher-context"
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
|
||||
>
|
||||
{org.logo ? (
|
||||
// The org's own logo IS the label — the uploaded mark takes the
|
||||
// slot the name held, height-capped to the row so any aspect fits.
|
||||
// A scoped project keeps its text beside it; the full text stays
|
||||
// in the aria-label either way. Arbitrary tenant URL/data URL, so
|
||||
// a raw <img> (next/image would need a per-tenant remote
|
||||
// allow-list) — same call BrandLogo makes.
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={org.logo}
|
||||
alt={orgLabel}
|
||||
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
{scope.project ? (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
/ {scope.project}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Menu
|
||||
mark={org}
|
||||
label={contextLabel(name, scope.project)}
|
||||
aria={`Organization and project — ${contextLabel(name, scope.project)}`}
|
||||
testId="switcher-context"
|
||||
onOpen={() => {
|
||||
if (orgs === null) void loadOrgs('')
|
||||
}}
|
||||
>
|
||||
{(close) => {
|
||||
const pick = (fn: () => void) => () => {
|
||||
close()
|
||||
fn()
|
||||
}
|
||||
return (
|
||||
<YStack gap="$0.5">
|
||||
<MenuLabel>Organization</MenuLabel>
|
||||
|
||||
<Popover.Content {...paper} p="$2" width={280}>
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Organization
|
||||
</Text>
|
||||
{/* Admins only: the cross-tenant list is server-paged and longer than
|
||||
one page, so reaching a tenant nobody is a member of means SEARCHING
|
||||
it, not scrolling. A regular user has one org and no field. */}
|
||||
{isSuperAdmin ? (
|
||||
<YStack px="$1" pb="$1">
|
||||
{/* A search landmark names the control for assistive tech — the shared
|
||||
SearchInput has no accessible-name prop of its own. */}
|
||||
<div role="search" aria-label="Find an organization">
|
||||
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
|
||||
</div>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* Admins only: the cross-tenant list is server-paged and longer than
|
||||
one page, so reaching a tenant nobody is a member of means SEARCHING
|
||||
it, not scrolling. A regular user has one org and no field. */}
|
||||
{isSuperAdmin ? (
|
||||
<YStack px="$1" pb="$1">
|
||||
{/* A search landmark names the control for assistive tech — the shared
|
||||
SearchInput has no accessible-name prop of its own. */}
|
||||
<div role="search" aria-label="Find an organization">
|
||||
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
|
||||
</div>
|
||||
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
|
||||
{orgRows.map((o) => (
|
||||
<MenuRow
|
||||
key={o.name}
|
||||
label={orgLabel(o)}
|
||||
icon={<OrgMark org={o} size={18} />}
|
||||
active={scoped === o.name}
|
||||
onPress={pick(() => {
|
||||
if (o.name !== scoped) switchOrg(o.name)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
|
||||
{orgRows.map((o) => (
|
||||
<MenuRow
|
||||
key={o.name}
|
||||
label={o.displayName || o.name}
|
||||
active={scoped === o.name}
|
||||
onPress={pick(() => {
|
||||
if (o.name !== scoped) switchOrg(o.name)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
{orgRows.length === 0 ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
{orgs === null ? 'Loading…' : 'No organization matches that.'}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{orgRows.length === 0 ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
{orgs === null ? 'Loading…' : 'No organization matches that.'}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Project
|
||||
</Text>
|
||||
|
||||
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<MenuRow
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={pick(() => selectProject(undefined))}
|
||||
label="Organization settings"
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
onPress={pick(() => router.push('/settings/branding'))}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
|
||||
|
||||
<MenuRule />
|
||||
|
||||
<MenuLabel>Project</MenuLabel>
|
||||
|
||||
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<MenuRow
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={pick(() => selectProject(p.name))}
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={pick(() => selectProject(undefined))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{projects.map((p) => (
|
||||
<MenuRow
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={pick(() => selectProject(p.name))}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow
|
||||
label="New project"
|
||||
icon={<FolderGit2 size={14} />}
|
||||
onPress={pick(() => router.push('/projects'))}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow
|
||||
label="New project"
|
||||
icon={<FolderGit2 size={14} />}
|
||||
onPress={pick(() => router.push('/projects'))}
|
||||
/>
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,10 +216,12 @@ function ChatSheet({
|
||||
* listening. The mic renders only where the browser can actually listen, so there is
|
||||
* never a dead control.
|
||||
*
|
||||
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
|
||||
* exists only there), and its caller suppresses it exactly where the assistant is
|
||||
* already on screen: while the sheet is open, on the pages that ARE a composer
|
||||
* (`/chat`, `/playground`), and while the assistant IS the column.
|
||||
* It is the assistant's entry point on phones/tablets (`<lg`). At `lg+` the
|
||||
* Developers dock at the foot of the page hosts the same mic + brand-mark, so the
|
||||
* bubble is hidden there (`$lg` display:none) — one launcher per viewport, never two.
|
||||
* Its caller also suppresses it where the assistant is already on screen: while the
|
||||
* sheet is open, on the pages that ARE a composer (`/chat`, `/playground`), and while
|
||||
* the assistant IS the column.
|
||||
*/
|
||||
function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () => void }) {
|
||||
const [voiceOk] = useState(() => voiceSupported())
|
||||
@@ -229,7 +231,7 @@ function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () =>
|
||||
position="fixed"
|
||||
r={20}
|
||||
b={20}
|
||||
$lg={{ b: 64 }}
|
||||
$lg={{ display: 'none' }}
|
||||
items="center"
|
||||
gap="$2"
|
||||
style={{ zIndex: Z.raised }}
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
* to that org (X-Org-Id) and drops into it; the sidebar "Home" affordance
|
||||
* ({@link leaveOrg}) returns here and de-scopes.
|
||||
*
|
||||
* Data source mirrors {@link OrgSwitcher}: a global admin lists all orgs via the
|
||||
* gated `/admin/iam` proxy; a tenant (who 403s that list) sees just their own org,
|
||||
* synthesized from the session — so the picker is always honest and never fabricates
|
||||
* an org. All decisions (sort, filter, paginate, card view-model) live in the pure
|
||||
* Data source: a global admin lists every org via the gated `/admin/iam` proxy;
|
||||
* everyone else lists the orgs they are a MEMBER of — their membership rows
|
||||
* unioned with their home org, each read as its own record so a card carries the
|
||||
* ORG's name and logo. It never fabricates an org, and it never labels one with
|
||||
* the signed-in person. All decisions (sort, filter, paginate, card view-model) live in the pure
|
||||
* `org-picker/logic.ts`; this file is a thin render of it with honest loading /
|
||||
* empty / error states.
|
||||
*/
|
||||
@@ -25,7 +26,7 @@ import { getBrand } from '~/lib/branding/brands'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { enterOrg } from '~/lib/org-scope'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { IamAdminApi, MembershipApi, TeamApi, orgNamesFor, type Organization } from '~/lib/api'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { OrgOnboarding } from '~/components/OrgOnboarding'
|
||||
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
|
||||
@@ -109,30 +110,71 @@ export function OrgPicker() {
|
||||
const owner = account?.owner ?? ''
|
||||
|
||||
const [orgs, setOrgs] = useState<Organization[] | null>(null)
|
||||
// org -> the caller's role in it, straight from the membership rows. The card
|
||||
// states the role it was GRANTED rather than one inferred from the account.
|
||||
const [roles, setRoles] = useState<Record<string, string>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// The caller's OWN org, synthesized from the session — the single org that IS a
|
||||
// tenant's identity, and the honest fallback if the cross-tenant list can't load.
|
||||
// The caller's HOME org, named by its own slug — the last-resort fallback when
|
||||
// even the membership read fails.
|
||||
//
|
||||
// Its displayName used to be `account.displayName`, which is the signed-in
|
||||
// PERSON. An org card then announced a human ("Dave Lorenzini") where the
|
||||
// organization belongs, and the monogram it derived was the person's initials
|
||||
// rather than the org's mark. A person is not an org; when the org's own row
|
||||
// cannot be read, its slug is the honest label.
|
||||
const ownOrgOnly = useMemo<Organization[]>(
|
||||
() =>
|
||||
owner
|
||||
? [{ owner: 'admin', name: owner, displayName: account?.displayName?.trim() || titleCase(owner) } as Organization]
|
||||
: [],
|
||||
[owner, account?.displayName],
|
||||
() => (owner ? [{ owner: 'admin', name: owner, displayName: titleCase(owner) } as Organization] : []),
|
||||
[owner],
|
||||
)
|
||||
|
||||
// Load the caller's visible orgs. A global admin gets the full cross-tenant list
|
||||
// (paged large, then this component client-paginates); a tenant 403s that list, so
|
||||
// it sees just its own org — honest, never fabricated.
|
||||
// (paged large, then this component client-paginates). Everyone else gets the
|
||||
// orgs they are a MEMBER of — their memberships unioned with their home org,
|
||||
// each read as its own row so the card shows the ORG's name and logo.
|
||||
//
|
||||
// This used to be one card synthesized from the session, which could never show
|
||||
// a second org: a customer with a workspace besides their home tenant simply did
|
||||
// not see it. Reading each row is also what puts a real logo on the card; IAM
|
||||
// authorizes a member to read the orgs they belong to (v1.34.26), so the fetch
|
||||
// that used to 403 for a tenant now answers.
|
||||
useEffect(() => {
|
||||
if (!owner) return
|
||||
let live = true
|
||||
if (!isSuperAdmin) {
|
||||
setOrgs(ownOrgOnly)
|
||||
return
|
||||
const me = account?.name ? `${owner}/${account.name}` : ''
|
||||
if (!me) {
|
||||
setOrgs(ownOrgOnly)
|
||||
return
|
||||
}
|
||||
MembershipApi.mine(me)
|
||||
.then((rows) => {
|
||||
if (live) setRoles(Object.fromEntries(rows.map((m) => [m.org, m.role])))
|
||||
return orgNamesFor(owner, rows)
|
||||
})
|
||||
.then((names) =>
|
||||
// One read per org, and a row that cannot be read degrades to its slug
|
||||
// rather than dropping the org off a list the person is entitled to see.
|
||||
Promise.all(
|
||||
names.map((name) =>
|
||||
TeamApi.organization(name).catch(
|
||||
() => ({ owner: 'admin', name, displayName: titleCase(name) }) as Organization,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => {
|
||||
if (live) setOrgs(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setOrgs(ownOrgOnly)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}
|
||||
setOrgs(null)
|
||||
setError(null)
|
||||
@@ -151,11 +193,11 @@ export function OrgPicker() {
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [owner, isSuperAdmin, ownOrgOnly])
|
||||
}, [owner, isSuperAdmin, ownOrgOnly, account?.name])
|
||||
|
||||
const ctx: PickerContext = useMemo(
|
||||
() => ({ ownOrg: owner, isSuperAdmin, callerIsAdmin: Boolean(account?.isAdmin) }),
|
||||
[owner, isSuperAdmin, account?.isAdmin],
|
||||
() => ({ ownOrg: owner, isSuperAdmin, callerIsAdmin: Boolean(account?.isAdmin), roles }),
|
||||
[owner, isSuperAdmin, account?.isAdmin, roles],
|
||||
)
|
||||
|
||||
const view = useMemo(
|
||||
|
||||
@@ -31,6 +31,7 @@ import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { orgLabel } from '~/lib/account/org-state'
|
||||
import { Z } from '~/lib/z'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
|
||||
@@ -113,7 +114,9 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
// The tenant leads the chrome: its own logo when set, else its monogram — never
|
||||
// the house mark. `useOrgIdentity` is the ONE cached org-identity source.
|
||||
const org = useOrgIdentity()
|
||||
const orgLabel = org.displayName || org.name
|
||||
// ONE naming rule, shared with the context switcher, so the collapsed rail and
|
||||
// the expanded one cannot call the same org two different things.
|
||||
const label = orgLabel(org)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const go = useCallback(
|
||||
@@ -149,8 +152,8 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
onClick={() => go('/')}
|
||||
onContextMenu={onContextMenu}
|
||||
role="link"
|
||||
aria-label={`${orgLabel} — home (right-click for brand menu)`}
|
||||
title={orgLabel}
|
||||
aria-label={`${label} — home (right-click for brand menu)`}
|
||||
title={label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -161,8 +164,14 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
|
||||
color: 'var(--color12)',
|
||||
}}
|
||||
>
|
||||
{/* A logo may be a wordmark, so it is allowed to run wide; the monogram
|
||||
stays the square tile the account avatar wears.
|
||||
{/* The TENANT's mark, always — its uploaded logo when it has one, else its
|
||||
own monogram. It used to fall back to the HOST's glyph, so an org with
|
||||
no logo wore the Hanzo mark here and its own initials in the switcher:
|
||||
one org, two marks, depending on whether the rail was collapsed.
|
||||
`OrgMark` already resolves logo-else-monogram, which is the whole rule.
|
||||
|
||||
A logo may be a wordmark, so it is allowed to run wide; the monogram
|
||||
stays the square tile the account mark wears.
|
||||
|
||||
`data-monogram`: OrgMark is a DISTRIBUTED component that sizes its
|
||||
monogram glyph proportionally to its tile, so it paints text off the
|
||||
|
||||
@@ -182,3 +182,36 @@ describe('pickerView — filter → sort → paginate → cards', () => {
|
||||
expect(p2.hasMore).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ONE PERSON, MANY ORGS — and each card states the role it was actually granted.
|
||||
//
|
||||
// roleFor's last branch used to carry a comment calling itself unreachable
|
||||
// ("a non-global-admin only ever sees their own org"). The picker now lists
|
||||
// memberships, so that branch runs for every joined org — and it answered
|
||||
// "Member" for all of them, including one the person administers. Measured
|
||||
// live: dave, admin of maxpower, read "Member" on the maxpower card.
|
||||
describe('roleFor — the membership is the truth once you can see more than one org', () => {
|
||||
const org = (name: string) => ({ owner: 'admin', name }) as Organization
|
||||
const base = { ownOrg: 'hanzo', isSuperAdmin: false, callerIsAdmin: false }
|
||||
|
||||
it('states the granted role for a joined org, not a guess', () => {
|
||||
expect(roleFor(org('maxpower'), { ...base, roles: { maxpower: 'admin' } })).toBe('Admin')
|
||||
expect(roleFor(org('acme'), { ...base, roles: { acme: 'owner' } })).toBe('Owner')
|
||||
expect(roleFor(org('other'), { ...base, roles: { other: 'member' } })).toBe('Member')
|
||||
})
|
||||
|
||||
it('prefers the membership over the own-org inference', () => {
|
||||
// Their home org, where the account flag says plain member but the
|
||||
// membership says admin — the membership is the grant.
|
||||
expect(roleFor(org('hanzo'), { ...base, roles: { hanzo: 'admin' } })).toBe('Admin')
|
||||
})
|
||||
|
||||
it('falls back to the own-org inference when no membership row exists', () => {
|
||||
expect(roleFor(org('hanzo'), base)).toBe('Member')
|
||||
expect(roleFor(org('hanzo'), { ...base, callerIsAdmin: true })).toBe('Admin')
|
||||
})
|
||||
|
||||
it('still calls a super admin viewing another tenant what they are', () => {
|
||||
expect(roleFor(org('maxpower'), { ...base, isSuperAdmin: true })).toBe('Super admin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,12 @@ export type PickerContext = {
|
||||
isSuperAdmin: boolean
|
||||
/** Whether the caller is an admin of their OWN org (`account.isAdmin`). */
|
||||
callerIsAdmin: boolean
|
||||
/**
|
||||
* The caller's role in each org they hold a MEMBERSHIP in (`org -> role`),
|
||||
* which is the only place the truth lives once a person can see more than one
|
||||
* org. Optional so a caller that has not loaded memberships still renders.
|
||||
*/
|
||||
roles?: Record<string, string>
|
||||
}
|
||||
|
||||
/** The full picker view for a query + page — everything the UI renders. */
|
||||
@@ -79,16 +85,24 @@ export function initialsOf(org: Organization): string {
|
||||
/**
|
||||
* The caller's HONEST role in this org — derived from real context, never guessed:
|
||||
* - super admin viewing another org → "Super admin" (masquerade access)
|
||||
* - the caller's own org, they admin it → "Admin"
|
||||
* - the caller's own org otherwise → "Member"
|
||||
* - super admin viewing their own org → "Admin"
|
||||
* - an org they hold a MEMBERSHIP in → that membership's role
|
||||
* - the caller's own org → "Admin" when they admin it, else "Member"
|
||||
*
|
||||
* The membership is consulted BEFORE the own-org fallback, because once a person
|
||||
* can see more than one org the fallback is a guess. It used to end with a
|
||||
* comment calling its own last branch unreachable — "a non-global-admin only
|
||||
* ever sees their own org" — and that stopped being true the moment the picker
|
||||
* started listing memberships: every joined org then rendered "Member",
|
||||
* including ones the person administers.
|
||||
*/
|
||||
export function roleFor(org: Organization, ctx: PickerContext): string {
|
||||
const isOwn = org.name === ctx.ownOrg
|
||||
if (ctx.isSuperAdmin && !isOwn) return 'Super admin'
|
||||
const membership = ctx.roles?.[org.name]
|
||||
if (membership) return titleCase(membership)
|
||||
if (isOwn) return ctx.callerIsAdmin ? 'Admin' : 'Member'
|
||||
// A non-global-admin only ever sees their own org, so this is unreachable in
|
||||
// practice; be honest rather than invent a role if it ever isn't.
|
||||
// No membership row and not their own org: say the weaker thing rather than
|
||||
// invent authority the caller may not have.
|
||||
return ctx.isSuperAdmin ? 'Admin' : 'Member'
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import { useSession } from '~/lib/auth/session'
|
||||
import { AccountApi, ApiError } from '~/lib/api'
|
||||
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
|
||||
import { ApiKeysView } from './ApiKeysModule'
|
||||
import { FieldRow, PageHeader } from '@hanzo/ui/product'
|
||||
import { FieldRow, FieldSwitch, PageHeader } from '@hanzo/ui/product'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { NAV_CATALOG_PREF } from '~/lib/products/nav'
|
||||
|
||||
/** A labeled read-only value row; dim em-dash when empty. */
|
||||
function InfoRow({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
||||
@@ -161,6 +163,30 @@ function PhotoCard() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sidebar lists. Every product is available to every org, so the rail
|
||||
* shows all of them by default; a person who works in six can put the catalog
|
||||
* away and keep their pins. Nothing goes out of reach either way — the search box
|
||||
* at the top of the rail and "All products" at its foot both stay whole.
|
||||
*/
|
||||
function SidebarCard() {
|
||||
const prefs = usePreferences()
|
||||
const catalog = prefs.get<boolean>(NAV_CATALOG_PREF, true)
|
||||
return (
|
||||
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
|
||||
<FieldRow label="Show every product">
|
||||
<XStack items="center" gap="$3">
|
||||
<FieldSwitch checked={catalog} onChange={(v) => prefs.set(NAV_CATALOG_PREF, v)} />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
List the whole catalog in the sidebar. Off, it shows what you pinned and
|
||||
wherever you are; search and All products still reach everything.
|
||||
</Text>
|
||||
</XStack>
|
||||
</FieldRow>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountTab() {
|
||||
const { account, signOut } = useSession()
|
||||
|
||||
@@ -180,6 +206,8 @@ function AccountTab() {
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
<SidebarCard />
|
||||
|
||||
<XStack>
|
||||
<Button icon={<LogOut size={16} />} onPress={() => void signOut()}>Sign out</Button>
|
||||
</XStack>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function ProductObservability({
|
||||
{!service ? (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{label} is a managed surface with no dedicated telemetry service — its signals roll up under the
|
||||
platform-wide Observe views. No per-product metrics are fabricated here.
|
||||
platform-wide Observe views.
|
||||
</Text>
|
||||
) : st.error ? (
|
||||
<RuntimeNotice surface="observability" error={st.error} />
|
||||
|
||||
@@ -45,10 +45,10 @@ export function RuntimeNotice({ surface, error }: { surface: string; error: unkn
|
||||
const status = classifyRuntime(error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const body: Record<RuntimeStatus, string> = {
|
||||
'not-initialized': `Observability runtime initializing — your ${surface} will appear here once it's enabled. The /v1/o11y routes are mounted, but the runtime (telemetry stores, query service) is not initialized on this deployment yet. This page shows live ${surface} the moment the runtime is online — it never shows placeholder data.`,
|
||||
'not-initialized': `Observability runtime initializing — your ${surface} will appear here once it's enabled. The /v1/o11y routes are mounted, but the runtime (telemetry stores, query service) is not initialized on this deployment yet. This page shows live ${surface} the moment the runtime is online.`,
|
||||
unavailable: `The /v1/o11y/${surface} surface is not proxied on this host yet.`,
|
||||
// 403 for a signed-in user — observability isn't provisioned for their org.
|
||||
access: `Observability isn't enabled for your organization yet, so your ${surface} can't be read. It appears here automatically once it is — never placeholder data.`,
|
||||
access: `Observability isn't enabled for your organization yet, so your ${surface} can't be read. It appears here automatically once it is.`,
|
||||
// 401 — the session itself lapsed.
|
||||
signin: `Your session has expired or isn't recognized here. Sign in again to view your ${surface}.`,
|
||||
error: message,
|
||||
|
||||
@@ -11,16 +11,17 @@
|
||||
* org's lines (o11y scopes every query by the JWT owner → `X-Org-Id`).
|
||||
*
|
||||
* DRY: reuses `ApmApi.logs(window, limit, service)` + the shared o11y `RuntimeNotice`
|
||||
* — there is ONE o11y client and ONE honest-state card, parameterized per product.
|
||||
* — there is ONE o11y client and ONE empty-state card, parameterized per product.
|
||||
* The rows are additionally re-filtered client-side to the product's service, so a
|
||||
* runtime that ignored the filter can never leak another service's lines here.
|
||||
*
|
||||
* Honest states, never a fabricated/blank grid:
|
||||
* - product with no backing service → an honest "no product log source" card;
|
||||
* States:
|
||||
* - product with no dedicated service → the logs surface in its empty state (a
|
||||
* calm "no logs yet" card), never a dead end;
|
||||
* - o11y 503 (initializing) / 404 (unrouted) / 401 (session) / 403 (not enabled) →
|
||||
* the shared `RuntimeNotice` (names the reason + endpoint);
|
||||
* - o11y answered but the window is empty (the service ships no OTLP logs yet) →
|
||||
* an honest "Connected · no logs in the last <range>" card, never placeholder.
|
||||
* a "no logs in the last <range>" card.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
@@ -110,11 +111,16 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
|
||||
{ key: 'body', header: 'Message', render: (l) => <Text fontSize="$2" color="$color11" numberOfLines={1}>{l.body || '—'}</Text> },
|
||||
]
|
||||
|
||||
const hasService = Boolean(o11yService)
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={`${entry.label} · Logs`}
|
||||
subtitle={`Live application logs for the ${entry.label} service, from the o11y runtime.`}
|
||||
subtitle={
|
||||
hasService
|
||||
? `Live application logs for the ${entry.label} service, from the o11y runtime.`
|
||||
: `Application logs for ${entry.label}.`
|
||||
}
|
||||
actions={
|
||||
<Button icon={<RefreshCw size={16} />} onPress={() => void load(rangeIdx)}>
|
||||
Refresh
|
||||
@@ -122,32 +128,25 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
|
||||
}
|
||||
/>
|
||||
|
||||
{!o11yService ? (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={640}>
|
||||
<XStack gap="$2" items="center">
|
||||
<ScrollText size={16} color="$color10" />
|
||||
<Text fontSize="$4" fontWeight="700">No product log source</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{entry.label} is a managed capability with no discrete service that ships logs. Application
|
||||
logs appear here automatically if it ever emits OpenTelemetry logs to o11y — nothing is fabricated.
|
||||
</Text>
|
||||
</Card>
|
||||
) : state.phase === 'error' ? (
|
||||
{state.phase === 'error' ? (
|
||||
<RuntimeNotice surface="logs" error={state.err} />
|
||||
) : (
|
||||
<YStack gap="$3">
|
||||
<XStack gap="$3" items="center" flexWrap="wrap" justify="space-between">
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<ScrollText size={16} />
|
||||
<Text fontSize="$2" color="$color10">Range</Text>
|
||||
<Segmented value={RANGES[rangeIdx].key} options={RANGES} onChange={(k) => setRangeIdx(RANGES.findIndex((r) => r.key === k))} />
|
||||
{/* The range + severity controls only make sense against a live service; a
|
||||
managed capability with no service shows the empty surface alone. */}
|
||||
{hasService ? (
|
||||
<XStack gap="$3" items="center" flexWrap="wrap" justify="space-between">
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<ScrollText size={16} />
|
||||
<Text fontSize="$2" color="$color10">Range</Text>
|
||||
<Segmented value={RANGES[rangeIdx].key} options={RANGES} onChange={(k) => setRangeIdx(RANGES.findIndex((r) => r.key === k))} />
|
||||
</XStack>
|
||||
<SelectMenu options={severityOptions} value={severity} onChange={setSeverity} allLabel="All severities" />
|
||||
</XStack>
|
||||
<SelectMenu options={severityOptions} value={severity} onChange={setSeverity} allLabel="All severities" />
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{state.phase === 'ready' && all.length === 0 ? (
|
||||
<NoLogs entry={entry} range={RANGES[rangeIdx].label} service={o11yService} />
|
||||
<NoLogs entry={entry} range={hasService ? RANGES[rangeIdx].label : null} service={o11yService} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@@ -159,9 +158,15 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
|
||||
)}
|
||||
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Application logs from the Hanzo o11y runtime (OTLP → O11y), scoped to service{' '}
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">{o11yService}</Text> and your organization. If {entry.label} isn't
|
||||
instrumented yet, no lines appear — never placeholder data.
|
||||
Application logs from the Hanzo o11y runtime (OTLP), scoped to{' '}
|
||||
{hasService ? (
|
||||
<>
|
||||
service <Text fontSize="$1" color="$color11" fontWeight="600">{o11yService}</Text> and your organization. Lines
|
||||
appear here as {entry.label} emits them.
|
||||
</>
|
||||
) : (
|
||||
<>your organization. Lines appear here as {entry.label} emits them.</>
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
@@ -169,20 +174,34 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Honest "connected · no logs in window" — o11y answered, this service ships no OTLP logs yet. */
|
||||
function NoLogs({ entry, range, service }: { entry: CatalogEntry; range: string; service: string }) {
|
||||
/**
|
||||
* Empty logs surface. Two shapes, one card:
|
||||
* - a product WITH a service, whose window came back empty → "no logs in the last <range>";
|
||||
* - a managed capability with no dedicated service → a calm "no logs yet".
|
||||
*/
|
||||
function NoLogs({ entry, range, service }: { entry: CatalogEntry; range: string | null; service: string | null }) {
|
||||
const connected = Boolean(service)
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={680}>
|
||||
<XStack gap="$2" items="center">
|
||||
<Server size={16} />
|
||||
<Server size={16} color={connected ? undefined : '$color10'} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Connected · no logs for {entry.label} in the last {range}
|
||||
{connected ? `No logs for ${entry.label} in the last ${range}` : 'No logs yet'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
The o11y log runtime answered, but service <Text fontSize="$3" color="$color12" fontWeight="600">{service}</Text> shipped no
|
||||
OpenTelemetry logs for your organization in this window. This is a real empty result, not placeholder data — lines appear
|
||||
here as {entry.label} emits OTLP logs. Try a wider range.
|
||||
{connected ? (
|
||||
<>
|
||||
The o11y runtime is connected, but service{' '}
|
||||
<Text fontSize="$3" color="$color12" fontWeight="600">{service}</Text> shipped no logs for your organization in this
|
||||
window. Lines appear here as {entry.label} emits them — try a wider range.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{entry.label} is a managed capability with no dedicated service. Application logs appear here as it emits them to the
|
||||
o11y runtime.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ function O11yHealthBand({ label, health }: { label: string; health: ServiceHealt
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Live RED metrics from the o11y runtime for service{' '}
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">{health.service}</Text>, scoped to your organization. {label} is
|
||||
serving traffic — this is real telemetry, not a fabricated status.
|
||||
serving traffic.
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
@@ -210,8 +210,8 @@ function ManagedCard({ entry, hasService }: { entry: CatalogEntry; hasService: b
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{hasService
|
||||
? `Neither the o11y runtime nor the control-plane inventory reports a running ${entry.label} service for your organization right now. It may be a shared managed service reported elsewhere, or idle in this window — its live health lights up automatically once it serves traffic or is reported. No status is fabricated.`
|
||||
: `${entry.label} is a managed Hanzo Cloud capability with no discrete service to report health for. It is available through the API; there is no fabricated status shown.`}
|
||||
? `Neither the o11y runtime nor the control-plane inventory reports a running ${entry.label} service for your organization right now. It may be a shared managed service reported elsewhere, or idle in this window — its live health lights up automatically once it serves traffic or is reported.`
|
||||
: `${entry.label} is a managed Hanzo Cloud capability with no discrete service to report health for. It is available through the API.`}
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Text, XStack } from '@hanzo/gui'
|
||||
import { ChevronRight } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { findEntry, categoryFromSlug } from '~/lib/products/registry'
|
||||
import { productSubpages } from '~/lib/products/match'
|
||||
|
||||
type Crumb = { label: string; href?: string }
|
||||
|
||||
@@ -38,9 +39,18 @@ function crumbsFor(pathname: string): Crumb[] {
|
||||
|
||||
const entry = findEntry(segs[0])
|
||||
if (entry) {
|
||||
crumbs.push({ label: entry.category })
|
||||
// Skip the category crumb when it just repeats the product's own name — the
|
||||
// `Settings` product lives in the `Settings` category, and `Home / Settings /
|
||||
// Settings / …` says the same word twice.
|
||||
if (entry.category !== entry.label) crumbs.push({ label: entry.category })
|
||||
crumbs.push({ label: entry.label, href: segs.length > 1 ? `/${entry.id}` : undefined })
|
||||
for (let i = 1; i < segs.length; i++) crumbs.push({ label: decodeURIComponent(segs[i]) })
|
||||
// Label trailing segments from the product's own sub-page list (so `/settings/logs`
|
||||
// reads `… / Logs`, not the raw slug); detail params that aren't sub-pages pass through.
|
||||
const subs = productSubpages(entry)
|
||||
for (let i = 1; i < segs.length; i++) {
|
||||
const sp = subs.find((s) => s.slug === segs[i])
|
||||
crumbs.push({ label: sp ? sp.label : decodeURIComponent(segs[i]) })
|
||||
}
|
||||
} else {
|
||||
for (const s of segs) crumbs.push({ label: decodeURIComponent(s) })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Menu — the ONE anchored menu the console's two identity controls wear.
|
||||
*
|
||||
* The rail asks two questions at its two ends: WHERE you are (organization and
|
||||
* project, top-left) and WHO you are (account, bottom-left). They are peers, and
|
||||
* they must read as peers. They did not: the org control was a @hanzo/gui popover
|
||||
* with a 20px rounded-square mark, a two-letter monogram and a chevron, while the
|
||||
* account control was `@hanzo/iam`'s `UserMenu` — a second rendering system inside
|
||||
* one rail, drawing raw DOM through an injected global `hz-iam-*` stylesheet into
|
||||
* its own portal, with a 28px CIRCLE, a one-letter initial and no chevron. Two
|
||||
* component systems, two marks, two type scales, two sets of a11y roles, for one
|
||||
* shape. This is that shape once: the trigger, the sheet, and the rows beneath it.
|
||||
*
|
||||
* The MARK is `OrgMark` for both, because a person and an organization wear the
|
||||
* same thing — an uploaded image when there is one, their own initials when there
|
||||
* is not. One component means the two can no longer disagree about which.
|
||||
*
|
||||
* `children` receives `close`, so a row can dismiss the menu before it navigates
|
||||
* without every call site keeping its own copy of the open state.
|
||||
*/
|
||||
import { useCallback, useState, type ReactNode } from 'react'
|
||||
import { Button, Popover, Text, XStack } from '@hanzo/gui'
|
||||
import { ChevronsUpDown } from '@hanzogui/lucide-icons-2'
|
||||
import { OrgMark, type Org } from '@hanzo/ui/product'
|
||||
|
||||
import { Z } from '~/lib/z'
|
||||
import { paper } from './paper'
|
||||
|
||||
/** The mark both controls wear. One number, so they cannot drift. */
|
||||
export const MARK = 28
|
||||
|
||||
export function Menu({
|
||||
mark,
|
||||
label,
|
||||
aria,
|
||||
testId,
|
||||
up,
|
||||
onOpen,
|
||||
children,
|
||||
}: {
|
||||
/** Who or what this names — an org, or a person as `{ name, logo: avatar }`. */
|
||||
mark: Org
|
||||
/** The one line in the trigger. Never empty: a nameless control reads as a bug. */
|
||||
label: string
|
||||
/** The accessible name (says what the control DOES, which the label alone cannot). */
|
||||
aria: string
|
||||
testId: string
|
||||
/** Open UPWARD — for the control at the FOOT of the rail, where a downward sheet
|
||||
* would fall off the bottom of the viewport. */
|
||||
up?: boolean
|
||||
/** Fired the first time the sheet opens — for a list that is fetched on demand. */
|
||||
onOpen?: () => void
|
||||
children: (close: () => void) => ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
const change = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next) onOpen?.()
|
||||
},
|
||||
[onOpen],
|
||||
)
|
||||
return (
|
||||
// `up` is a PREFERENCE, not a promise: `allowFlip`/`stayInFrame` let the sheet
|
||||
// turn over and slide back into view when the side it wants has no room. The
|
||||
// account control is at the foot of the desktop rail (so: upward) and near the
|
||||
// TOP of the phone's account sheet, where upward is 365px off the screen.
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={change}
|
||||
placement={up ? 'top-start' : 'bottom-start'}
|
||||
allowFlip
|
||||
stayInFrame
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$3"
|
||||
height={44}
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
px="$2"
|
||||
data-testid={testId}
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={aria}
|
||||
>
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
<OrgMark org={mark} size={MARK} />
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
{/* `Z.popover` — the ladder's layer for a popover anchored inside a modal.
|
||||
On a phone this menu opens from inside the account SHEET, which sits at
|
||||
`Z.modal`; without a layer of its own it was measurable and unclickable. */}
|
||||
<Popover.Content {...paper} role="menu" p="$2" width={280} style={{ zIndex: Z.popover }}>
|
||||
{children(close)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/** A quiet heading over a group of rows — "Organization", "Project", "Theme". */
|
||||
export function MenuLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** The hairline between two groups of rows. */
|
||||
export function MenuRule() {
|
||||
return <XStack height={1} bg="$borderColor" my="$1" />
|
||||
}
|
||||
@@ -27,9 +27,16 @@ import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Circle,
|
||||
Coins,
|
||||
CreditCard,
|
||||
FileText,
|
||||
House,
|
||||
Receipt,
|
||||
Repeat,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
Target,
|
||||
Users,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
@@ -45,6 +52,15 @@ const SUBPAGE_ICON: Record<string, ComponentType<{ size?: number }>> = {
|
||||
status: Activity,
|
||||
logs: ScrollText,
|
||||
metrics: BarChart3,
|
||||
// Common billing/finance slugs get a real icon here (the ONE shared map) rather
|
||||
// than the quiet dot, so every product that names one is iconed — not just billing.
|
||||
reports: FileText,
|
||||
accounts: Users,
|
||||
budgets: Target,
|
||||
invoices: Receipt,
|
||||
subscriptions: Repeat,
|
||||
'payment-methods': CreditCard,
|
||||
credits: Coins,
|
||||
}
|
||||
|
||||
/** A View is flex-shrink:0 by default; a wrapping row needs this to wrap at all. */
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
*/
|
||||
export const paper = {
|
||||
className: 'hz-paper hz-menu-in',
|
||||
// `bordered` alone declares the intent and leaves the width at 0, so the sheet
|
||||
// met the page with no edge at all. The hairline is stated.
|
||||
bordered: true,
|
||||
borderWidth: 1,
|
||||
bg: '$color2',
|
||||
borderColor: '$borderColor',
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The cloud shell — a REAL terminal in the Developers dock.
|
||||
*
|
||||
* What was here before was an explorer: a prompt that took `GET /v1/models` and
|
||||
* printed the response. It looked like a shell and answered like a form, and the
|
||||
* gap between the two is the whole reason this exists — there was no way to run
|
||||
* anything, so the `$` in the dock was a promise the dock could not keep.
|
||||
*
|
||||
* THE SHELL IS A SANDBOX. Not a simulator, not a command allow-list: a login
|
||||
* shell on a pseudo-terminal inside the org's own gVisor pod. Whatever the image
|
||||
* carries — the hanzo CLI is on its PATH — is a command the user types, and
|
||||
* nothing here decides what may run. That decision belongs to the runtime
|
||||
* boundary the pod already has, and a second one in a browser tab would only be a
|
||||
* fiction.
|
||||
*
|
||||
* THE TERMINAL IS NOT BUILT HERE. Cloud serves it, whole, at the same address as
|
||||
* the socket, and this frames it. That is not laziness about an emulator — it is
|
||||
* that the console is one of several hosts that show a shell, and a terminal
|
||||
* built per host is a terminal that is subtly different in each of them. One
|
||||
* implementation, one place a fix lands, and this file is left with the only part
|
||||
* that is genuinely the console's: which sandbox, and what to say while it is
|
||||
* coming up.
|
||||
*
|
||||
* WHAT THIS STILL OWNS is the credential. A frame carries no Authorization
|
||||
* header any more than a socket does, so the ticket is fetched through the
|
||||
* same-origin `/v1` proxy — where identity lives — and handed to the page in its
|
||||
* URL. Single-use, thirty seconds, bound to one sandbox: that is what makes
|
||||
* putting it in a URL safe, and why nothing long-lived ever goes there.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { ApiError, cloudProxyV1Url, restGet, restPost } from '~/lib/api/client'
|
||||
import { config } from '~/config'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { terminalFor } from './logic'
|
||||
|
||||
/**
|
||||
* The project the dock's shell holds. A `dev` sandbox is attached to a project
|
||||
* and the project names the VOLUME, so this constant is what makes the shell the
|
||||
* same shell tomorrow: the checkout and the caches are still there when the lease
|
||||
* has long since ended. One live sandbox per project is the server's rule, which
|
||||
* is also why reopening the dock finds the running one instead of leasing a
|
||||
* second.
|
||||
*/
|
||||
const PROJECT = 'console'
|
||||
|
||||
/** The tmux session this dock attaches to, so reopening it finds the same shell. */
|
||||
const SESSION = 'dock'
|
||||
|
||||
/**
|
||||
* How long to wait for the terminal to say it is up.
|
||||
*
|
||||
* The page posts `{source:'hanzo-term'}` when its socket opens. Without a
|
||||
* deadline a frame that failed into something else — an expired ticket, an
|
||||
* origin that refused to be framed — is indistinguishable from one that is still
|
||||
* loading, and the dock would sit on "Starting…" forever rather than offering the
|
||||
* reconnect that fixes it.
|
||||
*/
|
||||
const READY_BY = 6000
|
||||
|
||||
type Phase = 'starting' | 'live' | 'gone'
|
||||
|
||||
type Sandbox = { id: string; status: string; project?: string }
|
||||
|
||||
/**
|
||||
* The org's running dock sandbox, or a freshly leased one.
|
||||
*
|
||||
* Asking the server which one is live is what makes the shell survive a reload
|
||||
* without remembering anything: there is exactly one live sandbox per project by
|
||||
* the server's own rule, so the answer to "which one is mine" is a query and
|
||||
* never a stored id that can go stale. The match is re-checked here rather than
|
||||
* trusted from the query string — a filter is the server's convenience, and the
|
||||
* sandbox this reattaches to had better be the right one.
|
||||
*/
|
||||
async function sandbox(): Promise<Sandbox> {
|
||||
const live = await restGet<{ sandboxes?: Sandbox[] }>(
|
||||
cloudProxyV1Url(`sandboxes?project=${PROJECT}&status=running`),
|
||||
)
|
||||
const held = live.sandboxes?.find((m) => m.status === 'running' && m.project === PROJECT)
|
||||
if (held) return held
|
||||
return restPost<Sandbox>(cloudProxyV1Url('sandboxes'), { class: 'dev', project: PROJECT })
|
||||
}
|
||||
|
||||
const reason = (err: unknown): string =>
|
||||
err instanceof ApiError
|
||||
? `${err.message}${err.status ? ` (${err.status})` : ''}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
|
||||
export function Terminal() {
|
||||
const [phase, setPhase] = useState<Phase>('starting')
|
||||
const [why, setWhy] = useState('')
|
||||
const [src, setSrc] = useState('')
|
||||
// A change to this is the ONE way a session restarts: the effect below owns the
|
||||
// whole lifetime — sandbox, ticket, frame — and reruns as a unit, so there is
|
||||
// no half-torn-down session to reason about. A ticket is spent once, so a
|
||||
// reconnect is a new ticket and never the old frame reloaded.
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const frame = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setPhase('starting')
|
||||
setWhy('')
|
||||
setSrc('')
|
||||
setAttempt((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// `alive` is the barrier for everything this effect started. React mounts an
|
||||
// effect twice in development, and a ticket fetched by the first pass would
|
||||
// otherwise land in a frame the second pass has replaced.
|
||||
let alive = true
|
||||
let waiting: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const end = (message: string) => {
|
||||
if (!alive) return
|
||||
setWhy(message)
|
||||
setPhase('gone')
|
||||
}
|
||||
|
||||
// The readiness handshake. Only the frame we opened may speak for it: the
|
||||
// origin is checked against the API host, so another page cannot post its way
|
||||
// into a terminal that is not there.
|
||||
const heard = (e: MessageEvent) => {
|
||||
if (!alive || e.source !== frame.current?.contentWindow) return
|
||||
if (new URL(config.apiUrl).origin !== e.origin) return
|
||||
const d = e.data as { source?: string } | null
|
||||
if (d && d.source === 'hanzo-term') {
|
||||
if (waiting) clearTimeout(waiting)
|
||||
setPhase('live')
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', heard)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const m = await sandbox()
|
||||
if (!alive) return
|
||||
const pass = await restPost<{ ticket: string }>(
|
||||
cloudProxyV1Url(`sandboxes/${m.id}/terminal/ticket`),
|
||||
)
|
||||
if (!alive) return
|
||||
setSrc(terminalFor(config.apiUrl, m.id, pass.ticket, SESSION))
|
||||
waiting = setTimeout(() => end('The terminal did not come up.'), READY_BY)
|
||||
} catch (err) {
|
||||
end(reason(err))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
if (waiting) clearTimeout(waiting)
|
||||
window.removeEventListener('message', heard)
|
||||
}
|
||||
}, [attempt])
|
||||
|
||||
// The frame is ALWAYS laid out and the status covers it, because a frame that
|
||||
// is display:none has no size — and a terminal sized to nothing measures 80x24
|
||||
// and never corrects.
|
||||
return (
|
||||
<YStack flex={1} minH={0} position="relative" bg="#000">
|
||||
{src ? (
|
||||
<iframe
|
||||
ref={frame}
|
||||
src={src}
|
||||
title="Cloud shell"
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
{phase === 'live' ? null : (
|
||||
<YStack position="absolute" t={0} l={0} r={0} b={0} items="center" justify="center" gap="$2" p="$4" bg="$color1">
|
||||
{phase === 'starting' ? (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Starting your cloud shell…
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$2" color={toneColor('critical')}>
|
||||
Disconnected
|
||||
</Text>
|
||||
<Button size="$2" onPress={retry} aria-label="Reconnect the cloud shell">
|
||||
Reconnect
|
||||
</Button>
|
||||
</XStack>
|
||||
{why ? (
|
||||
<Text fontSize="$1" color="$color10" className="hz-mono">
|
||||
{why}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -31,9 +31,8 @@ import { useRouter } from 'next/navigation'
|
||||
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
Activity,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Maximize2,
|
||||
Mic,
|
||||
Minimize2,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
@@ -43,6 +42,9 @@ import {
|
||||
|
||||
import { fetchUsageRecords, type UsageRecord } from '~/lib/api/aimetrics'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { useFloatingChat } from '~/components/FloatingChat'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { voiceSupported } from '~/lib/voice'
|
||||
import {
|
||||
EventsTab,
|
||||
HealthTab,
|
||||
@@ -73,6 +75,10 @@ const LEDGER_TABS: ReadonlySet<TabId> = new Set<TabId>(['overview', 'logs', 'eve
|
||||
export function WorkbenchDock() {
|
||||
const router = useRouter()
|
||||
const { get, set } = usePreferences()
|
||||
// The assistant lives in this bar on desktop (the floating bubble is suppressed
|
||||
// at lg+). The mic starts it listening; the brand mark opens it.
|
||||
const { openChat, startVoice } = useFloatingChat()
|
||||
const [voiceOk] = useState(() => voiceSupported())
|
||||
const open = get<boolean>('workbenchOpen', false)
|
||||
const [tab, setTab] = useState<TabId>('overview')
|
||||
// Freely resizable dock height (px), persisted per-user — drag the top handle to
|
||||
@@ -283,7 +289,7 @@ export function WorkbenchDock() {
|
||||
$
|
||||
</Text>
|
||||
<Text flex={1} fontSize="$2" color="$color10" className="hz-mono" numberOfLines={1}>
|
||||
Run a /v1 command — models, agents, logs…
|
||||
Open a cloud shell — a real terminal in your sandbox
|
||||
</Text>
|
||||
</XStack>
|
||||
) : (
|
||||
@@ -291,13 +297,24 @@ export function WorkbenchDock() {
|
||||
)}
|
||||
<Button size="$2" chromeless icon={<Activity size={16} />} onPress={() => openTo('overview')} aria-label="API activity" />
|
||||
<Button size="$2" chromeless icon={<ScrollText size={16} />} onPress={() => openTo('logs')} aria-label="Recent API logs" />
|
||||
{/* The assistant's home on desktop — mic starts it listening, the brand mark
|
||||
opens it. The floating bubble is hidden at lg+ (this is where it lives);
|
||||
it stays on phones, where this dock is not shown. */}
|
||||
{voiceOk ? (
|
||||
<Button size="$2" chromeless icon={<Mic size={16} />} onPress={startVoice} aria-label="Talk to Hanzo" />
|
||||
) : null}
|
||||
<Button
|
||||
size="$2"
|
||||
icon={open ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
onPress={() => set('workbenchOpen', !open)}
|
||||
aria-label={open ? 'Collapse the workbench' : 'Open the workbench'}
|
||||
bg="$color4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<BrandMark size={16} />}
|
||||
onPress={openChat}
|
||||
aria-label="Ask Hanzo"
|
||||
>
|
||||
{open ? 'Hide' : 'Open'}
|
||||
<Text fontSize="$2" fontWeight="700" color="$color12">
|
||||
AI
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput } from './logic'
|
||||
|
||||
describe('parseCommand', () => {
|
||||
it('accepts every documented form and normalizes to a /v1-relative path', () => {
|
||||
expect(parseCommand('GET /v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('get v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('/v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand(' agents?limit=5 ')).toEqual({ path: 'agents?limit=5' })
|
||||
})
|
||||
|
||||
it('is read-only — every mutating method is refused', () => {
|
||||
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
expect(parseCommand(`${m} /v1/agents`)).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses empty, multi-path, traversal, and smuggled inputs', () => {
|
||||
expect(parseCommand('')).toHaveProperty('error')
|
||||
expect(parseCommand('GET')).toHaveProperty('error')
|
||||
expect(parseCommand('/v1')).toHaveProperty('error')
|
||||
expect(parseCommand('GET /v1/a /v1/b')).toHaveProperty('error')
|
||||
expect(parseCommand('/v1/../admin')).toHaveProperty('error')
|
||||
expect(parseCommand('//evil.com/x')).toHaveProperty('error')
|
||||
expect(parseCommand('https://evil.com/x')).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
import { curlFor, eventsFrom, inspectorRoute, renderOutput, terminalFor } from './logic'
|
||||
|
||||
describe('renderOutput', () => {
|
||||
it('pretty-prints JSON and passes strings through', () => {
|
||||
@@ -77,10 +51,6 @@ describe('show code', () => {
|
||||
expect(curlFor('models')).toBe('curl https://api.hanzo.ai/v1/models \\\n -H "Authorization: Bearer $HANZO_API_KEY"')
|
||||
expect(curlFor('/v1/agents')).toContain('https://api.hanzo.ai/v1/agents')
|
||||
})
|
||||
it('builds the Hanzo CLI form', () => {
|
||||
expect(hanzoCli('/v1/models')).toBe('hanzo api get /v1/models')
|
||||
expect(hanzoCli('agents')).toBe('hanzo api get /v1/agents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventsFrom', () => {
|
||||
@@ -105,3 +75,26 @@ describe('eventsFrom', () => {
|
||||
expect(eventsFrom([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud shell — where the terminal is', () => {
|
||||
it('addresses the API host, not the console origin — a frame cannot ride the /v1 proxy', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai', 'm_abc', 'tok', 'dock')).toBe(
|
||||
'https://api.hanzo.ai/v1/sandboxes/m_abc/terminal?ticket=tok&arg=dock',
|
||||
)
|
||||
})
|
||||
|
||||
it('tolerates a trailing slash and escapes what it interpolates', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai/', 'm_1', 't', 'dock')).toContain(
|
||||
'api.hanzo.ai/v1/sandboxes/m_1/terminal',
|
||||
)
|
||||
// An id is hex and a ticket is base64url, but neither is trusted to be: an
|
||||
// unescaped `&` would silently truncate the credential.
|
||||
expect(terminalFor('https://api.hanzo.ai', 'a/b', 'x&y=z', 'p 1')).toBe(
|
||||
'https://api.hanzo.ai/v1/sandboxes/a%2Fb/terminal?ticket=x%26y%3Dz&arg=p%201',
|
||||
)
|
||||
})
|
||||
|
||||
it('names a session, so reopening the dock reattaches instead of starting over', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai', 'm_1', 't', 'dock')).toContain('arg=dock')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
/**
|
||||
* Workbench logic — the PURE half of the bottom Developers dock (parse a shell
|
||||
* command into a safe same-origin `/v1` read; render a response for the terminal).
|
||||
* No React/Gui/registry imports so it is node-testable in isolation.
|
||||
* Workbench logic — the PURE half of the bottom Developers dock: where the cloud
|
||||
* shell's socket lives and what goes over it, how a response renders, and how an
|
||||
* id routes to the `/v1` read that explains it. No React/Gui/registry imports, so
|
||||
* every decision here is node-testable on its own.
|
||||
*/
|
||||
|
||||
export type Command = { path: string } | { error: string }
|
||||
|
||||
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
|
||||
/** Path charset — segments, query string; nothing that can smuggle a scheme/host. */
|
||||
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
|
||||
|
||||
/**
|
||||
* Parse a workbench shell line into a `/v1`-relative GET path. Accepted forms:
|
||||
* `GET /v1/models` · `/v1/models` · `v1/models` · `models` (a bare head). The
|
||||
* shell is deliberately READ-ONLY (GET) — a mutation belongs in the product UI,
|
||||
* where it gets its confirm/undo affordances — and `/v1`-only (the one API root).
|
||||
*/
|
||||
export function parseCommand(input: string): Command {
|
||||
const words = input.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return { error: 'Enter a path — e.g. GET /v1/models' }
|
||||
let rest = words
|
||||
const head = words[0].toUpperCase()
|
||||
if (METHODS.has(head)) {
|
||||
if (head !== 'GET') return { error: 'The workbench shell is read-only — only GET is supported.' }
|
||||
rest = words.slice(1)
|
||||
}
|
||||
if (rest.length !== 1) return { error: 'One path per command — e.g. GET /v1/models' }
|
||||
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(rest[0])) return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
|
||||
let path = rest[0].replace(/^\/+/, '')
|
||||
if (path === 'v1' || path === 'v1/') return { error: 'Name a resource — e.g. /v1/models' }
|
||||
if (path.startsWith('v1/')) path = path.slice(3)
|
||||
if (!path || !PATH_OK.test(path) || path.includes('..') || path.includes('//')) {
|
||||
return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
|
||||
}
|
||||
return { path }
|
||||
}
|
||||
|
||||
/** Pretty-print a shell response, bounded so a huge payload never wedges the DOM. */
|
||||
export function renderOutput(value: unknown, maxChars = 20000): string {
|
||||
let text: string
|
||||
@@ -50,6 +19,9 @@ export function renderOutput(value: unknown, maxChars = 20000): string {
|
||||
|
||||
// ── Inspector — route an object id to its `/v1` GET ───────────────────────────
|
||||
|
||||
/** Path charset — segments and a query string; nothing that can smuggle a scheme/host. */
|
||||
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
|
||||
|
||||
/** A resolved inspect target (a same-origin `/v1` GET) or an honest parse error. */
|
||||
export type InspectTarget = { path: string; kind: string; label: string } | { error: string }
|
||||
|
||||
@@ -110,11 +82,6 @@ export function curlFor(path: string, origin = 'https://api.hanzo.ai'): string {
|
||||
return `curl ${origin}/v1/${bareResource(path)} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`
|
||||
}
|
||||
|
||||
/** The Hanzo CLI form of the same `/v1` GET. */
|
||||
export function hanzoCli(path: string): string {
|
||||
return `hanzo api get /v1/${bareResource(path)}`
|
||||
}
|
||||
|
||||
// ── Events — project the usage ledger into a platform-event stream ────────────
|
||||
|
||||
/** One platform event, projected from a real charged ledger row (never fabricated). */
|
||||
@@ -152,3 +119,27 @@ export function eventsFrom(records: EventSource[]): PlatformEvent[] {
|
||||
})
|
||||
.sort((a, b) => (b.at ?? -Infinity) - (a.at ?? -Infinity))
|
||||
}
|
||||
|
||||
// ── Cloud shell — where the terminal is ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The terminal's address on the API host.
|
||||
*
|
||||
* Cloud SERVES the terminal — emulator, socket, resize and reconnect, one
|
||||
* self-contained page — so a host that wants a shell frames this rather than
|
||||
* building one. It is the one address in the console that is not same-origin, and
|
||||
* that is forced rather than chosen: the same-origin `/v1` proxy is a Next route
|
||||
* handler, and a route handler forwards requests, not sockets and not frames.
|
||||
* What crosses instead of the session is the single-use ticket the proxy just
|
||||
* fetched, which is the only credential a URL can safely hold.
|
||||
*
|
||||
* `arg` names a tmux session, so reopening the dock reattaches to the shell it
|
||||
* left instead of opening a fresh one over the user's work.
|
||||
*/
|
||||
export function terminalFor(apiBase: string, id: string, ticket: string, session: string): string {
|
||||
const base = apiBase.trim().replace(/\/+$/, '')
|
||||
return (
|
||||
`${base}/v1/sandboxes/${encodeURIComponent(id)}/terminal` +
|
||||
`?ticket=${encodeURIComponent(ticket)}&arg=${encodeURIComponent(session)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ import { config } from '~/config'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { RuntimeNotice } from '~/components/products/observability/RuntimeNotice'
|
||||
import { TracesModule } from '~/components/products/TracesModule'
|
||||
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput, type PlatformEvent } from './logic'
|
||||
import { curlFor, eventsFrom, inspectorRoute, renderOutput, type PlatformEvent } from './logic'
|
||||
import { Terminal } from './Terminal'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
|
||||
const usd = (cents: number, dp = 2): string => `$${(cents / 100).toFixed(dp)}`
|
||||
@@ -903,119 +904,13 @@ export function TracesTab() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shell / API Explorer ─────────────────────────────────────────────────────────
|
||||
|
||||
type ShellEntry = { cmd: string; path: string; output: string; ok: boolean }
|
||||
|
||||
/** Quick GET resources — the common `/v1` reads, run with one tap. */
|
||||
const SHELL_RESOURCES = ['models', 'agents', 'prompts', 'functions', 'automations/flows', 'billing/usage'] as const
|
||||
// ── Shell — the cloud shell ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Shell tab IS a terminal. The component that carries it lives on its own
|
||||
* (Terminal.tsx) because it loads xterm in the browser and owns a socket, and
|
||||
* neither belongs in a file of render-only tabs.
|
||||
*/
|
||||
export function ShellTab() {
|
||||
const [entries, setEntries] = useState<ShellEntry[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
}, [entries])
|
||||
|
||||
const run = useCallback(async (line: string) => {
|
||||
const cmd = line.trim()
|
||||
if (!cmd || running) return
|
||||
setInput('')
|
||||
const parsed = parseCommand(cmd)
|
||||
if ('error' in parsed) {
|
||||
setEntries((e) => [...e, { cmd, path: '', output: parsed.error, ok: false }])
|
||||
return
|
||||
}
|
||||
setRunning(true)
|
||||
try {
|
||||
const data = await restGet<unknown>(cloudProxyV1Url(parsed.path))
|
||||
setEntries((e) => [...e, { cmd, path: parsed.path, output: renderOutput(data), ok: true }])
|
||||
} catch (err) {
|
||||
const output =
|
||||
err instanceof ApiError ? `HTTP ${err.status} — ${err.message}` : err instanceof Error ? err.message : String(err)
|
||||
setEntries((e) => [...e, { cmd, path: parsed.path, output, ok: false }])
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
return (
|
||||
<YStack flex={1} minH={0}>
|
||||
{/* Resource picker — run a common /v1 GET with one tap. */}
|
||||
<XStack items="center" gap="$1" px="$2" height={34} borderBottomWidth={1} borderColor="$borderColor" flexWrap="wrap">
|
||||
<Text fontSize="$1" color="$color10">
|
||||
GET
|
||||
</Text>
|
||||
{SHELL_RESOURCES.map((r) => (
|
||||
<Button
|
||||
key={r}
|
||||
size="$1"
|
||||
chromeless
|
||||
bg="$color3"
|
||||
rounded="$4"
|
||||
px="$2"
|
||||
onPress={() => void run(`GET /v1/${r}`)}
|
||||
aria-label={`Run GET /v1/${r}`}
|
||||
>
|
||||
<Text fontSize="$1" color="$color11" className="hz-mono">
|
||||
{r}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack p="$3" gap="$2">
|
||||
{entries.length === 0 ? (
|
||||
<Text fontSize="$1" color="$color10" className="hz-mono">
|
||||
Read-only /v1 explorer — pick a resource above or type `GET /v1/models`. Runs as you, in your org.
|
||||
</Text>
|
||||
) : null}
|
||||
{entries.map((e, i) => (
|
||||
<YStack key={i} gap="$1">
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$1" color="$color11" className="hz-mono" flex={1} numberOfLines={1}>
|
||||
$ {e.cmd}
|
||||
</Text>
|
||||
{e.ok && e.path ? (
|
||||
<>
|
||||
<CopyBtn value={curlFor(e.path)} label="curl" />
|
||||
<CopyBtn value={hanzoCli(e.path)} label="CLI" />
|
||||
</>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$1" color={e.ok ? '$color12' : toneColor('critical')} className="hz-mono" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{e.output}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
<XStack items="center" gap="$2" px="$3" height={44} borderTopWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" color="$color10" className="hz-mono">
|
||||
$
|
||||
</Text>
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
autoFocus
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={() => void run(input)}
|
||||
placeholder={running ? 'Running…' : 'Enter a /v1 command…'}
|
||||
fontSize="$2"
|
||||
color="$color12"
|
||||
className="hz-mono"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
aria-label="Workbench shell command"
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
return <Terminal />
|
||||
}
|
||||
|
||||
+242
-274
@@ -1,32 +1,35 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dashboard shell — a TWO-LEVEL sidebar (products, each expanding its own sub-pages
|
||||
* in place) + top bar + content, responsive across phone / tablet / laptop / desktop.
|
||||
* Dashboard shell — a TWO-LEVEL sidebar + top bar + content, responsive across
|
||||
* phone / tablet / laptop / desktop.
|
||||
*
|
||||
* Level 1 (the product list) renders from the catalog: fixed Overview/Docs, a
|
||||
* ONE LEVEL SHOWS AT A TIME, and the ROUTE picks which:
|
||||
*
|
||||
* Level 1 (anywhere outside a product) is the catalog: fixed Overview/Docs, a
|
||||
* Pinned section the user curates, then every product grouped by category. Each
|
||||
* CATEGORY is an INDEPENDENTLY collapsible section that renders EXPANDED by default
|
||||
* (nothing auto-collapses); the header is flush-left with the top-level items and
|
||||
* carries an OPTIONAL collapse chevron whose state persists per-user.
|
||||
*
|
||||
* Level 2 — a product's sub-pages (Overview + specifics + the uniform base set:
|
||||
* Settings · Status · Logs · Metrics) — expands BENEATH that product's own row, so
|
||||
* its options appear without the rest of the catalog going away. The label
|
||||
* navigates; the chevron beside it only expands or collapses, and that choice
|
||||
* persists per-user (the product you are IN is open unless you closed it). Sub-pages
|
||||
* with no backend yet are dimmed and open an honest placeholder, never a dead link.
|
||||
* Level 2 (inside a product) is that product's pages — Overview + specifics + the
|
||||
* uniform base set (Settings · Status · Logs · Metrics) — sitting FLUSH where the
|
||||
* catalog was, under the category you came through, with the rest of that category
|
||||
* beneath them so a sibling stays one click away. A page with no backend yet is
|
||||
* dimmed and opens an honest placeholder, never a dead link.
|
||||
*
|
||||
* This replaced a DRILL: clicking a product used to swap the whole rail for that
|
||||
* product's sub-nav, behind a "Back to all products" button. The options were the
|
||||
* same either way — what the drill took away was every OTHER product, which is
|
||||
* precisely what someone needs when the reason they opened the rail was to go
|
||||
* somewhere else.
|
||||
* Both halves of that used to be true at once: the pages appeared INDENTED under the
|
||||
* product's row while the whole catalog stayed painted below, so two levels shared
|
||||
* the screen and a pinned product carried its pages in one place while its own
|
||||
* category row sat inert in another — the same product twice, one of them dead. The
|
||||
* level is a REPLACEMENT now, which is the only reading of "level" that stays true
|
||||
* when the list is long.
|
||||
*
|
||||
* Level 2 is DECLARED once, in the registry (`subpages` + `indexLabel`), and read
|
||||
* here and by `SubNav` (the same nav, for the viewports where this sidebar is a
|
||||
* drawer). No module carries its own tab list. The level itself is carried by the
|
||||
* URL and nothing else, so a reload, a deep link and Back all agree.
|
||||
* URL and nothing else, so a reload, a deep link and Back all agree — there is no
|
||||
* remembered expansion to disagree with them.
|
||||
*
|
||||
* The WHOLE sidebar collapses to an icon RAIL (the topbar panel toggle, persisted).
|
||||
* When collapsed, HOVER reveals the full sidebar as an OVERLAY flyout (it doesn't
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
CircleHelp,
|
||||
@@ -93,18 +97,15 @@ import { ConsoleFooter } from '~/components/ConsoleFooter'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { entryMatches } from '~/lib/products/search'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { orderEntries } from '~/lib/products/order'
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
NAV_CATALOG_PREF,
|
||||
EMPTY_OPEN,
|
||||
type CategoryOpen,
|
||||
} from '~/lib/products/nav-accordion'
|
||||
} from '~/lib/products/nav'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useEntitlements } from '~/lib/entitlements-context'
|
||||
@@ -138,18 +139,6 @@ const CONTENT_MAX = 1680
|
||||
/** Collapsed-rail icon size — large enough to be a comfortable hit target. */
|
||||
const ICON = 20
|
||||
|
||||
/** Icons for the Billing Center tabs — used by the billing-only shell nav. */
|
||||
const BILLING_SUBPAGE_ICON: Record<string, ComponentType<{ size?: number }>> = {
|
||||
'': House,
|
||||
reports: BarChart3,
|
||||
budgets: Bell,
|
||||
invoices: ScrollText,
|
||||
subscriptions: Repeat,
|
||||
'payment-methods': CreditCard,
|
||||
credits: Wallet,
|
||||
}
|
||||
const billingSubpageIcon = (slug: string): ComponentType<{ size?: number }> => BILLING_SUBPAGE_ICON[slug] ?? Circle
|
||||
|
||||
/** The active in-console module id for a path, or null (home / external / unknown). */
|
||||
function activeModuleId(pathname: string): string | null {
|
||||
const seg = pathname.split('/').filter(Boolean)[0]
|
||||
@@ -310,55 +299,49 @@ function NavRow({
|
||||
}
|
||||
|
||||
/**
|
||||
* Level 2 — a product's sub-pages, expanded IN PLACE beneath its own row. The list
|
||||
* is `productSubpages(entry)` (Overview + specifics + the uniform base set), with an
|
||||
* unwired sub-page dimmed but honest: it opens a placeholder, never a dead link.
|
||||
* ONE level-2 row — a single page of the product the rail is currently showing.
|
||||
*
|
||||
* Indented under the product and hung on a hairline, so the nesting is legible
|
||||
* without a second heading — the product's own row above IS the heading. Collapsed,
|
||||
* the rows are `inert`, so hidden options leave the tab order.
|
||||
* Level 2 is a REPLACEMENT, not a nesting: when a product is open the rail lists
|
||||
* ITS pages, so these rows sit FLUSH with the level-1 rows they stand in for. They
|
||||
* used to be indented under the product's row while the whole catalog stayed
|
||||
* painted below, which put two levels on screen at once and left the reader to work
|
||||
* out which list they were in.
|
||||
*
|
||||
* An unwired page is dimmed but honest — it opens a placeholder, never a dead link.
|
||||
* Both faces render through this one row: the full console's level 2 and the
|
||||
* product-shell face, whose nav IS its root module's pages.
|
||||
*/
|
||||
function SubRows({
|
||||
entry,
|
||||
subs,
|
||||
pathname,
|
||||
open,
|
||||
function SubRow({
|
||||
id,
|
||||
sub,
|
||||
active,
|
||||
collapsed,
|
||||
onGo,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
subs: ProductSubpage[]
|
||||
pathname: string
|
||||
open: boolean
|
||||
id: string
|
||||
sub: ProductSubpage
|
||||
active: boolean
|
||||
collapsed: boolean
|
||||
onGo: (path: string) => void
|
||||
}) {
|
||||
const activeSlug = activeSubpage(pathname, entry.id)
|
||||
const wired = subpageWired(id, sub.slug)
|
||||
const Icon = sub.icon ?? subpageIcon(sub.slug)
|
||||
return (
|
||||
<div className="hz-acc" data-open={open ? 'true' : 'false'} id={`nav-sub-${entry.id}`} inert={!open}>
|
||||
<div className="hz-acc-inner">
|
||||
<YStack gap="$0.5" ml="$4" pl="$2" pt="$0.5" borderLeftWidth={1} borderColor="$borderColor">
|
||||
{subs.map((sp) => {
|
||||
const wired = subpageWired(entry.id, sp.slug)
|
||||
const active = sp.slug === activeSlug
|
||||
const SubIcon = sp.icon ?? subpageIcon(sp.slug)
|
||||
return (
|
||||
<Button
|
||||
key={sp.slug || 'overview'}
|
||||
onPress={() => onGo(sp.slug ? `/${entry.id}/${sp.slug}` : `/${entry.id}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify="flex-start"
|
||||
icon={<SubIcon size={15} />}
|
||||
iconAfter={!wired ? <Circle size={7} opacity={0.5} /> : undefined}
|
||||
size="$2"
|
||||
opacity={wired ? 1 : 0.6}
|
||||
aria-label={wired ? sp.label : `${sp.label} (not available yet)`}
|
||||
>
|
||||
{sp.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => onGo(sub.slug ? `/${id}/${sub.slug}` : `/${id}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify={collapsed ? 'center' : 'flex-start'}
|
||||
px={collapsed ? '$0' : '$2.5'}
|
||||
height={collapsed ? 44 : undefined}
|
||||
icon={<Icon size={collapsed ? ICON : 17} />}
|
||||
iconAfter={!collapsed && !wired ? <Circle size={7} opacity={0.5} /> : undefined}
|
||||
size="$3"
|
||||
opacity={wired ? 1 : 0.6}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={wired ? sub.label : `${sub.label} (not available yet)`}
|
||||
>
|
||||
{collapsed ? undefined : sub.label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,11 +456,9 @@ function SidebarNav({
|
||||
const { colorOf } = useProductColors()
|
||||
const detail = useDetailPane()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const showBeta = useAppsBeta(showAdmin)
|
||||
// Entitlement scope: currently ungated in prod (the endpoint 404s → `enabled` is
|
||||
// null → the full catalog shows), matching "every product is always available".
|
||||
const { enabled } = useEntitlements()
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
// Collapsible-category accordion state — the user's EXPLICIT per-category collapse
|
||||
// choices, persisted per-user. Everything is EXPANDED by default; a collapsed
|
||||
@@ -485,75 +466,52 @@ function SidebarNav({
|
||||
const prefs = usePreferences()
|
||||
const navOpen = prefs.get<CategoryOpen>(NAV_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleSection = (category: string) => prefs.set(NAV_OPEN_PREF, toggleCategory(navOpen, category))
|
||||
// Does the rail list the whole catalog, or only what you keep? (Profile → Account.)
|
||||
const catalogInRail = prefs.get<boolean>(NAV_CATALOG_PREF, true)
|
||||
|
||||
// ── Level 2, in place ─────────────────────────────────────────────────────
|
||||
// A product's sub-pages expand beneath its own row; nothing replaces the list.
|
||||
// ── Which level the rail is on ────────────────────────────────────────────
|
||||
// The ROUTE decides, and nothing else: inside a product the rail shows THAT
|
||||
// product's pages plus the rest of its category (level 2); anywhere else it shows
|
||||
// the catalog (level 1). One level on screen at a time, and a reload, a deep link
|
||||
// and Back all land on the same rail because none of it is remembered state.
|
||||
const activeId = activeModuleId(pathname)
|
||||
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
|
||||
const productOpen = prefs.get<CategoryOpen>(NAV_PRODUCT_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleExpand = (id: string) =>
|
||||
prefs.set(NAV_PRODUCT_OPEN_PREF, toggleProduct(productOpen, id, { active: id === activeId }))
|
||||
|
||||
// Navigate to a LEAF (a sub-page or a no-sub-page product) — closes the drawer.
|
||||
const go = (path: string) => {
|
||||
router.push(path)
|
||||
onNavigate()
|
||||
}
|
||||
// Open a product from the list. One with sub-pages keeps the drawer open, because
|
||||
// becoming active expands it in place and its options are the next thing to read;
|
||||
// a leaf navigates and closes. An external launch tile opens its deployed app in a
|
||||
// new tab.
|
||||
// Open a product from the list. One with pages keeps the drawer open, because the
|
||||
// rail is about to become that product's pages and they are the next thing to
|
||||
// read; a leaf navigates and closes. An external tile opens its app in a new tab.
|
||||
const open = (entry: CatalogEntry) => {
|
||||
if (entry.kind === 'external') {
|
||||
openProduct(entry, go)
|
||||
onNavigate()
|
||||
return
|
||||
}
|
||||
setFilter('')
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
if (subs.length > 1) {
|
||||
router.push(`/${entry.id}`) // its sub-pages open beneath it
|
||||
if (productSubpages(entry, showAdmin).length > 1) {
|
||||
router.push(`/${entry.id}`) // the rail becomes its pages
|
||||
} else {
|
||||
go(`/${entry.id}`) // leaf — navigate + close
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE product row — the row itself plus, for a product that has them, its sub-pages
|
||||
* expanded beneath. Both the Pinned group and the category groups render through
|
||||
* this, so a product looks and behaves identically wherever it appears.
|
||||
*/
|
||||
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => {
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
// A pinned product appears TWICE — once under Pinned, once in its category — and
|
||||
// only ONE of those may carry the sub-pages. Two copies of the same list is two
|
||||
// navs painting at once, which is the very thing this rail exists to avoid, and
|
||||
// it doubles the rail's height for no information. The PINNED copy owns it: the
|
||||
// user put it up there, and it is the one they read first.
|
||||
const owns = opts.pinned || !isPinned(entry.id)
|
||||
const expandable = owns && entry.kind === 'module' && subs.length > 1
|
||||
const expanded = expandable && productIsOpen(productOpen, entry.id, { filtering, active: entry.id === activeId })
|
||||
return (
|
||||
<YStack key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`} gap="$0.5">
|
||||
<NavRow
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={opts.pinned ?? isPinned(entry.id)}
|
||||
expandable={expandable}
|
||||
expanded={expanded}
|
||||
onExpand={expandable ? () => toggleExpand(entry.id) : undefined}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
|
||||
onCustomize={opts.pinned ? () => customize(entry) : undefined}
|
||||
/>
|
||||
{expandable ? (
|
||||
<SubRows entry={entry} subs={subs} pathname={pathname} open={expanded} onGo={go} />
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
/** ONE product row. Pinned rows carry the customize dot, catalog rows the pin star. */
|
||||
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => (
|
||||
<NavRow
|
||||
key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`}
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={opts.pinned ?? isPinned(entry.id)}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
|
||||
onCustomize={opts.pinned ? () => customize(entry) : undefined}
|
||||
/>
|
||||
)
|
||||
const openDocs = () => {
|
||||
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
|
||||
onNavigate()
|
||||
@@ -580,9 +538,6 @@ function SidebarNav({
|
||||
content: <AddProductPanel />,
|
||||
})
|
||||
|
||||
const q = (collapsed ? '' : filter).trim().toLowerCase()
|
||||
const filtering = q.length > 0
|
||||
|
||||
// Grouped pins, gated so a customer never sees an admin-only surface.
|
||||
const pinnedGroups = useMemo(
|
||||
() =>
|
||||
@@ -591,28 +546,43 @@ function SidebarNav({
|
||||
...g,
|
||||
entries: g.entries.filter((e) => {
|
||||
const found = findEntry(e.id)
|
||||
return Boolean(found) && (showAdmin || !found!.admin) && (showBeta || !found!.beta)
|
||||
return Boolean(found) && (showAdmin || !found!.admin)
|
||||
}),
|
||||
}))
|
||||
.filter((g) => g.entries.length > 0),
|
||||
[view, showAdmin, showBeta],
|
||||
[view, showAdmin],
|
||||
)
|
||||
|
||||
// Within-scope ordering is CONTINUOUS ALPHABETICAL with the SELECTED product pinned
|
||||
// first — via the ONE shared `orderEntries` helper. Categories stay in their
|
||||
// canonical order; only the items inside each are alphabetized + selected-first.
|
||||
//
|
||||
// "Show every product" opens the ENTITLEMENT scope, exactly as the All-products
|
||||
// panel already does: every product is available to every org on demand, so what
|
||||
// an org has ENABLED is a statement about use, not about permission. Off, the rail
|
||||
// narrows to that enabled set. PERMISSION — admin surfaces, brand scope — holds
|
||||
// either way, so nothing on the rail is ever a surface the viewer cannot open.
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
// Search is DISCOVERY: while a query is typed the entitlement scope opens
|
||||
// to the whole catalog — the point of searching is finding what you do
|
||||
// not have yet — while the admin and beta gates keep holding. The resting
|
||||
// rail stays scoped to the org's enabled set.
|
||||
visibleCatalogByCategory(showAdmin, filtering ? null : enabled, showBeta)
|
||||
.map((g) => ({ category: g.category, entries: orderEntries(g.entries.filter((e) => entryMatches(e, q)), activeId) }))
|
||||
visibleCatalogByCategory(showAdmin, catalogInRail ? null : enabled)
|
||||
.map((g) => ({ category: g.category, entries: orderEntries(g.entries, activeId) }))
|
||||
.filter((g) => g.entries.length > 0),
|
||||
[q, filtering, showAdmin, showBeta, enabled, activeId],
|
||||
[showAdmin, catalogInRail, enabled, activeId],
|
||||
)
|
||||
|
||||
// ── Level 2 — the product the route is inside ─────────────────────────────
|
||||
// Its pages become the rail, and the rest of its category follows them, so moving
|
||||
// to a sibling stays one click.
|
||||
const level = activeId ? findEntry(activeId) : undefined
|
||||
// A product with no pages of its own is a LEAF: there is no second level to show,
|
||||
// so the rail stays on the catalog with that row lit. Same rule `open` navigates by.
|
||||
const here =
|
||||
level && (showAdmin || !level.admin) && productSubpages(level, showAdmin).length > 1 ? level : undefined
|
||||
const siblings =
|
||||
here && catalogInRail
|
||||
? (groups.find((g) => g.category === here.category)?.entries ?? []).filter((e) => e.id !== here.id)
|
||||
: []
|
||||
|
||||
// ── Product-shell face — the nav IS the root module's sub-pages ────────────
|
||||
if (isProductShell(config.shell)) {
|
||||
const shell = shellFor(config.shell)
|
||||
@@ -650,25 +620,16 @@ function SidebarNav({
|
||||
) : null}
|
||||
<ScrollView flex={1}>
|
||||
<YStack gap="$1">
|
||||
{subs.map((sp) => {
|
||||
const active = sp.slug === activeSlug
|
||||
const SubIcon = sp.icon ?? billingSubpageIcon(sp.slug)
|
||||
return (
|
||||
<Button
|
||||
key={sp.slug || 'overview'}
|
||||
onPress={() => go(sp.slug ? `/${rootId}/${sp.slug}` : `/${rootId}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify={collapsed ? 'center' : 'flex-start'}
|
||||
px={collapsed ? '$0' : '$2.5'}
|
||||
height={collapsed ? 44 : undefined}
|
||||
icon={<SubIcon size={collapsed ? ICON : 17} />}
|
||||
size="$3"
|
||||
aria-label={sp.label}
|
||||
>
|
||||
{collapsed ? undefined : sp.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{subs.map((sp) => (
|
||||
<SubRow
|
||||
key={sp.slug || 'overview'}
|
||||
id={rootId}
|
||||
sub={sp}
|
||||
active={sp.slug === activeSlug}
|
||||
collapsed={collapsed}
|
||||
onGo={go}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
<SidebarAccount collapsed={collapsed} />
|
||||
@@ -684,7 +645,7 @@ function SidebarNav({
|
||||
const seen = new Set<string>()
|
||||
for (const id of [...pinnedIds, ...(activeId ? [activeId] : [])]) {
|
||||
const e = findEntry(id)
|
||||
if (e && !seen.has(id) && (showAdmin || !e.admin) && (showBeta || !e.beta)) {
|
||||
if (e && !seen.has(id) && (showAdmin || !e.admin)) {
|
||||
seen.add(id)
|
||||
railIds.push(id)
|
||||
}
|
||||
@@ -725,9 +686,8 @@ function SidebarNav({
|
||||
)
|
||||
}
|
||||
|
||||
// ── The product list: brand; filter; Overview/Docs; Pinned; every category
|
||||
// (EXPANDED by default, collapsible), each product expanding its own sub-pages
|
||||
// in place; All-products; identity + wallet. ──
|
||||
// ── The rail: switcher; filter; Overview/Docs; then ONE level — the catalog, or
|
||||
// the open product's pages followed by its category; All-products; identity. ──
|
||||
return (
|
||||
// The product rail is a NAVIGATION LANDMARK. It had no role at all, so a
|
||||
// screen-reader user had no way to jump to the product list and no way to
|
||||
@@ -744,109 +704,139 @@ function SidebarNav({
|
||||
no switcher to carry the identity there. */}
|
||||
<ContextSwitcher />
|
||||
|
||||
{/* Product filter — narrows the whole list; a match from any category jumps
|
||||
straight there. Typing hides the section chrome so the list stays scannable. */}
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
mb="$2"
|
||||
height={34}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
>
|
||||
<Search size={14} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={filter}
|
||||
onChangeText={setFilter}
|
||||
placeholder="Filter products…"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{filter ? (
|
||||
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setFilter('')} aria-label="Clear filter" />
|
||||
) : null}
|
||||
{/* Search — the SAME palette the header and the drawer open, so the whole
|
||||
catalog (every product AND every page inside one) is one query away from
|
||||
the rail, at either level. The rail used to carry its own text filter that
|
||||
narrowed only the rows it had already drawn: a second search, answering a
|
||||
smaller question. */}
|
||||
<XStack mb="$2">
|
||||
<CommandSearchBox />
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack gap="$3.5">
|
||||
{!filtering ? (
|
||||
<YStack gap="$1">
|
||||
<FixedRow icon={House} label="Overview" active={pathname === '/'} collapsed={false} onPress={() => go('/')} />
|
||||
<FixedRow icon={BookOpen} label="Docs" external collapsed={false} onPress={openDocs} />
|
||||
</YStack>
|
||||
) : null}
|
||||
<YStack gap="$1">
|
||||
<FixedRow icon={House} label="Overview" active={pathname === '/'} collapsed={false} onPress={() => go('/')} />
|
||||
<FixedRow icon={BookOpen} label="Docs" external collapsed={false} onPress={openDocs} />
|
||||
</YStack>
|
||||
|
||||
{!filtering && pinnedGroups.length > 0 ? (
|
||||
<YStack gap="$1.5">
|
||||
<XStack items="center" justify="space-between" px="$2.5">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Pinned
|
||||
</Text>
|
||||
<Button size="$1" chromeless onPress={manage} aria-label="Manage pins">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700">
|
||||
Manage
|
||||
{here ? (
|
||||
/* LEVEL 2 — the open product's pages, flush, then the rest of its
|
||||
category. The catalog is not painted underneath: one level at a
|
||||
time, and the category row above is the way back up to it. */
|
||||
<>
|
||||
<YStack gap="$1">
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
height={32}
|
||||
px="$2.5"
|
||||
justify="flex-start"
|
||||
onPress={() => go(`/category/${categorySlug(here.category)}`)}
|
||||
icon={<ChevronLeft size={14} opacity={0.7} />}
|
||||
aria-label={`Back to ${here.category}`}
|
||||
>
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
{here.category}
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
{pinnedGroups.map((group) => (
|
||||
<YStack key={group.name || 'default'} gap="$1">
|
||||
{group.name ? (
|
||||
<Text px="$2.5" fontSize="$1" color="$color9" fontWeight="700">
|
||||
{group.label}
|
||||
</Text>
|
||||
) : null}
|
||||
{group.entries.map((e) => {
|
||||
const entry = findEntry(e.id)
|
||||
if (!entry) return null
|
||||
return productRow(entry, { pinned: true })
|
||||
})}
|
||||
{/* The product names the list below it — a heading, not a link:
|
||||
its index is the first row, and two ways to the same page is
|
||||
the duplication this level exists to remove. */}
|
||||
<XStack items="center" gap="$2" px="$2.5" pb="$0.5">
|
||||
<ProductIcon icon={here.icon} color={colorOf(here.id)} size={18} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{here.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
{productSubpages(here, showAdmin).map((sp) => (
|
||||
<SubRow
|
||||
key={sp.slug || 'overview'}
|
||||
id={here.id}
|
||||
sub={sp}
|
||||
active={sp.slug === activeSubpage(pathname, here.id)}
|
||||
collapsed={false}
|
||||
onGo={go}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{siblings.length > 0 ? (
|
||||
<YStack gap="$1">
|
||||
<Text px="$2.5" fontSize="$1" color="$color10" fontWeight="500">
|
||||
More in {here.category}
|
||||
</Text>
|
||||
{siblings.map((entry) => productRow(entry))}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
/* LEVEL 1 — what you pinned, then every category. */
|
||||
<>
|
||||
{pinnedGroups.length > 0 ? (
|
||||
<YStack gap="$1.5">
|
||||
<XStack items="center" justify="space-between" px="$2.5">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Pinned
|
||||
</Text>
|
||||
<Button size="$1" chromeless onPress={manage} aria-label="Manage pins">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700">
|
||||
Manage
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
{pinnedGroups.map((group) => (
|
||||
<YStack key={group.name || 'default'} gap="$1">
|
||||
{group.name ? (
|
||||
<Text px="$2.5" fontSize="$1" color="$color9" fontWeight="700">
|
||||
{group.label}
|
||||
</Text>
|
||||
) : null}
|
||||
{group.entries.map((e) => {
|
||||
const entry = findEntry(e.id)
|
||||
if (!entry) return null
|
||||
return productRow(entry, { pinned: true })
|
||||
})}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{groups.map((group) => (
|
||||
<CategorySection
|
||||
key={group.category}
|
||||
category={group.category}
|
||||
count={group.entries.length}
|
||||
open={categoryIsOpen(navOpen, group.category, { filtering })}
|
||||
onToggle={() => toggleSection(group.category)}
|
||||
>
|
||||
{group.entries.map((entry) => productRow(entry))}
|
||||
</CategorySection>
|
||||
))}
|
||||
|
||||
{filtering && groups.length === 0 ? (
|
||||
<Text px="$2.5" py="$3" fontSize="$2" color="$color10">
|
||||
No products match “{filter.trim()}”.
|
||||
</Text>
|
||||
) : null}
|
||||
{catalogInRail ? (
|
||||
groups.map((group) => (
|
||||
<CategorySection
|
||||
key={group.category}
|
||||
category={group.category}
|
||||
count={group.entries.length}
|
||||
open={categoryIsOpen(navOpen, group.category)}
|
||||
onToggle={() => toggleSection(group.category)}
|
||||
>
|
||||
{group.entries.map((entry) => productRow(entry))}
|
||||
</CategorySection>
|
||||
))
|
||||
) : level && !isPinned(level.id) ? (
|
||||
/* The catalog is put away, so the rail is the pins — plus wherever
|
||||
you are, which would otherwise be the one place with no row. */
|
||||
<YStack gap="$1">{productRow(level)}</YStack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Browse the full catalog — pin/unpin + find what's in use. Always
|
||||
available (no enable gate; every product is always on). */}
|
||||
{!filtering ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
icon={<LayoutGrid size={16} />}
|
||||
onPress={allProducts}
|
||||
aria-label="All products"
|
||||
mt="$1"
|
||||
>
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
All products
|
||||
</Text>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
icon={<LayoutGrid size={16} />}
|
||||
onPress={allProducts}
|
||||
aria-label="All products"
|
||||
mt="$1"
|
||||
>
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
All products
|
||||
</Text>
|
||||
</Button>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
@@ -868,29 +858,7 @@ function NavDrawer({ open, onOpenChange }: { open: boolean; onOpenChange: (o: bo
|
||||
on phones/tablets (see globals.css); the desktop sidebar stays dense. */}
|
||||
<YStack flex={1} minH={0} p="$3" gap="$2.5" className="hz-touch-target">
|
||||
<XStack gap="$2" items="center">
|
||||
<XStack
|
||||
flex={1}
|
||||
onPress={() => {
|
||||
onOpenChange(false)
|
||||
palette.open()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$3"
|
||||
height={44}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
>
|
||||
<Search size={15} opacity={0.6} />
|
||||
<Text flex={1} fontSize="$3" color="$color10" numberOfLines={1}>
|
||||
Search or ask AI…
|
||||
</Text>
|
||||
<Command size={13} opacity={0.5} />
|
||||
</XStack>
|
||||
<CommandSearchBox height={44} onOpen={() => onOpenChange(false)} />
|
||||
{/* No second "Apps" trigger here either — the search row above opens the
|
||||
very same palette, so one row is the whole affordance. */}
|
||||
{/* Explicit close — right-aligned INSIDE the drawer header, always reachable
|
||||
|
||||
@@ -27,6 +27,17 @@ export function titleCase(name: string): string {
|
||||
return name ? name.charAt(0).toUpperCase() + name.slice(1) : name
|
||||
}
|
||||
|
||||
/**
|
||||
* What to CALL an org — its IAM display name when it has one, else its slug
|
||||
* titled. ONE rule, so the switcher's trigger, the row for that same org inside
|
||||
* it, and the rail's brand all say the same word. They used to disagree: the
|
||||
* trigger titled the slug and the rows printed it raw, so the control could read
|
||||
* "Acme" over an active row reading "acme".
|
||||
*/
|
||||
export function orgLabel(org: { name?: string; displayName?: string }): string {
|
||||
return org.displayName?.trim() || titleCase(org.name ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary the context switcher shows: the org, then the project
|
||||
* slice of it. Org-level scope has no project, and says so by omission rather
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('./client', async () => {
|
||||
})
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: vi.fn(),
|
||||
iamHasSession: vi.fn(() => false),
|
||||
iamUserInfo: vi.fn(),
|
||||
iamExpiresInSeconds: vi.fn(),
|
||||
iamSignOut: vi.fn(),
|
||||
|
||||
@@ -6,6 +6,7 @@ const token = vi.hoisted(() => ({ value: null as string | null }))
|
||||
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: async () => token.value,
|
||||
iamHasSession: () => token.value != null,
|
||||
iamUserInfo: async () => null,
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamSignOut: () => {},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// The IAM seam, both halves. The access TOKEN answers who you are; USERINFO is the
|
||||
// only place IAM publishes the avatar (`picture`) — deliberately, so a bounded data
|
||||
// URI never rides every JWT. `userinfoCalls` counts the round trips so the cache can
|
||||
// be asserted rather than assumed.
|
||||
const token = vi.hoisted(() => ({ value: null as string | null }))
|
||||
const userinfo = vi.hoisted(() => ({
|
||||
value: null as Record<string, unknown> | null,
|
||||
calls: 0,
|
||||
throws: false,
|
||||
}))
|
||||
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: async () => token.value,
|
||||
iamUserInfo: async () => {
|
||||
userinfo.calls++
|
||||
if (userinfo.throws) throw new Error('userinfo unreachable')
|
||||
return userinfo.value
|
||||
},
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamSignOut: () => {},
|
||||
}))
|
||||
|
||||
const { AccountApi } = await import('~/lib/api/account')
|
||||
|
||||
function jwt(claims: Record<string, unknown>): string {
|
||||
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
||||
return `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims)}.`
|
||||
}
|
||||
|
||||
// The claim set hanzo.id issues. Note what is NOT here: no `avatar`, no `picture`.
|
||||
// That absence IS the contract — reading only these is why the console showed
|
||||
// initials for every user who had uploaded a photo.
|
||||
const CLAIMS = { iss: 'https://hanzo.id', sub: 'u-1', owner: 'hanzo', name: 'z', email: 'z@hanzo.ai' }
|
||||
const PHOTO = 'data:image/webp;base64,UklGRhoAAABXRUJQ'
|
||||
|
||||
describe('the profile photo the token does not carry', () => {
|
||||
beforeEach(async () => {
|
||||
token.value = null
|
||||
userinfo.value = null
|
||||
userinfo.calls = 0
|
||||
userinfo.throws = false
|
||||
await AccountApi.signout() // drops the cached photo between cases
|
||||
})
|
||||
|
||||
it('reads the photo from userinfo when the token has none', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.value = { picture: PHOTO }
|
||||
const { account } = await AccountApi.session()
|
||||
expect(account?.avatar).toBe(PHOTO)
|
||||
})
|
||||
|
||||
it('prefers the token when it DOES carry one, and asks userinfo nothing', async () => {
|
||||
token.value = jwt({ ...CLAIMS, avatar: 'data:image/png;base64,FROMTOKEN' })
|
||||
userinfo.value = { picture: PHOTO }
|
||||
const { account } = await AccountApi.session()
|
||||
expect(account?.avatar).toBe('data:image/png;base64,FROMTOKEN')
|
||||
expect(userinfo.calls).toBe(0)
|
||||
})
|
||||
|
||||
it('costs ONE round trip across repeated loads — including for a user with no photo', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.value = { picture: PHOTO }
|
||||
await AccountApi.session()
|
||||
await AccountApi.session()
|
||||
await AccountApi.session()
|
||||
expect(userinfo.calls).toBe(1)
|
||||
|
||||
await AccountApi.signout()
|
||||
userinfo.value = {} // no photo — the MISS must cache too, or it refetches forever
|
||||
userinfo.calls = 0
|
||||
await AccountApi.session()
|
||||
await AccountApi.session()
|
||||
expect(userinfo.calls).toBe(1)
|
||||
})
|
||||
|
||||
it('never shows the previous account’s face after a switch', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.value = { picture: PHOTO }
|
||||
expect((await AccountApi.session()).account?.avatar).toBe(PHOTO)
|
||||
|
||||
// A different principal, same tab, no sign-out in between.
|
||||
token.value = jwt({ ...CLAIMS, owner: 'maxpower', name: 'dave' })
|
||||
userinfo.value = {}
|
||||
expect((await AccountApi.session()).account?.avatar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a failing userinfo costs the photo, never the session', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.throws = true
|
||||
const { account } = await AccountApi.session()
|
||||
expect(account?.owner).toBe('hanzo')
|
||||
expect(account?.name).toBe('z')
|
||||
expect(account?.avatar).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ const iam = vi.hoisted(() => ({ token: 'live-access-token' as string | null }))
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamAccessToken: () => iam.token,
|
||||
iamValidAccessToken: async () => iam.token,
|
||||
iamHasSession: () => iam.token != null,
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamUserInfo: async () => null,
|
||||
iamSignOut: () => {},
|
||||
|
||||
+62
-2
@@ -11,8 +11,10 @@ import {
|
||||
iamValidAccessToken,
|
||||
iamUserInfo,
|
||||
iamExpiresInSeconds,
|
||||
iamHasSession,
|
||||
iamSignOut,
|
||||
} from '~/lib/auth/iam'
|
||||
import { refreshSession } from '~/lib/auth/refresh'
|
||||
import { config } from '~/config'
|
||||
import { type Account } from './types'
|
||||
|
||||
@@ -92,6 +94,53 @@ function decodeJwtClaims(token: string): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile photo, which the access token does not carry and never will.
|
||||
*
|
||||
* Identity above is read from the token's own claims — self-contained, no round
|
||||
* trip, and immune to `getUserInfo()` answering null on a 200, which once
|
||||
* dead-ended the session into a /signin loop. That is the right source and this
|
||||
* does not move it. But IAM puts the avatar on `picture` in the USERINFO
|
||||
* response ONLY (`internal/oidc/userinfo.go`), deliberately: an avatar is a
|
||||
* bounded data URI, so carrying it would add kilobytes to every JWT on every
|
||||
* request for a value almost nothing reads. hanzoai/app's `lib/profile.ts`
|
||||
* states the same contract from the writing side.
|
||||
*
|
||||
* So the token is authoritative for WHO you are and silent about your picture,
|
||||
* and reading only the token meant `account.avatar` was undefined for every
|
||||
* user, forever — the account menu fell back to initials no matter what anyone
|
||||
* uploaded. One fetch fills exactly that gap.
|
||||
*
|
||||
* Best-effort and cached by design. A failure, a signed-out 401, or a user with
|
||||
* no photo all resolve to "no avatar" — never a broken session, because a
|
||||
* missing picture must not cost anyone their sign-in. The cache is keyed by
|
||||
* identity so switching accounts cannot show the previous face, and it holds
|
||||
* the miss too: a user with no photo should cost one request, not one per load.
|
||||
*/
|
||||
let avatarCache: { key: string; url: string | undefined } | null = null
|
||||
|
||||
async function withAvatar(account: Account): Promise<Account> {
|
||||
if (account.avatar) return account
|
||||
const key = `${account.owner}/${account.name}`
|
||||
if (avatarCache?.key !== key) {
|
||||
let url: string | undefined
|
||||
try {
|
||||
const info = await iamUserInfo()
|
||||
const pic = info?.['picture'] ?? info?.['avatar']
|
||||
if (typeof pic === 'string' && pic) url = pic
|
||||
} catch {
|
||||
/* no photo is not a session failure */
|
||||
}
|
||||
avatarCache = { key, url }
|
||||
}
|
||||
return avatarCache.url ? { ...account, avatar: avatarCache.url } : account
|
||||
}
|
||||
|
||||
/** Drop the cached photo — sign-out, so the next account never inherits this face. */
|
||||
function forgetAvatar(): void {
|
||||
avatarCache = null
|
||||
}
|
||||
|
||||
export const AccountApi = {
|
||||
/**
|
||||
* Resolve the current session from IAM: a valid access token (refreshed if
|
||||
@@ -100,14 +149,24 @@ export const AccountApi = {
|
||||
* silently refreshed by the SDK before the claims are read.
|
||||
*/
|
||||
session: async (): Promise<SessionResult> => {
|
||||
const token = await iamValidAccessToken()
|
||||
let token = await iamValidAccessToken()
|
||||
// A transient IAM blip (network / a 5xx from the token endpoint) yields a null
|
||||
// token even though the browser still holds a session — don't boot the user to the
|
||||
// sign-in card on a hiccup at load. Retry via the ONE resilient, single-flight
|
||||
// refresh before concluding signed-out; an anonymous visitor (no stored token)
|
||||
// skips it (iamHasSession is false) and resolves signed-out immediately.
|
||||
if (!token && iamHasSession() && (await refreshSession())) {
|
||||
token = await iamValidAccessToken()
|
||||
}
|
||||
if (!token) return { account: null, expiresIn: null }
|
||||
// Resolve identity from the access-token JWT claims directly — self-contained
|
||||
// and immune to the SDK's getUserInfo() returning null on a 200 (which dead-ended
|
||||
// the session and looped /signin). Fall back to userinfo only if not a JWT.
|
||||
const claims = decodeJwtClaims(token) ?? (await iamUserInfo())
|
||||
if (!claims) return { account: null, expiresIn: null }
|
||||
return { account: accountFromClaims(claims), expiresIn: iamExpiresInSeconds() }
|
||||
const account = accountFromClaims(claims)
|
||||
if (!account) return { account: null, expiresIn: null }
|
||||
return { account: await withAvatar(account), expiresIn: iamExpiresInSeconds() }
|
||||
},
|
||||
|
||||
/** The current signed-in account, or null. */
|
||||
@@ -115,6 +174,7 @@ export const AccountApi = {
|
||||
|
||||
/** Sign out: clear the IAM tokens (client) and best-effort the casibase session. */
|
||||
signout: async (): Promise<void> => {
|
||||
forgetAvatar()
|
||||
iamSignOut()
|
||||
try {
|
||||
await post('signout')
|
||||
|
||||
@@ -8,6 +8,7 @@ const iam = vi.hoisted(() => ({ token: null as string | null }))
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamAccessToken: () => iam.token,
|
||||
iamValidAccessToken: async () => iam.token,
|
||||
iamHasSession: () => iam.token != null,
|
||||
iamExpiresInSeconds: () => (iam.token ? 3600 : null),
|
||||
iamUserInfo: async () => null,
|
||||
iamSignOut: () => {},
|
||||
|
||||
@@ -17,48 +17,55 @@ vi.mock('./client', () => ({
|
||||
|
||||
import { ApmApi, apmWindow } from './apm'
|
||||
|
||||
type Body = { compositeQuery: { builderQueries: { A: { filters: { items: { op: string; value: string; key: { key: string } }[] } } } } }
|
||||
type Body = {
|
||||
requestType: string
|
||||
compositeQuery: { queries: { type: string; spec: { signal: string; limit: number } & Record<string, unknown> }[] }
|
||||
}
|
||||
|
||||
describe('ApmApi.logs — the per-product o11y query builds with the service filter + maps real rows', () => {
|
||||
describe('ApmApi.logs — the per-product o11y v5 query builds + maps real rows, service-scoped', () => {
|
||||
beforeEach(() => restPost.mockReset())
|
||||
|
||||
it('sends a service.name filter and normalizes a real O11y logs response to service-scoped rows', async () => {
|
||||
it('asks the v5 raw surface for a deep page and normalizes rows to the ONE service', async () => {
|
||||
const nsTs = String(Date.parse('2026-07-03T00:00:00Z') * 1_000_000) // O11y ns epoch
|
||||
restPost.mockResolvedValueOnce({
|
||||
data: { result: [{ list: [{ timestamp: nsTs, data: { id: 'l1', severity_text: 'INFO', 'service.name': 'iam', body: 'signed in' } }] }] },
|
||||
data: { data: { results: [{ rows: [{ timestamp: nsTs, data: { id: 'l1', severity_text: 'INFO', resources_string: { 'service.name': 'iam' }, body: 'signed in' } }] }] } },
|
||||
})
|
||||
|
||||
const rows = await ApmApi.logs(apmWindow(3600), 500, 'iam')
|
||||
|
||||
// 1) the outgoing query carried the per-service filter (serviceFilterItem)
|
||||
// 1) the outgoing query is the v5 raw shape on the version-less canonical
|
||||
// surface, addressed via the /v1 bearer BFF. Service scoping is client-side
|
||||
// (the runtime's key resolution is down — see rawQueryPayload), so a scoped
|
||||
// read asks for the deepest page instead of a server filter.
|
||||
const [url, body] = restPost.mock.calls[0] as [string, Body]
|
||||
// Version-less canonical o11y surface, addressed via the /v1 bearer BFF.
|
||||
expect(url).toBe('/v1/o11y/query_range')
|
||||
const items = body.compositeQuery.builderQueries.A.filters.items
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({ op: '=', value: 'iam', key: { key: 'service.name' } })
|
||||
expect(body.requestType).toBe('raw')
|
||||
const q = body.compositeQuery.queries[0]
|
||||
expect(q.type).toBe('builder_query')
|
||||
expect(q.spec).toMatchObject({ signal: 'logs', limit: 1000 })
|
||||
expect('filter' in q.spec).toBe(false)
|
||||
|
||||
// 2) the real response maps to real, normalized rows
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ service: 'iam', severity: 'info', body: 'signed in' })
|
||||
})
|
||||
|
||||
it('re-filters client-side so a runtime that ignored the filter cannot leak another service onto the page', async () => {
|
||||
it('re-filters client-side so another service can never leak onto the page', async () => {
|
||||
restPost.mockResolvedValueOnce({
|
||||
data: { result: [{ list: [
|
||||
{ timestamp: '2', data: { body: 'mine', 'service.name': 'iam' } },
|
||||
{ timestamp: '1', data: { body: 'not mine', 'service.name': 'kms' } },
|
||||
] }] },
|
||||
data: { data: { results: [{ rows: [
|
||||
{ timestamp: '2', data: { body: 'mine', resources_string: { 'service.name': 'iam' } } },
|
||||
{ timestamp: '1', data: { body: 'not mine', resources_string: { 'service.name': 'kms' } } },
|
||||
] }] } },
|
||||
})
|
||||
const rows = await ApmApi.logs(apmWindow(3600), 500, 'iam')
|
||||
expect(rows.map((r) => r.body)).toEqual(['mine'])
|
||||
})
|
||||
|
||||
it('sends NO filter for the org-wide stream (back-compat with the Observe Logs board)', async () => {
|
||||
restPost.mockResolvedValueOnce({ data: { result: [] } })
|
||||
it('keeps the caller limit for the org-wide stream (no service, no deep page)', async () => {
|
||||
restPost.mockResolvedValueOnce({ data: { data: { results: [] } } })
|
||||
await ApmApi.logs(apmWindow(3600), 500)
|
||||
const [, body] = restPost.mock.calls[0] as [string, Body]
|
||||
expect(body.compositeQuery.builderQueries.A.filters.items).toEqual([])
|
||||
expect(body.compositeQuery.queries[0].spec.limit).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+82
-68
@@ -14,11 +14,10 @@ import {
|
||||
normalizeExceptions,
|
||||
normalizeDashboard,
|
||||
normalizeDashboards,
|
||||
listQueryPayload,
|
||||
serviceFilterItem,
|
||||
rawQueryPayload,
|
||||
pickService,
|
||||
serviceHealthOf,
|
||||
parseListRows,
|
||||
parseRawRows,
|
||||
toIso,
|
||||
normalizeLogRow,
|
||||
normalizeLogs,
|
||||
@@ -234,65 +233,55 @@ describe('normalizeDashboard / normalizeDashboards', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('listQueryPayload (O11y v3 query_range LIST)', () => {
|
||||
describe('rawQueryPayload (O11y v5 query_range RAW)', () => {
|
||||
const w = apmWindow(3600)
|
||||
type V5 = {
|
||||
schemaVersion: string
|
||||
start: number
|
||||
end: number
|
||||
requestType: string
|
||||
compositeQuery: { queries: { type: string; spec: Record<string, unknown> }[] }
|
||||
}
|
||||
|
||||
it('builds a noop list builder query over the given dataSource with ms bounds', () => {
|
||||
const p = listQueryPayload('logs', w, 100) as {
|
||||
start: number
|
||||
end: number
|
||||
compositeQuery: { queryType: string; panelType: string; builderQueries: Record<string, Record<string, unknown>> }
|
||||
}
|
||||
it('builds ONE builder_query envelope over the given signal with ms bounds', () => {
|
||||
const p = rawQueryPayload('logs', w, 100) as V5
|
||||
expect(p.schemaVersion).toBe('v1')
|
||||
expect(p.start).toBe(w.startMs)
|
||||
expect(p.end).toBe(w.endMs)
|
||||
expect(p.compositeQuery.queryType).toBe('builder')
|
||||
expect(p.compositeQuery.panelType).toBe('list')
|
||||
const A = p.compositeQuery.builderQueries.A
|
||||
expect(A.dataSource).toBe('logs')
|
||||
expect(A.aggregateOperator).toBe('noop')
|
||||
expect(A.expression).toBe('A')
|
||||
expect(A.pageSize).toBe(100)
|
||||
// newest-first
|
||||
expect(A.orderBy).toEqual([{ columnName: 'timestamp', order: 'desc' }])
|
||||
expect(p.requestType).toBe('raw')
|
||||
expect(p.compositeQuery.queries).toHaveLength(1)
|
||||
const q = p.compositeQuery.queries[0]
|
||||
expect(q.type).toBe('builder_query')
|
||||
expect(q.spec).toMatchObject({ name: 'A', signal: 'logs', disabled: false, limit: 100, offset: 0 })
|
||||
})
|
||||
|
||||
it('carries dataSource=traces for a span search', () => {
|
||||
const p = listQueryPayload('traces', w, 50) as { compositeQuery: { builderQueries: { A: { dataSource: string } } } }
|
||||
expect(p.compositeQuery.builderQueries.A.dataSource).toBe('traces')
|
||||
it('carries signal=traces for a span search', () => {
|
||||
const p = rawQueryPayload('traces', w, 50) as V5
|
||||
expect(p.compositeQuery.queries[0].spec.signal).toBe('traces')
|
||||
})
|
||||
|
||||
it('clamps pageSize into [1,1000] and floors it', () => {
|
||||
const big = listQueryPayload('logs', w, 99999) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
|
||||
const zero = listQueryPayload('logs', w, 0) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
|
||||
const frac = listQueryPayload('logs', w, 12.9) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
|
||||
expect(big.compositeQuery.builderQueries.A.pageSize).toBe(1000)
|
||||
expect(zero.compositeQuery.builderQueries.A.pageSize).toBe(1)
|
||||
expect(frac.compositeQuery.builderQueries.A.pageSize).toBe(12)
|
||||
it('clamps limit into [1,1000] and floors it', () => {
|
||||
const lim = (n: number) => (rawQueryPayload('logs', w, n) as V5).compositeQuery.queries[0].spec.limit
|
||||
expect(lim(99999)).toBe(1000)
|
||||
expect(lim(0)).toBe(1)
|
||||
expect(lim(12.9)).toBe(12)
|
||||
})
|
||||
|
||||
it('defaults to NO filters (whole-org stream) when none are given — back-compat', () => {
|
||||
const p = listQueryPayload('logs', w, 100) as { compositeQuery: { builderQueries: { A: { filters: { items: unknown[]; op: string } } } } }
|
||||
expect(p.compositeQuery.builderQueries.A.filters).toEqual({ items: [], op: 'AND' })
|
||||
it('keeps order and filter OFF the wire — the runtime 500s resolving any key today', () => {
|
||||
// Both spec features route through telemetry-metadata key resolution, which the
|
||||
// deployed runtime cannot serve ("failed to get logs keys"). Raw is newest-first
|
||||
// by default, and service scoping is the client-side re-filter in ApmApi.
|
||||
const spec = (rawQueryPayload('logs', w, 100) as V5).compositeQuery.queries[0].spec
|
||||
expect('order' in spec).toBe(false)
|
||||
expect('filter' in spec).toBe(false)
|
||||
})
|
||||
|
||||
it('carries a per-service filter into the builder query when given (per-product scope)', () => {
|
||||
const item = serviceFilterItem('logs', 'iam')
|
||||
const p = listQueryPayload('logs', w, 100, [item]) as { compositeQuery: { builderQueries: { A: { filters: { items: unknown[]; op: string } } } } }
|
||||
expect(p.compositeQuery.builderQueries.A.filters).toEqual({ items: [item], op: 'AND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('serviceFilterItem (scope a logs/traces query to one OTel service.name)', () => {
|
||||
it('builds the service.name resource-attribute equality O11y expects', () => {
|
||||
const f = serviceFilterItem('logs', 'vector')
|
||||
expect(f.op).toBe('=')
|
||||
expect(f.value).toBe('vector')
|
||||
expect(f.key.key).toBe('service.name')
|
||||
expect(f.key.type).toBe('resource')
|
||||
expect(f.key.isColumn).toBe(false) // logs: resource attribute, not an indexed column
|
||||
})
|
||||
it('marks service.name as an indexed column for traces (where it is materialized)', () => {
|
||||
expect(serviceFilterItem('traces', 'gateway').key.isColumn).toBe(true)
|
||||
it('never emits a v3 field the strict v5 decoder refuses', () => {
|
||||
const p = rawQueryPayload('logs', w, 100) as Record<string, unknown>
|
||||
const composite = p.compositeQuery as Record<string, unknown>
|
||||
expect('builderQueries' in composite).toBe(false)
|
||||
expect('queryType' in composite).toBe(false)
|
||||
expect('panelType' in composite).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -338,21 +327,40 @@ describe('serviceHealthOf (RED verdict for one service)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseListRows', () => {
|
||||
it('reads rows from data.result[].list', () => {
|
||||
const body = { data: { result: [{ list: [{ timestamp: '1', data: { body: 'a' } }, { timestamp: '2', data: { body: 'b' } }] }] } }
|
||||
expect(parseListRows(body)).toHaveLength(2)
|
||||
describe('parseRawRows', () => {
|
||||
it('reads rows from the {status,data:{data:{results:[{rows}]}}} envelope', () => {
|
||||
const body = {
|
||||
status: 'success',
|
||||
data: { type: 'raw', data: { results: [{ queryName: 'A', rows: [{ timestamp: '1', data: { body: 'a' } }, { timestamp: '2', data: { body: 'b' } }] }] } },
|
||||
}
|
||||
expect(parseRawRows(body)).toHaveLength(2)
|
||||
})
|
||||
it('reads rows from the nested data.newResult.data.result[].list mirror', () => {
|
||||
const body = { data: { newResult: { data: { result: [{ list: [{ timestamp: '1', data: {} }] }] } } } }
|
||||
expect(parseListRows(body)).toHaveLength(1)
|
||||
it('reads rows from a bare {data:{results}} response', () => {
|
||||
const body = { data: { results: [{ rows: [{ timestamp: '1', data: {} }] }] } }
|
||||
expect(parseRawRows(body)).toHaveLength(1)
|
||||
})
|
||||
it('flattens the OTel attribute maps over the row scalars into ONE namespace', () => {
|
||||
const body = {
|
||||
data: { data: { results: [{ rows: [{
|
||||
timestamp: '2026-08-06T23:00:03Z',
|
||||
data: {
|
||||
body: 'request',
|
||||
severity_text: 'info',
|
||||
resources_string: { 'service.name': 'cloud' },
|
||||
attributes_string: { 'http.method': 'GET' },
|
||||
attributes_number: { 'http.status_code': 200 },
|
||||
},
|
||||
}] }] } },
|
||||
}
|
||||
const [row] = parseRawRows(body)
|
||||
expect(row.data).toMatchObject({ body: 'request', 'service.name': 'cloud', 'http.method': 'GET', 'http.status_code': 200 })
|
||||
})
|
||||
it('returns [] for empty/garbage/missing shapes (never throws)', () => {
|
||||
expect(parseListRows(null)).toEqual([])
|
||||
expect(parseListRows({})).toEqual([])
|
||||
expect(parseListRows({ data: { result: null } })).toEqual([])
|
||||
expect(parseListRows({ data: { result: [{ list: null }] } })).toEqual([])
|
||||
expect(parseListRows('nope')).toEqual([])
|
||||
expect(parseRawRows(null)).toEqual([])
|
||||
expect(parseRawRows({})).toEqual([])
|
||||
expect(parseRawRows({ data: { results: null } })).toEqual([])
|
||||
expect(parseRawRows({ data: { results: [{ rows: null }] } })).toEqual([])
|
||||
expect(parseRawRows('nope')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -402,15 +410,21 @@ describe('normalizeLogRow / normalizeLogs', () => {
|
||||
expect(l.body).toBe('hi')
|
||||
expect(l.id).toBe('5-3') // ts-idx fallback
|
||||
})
|
||||
it('maps a full query_range logs response, newest-first order preserved', () => {
|
||||
it('maps a full v5 query_range logs response, newest-first order preserved', () => {
|
||||
const body = {
|
||||
data: { result: [{ list: [{ timestamp: '2', data: { body: 'newer' } }, { timestamp: '1', data: { body: 'older' } }] }] },
|
||||
data: { data: { results: [{ rows: [{ timestamp: '2', data: { body: 'newer' } }, { timestamp: '1', data: { body: 'older' } }] }] } },
|
||||
}
|
||||
const rows = normalizeLogs(body)
|
||||
expect(rows.map((r) => r.body)).toEqual(['newer', 'older'])
|
||||
})
|
||||
it('empty result → empty list (honest empty, not a throw)', () => {
|
||||
expect(normalizeLogs({ data: { result: [] } })).toEqual([])
|
||||
it('reads the service from the nested resources_string map (as the runtime emits it)', () => {
|
||||
const body = {
|
||||
data: { data: { results: [{ rows: [{ timestamp: '1', data: { body: 'hi', severity_text: 'INFO', resources_string: { 'service.name': 'iam' } } }] }] } },
|
||||
}
|
||||
expect(normalizeLogs(body)[0]).toMatchObject({ service: 'iam', severity: 'info', body: 'hi' })
|
||||
})
|
||||
it('empty results → empty list (honest empty, not a throw)', () => {
|
||||
expect(normalizeLogs({ data: { data: { results: [] } } })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,8 +446,8 @@ describe('normalizeTraceSpan / normalizeSpans', () => {
|
||||
expect(normalizeTraceSpan({ data: { spanID: 's' } }, 0).durationNano).toBeNull()
|
||||
expect(normalizeTraceSpan({ data: { spanID: 's', durationNano: '' } }, 0).durationNano).toBeNull()
|
||||
})
|
||||
it('maps a full query_range traces response', () => {
|
||||
const body = { data: { result: [{ list: [{ timestamp: '1', data: { traceID: 't', name: 'op' } }] }] } }
|
||||
it('maps a full v5 query_range traces response', () => {
|
||||
const body = { data: { data: { results: [{ rows: [{ timestamp: '1', data: { trace_id: 't', name: 'op' } }] }] } } }
|
||||
expect(normalizeSpans(body)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
+78
-114
@@ -532,122 +532,86 @@ export function normalizeDashboards(body: unknown): Dashboard[] {
|
||||
return rows.map(normalizeDashboard).filter((d) => d.uuid !== '')
|
||||
}
|
||||
|
||||
// ── Logs + Traces (O11y composite query_range) ──────────────────────────────
|
||||
// ── Logs + Traces (O11y v5 composite query_range) ────────────────────────────
|
||||
//
|
||||
// The universal `POST /v1/o11y/query_range` builder query (version-less canonical
|
||||
// surface). A `list`-panel `noop` query over `dataSource: logs | traces` returns RAW
|
||||
// rows (recent log lines / spans), newest first — the one true logs/traces read.
|
||||
// Time is epoch MILLISECONDS = `ApmWindow.startMs/endMs`. Every helper is pure (JSON
|
||||
// in, view-model out) so it unit-tests without a live runtime.
|
||||
// The universal `POST /v1/o11y/query_range` builder query. The runtime behind the
|
||||
// flat path is the module's V5 querier — its composite is `{queries: [{type,
|
||||
// spec}]}` and its decoder is STRICT, so the old v3 `{queryType, panelType,
|
||||
// builderQueries}` envelope is refused outright ("unknown field \"builderQueries\"
|
||||
// in composite query"), which the overview panel rendered as "Could not reach
|
||||
// observability". A `requestType: "raw"` query over `signal: logs | traces`
|
||||
// returns raw rows (recent log lines / spans), newest first — the server's own
|
||||
// default order for raw.
|
||||
//
|
||||
// Two v5 spec features are deliberately NOT sent, verified against the live
|
||||
// runtime: an `order` clause and a `filter` expression both route through
|
||||
// telemetry-metadata key resolution, which currently fails server-side ("failed
|
||||
// to get logs keys", 500) — the runtime's key tables are not reachable from the
|
||||
// embedded store. Raw already returns newest-first without `order`, and service
|
||||
// scoping is enforced by the client-side re-filter below (which these readers
|
||||
// always did as their leak-proofing). When the metadata store heals, the filter
|
||||
// expression (`service.name = '<svc>'`) is the one-line addition.
|
||||
//
|
||||
// Time is epoch MILLISECONDS = `ApmWindow.startMs/endMs`. Every helper is pure
|
||||
// (JSON in, view-model out) so it unit-tests without a live runtime.
|
||||
|
||||
/** The telemetry signal a builder query reads. */
|
||||
export type O11yDataSource = 'logs' | 'traces' | 'metrics'
|
||||
|
||||
/**
|
||||
* One O11y builder-query filter item — the `{key, op, value}` shape the explorer
|
||||
* sends. `key` carries the attribute's name + type so the runtime resolves it
|
||||
* correctly (a resource attribute vs an indexed column).
|
||||
* The v5 `query_range` RAW payload — ONE `builder_query` envelope keyed `A`.
|
||||
* `limit` is clamped into [1,1000] (the page a reader shows). No `order`, no
|
||||
* `filter` — see the section note above for why both stay off the wire today.
|
||||
*/
|
||||
export type QueryFilterItem = {
|
||||
key: { key: string; dataType: 'string'; type: string; isColumn: boolean }
|
||||
op: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a logs/traces list query to ONE OpenTelemetry `service.name`. `service.name`
|
||||
* is a RESOURCE attribute on both signals (on traces it is also materialized as an
|
||||
* indexed column, so `isColumn` is set there) — this is the exact filter item O11y's
|
||||
* own explorer emits for a service-scoped list, so the runtime resolves it and never
|
||||
* 400s. The caller ALSO re-filters the normalized rows client-side (belt-and-suspenders),
|
||||
* so a runtime that ignores the item can never leak another service's rows onto a
|
||||
* per-product page.
|
||||
*/
|
||||
export function serviceFilterItem(dataSource: O11yDataSource, service: string): QueryFilterItem {
|
||||
return {
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource', isColumn: dataSource === 'traces' },
|
||||
op: '=',
|
||||
value: service,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `list`-panel `selectColumns` per signal. The traces v4 list builder HARD-fails
|
||||
* (`select columns cannot be empty for panelType list`, → 500) when a noop list query
|
||||
* carries no `selectColumns`; the query already emits timestamp/spanID/traceID, so
|
||||
* these ADD the display fields `normalizeTraceSpan` reads. Each name is a materialized
|
||||
* static trace column (o11y `StaticFieldsTraces`), so the runtime resolves it verbatim.
|
||||
* Logs list does NOT require selectColumns (its noop path returns the default row set),
|
||||
* so it stays empty — adding trace columns there would reference non-existent log columns.
|
||||
*/
|
||||
const listSelectColumns: Record<O11yDataSource, Array<Record<string, unknown>>> = {
|
||||
traces: [
|
||||
{ key: 'name', dataType: 'string', type: 'tag', isColumn: true },
|
||||
{ key: 'duration_nano', dataType: 'float64', type: 'tag', isColumn: true },
|
||||
{ key: 'response_status_code', dataType: 'string', type: 'tag', isColumn: true },
|
||||
],
|
||||
logs: [],
|
||||
metrics: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact v3 `query_range` LIST payload — ONE `noop` builder query keyed `A`,
|
||||
* newest-first, paged by `offset`/`pageSize`. Mirrors what O11y's own explorer
|
||||
* sends (verified against the frontend + the server `BuilderQuery` struct), so the
|
||||
* runtime never 400s on shape. `filters` (default none) scopes the query — e.g. a
|
||||
* `serviceFilterItem` restricts it to one product's OTel service. `selectColumns`
|
||||
* is REQUIRED by the traces list builder (empty → 500); see `listSelectColumns`.
|
||||
*/
|
||||
export function listQueryPayload(
|
||||
dataSource: O11yDataSource,
|
||||
w: ApmWindow,
|
||||
limit: number,
|
||||
filters: QueryFilterItem[] = [],
|
||||
): Record<string, unknown> {
|
||||
const pageSize = Math.max(1, Math.min(1000, Math.floor(limit)))
|
||||
export function rawQueryPayload(signal: O11yDataSource, w: ApmWindow, limit: number): Record<string, unknown> {
|
||||
const capped = Math.max(1, Math.min(1000, Math.floor(limit)))
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
start: w.startMs,
|
||||
end: w.endMs,
|
||||
step: 60,
|
||||
requestType: 'raw',
|
||||
compositeQuery: {
|
||||
queryType: 'builder',
|
||||
panelType: 'list',
|
||||
builderQueries: {
|
||||
A: {
|
||||
queryName: 'A',
|
||||
dataSource,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
filters: { items: filters, op: 'AND' },
|
||||
selectColumns: listSelectColumns[dataSource],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
limit: null,
|
||||
offset: 0,
|
||||
pageSize,
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', signal, disabled: false, limit: capped, offset: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A `list`-panel query returns rows under `data.result[].list` (newer runtimes
|
||||
* mirror them under `data.newResult.data.result[].list`); each is `{timestamp,data}`. */
|
||||
/** One raw row, its attribute maps flattened so `pick` reads one namespace. */
|
||||
type ListRow = { timestamp?: string | number; data?: Record<string, unknown> | null }
|
||||
|
||||
/** Pull the flat list rows out of either result location, never throwing on shape. */
|
||||
export function parseListRows(body: unknown): ListRow[] {
|
||||
const r = (body ?? {}) as { data?: { result?: unknown; newResult?: { data?: { result?: unknown } } } }
|
||||
const direct = r?.data?.result
|
||||
const nested = r?.data?.newResult?.data?.result
|
||||
const results = (Array.isArray(direct) ? direct : Array.isArray(nested) ? nested : []) as { list?: unknown }[]
|
||||
/**
|
||||
* Pull the raw rows out of a v5 `query_range` response, never throwing on shape.
|
||||
* The envelope is `{status, data: {type: "raw", data: {results: [{queryName,
|
||||
* rows: [{timestamp, data}]}]}}}` (a bare `{data: {results}}` is also accepted).
|
||||
* Each row's `data` nests the OTel attribute maps (`resources_string`,
|
||||
* `attributes_string`, `attributes_number`, `attributes_bool`); they are
|
||||
* flattened over the row's own scalars so downstream readers keep addressing one
|
||||
* flat namespace (`body`, `severity_text`, `service.name`, …) exactly as the v3
|
||||
* list rows carried it.
|
||||
*/
|
||||
export function parseRawRows(body: unknown): ListRow[] {
|
||||
const r = (body ?? {}) as { data?: { data?: { results?: unknown }; results?: unknown } }
|
||||
const results = ([] as unknown[]).concat(
|
||||
(Array.isArray(r?.data?.data?.results) ? r.data.data.results : Array.isArray(r?.data?.results) ? r.data.results : []) as unknown[],
|
||||
) as { rows?: unknown }[]
|
||||
const out: ListRow[] = []
|
||||
for (const res of results) {
|
||||
if (Array.isArray(res?.list)) out.push(...(res.list as ListRow[]).filter((x): x is ListRow => x != null))
|
||||
if (!Array.isArray(res?.rows)) continue
|
||||
for (const raw of res.rows as ListRow[]) {
|
||||
if (raw == null) continue
|
||||
const d = (raw.data ?? {}) as Record<string, unknown>
|
||||
const flat: Record<string, unknown> = { ...d }
|
||||
for (const mapKey of ['resources_string', 'attributes_string', 'attributes_number', 'attributes_bool']) {
|
||||
const m = d[mapKey]
|
||||
if (m && typeof m === 'object' && !Array.isArray(m)) Object.assign(flat, m as Record<string, unknown>)
|
||||
}
|
||||
out.push({ timestamp: raw.timestamp, data: flat })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -691,7 +655,7 @@ export function normalizeLogRow(row: ListRow, idx: number): LogRow {
|
||||
|
||||
/** Normalize a logs `query_range` response → LogRow[] (newest first). */
|
||||
export function normalizeLogs(body: unknown): LogRow[] {
|
||||
return parseListRows(body).map(normalizeLogRow)
|
||||
return parseRawRows(body).map(normalizeLogRow)
|
||||
}
|
||||
|
||||
/** One trace/span row from a `dataSource: traces` list query. */
|
||||
@@ -726,7 +690,7 @@ export function normalizeTraceSpan(row: ListRow, idx: number): TraceSpan {
|
||||
|
||||
/** Normalize a traces `query_range` response → TraceSpan[] (newest first). */
|
||||
export function normalizeSpans(body: unknown): TraceSpan[] {
|
||||
return parseListRows(body).map(normalizeTraceSpan)
|
||||
return parseRawRows(body).map(normalizeTraceSpan)
|
||||
}
|
||||
|
||||
// ── Transport ─────────────────────────────────────────────────────────────────
|
||||
@@ -734,12 +698,11 @@ export function normalizeSpans(body: unknown): TraceSpan[] {
|
||||
const u = (path: string): string => cloudProxyV1Url(`o11y/${path}`)
|
||||
|
||||
// The composite builder query rides the FLAT public path `/v1/o11y/query_range` (one
|
||||
// /v1/, no nested /api/vN). `listQueryPayload` + `parseListRows` are a matched v3 pair
|
||||
// (`compositeQuery.{queryType,builderQueries}` → `data.result[].list`); the cloud
|
||||
// clients/o11y flat route (query.go) resolves this flat path to the v3 engine handler
|
||||
// SERVER-SIDE. (The upstream module's version-less alias would instead resolve to the
|
||||
// HIGHEST engine version (v5), whose composite accepts only `{queries:[…]}` and 400s
|
||||
// the v3 shape — which is exactly why the mapping is pinned in cloud, not here.)
|
||||
// /v1/, no nested /api/vN). The version-less address resolves to the module's HIGHEST
|
||||
// engine — the v5 querier — so `rawQueryPayload` + `parseRawRows` are its matched pair
|
||||
// (`compositeQuery.queries[]` → `data.data.results[].rows`). The v3 pin this comment
|
||||
// used to cite (cloud's query.go) is deleted; a v3 envelope sent here is refused by
|
||||
// the strict v5 decoder, not quietly served.
|
||||
const COMPOSITE_QUERY_RANGE = 'query_range'
|
||||
|
||||
/** The APM POST body — a start/end window + optional tags filter (O11y shape). */
|
||||
@@ -782,19 +745,20 @@ export const ApmApi = {
|
||||
dashboards: async (): Promise<Dashboard[]> => normalizeDashboards(await restGet<unknown>(u('dashboards'))),
|
||||
dashboard: (uuid: string): Promise<unknown> => restGet<unknown>(u(`dashboards/${encodeURIComponent(uuid)}`)),
|
||||
|
||||
// ── Logs + Traces (composite query_range; `/v1/o11y/logs` is a stub) ──
|
||||
// A `service` scopes the query to ONE product's OTel `service.name` (the per-product
|
||||
// Logs sub-page); omit it for the org-wide stream. The rows are re-filtered client-side
|
||||
// to the same service so a runtime ignoring the item can never leak other services' lines.
|
||||
// ── Logs + Traces (v5 raw query_range; `/v1/o11y/logs` is a stub) ──
|
||||
// A `service` scopes the read to ONE product's OTel `service.name` (the
|
||||
// per-product Logs sub-page); omit it for the org-wide stream. Scoping is the
|
||||
// CLIENT-SIDE re-filter — the server-side filter expression is off the wire
|
||||
// while the runtime's key resolution is down (see rawQueryPayload) — so a
|
||||
// scoped read asks for a deeper page (up to the 1000 cap) and keeps what
|
||||
// matches. A row with no service survives the filter, as it always did here.
|
||||
logs: async (w: ApmWindow, limit = 200, service?: string): Promise<LogRow[]> => {
|
||||
const filters = service ? [serviceFilterItem('logs', service)] : []
|
||||
const rows = normalizeLogs(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), listQueryPayload('logs', w, limit, filters)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service) : rows
|
||||
const rows = normalizeLogs(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), rawQueryPayload('logs', w, service ? 1000 : limit)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service).slice(0, limit) : rows
|
||||
},
|
||||
traceSearch: async (w: ApmWindow, limit = 200, service?: string): Promise<TraceSpan[]> => {
|
||||
const filters = service ? [serviceFilterItem('traces', service)] : []
|
||||
const rows = normalizeSpans(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), listQueryPayload('traces', w, limit, filters)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service) : rows
|
||||
const rows = normalizeSpans(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), rawQueryPayload('traces', w, service ? 1000 : limit)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service).slice(0, limit) : rows
|
||||
},
|
||||
|
||||
// ── Per-product service health (RED metrics for ONE product's OTel service) ──
|
||||
|
||||
@@ -110,7 +110,7 @@ export {
|
||||
type RawValidator,
|
||||
type RawPeer,
|
||||
} from './nodes'
|
||||
export { TeamApi } from './team'
|
||||
export { TeamApi, MembershipApi, orgNamesFor, type Membership } from './team'
|
||||
export {
|
||||
PlaygroundApi,
|
||||
type ChatMessage,
|
||||
@@ -283,8 +283,8 @@ export {
|
||||
normalizeIssueDetail,
|
||||
normalizeDashboard,
|
||||
normalizeDashboards,
|
||||
listQueryPayload,
|
||||
parseListRows,
|
||||
rawQueryPayload,
|
||||
parseRawRows,
|
||||
toIso,
|
||||
normalizeLogRow,
|
||||
normalizeLogs,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { orgNamesFor } from './team'
|
||||
|
||||
describe('orgNamesFor — the orgs a person may act in', () => {
|
||||
it('leads with the home org, then the memberships', () => {
|
||||
expect(
|
||||
orgNamesFor('hanzo', [
|
||||
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
|
||||
{ user: 'hanzo/dave', org: 'acme', role: 'member' },
|
||||
]),
|
||||
).toEqual(['hanzo', 'maxpower', 'acme'])
|
||||
})
|
||||
|
||||
it('never repeats the home org when it is also a membership row', () => {
|
||||
expect(
|
||||
orgNamesFor('hanzo', [
|
||||
{ user: 'hanzo/dave', org: 'hanzo', role: 'member' },
|
||||
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
|
||||
]),
|
||||
).toEqual(['hanzo', 'maxpower'])
|
||||
})
|
||||
|
||||
it('is the home org alone when there are no memberships', () => {
|
||||
expect(orgNamesFor('hanzo', [])).toEqual(['hanzo'])
|
||||
})
|
||||
|
||||
it('drops blank names rather than rendering a nameless card', () => {
|
||||
expect(
|
||||
orgNamesFor('hanzo', [
|
||||
{ user: 'hanzo/dave', org: '', role: 'member' },
|
||||
{ user: 'hanzo/dave', org: ' ', role: 'member' },
|
||||
{ user: 'hanzo/dave', org: 'maxpower', role: 'admin' },
|
||||
]),
|
||||
).toEqual(['hanzo', 'maxpower'])
|
||||
})
|
||||
|
||||
// A signed-in person with no resolvable home org still gets an honest empty
|
||||
// list rather than a card named "".
|
||||
it('is empty when there is no home org and no membership', () => {
|
||||
expect(orgNamesFor('', [])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,46 @@ import type { Organization, IamUser, Role } from './admin'
|
||||
* email/OTP is not wired on this deployment). */
|
||||
export type InviteLink = { link: string; org: string; name: string; email: string }
|
||||
|
||||
/** One org a person may act in, with the role they hold there. */
|
||||
export type Membership = { user: string; org: string; role: string }
|
||||
|
||||
/**
|
||||
* The organizations THIS person may act in.
|
||||
*
|
||||
* A person's account lives in ONE tenant; the organizations they work in are a
|
||||
* SET, and the membership rows are that set — the same one the token's `orgs`
|
||||
* claim carries and the same one IAM's policy authorizes reads with. Anything
|
||||
* that lists "your orgs" reads this: deriving the list from the account's owner
|
||||
* instead is how a second org became invisible and a card ended up titled with
|
||||
* the signed-in person's name.
|
||||
*
|
||||
* The caller's HOME org is not necessarily a row here (it is implicit), so
|
||||
* callers union it in — {@link orgNamesFor} does.
|
||||
*/
|
||||
export const MembershipApi = {
|
||||
mine: async (userId: string): Promise<Membership[]> => {
|
||||
const { rows } = await iamList<Membership>('memberships', { user: userId })
|
||||
return rows.filter((m) => m && typeof m.org === 'string' && m.org !== '')
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Every org name the caller can act in, home org FIRST and duplicates removed.
|
||||
* Pure, so the ordering rule is testable without a network: the home org leads
|
||||
* because it is the one a person lands in by default.
|
||||
*/
|
||||
export function orgNamesFor(homeOrg: string, memberships: Membership[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const name of [homeOrg, ...memberships.map((m) => m.org)]) {
|
||||
const n = (name ?? '').trim()
|
||||
if (!n || seen.has(n)) continue
|
||||
seen.add(n)
|
||||
out.push(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const TeamApi = {
|
||||
/** Members of `orgName` (the caller's own org, or any for a global admin). */
|
||||
members: (orgName: string, params: ListParams = {}): Promise<Paged<IamUser>> =>
|
||||
|
||||
@@ -80,6 +80,18 @@ export function iamAccessToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the browser still holds an IAM session to refresh — an access token is
|
||||
* stored, even if the SDK considers it expired (`getAccessToken` returns the raw
|
||||
* stored token). Cheap + synchronous, no network. Used to BOUND the refresh retry:
|
||||
* an anonymous visitor (no stored token) never waits through the backoff, and a
|
||||
* retry stops the moment the session is gone from storage (a revoked token the SDK
|
||||
* cleared cannot be brought back by retrying).
|
||||
*/
|
||||
export function iamHasSession(): boolean {
|
||||
return iamAccessToken() != null
|
||||
}
|
||||
|
||||
/** A valid (auto-refreshed if needed) access token, or null. */
|
||||
export async function iamValidAccessToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
@@ -132,6 +144,47 @@ export function iamSignOut(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to send the browser to END THE SESSION AT THE ISSUER.
|
||||
*
|
||||
* `iamSignOut()` above drops this tab's tokens and nothing else, which is only
|
||||
* half of signing out and is the half nobody notices. The other half is the
|
||||
* `iam_session_id` cookie at hanzo.id: leave it and signing out does not stick,
|
||||
* because the very next thing the app does is bounce to `/signin`, and sign-in
|
||||
* asks the issuer for a code from the EXISTING session. Measured against prod —
|
||||
* clear the tokens the way this module did and silent SSO still answers
|
||||
* `status: ok` with a code, so the user lands straight back in the console they
|
||||
* just left. Clearing the whole session cookie jar is not an option either: it
|
||||
* is set on hanzo.id, and this origin cannot touch it.
|
||||
*
|
||||
* Only the issuer can end the issuer's session, so sign-out has to be a
|
||||
* NAVIGATION there. RP-initiated logout returns the browser to
|
||||
* `post_logout_redirect_uri`, which is why this replaces the `/signin` assign
|
||||
* rather than racing it: one navigation, and it lands where the old one did.
|
||||
*
|
||||
* Verified: after this URL, the same silent-SSO probe answers
|
||||
* "please sign in first" and mints nothing.
|
||||
*/
|
||||
export function iamSignOutUrl(origin: string, returnPath = '/signin'): string {
|
||||
// The origin is a PARAMETER, not `window.location.origin` read in here. Every
|
||||
// other function in this module guards `typeof window === 'undefined'` because
|
||||
// this file is imported by server-rendered code; one that reads `window`
|
||||
// unguarded throws the moment anything touches it during SSR. Taking it as an
|
||||
// argument removes the hazard instead of guarding it, and makes the URL a pure
|
||||
// function of its inputs — testable with no DOM, which is what this repo's
|
||||
// suite runs.
|
||||
const url = new URL('/v1/iam/oauth/logout', config.iamUrl)
|
||||
// Resolve the return against OUR origin, then require it to have stayed there.
|
||||
// An absolute URL wins over a base in `new URL`, so a caller passing a foreign
|
||||
// one would otherwise hand the IdP an open redirect to hand back. A return leg
|
||||
// that left this origin is never what sign-out meant, so it falls back to the
|
||||
// sign-in page rather than being honored.
|
||||
const back = new URL(returnPath, origin)
|
||||
const safe = back.origin === origin ? back : new URL('/signin', origin)
|
||||
url.searchParams.set('post_logout_redirect_uri', safe.toString())
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// -- Graceful re-auth: return the user to their task after re-signing in --------
|
||||
// A mid-task session expiry (a 401) should not dump the user on the home page — we
|
||||
// stash where they were and the callback lands them back there.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// refreshSession is browser-only and delegates to the IAM SDK; mock the SDK wrapper so
|
||||
// the single-flight wiring is exercised in the node test env. resilientRefresh below is
|
||||
// pure over injected deps, so it needs no mock (it never touches iam).
|
||||
vi.mock('./iam', () => ({
|
||||
iamValidAccessToken: vi.fn(),
|
||||
iamHasSession: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
import { resilientRefresh, refreshSession, REFRESH_RETRY_MS } from './refresh'
|
||||
import { iamValidAccessToken } from './iam'
|
||||
|
||||
const noSleep = (_ms: number) => Promise.resolve()
|
||||
|
||||
// The FIX itself — the exact `resilientFetch` injected-deps idiom the API client uses.
|
||||
describe('resilientRefresh — a transient blip self-heals; a dead session does not spin', () => {
|
||||
it('returns true on the first attempt, no retry, no sleep', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue('tok')
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
|
||||
expect(attempt).toHaveBeenCalledTimes(1)
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers a TRANSIENT failure: null then a token → true (the whole point of the fix)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValueOnce(null).mockResolvedValue('tok')
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
|
||||
expect(attempt).toHaveBeenCalledTimes(2)
|
||||
expect(sleep).toHaveBeenCalledTimes(1)
|
||||
expect(sleep).toHaveBeenCalledWith(REFRESH_RETRY_MS[0])
|
||||
})
|
||||
|
||||
it('a genuinely-dead session resolves false only after exhausting the bounded retries', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(false)
|
||||
// one initial attempt + one per backoff slot
|
||||
expect(attempt).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length + 1)
|
||||
expect(sleep).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length)
|
||||
expect(sleep.mock.calls.map((c) => c[0])).toEqual(REFRESH_RETRY_MS)
|
||||
})
|
||||
|
||||
it('never waits through the backoff when there is no session to refresh (anonymous / revoked)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => false, sleep })).toBe(false)
|
||||
expect(attempt).toHaveBeenCalledTimes(1) // one try, then hasSession() false → stop
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops the moment the session disappears mid-retry (a revoked token the SDK cleared)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
const hasSession = vi.fn().mockReturnValueOnce(true).mockReturnValue(false)
|
||||
expect(await resilientRefresh({ attempt, hasSession, sleep })).toBe(false)
|
||||
expect(attempt).toHaveBeenCalledTimes(2) // initial + one retry, then session gone
|
||||
expect(sleep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
const mockAttempt = iamValidAccessToken as ReturnType<typeof vi.fn>
|
||||
|
||||
// The wiring: browser-only + single-flight (concurrent callers share ONE rotation —
|
||||
// load-bearing for a one-time-use rotating refresh token).
|
||||
describe('refreshSession — browser-only, single-flight', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('window', {} as unknown as Window & typeof globalThis)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('is a no-op on the server (no window) — resolves false, never touches the SDK', async () => {
|
||||
vi.unstubAllGlobals() // remove the window stub → typeof window === 'undefined'
|
||||
expect(await refreshSession()).toBe(false)
|
||||
expect(mockAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses concurrent callers onto ONE rotation (the timer + N parallel 401s)', async () => {
|
||||
mockAttempt.mockResolvedValue('tok')
|
||||
const p1 = refreshSession()
|
||||
const p2 = refreshSession()
|
||||
expect(p1).toBe(p2) // same in-flight promise
|
||||
expect(await Promise.all([p1, p2])).toEqual([true, true])
|
||||
expect(mockAttempt).toHaveBeenCalledTimes(1) // one rotation, not two
|
||||
// Settled → a later caller starts a fresh rotation (inflight cleared).
|
||||
expect(await refreshSession()).toBe(true)
|
||||
expect(mockAttempt).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
+59
-15
@@ -11,8 +11,53 @@
|
||||
* the token, the second replays the now-invalid one and 400s, needlessly killing a
|
||||
* healthy session. Sharing ONE in-flight promise means every concurrent caller (the
|
||||
* timer + N parallel 401s) awaits the SAME single rotation.
|
||||
*
|
||||
* RESILIENT, not jumpy. One attempt used to be the whole story: a single transient
|
||||
* failure (a network blip, a 5xx from IAM, a lost rotation race) yielded a bare
|
||||
* `false`, and the caller took that one "no" as a definitive sign-out — the "session
|
||||
* expired, sign in again" card fired mid-task on a hiccup. A transient failure and a
|
||||
* genuinely-dead refresh token are indistinguishable at this layer (the SDK collapses
|
||||
* both to a null token), so we RETRY a bounded few times with short backoff WHILE a
|
||||
* session still exists: a blip self-heals (session preserved, no false eviction), and a
|
||||
* truly-expired session resolves `false` ~1s later — an acceptable delay before the
|
||||
* honest re-auth card. An anonymous visitor (no stored token) never waits through the
|
||||
* backoff (`hasSession` short-circuits on the first miss).
|
||||
*/
|
||||
import { iamValidAccessToken } from './iam'
|
||||
import { iamValidAccessToken, iamHasSession } from './iam'
|
||||
|
||||
/** Backoff before each retry AFTER the first attempt — transient recovery only. Worst
|
||||
* case added before an honest `false` when a stored token is dead: ~1.6s. */
|
||||
export const REFRESH_RETRY_MS = [400, 1200]
|
||||
|
||||
/** Injected dependencies for `resilientRefresh` — real ones in `refreshSession`, fakes
|
||||
* in tests. Mirrors the `ResilientDeps` idiom the API client uses for `resilientFetch`. */
|
||||
export interface RefreshDeps {
|
||||
/** One refresh attempt: a live access token, or null (a TRANSIENT failure OR a
|
||||
* genuinely signed-out state — this layer cannot tell them apart). Never throws. */
|
||||
attempt: () => Promise<string | null>
|
||||
/** True while the browser still holds a session to refresh (else retrying is futile). */
|
||||
hasSession: () => boolean
|
||||
sleep: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure refresh orchestration (over its injected deps): try once, then retry a bounded
|
||||
* few times with backoff — but ONLY while a session still exists. So a network blip /
|
||||
* lost rotation race self-heals (returns true on recovery), while a genuinely signed-out
|
||||
* state resolves false without wasted retries. Bounded by `REFRESH_RETRY_MS`.
|
||||
*/
|
||||
export async function resilientRefresh(deps: RefreshDeps): Promise<boolean> {
|
||||
for (let i = 0; ; i++) {
|
||||
if (await deps.attempt()) return true
|
||||
// First attempt failed. Stop if we've exhausted the budget OR the session is gone
|
||||
// from storage (an anonymous visitor, or a revoked token the SDK cleared — retrying
|
||||
// cannot bring it back). Otherwise wait and retry: the failure may be transient.
|
||||
if (i >= REFRESH_RETRY_MS.length || !deps.hasSession()) return false
|
||||
await deps.sleep(REFRESH_RETRY_MS[i])
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
let inflight: Promise<boolean> | null = null
|
||||
|
||||
@@ -24,19 +69,18 @@ let inflight: Promise<boolean> | null = null
|
||||
export function refreshSession(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return Promise.resolve(false)
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
try {
|
||||
// getValidAccessToken() returns the current token, or transparently runs the
|
||||
// refresh grant when it is expired — the SDK's own single rotation.
|
||||
const token = await iamValidAccessToken()
|
||||
return !!token
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
|
||||
// rotation and one arriving after it starts a fresh one.
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
inflight = resilientRefresh({
|
||||
// getValidAccessToken() returns the current token, or transparently runs the refresh
|
||||
// grant when it is expired — the SDK's own single rotation. iamValidAccessToken wraps
|
||||
// it browser-safe and never throws (a failure is a null token).
|
||||
attempt: iamValidAccessToken,
|
||||
hasSession: iamHasSession,
|
||||
sleep,
|
||||
})
|
||||
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
|
||||
// rotation and one arriving after it starts a fresh one.
|
||||
void inflight.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty
|
||||
|
||||
import { AccountApi, type Account } from '~/lib/api'
|
||||
import { withTimeout } from '~/lib/with-timeout'
|
||||
import { signinRedirect, stashReturnTo, iamSignOut } from './iam'
|
||||
import { signinRedirect, stashReturnTo, iamSignOut, iamSignOutUrl } from './iam'
|
||||
import { refreshSession } from './refresh'
|
||||
import { setCurrentActor } from '~/lib/actor-scope'
|
||||
import { claimReferralOnce, stashReferralCode } from '~/lib/referrals/claim'
|
||||
@@ -133,10 +133,21 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
await AccountApi.signout()
|
||||
iamSignOut()
|
||||
applyAccount(null)
|
||||
// Redirect DETERMINISTICALLY to /signin. A hard navigation is the single source of
|
||||
// truth for "signed out -> /signin" and clears all in-memory state (org scope,
|
||||
// caches, balances) — the same `window.location.assign` the sign-IN path uses.
|
||||
if (typeof window !== 'undefined') window.location.assign('/signin')
|
||||
// Redirect DETERMINISTICALLY, and through the ISSUER. A hard navigation is
|
||||
// still the single source of truth for "signed out -> /signin" — it clears
|
||||
// all in-memory state (org scope, caches, balances) exactly as the sign-IN
|
||||
// path does — but it has to go via RP-initiated logout, which returns here
|
||||
// through post_logout_redirect_uri.
|
||||
//
|
||||
// Assigning '/signin' directly is what made sign-out not stick: the two
|
||||
// calls above end the session HERE, and the `iam_session_id` cookie at the
|
||||
// issuer survives, so /signin's silent SSO immediately mints a code from it
|
||||
// and puts the user back in the console they just left. Measured: same
|
||||
// probe, `status: ok` with a code before this change, "please sign in
|
||||
// first" after.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.assign(iamSignOutUrl(window.location.origin, '/signin'))
|
||||
}
|
||||
}, [applyAccount])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Only the issuer origin matters here; the rest of `config` is irrelevant to the
|
||||
// URL under test, so the mock stays the size of the dependency.
|
||||
vi.mock('~/config', () => ({ config: { iamUrl: 'https://hanzo.id' } }))
|
||||
|
||||
const { iamSignOutUrl } = await import('~/lib/auth/iam')
|
||||
|
||||
const HERE = 'https://console.hanzo.ai'
|
||||
const back = (u: string) => new URL(u).searchParams.get('post_logout_redirect_uri')!
|
||||
|
||||
/**
|
||||
* Signing out has to end the session at the ISSUER, not just in this tab.
|
||||
*
|
||||
* `iamSignOut()` drops this browser's tokens and nothing else. That is half of it,
|
||||
* and the half nobody notices: the `iam_session_id` cookie lives on hanzo.id, this
|
||||
* origin cannot touch it, and the very next thing the app does is bounce to
|
||||
* /signin — which asks the issuer for a code from the EXISTING session. Measured
|
||||
* against production before the fix: clear the tokens the way this module did, and
|
||||
* silent SSO still answered `status: ok` with a code, so the user landed straight
|
||||
* back in the console they had just left.
|
||||
*/
|
||||
describe('sign-out ends the session at the issuer', () => {
|
||||
it('points at RP-initiated logout on the ISSUER, not this origin', () => {
|
||||
const u = new URL(iamSignOutUrl(HERE))
|
||||
expect(u.origin).toBe('https://hanzo.id')
|
||||
expect(u.pathname).toBe('/v1/iam/oauth/logout')
|
||||
})
|
||||
|
||||
it('returns the browser to an absolute URL on THIS origin', () => {
|
||||
// RP-initiated logout redirects; a bare path would be resolved against the
|
||||
// ISSUER, landing the user on hanzo.id/signin instead of the console's.
|
||||
expect(back(iamSignOutUrl(HERE, '/signin'))).toBe(`${HERE}/signin`)
|
||||
})
|
||||
|
||||
it('defaults to /signin, so a caller cannot forget where to land', () => {
|
||||
expect(back(iamSignOutUrl(HERE))).toBe(`${HERE}/signin`)
|
||||
})
|
||||
|
||||
it('refuses a return leg that leaves this origin', () => {
|
||||
// An absolute URL beats a base in `new URL`, so without the check the IdP
|
||||
// would be handed an open redirect to hand back. A foreign return is never
|
||||
// what sign-out meant — fall back to our own sign-in page.
|
||||
expect(back(iamSignOutUrl(HERE, 'https://evil.example.com/steal'))).toBe(`${HERE}/signin`)
|
||||
expect(back(iamSignOutUrl(HERE, '//evil.example.com/steal'))).toBe(`${HERE}/signin`)
|
||||
})
|
||||
|
||||
it('is a pure function of its inputs — no window, so SSR cannot throw', () => {
|
||||
// This module is imported by server-rendered code. Every sibling guards
|
||||
// `typeof window === 'undefined'`; this one has nothing to guard, which is
|
||||
// why the test can run in the repo's node environment at all.
|
||||
expect(typeof globalThis.window).toBe('undefined')
|
||||
expect(() => iamSignOutUrl(HERE)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -54,46 +54,6 @@ export const ALWAYS_ON_PRODUCTS: readonly string[] = [
|
||||
'captable', // the org's capitalization ledger — foundational company surface, every org (peer of 'company')
|
||||
]
|
||||
|
||||
/**
|
||||
* THE LAUNCH SET — what a new signup sees on console.hanzo.ai tonight.
|
||||
*
|
||||
* We are launching with hanzo.chat, hanzo.app and the console, so the console
|
||||
* shows exactly what those need and nothing else. Every other product in the
|
||||
* catalog (the whole cloud: compute, data, network, security, web3, the app
|
||||
* suite, the fleet admin) is BETA — present, routable, and invisible until an
|
||||
* org holds the beta flag. A superadmin always sees everything.
|
||||
*
|
||||
* This is an ALLOW-LIST on purpose: a new product added to the catalog is
|
||||
* hidden by DEFAULT and joins the launch only when someone names it here. The
|
||||
* inverse (a deny-list) leaks every future addition onto a customer's first
|
||||
* screen.
|
||||
*
|
||||
* The set: the AI plane the two products run on, the credential to call it,
|
||||
* the money surfaces, org/account management, and the beta door itself.
|
||||
*/
|
||||
export const LAUNCH_PRODUCTS: readonly string[] = [
|
||||
// the console itself
|
||||
'overview', // the home board
|
||||
'beta-features', // the door to everything else — never behind its own flag
|
||||
// the AI plane hanzo.chat + hanzo.app run on
|
||||
'chat', // hanzo.chat
|
||||
'models', // the model catalog
|
||||
'playground', // try a model
|
||||
'api-keys', // the credential both products call with
|
||||
'usage', // what the AI cost
|
||||
'logs', // the request log for those calls
|
||||
// money
|
||||
'billing',
|
||||
'plans',
|
||||
// org + account
|
||||
'settings',
|
||||
'team',
|
||||
'profile',
|
||||
]
|
||||
|
||||
/** True when a product is part of tonight's launch surface. */
|
||||
export const isLaunchProduct = (id: string): boolean => LAUNCH_PRODUCTS.includes(id)
|
||||
|
||||
/** True when a product is always-on (implicit, never stored in `enabled`). */
|
||||
export const isAlwaysOn = (id: string): boolean => ALWAYS_ON_PRODUCTS.includes(id)
|
||||
|
||||
@@ -130,23 +90,6 @@ export function filterEntitled<T extends { id: string }>(
|
||||
* semantics so the UI can optimistically preview a change with the same result the
|
||||
* backend would compute. Remove wins over add for the same id in one patch.
|
||||
*/
|
||||
/**
|
||||
* Keep only the entries a viewer's BETA standing admits. Pure and generic like
|
||||
* `filterEntitled`, and the same one-predicate rule: a superadmin sees
|
||||
* everything, a beta org sees everything, everyone else loses `beta: true`
|
||||
* entries. Fails CLOSED — callers that have not asked the enablement plane
|
||||
* pass `showBeta: false` and beta surfaces stay hidden.
|
||||
*/
|
||||
export function filterBeta<T extends { id: string; beta?: boolean }>(
|
||||
entries: readonly T[],
|
||||
showBeta: boolean,
|
||||
showAdmin: boolean,
|
||||
): T[] {
|
||||
if (showAdmin || showBeta) return [...entries]
|
||||
// Beta is the COMPLEMENT of the launch set: outside it, or stamped.
|
||||
return entries.filter((e) => isLaunchProduct(e.id) && e.beta !== true)
|
||||
}
|
||||
|
||||
export function nextEnabled(current: readonly string[], patch: EntitlementPatch): string[] {
|
||||
const set = new Set<string>(current)
|
||||
for (const id of patch.add ?? []) if (id) set.add(id)
|
||||
|
||||
+37
-32
@@ -19,9 +19,10 @@
|
||||
* Wiring for the console SPA:
|
||||
* - `host: ''` — SAME-ORIGIN. Events POST to the console's own `/v1/event`. The
|
||||
* client NEVER sends an org/tenant — Cloud stamps it from the validated bearer.
|
||||
* - `getToken` — the signed-in visitor's own Hanzo IAM access token. THIS is what
|
||||
* attributes the stream, and it is the only mechanism that is correct here; see
|
||||
* the note below for why no publishable key is passed.
|
||||
* - `getToken` — the signed-in visitor's own Hanzo IAM access token, falling back
|
||||
* to the publishable key when there is none. The token attributes the stream to
|
||||
* the visitor's own org on every brand host; the key exists so a SIGNED-OUT view
|
||||
* is admitted at all. Token first, key second — see the note below.
|
||||
* - `dsn` (`NEXT_PUBLIC_HANZO_EVENT_DSN`) — the error plane's own credential,
|
||||
* shaped `https://<key>@api.hanzo.ai/v1/sentry/<project>`. Publishable by design
|
||||
* (it ships in the client bundle). Unset → errors are captured and dropped.
|
||||
@@ -51,38 +52,42 @@ function consented(): boolean {
|
||||
return dnt !== '1' && dnt !== 'yes'
|
||||
}
|
||||
|
||||
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
|
||||
// ── The token FIRST, the publishable key only when there is no token ─────────
|
||||
//
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
|
||||
// here.
|
||||
// The signed-in path is unchanged and remains the point: cloud resolves the
|
||||
// tenant from the IAM bearer's OWN owner claim, so each visitor's events land in
|
||||
// THEIR org on every brand host — cloud.hanzo.ai, cloud.lux.cloud,
|
||||
// cloud.zoo.cloud — with no per-brand configuration. The JWT carries the org;
|
||||
// everything downstream is org-scoped by it.
|
||||
//
|
||||
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
|
||||
// this image is brand-agnostic: one build serves cloud.hanzo.ai, cloud.lux.cloud and
|
||||
// cloud.zoo.cloud, with the brand resolved at RUNTIME from the request hostname
|
||||
// (src/config). Baking a key would file every brand's — and every customer's —
|
||||
// traffic into whichever org the key belongs to, which is both wrong data and a
|
||||
// cross-tenant leak. It is the same reason Dockerfile bakes no NEXT_PUBLIC_*.
|
||||
// What changed is the SIGNED-OUT path, which reported nothing at all. A
|
||||
// credential-less POST is refused outright — `401 ingest_key_required`, measured
|
||||
// against the live door with and without a browser Origin, for pageviews and
|
||||
// exceptions alike. (An earlier version of this comment described an "anonymous
|
||||
// lane" that admitted pageview + error under a `$public` tenant and answered 200.
|
||||
// That lane is not implemented in the deployed cloud; the same claim was wrong in
|
||||
// four other repos and is why keyless surfaces were believed to be half-working.)
|
||||
//
|
||||
// Worse, it would be SILENT: @hanzo/event resolves the outgoing credential as
|
||||
// `ingestKey ?? token`, so a key takes PRECEDENCE over the bearer — setting one
|
||||
// would OVERRIDE each signed-in user's own identity rather than supplement it.
|
||||
// So the publishable key is a FALLBACK, never an override. That distinction is
|
||||
// load-bearing: @hanzo/event resolves the outgoing credential as
|
||||
// `ingestKey ?? token`, so passing `ingestKey` would REPLACE each signed-in
|
||||
// user's bearer and file their events under the key's org instead of their own —
|
||||
// on a multi-brand image, that is a cross-tenant defect. Supplying the key
|
||||
// through `getToken` inverts that precedence into `token ?? key`, which is the
|
||||
// order this product actually wants.
|
||||
//
|
||||
// THE COOKIE IS NOT A CREDENTIAL HERE. This file used to claim that posting
|
||||
// same-origin let the first-party session ride along, so signed-in traffic landed
|
||||
// correctly. It does not. That cookie is the casibase session, while cloud resolves
|
||||
// a tenant from a VALIDATED IAM bearer (SanitizeIdentity) — so a cookie-only POST
|
||||
// carries no principal. It is not refused: it silently takes the ANONYMOUS lane,
|
||||
// which files every row under the `$public` tenant (a partition no org can read) and
|
||||
// drops `identify` with a 200 receipt. Production proved it — 498 console rows, all
|
||||
// `$public`, zero identified users.
|
||||
//
|
||||
// `getToken` below is the fix, and it has neither problem: cloud resolves the tenant
|
||||
// from the token's OWN owner claim, so each visitor's events land in THEIR org, on
|
||||
// every brand host, with no per-brand configuration. Logged-out views carry no token
|
||||
// and stay anonymous — the honest outcome for a visitor who has not identified
|
||||
// themselves, and still the open question for the public/marketing faces (closing
|
||||
// that needs a PER-HOST key resolved at RUNTIME, e.g. the `GET /v1/brand?host=`
|
||||
// shape src/config already anticipates; a module-scope const cannot receive it).
|
||||
// THE COOKIE IS NOT A CREDENTIAL HERE. This file once claimed that posting
|
||||
// same-origin let the first-party session ride along. It does not: that cookie is
|
||||
// the casibase session, while cloud resolves a tenant from a VALIDATED IAM bearer
|
||||
// (SanitizeIdentity), so a cookie-only POST carries no principal.
|
||||
|
||||
/**
|
||||
* Publishable ingest key — the SIGNED-OUT credential only. Org-scoped and
|
||||
* write-only by construction, so it is safe in the bundle. Unset → signed-out
|
||||
* views go back to reporting nothing, which is the previous behaviour and not a
|
||||
* crash.
|
||||
*/
|
||||
const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_PUBLISHABLE_KEY?.trim() || undefined
|
||||
|
||||
/** Error-plane credential. Unset → captureError is inert (fail-safe). */
|
||||
const dsn = process.env.NEXT_PUBLIC_HANZO_EVENT_DSN?.trim() || undefined
|
||||
@@ -100,7 +105,7 @@ export const eventClient: Analytics = createAnalytics({
|
||||
// The client calls this at flush time, so a sign-in — and every silent refresh
|
||||
// after it — is picked up with no rebuild. Returns undefined on the server and
|
||||
// when signed out, which is the anonymous path.
|
||||
getToken: () => iamAccessToken() ?? undefined,
|
||||
getToken: () => iamAccessToken() ?? PUBLISHABLE_KEY,
|
||||
dsn,
|
||||
enabled: consented(),
|
||||
})
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterBeta, LAUNCH_PRODUCTS } from '~/lib/entitlements'
|
||||
|
||||
// We launch with hanzo.chat, hanzo.app and the console: the launch set is an
|
||||
// ALLOW-LIST, so everything else is beta by default and a NEW catalog entry is
|
||||
// hidden the day it lands. That default is the whole point — pin it.
|
||||
describe('filterBeta — the launch gate', () => {
|
||||
const entries = [
|
||||
{ id: 'chat' },
|
||||
{ id: 'models' },
|
||||
{ id: 'api-keys' },
|
||||
{ id: 'beta-features' },
|
||||
{ id: 'crm' },
|
||||
{ id: 'gpus' },
|
||||
{ id: 'lux-bridge' },
|
||||
{ id: 'a-product-nobody-has-written-yet' },
|
||||
]
|
||||
|
||||
it('shows the launch set and hides everything else', () => {
|
||||
expect(filterBeta(entries, false, false).map((e) => e.id)).toEqual([
|
||||
'chat',
|
||||
'models',
|
||||
'api-keys',
|
||||
'beta-features',
|
||||
])
|
||||
})
|
||||
|
||||
it('a brand-new catalog entry is hidden by DEFAULT, not by remembering to stamp it', () => {
|
||||
const shown = filterBeta([{ id: 'something-new-2027' }], false, false)
|
||||
expect(shown).toEqual([])
|
||||
})
|
||||
|
||||
it('the flag reveals everything; a superadmin never needed it', () => {
|
||||
expect(filterBeta(entries, true, false)).toHaveLength(entries.length)
|
||||
expect(filterBeta(entries, false, true)).toHaveLength(entries.length)
|
||||
})
|
||||
|
||||
it('the beta door itself is in the launch set — otherwise nobody can opt in', () => {
|
||||
expect(LAUNCH_PRODUCTS).toContain('beta-features')
|
||||
})
|
||||
|
||||
it('a stamped entry inside the launch set can still ship dark', () => {
|
||||
expect(filterBeta([{ id: 'chat', beta: true }], false, false)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* useAppsBeta — does this viewer see the beta (Apps) surfaces?
|
||||
*
|
||||
* ONE source of truth: the enablement plane (`/v1/enablement`), the same
|
||||
* self-service opt-in the Beta features module manages, scoped server-side to
|
||||
* the caller's validated org. The gate looks for the `apps` feature (kind
|
||||
* `feature`, id `apps`) being EFFECTIVE for the org — an admin sets it to
|
||||
* `beta` (optionally granting orgs), users opt in where allowed, and this hook
|
||||
* simply reads the resulting truth.
|
||||
*
|
||||
* Fails CLOSED: until the read answers — and whenever it refuses — beta
|
||||
* surfaces stay hidden. A superadmin always sees them (mirror of the `admin`
|
||||
* gate, and the only way the flag surface itself can be administered when the
|
||||
* plane is down). Cached for the session like the org identity is: every nav
|
||||
* surface asks, one request answers.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { EnablementApi } from '~/lib/api/admin-cockpit'
|
||||
|
||||
const APPS_KIND = 'feature'
|
||||
const APPS_ID = 'apps'
|
||||
|
||||
let cached: boolean | null = null
|
||||
let inflight: Promise<boolean> | null = null
|
||||
|
||||
async function readAppsBeta(): Promise<boolean> {
|
||||
if (cached !== null) return cached
|
||||
if (!inflight) {
|
||||
inflight = EnablementApi.view()
|
||||
.then((v) => {
|
||||
const all = [...v.items, ...v.betas]
|
||||
const hit = all.find((i) => i.kind === APPS_KIND && i.id === APPS_ID)
|
||||
cached = Boolean(hit?.effective)
|
||||
return cached
|
||||
})
|
||||
.catch(() => {
|
||||
// A refusal is not an entitlement. Do not cache it — the next mount
|
||||
// may be after sign-in or after the plane recovers.
|
||||
inflight = null
|
||||
return false
|
||||
})
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function useAppsBeta(isSuperAdmin: boolean): boolean {
|
||||
const [on, setOn] = useState<boolean>(() => isSuperAdmin || cached === true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
setOn(true)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
readAppsBeta().then((v) => {
|
||||
if (live) setOn(v)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [isSuperAdmin])
|
||||
|
||||
return on
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
canonicalSlug,
|
||||
SLUG_ALIASES,
|
||||
BASE_SUBPAGES,
|
||||
baseSubpagesFor,
|
||||
subpageSlug,
|
||||
subpageHref,
|
||||
activeSubpage,
|
||||
@@ -183,24 +184,46 @@ describe('productSubpages — Overview + specifics + uniform base set', () => {
|
||||
const slugs = (e: CatalogEntry, showAdmin = true) => productSubpages(e, showAdmin).map((s) => s.slug)
|
||||
|
||||
it('auto-adds Overview + the base set to a single-screen product', () => {
|
||||
expect(slugs(vpc)).toEqual(['', 'settings', 'status', 'logs', 'metrics'])
|
||||
expect(slugs(vpc)).toEqual(['', 'settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('places a specific between Overview and the base set', () => {
|
||||
// models declares Routing (admin) — visible to an admin, before the base set.
|
||||
expect(slugs(models, true)).toEqual(['', 'routing', 'settings', 'status', 'logs', 'metrics'])
|
||||
expect(slugs(models, true)).toEqual(['', 'routing', 'settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('does NOT duplicate a base slug a product declares as a specific', () => {
|
||||
const withMetrics = mod('x', { subpages: [{ slug: 'metrics', label: 'Metrics' }] })
|
||||
expect(slugs(withMetrics)).toEqual(['', 'metrics', 'settings', 'status', 'logs'])
|
||||
expect(slugs(withMetrics)).toEqual(['', 'metrics', 'settings', 'logs', 'status'])
|
||||
})
|
||||
it('hides an admin-only specific from a customer', () => {
|
||||
expect(slugs(models, false)).toEqual(['', 'settings', 'status', 'logs', 'metrics'])
|
||||
expect(slugs(models, false)).toEqual(['', 'settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('drops a base slug that IS the product — Settings has no Settings child', () => {
|
||||
// The org-Settings product owns the `settings` concept; a base `settings`
|
||||
// sub-page beneath it is the `Settings › Settings` the rail used to show.
|
||||
expect(slugs(mod('settings'))).toEqual(['', 'logs', 'metrics', 'status'])
|
||||
// Same rule for the Observe products named after a base slug.
|
||||
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'metrics', 'status'])
|
||||
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'logs', 'status'])
|
||||
expect(slugs(mod('status'))).toEqual(['', 'settings', 'logs', 'metrics'])
|
||||
})
|
||||
it('fails closed (empty sub-pages) for a non-module entry', () => {
|
||||
expect(productSubpages(nonModule)).toEqual([])
|
||||
})
|
||||
it('BASE_SUBPAGES is exactly Settings · Status · Logs · Metrics', () => {
|
||||
expect(BASE_SUBPAGES.map((s) => s.slug)).toEqual(['settings', 'status', 'logs', 'metrics'])
|
||||
it('BASE_SUBPAGES is exactly Settings · Logs · Metrics · Status', () => {
|
||||
expect(BASE_SUBPAGES.map((s) => s.slug)).toEqual(['settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('a product that IS a base concern never gets a self-referential base tab', () => {
|
||||
// The Settings product: General (index) · Branding, then the base set MINUS
|
||||
// its own 'settings' — no second "Settings" tab of itself (the reported bug).
|
||||
const settings = mod('settings', { indexLabel: 'General', subpages: [{ slug: 'branding', label: 'Branding' }] })
|
||||
expect(slugs(settings)).toEqual(['', 'branding', 'logs', 'metrics', 'status'])
|
||||
// Same one rule for the other three Observe products named after a base slug.
|
||||
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'metrics', 'status'])
|
||||
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'logs', 'status'])
|
||||
expect(slugs(mod('status'))).toEqual(['', 'settings', 'logs', 'metrics'])
|
||||
// The rule is expressed once: baseSubpagesFor drops only the self-named slug.
|
||||
expect(baseSubpagesFor(mod('settings')).map((s) => s.slug)).toEqual(['logs', 'metrics', 'status'])
|
||||
expect(baseSubpagesFor(mod('vpc')).map((s) => s.slug)).toEqual(['settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,7 +231,7 @@ describe('the ONE level-2 nav — one declaration, read by both the rail and the
|
||||
it('names the index after the product when it owns one (Models is a Catalog)', () => {
|
||||
const named = mod('models2', { indexLabel: 'Catalog', subpages: [{ slug: 'blend', label: 'Blend' }] })
|
||||
expect(productSubpages(named).map((s) => s.label)).toEqual([
|
||||
'Catalog', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics',
|
||||
'Catalog', 'Blend', 'Settings', 'Logs', 'Metrics', 'Status',
|
||||
])
|
||||
})
|
||||
it('falls back to Overview when the product does not name its index', () => {
|
||||
@@ -276,6 +299,31 @@ describe('resolveProductView — base sub-pages are the shared per-product view
|
||||
// …but an UNowned base slug on the same product is still the shared view.
|
||||
expect(resolveProductView(cat, mods, ['emb', 'status']).kind).toBe('subpage')
|
||||
})
|
||||
it('the router agrees with the nav: a product IS-that-concern URL is not a base subpage', () => {
|
||||
// Settings (a :tab product): /settings/settings is NOT the shared per-product
|
||||
// Settings view — it falls through to the module (which lands on the index),
|
||||
// so there is never a self-referential Settings screen. Its OTHER base slugs
|
||||
// still render the shared view.
|
||||
const settings = mod('settings', {
|
||||
indexLabel: 'General',
|
||||
subpages: [{ slug: 'branding', label: 'Branding' }],
|
||||
routes: [
|
||||
{ path: '', component: C },
|
||||
{ path: ':tab', component: C },
|
||||
],
|
||||
})
|
||||
const cat = [settings]
|
||||
const mods = cat.map((e) => e as unknown as ProductModule)
|
||||
expect(resolveProductView(cat, mods, ['settings', 'settings']).kind).not.toBe('subpage')
|
||||
expect(resolveProductView(cat, mods, ['settings', 'status']).kind).toBe('subpage')
|
||||
// A single-screen product named after a base slug: the self-URL is an honest
|
||||
// 404 (nothing links there), while its other base slugs render the shared view.
|
||||
const logs = mod('logs')
|
||||
const lcat = [logs]
|
||||
const lmods = lcat.map((e) => e as unknown as ProductModule)
|
||||
expect(resolveProductView(lcat, lmods, ['logs', 'logs']).kind).toBe('notfound')
|
||||
expect(resolveProductView(lcat, lmods, ['logs', 'metrics']).kind).toBe('subpage')
|
||||
})
|
||||
it('stubs a DECLARED non-base specific that has no route yet (Tasks › Queues)', () => {
|
||||
const v = view(['tasks', 'queues'])
|
||||
expect(v.kind).toBe('stub')
|
||||
|
||||
@@ -121,15 +121,30 @@ export const indexSubpage = (entry: CatalogEntry): ProductSubpage =>
|
||||
*/
|
||||
export const BASE_SUBPAGES: ProductSubpage[] = [
|
||||
{ slug: 'settings', label: 'Settings' },
|
||||
{ slug: 'status', label: 'Status' },
|
||||
// Observability trio reads raw → summary: Logs, then Metrics, then Status LAST
|
||||
// (the live-health verdict comes after the signals it is derived from).
|
||||
{ slug: 'logs', label: 'Logs' },
|
||||
{ slug: 'metrics', label: 'Metrics' },
|
||||
{ slug: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
/**
|
||||
* The base sub-pages a product actually gets: the uniform set minus any whose
|
||||
* slug IS the product's own id. A product that already IS one of these concerns
|
||||
* — Settings, Status, Logs, Metrics — must not also carry a base sub-tab bearing
|
||||
* its own name (that is a self-referential duplicate: the Settings product would
|
||||
* show a "Settings" tab of itself). One rule, read by both the nav and the
|
||||
* router, so the two never disagree on whether that tab exists.
|
||||
*/
|
||||
export const baseSubpagesFor = (entry: CatalogEntry): ProductSubpage[] =>
|
||||
BASE_SUBPAGES.filter((b) => b.slug !== entry.id)
|
||||
|
||||
/**
|
||||
* The full ordered level-2 nav for a product: Overview, then its declared
|
||||
* SPECIFIC sub-pages, then the uniform base set (a base slug the product already
|
||||
* declares as a specific is not duplicated). Non-module entries have none.
|
||||
* SPECIFIC sub-pages, then the uniform base set (`baseSubpagesFor` — the uniform
|
||||
* set minus any slug that IS the product's own id, so Settings has no "Settings"
|
||||
* child). A base slug the product already declares as a specific is not duplicated.
|
||||
* Non-module entries have none.
|
||||
*
|
||||
* `showAdmin` gates admin-only specifics (e.g. Models › Routing): a customer
|
||||
* never sees them in the sub-nav (default true keeps every existing caller
|
||||
@@ -140,7 +155,7 @@ export function productSubpages(entry: CatalogEntry, showAdmin = true): ProductS
|
||||
const specifics = (entry.subpages ?? []).filter((s) => s.slug !== '' && (showAdmin || !s.admin))
|
||||
const seen = new Set(specifics.map((s) => s.slug))
|
||||
const out: ProductSubpage[] = [indexSubpage(entry), ...specifics]
|
||||
for (const b of BASE_SUBPAGES) if (!seen.has(b.slug)) out.push(b)
|
||||
for (const b of baseSubpagesFor(entry)) if (!seen.has(b.slug)) out.push(b)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -293,7 +308,7 @@ export function resolveProductView(
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const ownsAsSpecific = (entry.subpages ?? []).some((s) => s.slug === seg)
|
||||
const base = BASE_SUBPAGES.find((s) => s.slug === seg)
|
||||
const base = baseSubpagesFor(entry).find((s) => s.slug === seg)
|
||||
if (base && !ownsAsSpecific) return { kind: 'subpage', entry, subpage: base }
|
||||
}
|
||||
}
|
||||
@@ -306,7 +321,7 @@ export function resolveProductView(
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const declared = (entry.subpages ?? []).find((s) => s.slug === seg)
|
||||
const base = BASE_SUBPAGES.find((s) => s.slug === seg)
|
||||
const base = baseSubpagesFor(entry).find((s) => s.slug === seg)
|
||||
const sp = declared ?? base
|
||||
if (sp) return { kind: 'stub', entry, subpage: sp }
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
type CategoryOpen,
|
||||
} from './nav-accordion'
|
||||
|
||||
const ctx = (filtering = false) => ({ filtering })
|
||||
|
||||
describe('categoryIsOpen (expand-by-default)', () => {
|
||||
it('defaults to EXPANDED for an untouched category (nothing auto-collapses)', () => {
|
||||
expect(categoryIsOpen({}, 'AI', ctx())).toBe(true)
|
||||
expect(categoryIsOpen({}, 'Observe', ctx())).toBe(true)
|
||||
expect(categoryIsOpen({ Data: false }, 'AI', ctx())).toBe(true) // untouched section stays open
|
||||
})
|
||||
|
||||
it('respects an explicit COLLAPSE, and stays where the user left it', () => {
|
||||
expect(categoryIsOpen({ Observe: false }, 'Observe', ctx())).toBe(false)
|
||||
// a re-opened section (explicit true) stays open
|
||||
expect(categoryIsOpen({ Observe: true }, 'Observe', ctx())).toBe(true)
|
||||
})
|
||||
|
||||
it('each section is INDEPENDENT — collapsing one leaves the others expanded', () => {
|
||||
const stored: CategoryOpen = { Observe: false }
|
||||
expect(categoryIsOpen(stored, 'Observe', ctx())).toBe(false)
|
||||
expect(categoryIsOpen(stored, 'AI', ctx())).toBe(true)
|
||||
expect(categoryIsOpen(stored, 'Platform', ctx())).toBe(true)
|
||||
})
|
||||
|
||||
it('opens every group while filtering, so a search match is never hidden', () => {
|
||||
expect(categoryIsOpen({ AI: false }, 'AI', ctx(true))).toBe(true)
|
||||
expect(categoryIsOpen({ Observe: false }, 'Observe', ctx(true))).toBe(true)
|
||||
})
|
||||
|
||||
it('restores the stored/default state once filtering clears', () => {
|
||||
const stored: CategoryOpen = { AI: false, Data: true }
|
||||
expect(categoryIsOpen(stored, 'AI', ctx(false))).toBe(false)
|
||||
expect(categoryIsOpen(stored, 'Data', ctx(false))).toBe(true)
|
||||
expect(categoryIsOpen(stored, 'Compute', ctx(false))).toBe(true) // untouched → open
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleCategory (independent per-section)', () => {
|
||||
it('collapses a default-open (untouched) category on first toggle', () => {
|
||||
expect(toggleCategory({}, 'AI')).toEqual({ AI: false })
|
||||
})
|
||||
|
||||
it('re-opens an explicitly-collapsed category', () => {
|
||||
expect(toggleCategory({ AI: false }, 'AI')).toEqual({ AI: true })
|
||||
})
|
||||
|
||||
it('round-trips: toggling twice returns to the default-open state (stored true)', () => {
|
||||
const once = toggleCategory({}, 'Observe')
|
||||
expect(once).toEqual({ Observe: false })
|
||||
const twice = toggleCategory(once, 'Observe')
|
||||
expect(twice).toEqual({ Observe: true })
|
||||
expect(categoryIsOpen(twice, 'Observe', ctx())).toBe(true)
|
||||
})
|
||||
|
||||
it('NEVER touches other sections (independent — not single-open)', () => {
|
||||
// collapsing AI leaves an already-collapsed Observe collapsed and everything else default-open
|
||||
expect(toggleCategory({ Observe: false }, 'AI')).toEqual({ Observe: false, AI: false })
|
||||
// opening a second section does NOT collapse the first (the old single-open invariant is gone)
|
||||
expect(toggleCategory({ Observe: false, Data: false }, 'AI')).toEqual({ Observe: false, Data: false, AI: false })
|
||||
})
|
||||
|
||||
it('is immutable — never mutates the input state', () => {
|
||||
const stored: CategoryOpen = { AI: false }
|
||||
const next = toggleCategory(stored, 'AI')
|
||||
expect(stored).toEqual({ AI: false })
|
||||
expect(next).not.toBe(stored)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productIsOpen / toggleProduct', () => {
|
||||
// Where you are is the one product whose options you are certain to want.
|
||||
it('opens the ACTIVE product and leaves the others closed', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: false, active: true })).toBe(true)
|
||||
expect(productIsOpen({}, 'models', { filtering: false, active: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('respects an explicit choice over the active default, in both directions', () => {
|
||||
expect(productIsOpen({ agents: false }, 'agents', { filtering: false, active: true })).toBe(false)
|
||||
expect(productIsOpen({ models: true }, 'models', { filtering: false, active: false })).toBe(true)
|
||||
})
|
||||
|
||||
// The filter narrows PRODUCTS; a matched product's sub-pages are not themselves
|
||||
// matches, so expanding them would push the other hits off screen.
|
||||
it('closes everything while filtering, active or not', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
expect(productIsOpen({ agents: true }, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
})
|
||||
|
||||
// Whichever way the chevron points, the click does that.
|
||||
it('first click on the active one collapses it; on any other one expands it', () => {
|
||||
expect(toggleProduct({}, 'agents', { active: true })).toEqual({ agents: false })
|
||||
expect(toggleProduct({}, 'models', { active: false })).toEqual({ models: true })
|
||||
})
|
||||
|
||||
it('leaves every other product untouched and never mutates the input', () => {
|
||||
const stored = { models: true }
|
||||
expect(toggleProduct(stored, 'agents', { active: false })).toEqual({ models: true, agents: true })
|
||||
expect(stored).toEqual({ models: true })
|
||||
})
|
||||
|
||||
// Products and categories share a preference SHAPE but not a default, and they are
|
||||
// stored under different keys — a product must never inherit a category's open-by-default.
|
||||
it('is keyed apart from the category accordion', () => {
|
||||
expect(NAV_PRODUCT_OPEN_PREF).not.toBe(NAV_OPEN_PREF)
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Sidebar category accordion — the pure open/collapse model for the level-1
|
||||
* product nav. Each CATEGORY is an INDEPENDENTLY collapsible section; this module
|
||||
* holds the tiny decision logic (what renders open, how a toggle mutates it) with
|
||||
* NO React, so it is unit-testable in isolation and the shell (`Dashboard`)
|
||||
* stays a thin binding over it.
|
||||
*
|
||||
* EXPAND-BY-DEFAULT: every category renders EXPANDED by default — nothing
|
||||
* auto-collapses, so the whole product catalog reads at a glance (OBSERVE, PLATFORM,
|
||||
* DEV, APPS, SETTINGS, … all open). A user may explicitly COLLAPSE any section (the
|
||||
* optional per-section chevron); that ONE choice is persisted per-user (account-backed
|
||||
* + localStorage cache) via `usePreferences` under `NAV_OPEN_PREF` and RESPECTED on
|
||||
* every render — the section stays exactly where the user left it. While FILTERING,
|
||||
* every section opens so a search match is never hidden behind a collapsed section.
|
||||
*
|
||||
* This is NOT a single-open accordion: collapsing one section leaves the others
|
||||
* untouched (each is independent), so the default is a fully-expanded nav.
|
||||
*/
|
||||
|
||||
/** The user's EXPLICIT per-section open/closed choices (sparse). A MISSING key = the
|
||||
* default (OPEN). A stored `false` = the user collapsed that section; `true` = the
|
||||
* user re-opened one they'd collapsed. Kept as a Record for preference-shape stability. */
|
||||
export type CategoryOpen = Partial<Record<string, boolean>>
|
||||
|
||||
/** Preference key (account-backed prefs) for the accordion open-state. */
|
||||
export const NAV_OPEN_PREF = 'navCategoriesOpen'
|
||||
|
||||
/** A stable empty reference for the prefs fallback (avoids a fresh object per read,
|
||||
* which would otherwise re-trigger memo/effect deps downstream). */
|
||||
export const EMPTY_OPEN: CategoryOpen = {}
|
||||
|
||||
/**
|
||||
* Whether a category renders EXPANDED, given the user's stored choices + context:
|
||||
* - while FILTERING: always open, so a match is never hidden behind a collapsed
|
||||
* section (the group list is already narrowed to non-empty matches);
|
||||
* - otherwise: the user's EXPLICIT choice if they made one, else the DEFAULT (OPEN).
|
||||
* A section the user never touched is open; one they collapsed stays collapsed
|
||||
* (and one they re-opened stays open) — it stays where the user left it.
|
||||
*/
|
||||
export function categoryIsOpen(
|
||||
stored: CategoryOpen,
|
||||
category: string,
|
||||
ctx: { filtering: boolean },
|
||||
): boolean {
|
||||
if (ctx.filtering) return true
|
||||
const v = stored[category]
|
||||
return v === undefined ? true : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a single category (pure + immutable — never mutates the input). Each
|
||||
* section is INDEPENDENT (NOT single-open): toggling one leaves every other one
|
||||
* exactly as it was. A section with no stored choice is OPEN by default, so its
|
||||
* first toggle COLLAPSES it (stores `false`); toggling again re-opens it (`true`).
|
||||
*/
|
||||
export function toggleCategory(stored: CategoryOpen, category: string): CategoryOpen {
|
||||
const current = stored[category] === undefined ? true : stored[category]
|
||||
return { ...stored, [category]: !current }
|
||||
}
|
||||
|
||||
// ── Products, which expand IN PLACE ─────────────────────────────────────────
|
||||
//
|
||||
// A product with sub-pages expands beneath its own row, so its options appear
|
||||
// without the rest of the catalog disappearing. This replaced a DRILL — clicking a
|
||||
// product used to swap the entire rail for that product's sub-nav, behind a "Back to
|
||||
// all products" button. The options were the same either way; what the drill took
|
||||
// away was every other product, which is exactly what a person needs to see when the
|
||||
// reason they clicked was to compare or to move on somewhere else.
|
||||
//
|
||||
// The default here is the OPPOSITE of a category's, and deliberately: categories are
|
||||
// few and describe the whole catalog, so they open; products are many and each brings
|
||||
// four to eight rows, so opening them all would bury the catalog under its own detail.
|
||||
|
||||
/** Preference key for which products are expanded in the rail. */
|
||||
export const NAV_PRODUCT_OPEN_PREF = 'navProductsOpen'
|
||||
|
||||
/**
|
||||
* Whether a product's sub-pages render EXPANDED:
|
||||
* - while FILTERING: closed. The filter narrows PRODUCTS, and a matched product's
|
||||
* sub-pages are not themselves matches — expanding them would push the other hits
|
||||
* off screen;
|
||||
* - the ACTIVE product: open, unless the user explicitly collapsed it. Where you are
|
||||
* is the one place whose options you are certain to want;
|
||||
* - otherwise: the user's explicit choice, else CLOSED.
|
||||
*/
|
||||
export function productIsOpen(
|
||||
stored: CategoryOpen,
|
||||
id: string,
|
||||
ctx: { filtering: boolean; active: boolean },
|
||||
): boolean {
|
||||
if (ctx.filtering) return false
|
||||
const v = stored[id]
|
||||
return v === undefined ? ctx.active : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one product's expansion (pure + immutable). The stored value is what the
|
||||
* product is being toggled AWAY from, so the first click on the active product
|
||||
* collapses it and the first click on any other one expands it — in both cases the
|
||||
* click does the thing the chevron was pointing at.
|
||||
*/
|
||||
export function toggleProduct(stored: CategoryOpen, id: string, ctx: { active: boolean }): CategoryOpen {
|
||||
const current = stored[id] === undefined ? ctx.active : stored[id]
|
||||
return { ...stored, [id]: !current }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { categoryIsOpen, toggleCategory, type CategoryOpen } from './nav'
|
||||
|
||||
describe('categoryIsOpen (expand-by-default)', () => {
|
||||
it('defaults to EXPANDED for an untouched category (nothing auto-collapses)', () => {
|
||||
expect(categoryIsOpen({}, 'AI')).toBe(true)
|
||||
expect(categoryIsOpen({}, 'Observe')).toBe(true)
|
||||
expect(categoryIsOpen({ Data: false }, 'AI')).toBe(true) // untouched section stays open
|
||||
})
|
||||
|
||||
it('respects an explicit COLLAPSE, and stays where the user left it', () => {
|
||||
expect(categoryIsOpen({ Observe: false }, 'Observe')).toBe(false)
|
||||
// a re-opened section (explicit true) stays open
|
||||
expect(categoryIsOpen({ Observe: true }, 'Observe')).toBe(true)
|
||||
})
|
||||
|
||||
it('each section is INDEPENDENT — collapsing one leaves the others expanded', () => {
|
||||
const stored: CategoryOpen = { Observe: false }
|
||||
expect(categoryIsOpen(stored, 'Observe')).toBe(false)
|
||||
expect(categoryIsOpen(stored, 'AI')).toBe(true)
|
||||
expect(categoryIsOpen(stored, 'Platform')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleCategory (independent per-section)', () => {
|
||||
it('collapses a default-open (untouched) category on first toggle', () => {
|
||||
expect(toggleCategory({}, 'AI')).toEqual({ AI: false })
|
||||
})
|
||||
|
||||
it('re-opens an explicitly-collapsed category', () => {
|
||||
expect(toggleCategory({ AI: false }, 'AI')).toEqual({ AI: true })
|
||||
})
|
||||
|
||||
it('round-trips: toggling twice returns to the default-open state (stored true)', () => {
|
||||
const once = toggleCategory({}, 'Observe')
|
||||
expect(once).toEqual({ Observe: false })
|
||||
const twice = toggleCategory(once, 'Observe')
|
||||
expect(twice).toEqual({ Observe: true })
|
||||
expect(categoryIsOpen(twice, 'Observe')).toBe(true)
|
||||
})
|
||||
|
||||
it('NEVER touches other sections (independent — not single-open)', () => {
|
||||
// collapsing AI leaves an already-collapsed Observe collapsed and everything else default-open
|
||||
expect(toggleCategory({ Observe: false }, 'AI')).toEqual({ Observe: false, AI: false })
|
||||
// opening a second section does NOT collapse the first (the old single-open invariant is gone)
|
||||
expect(toggleCategory({ Observe: false, Data: false }, 'AI')).toEqual({ Observe: false, Data: false, AI: false })
|
||||
})
|
||||
|
||||
it('is immutable — never mutates the input state', () => {
|
||||
const stored: CategoryOpen = { AI: false }
|
||||
const next = toggleCategory(stored, 'AI')
|
||||
expect(stored).toEqual({ AI: false })
|
||||
expect(next).not.toBe(stored)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The sidebar's persisted view model — what the rail SHOWS and what is OPEN, as
|
||||
* pure values with NO React, so the decisions are unit-testable in isolation and
|
||||
* the shell (`Dashboard`) stays a thin binding over them.
|
||||
*
|
||||
* EXPAND-BY-DEFAULT: every category renders EXPANDED by default — nothing
|
||||
* auto-collapses, so the whole product catalog reads at a glance (OBSERVE, PLATFORM,
|
||||
* DEV, APPS, SETTINGS, … all open). A user may explicitly COLLAPSE any section (the
|
||||
* optional per-section chevron); that ONE choice is persisted per-user (account-backed
|
||||
* + localStorage cache) via `usePreferences` under `NAV_OPEN_PREF` and RESPECTED on
|
||||
* every render — the section stays exactly where the user left it.
|
||||
*
|
||||
* This is NOT a single-open accordion: collapsing one section leaves the others
|
||||
* untouched (each is independent), so the default is a fully-expanded nav.
|
||||
*/
|
||||
|
||||
/** The user's EXPLICIT per-section open/closed choices (sparse). A MISSING key = the
|
||||
* default (OPEN). A stored `false` = the user collapsed that section; `true` = the
|
||||
* user re-opened one they'd collapsed. Kept as a Record for preference-shape stability. */
|
||||
export type CategoryOpen = Partial<Record<string, boolean>>
|
||||
|
||||
/** Preference key (account-backed prefs) for the accordion open-state. */
|
||||
export const NAV_OPEN_PREF = 'navCategoriesOpen'
|
||||
|
||||
/** A stable empty reference for the prefs fallback (avoids a fresh object per read,
|
||||
* which would otherwise re-trigger memo/effect deps downstream). */
|
||||
export const EMPTY_OPEN: CategoryOpen = {}
|
||||
|
||||
/**
|
||||
* Whether a category renders EXPANDED: the user's EXPLICIT choice if they made one,
|
||||
* else the DEFAULT (OPEN). A section the user never touched is open; one they
|
||||
* collapsed stays collapsed (and one they re-opened stays open) — it stays where
|
||||
* the user left it.
|
||||
*/
|
||||
export function categoryIsOpen(stored: CategoryOpen, category: string): boolean {
|
||||
const v = stored[category]
|
||||
return v === undefined ? true : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a single category (pure + immutable — never mutates the input). Each
|
||||
* section is INDEPENDENT (NOT single-open): toggling one leaves every other one
|
||||
* exactly as it was. A section with no stored choice is OPEN by default, so its
|
||||
* first toggle COLLAPSES it (stores `false`); toggling again re-opens it (`true`).
|
||||
*/
|
||||
export function toggleCategory(stored: CategoryOpen, category: string): CategoryOpen {
|
||||
const current = stored[category] === undefined ? true : stored[category]
|
||||
return { ...stored, [category]: !current }
|
||||
}
|
||||
|
||||
// ── Whether the rail lists the catalog ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preference key: does the rail list EVERY product, or only the ones you keep?
|
||||
*
|
||||
* ON (the default) the rail is the whole catalog — every product the viewer may
|
||||
* SEE. Permission decides that (admin surfaces stay hidden; a brand shows only its
|
||||
* own categories); what the org has ENABLED does not, because every product is
|
||||
* available to every org on demand. The All-products panel had already settled this
|
||||
* for itself and the rail had not, so one catalog came back two different sizes
|
||||
* depending which you asked.
|
||||
*
|
||||
* OFF narrows the rail to the org's enabled set, plus what you pinned and wherever
|
||||
* you are — just what you work with. Nothing is unreachable either way: the search
|
||||
* box at the head of the rail asks the whole catalog, and "All products" at its foot
|
||||
* lists all of it.
|
||||
*/
|
||||
export const NAV_CATALOG_PREF = 'navCatalog'
|
||||
@@ -109,7 +109,7 @@ import { Users,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config, type BrandId, type ShellId } from '~/config'
|
||||
import { ALWAYS_ON_PRODUCTS, filterBeta, filterEntitled, isLaunchProduct } from '~/lib/entitlements'
|
||||
import { ALWAYS_ON_PRODUCTS, filterEntitled } from '~/lib/entitlements'
|
||||
import { type ProductCategory, categoryOrder, categoriesForBrand, categoryInBrand } from './brand-scope'
|
||||
import { shellFor, isProductShell } from './shell'
|
||||
import { ProvidersModule } from '~/components/products/ProvidersModule'
|
||||
@@ -421,14 +421,6 @@ type CatalogBase = {
|
||||
docs?: string
|
||||
/** Admin-gated surface (shown with a lock hint; access enforced server-side). */
|
||||
admin?: boolean
|
||||
/**
|
||||
* Beta (early-access) product — hidden from every nav, palette, discovery
|
||||
* panel and search until the caller's ORG holds the `apps` beta through the
|
||||
* enablement plane (kind `feature`, id `apps`) — the same self-service
|
||||
* opt-in the Beta features module manages. Superadmins always see them, and
|
||||
* the gate fails CLOSED: no enablement read, no beta surfaces.
|
||||
*/
|
||||
beta?: boolean
|
||||
/**
|
||||
* Per-brand scope — the brands whose console shows this entry (`entryInBrandScope`).
|
||||
* OMIT for a brand-agnostic entry (the default: shown on every brand its category
|
||||
@@ -1279,7 +1271,6 @@ export const catalog: CatalogEntry[] = [
|
||||
status: 'enabled',
|
||||
brands: ['hanzo'],
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/automations`,
|
||||
kind: 'module',
|
||||
routes: [
|
||||
{ path: '', component: AutomationsModule },
|
||||
@@ -1380,7 +1371,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'AI',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/knowledge`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: KnowledgeModule }],
|
||||
},
|
||||
@@ -1713,7 +1703,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Network',
|
||||
status: 'enabled',
|
||||
repo: 'luxfi/node',
|
||||
docs: `${DOCS}/nodes`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: NodesModule }],
|
||||
},
|
||||
@@ -2472,7 +2461,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Observe',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/o11y',
|
||||
docs: `${DOCS}/apm`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: ServiceMapModule }],
|
||||
},
|
||||
@@ -2611,7 +2599,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Observe',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/finance',
|
||||
docs: `${DOCS}/finance`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: FinanceModule }],
|
||||
},
|
||||
@@ -3225,7 +3212,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Apps',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/cms`,
|
||||
kind: 'module',
|
||||
routes: [
|
||||
{ path: '', component: CmsModule },
|
||||
@@ -3266,7 +3252,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Apps',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/helpdesk`,
|
||||
kind: 'module',
|
||||
routes: [
|
||||
{ path: '', component: HelpModule },
|
||||
@@ -3818,17 +3803,6 @@ export const catalogByCategory = (): { category: ProductCategory; entries: Catal
|
||||
/** An admin-only (global / Hanzo-managed) entry — hidden from a customer's nav. */
|
||||
export const isAdminEntry = (e: CatalogEntry): boolean => e.admin === true
|
||||
|
||||
/**
|
||||
* A BETA entry — hidden until the org holds the beta flag (or is a superadmin).
|
||||
*
|
||||
* Beta is the COMPLEMENT of the launch set, not a per-entry stamp: we are
|
||||
* launching with hanzo.chat, hanzo.app and the console, so everything outside
|
||||
* `LAUNCH_PRODUCTS` is beta by default and a new catalog entry is hidden the
|
||||
* day it lands. `beta: true` still forces the flag on for an entry inside the
|
||||
* launch set, which is how a launch surface can ship dark.
|
||||
*/
|
||||
export const isBetaEntry = (e: CatalogEntry): boolean => e.beta === true || !isLaunchProduct(e.id)
|
||||
|
||||
/**
|
||||
* Per-brand category scope — the ONE knob that makes each brand's console show
|
||||
* the right surfaces. `hanzo` is the full AI cloud. The sovereign-chain brands
|
||||
@@ -3862,13 +3836,7 @@ export const inBrand = (e: CatalogEntry): boolean =>
|
||||
// former `BILLING_CENTER_ID`/`MARKETING_ID`/`ADS_ID`/`SOCIAL_ID` per-mode consts were
|
||||
// collapsed into it (a name is a value in one namespace, no parallel id constants).
|
||||
|
||||
export const visibleCatalog = (
|
||||
showAdmin: boolean,
|
||||
enabled?: string[] | null,
|
||||
// Fails CLOSED on purpose: a caller that has not asked the enablement plane
|
||||
// does not show beta surfaces.
|
||||
showBeta = false,
|
||||
): CatalogEntry[] => {
|
||||
export const visibleCatalog = (showAdmin: boolean, enabled?: string[] | null): CatalogEntry[] => {
|
||||
// Product-shell face (billing / marketing / ads / social / sentry host, or an
|
||||
// override): the SAME console image, scoped to ONE product FACE — its root module
|
||||
// surfaced alone. Bypass the brand-category + entitlement scope so the face shows on
|
||||
@@ -3884,7 +3852,7 @@ export const visibleCatalog = (
|
||||
// belong to their face, not the general nav (e.g. the sentry panels are the o11y
|
||||
// surfaces' Sentry twin, shown only on sentry.<brand>). marketing/ads/social carry
|
||||
// NO `e.shell` (normal Apps products), so they ALSO show in the full console.
|
||||
const byAdmin = filterBeta(showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)), showBeta, showAdmin)
|
||||
const byAdmin = (showAdmin ? catalog : catalog.filter((e) => !isAdminEntry(e)))
|
||||
.filter((e) => !e.shell)
|
||||
.filter(inBrand)
|
||||
// ENTITLEMENT GATE (customer only): out-of-box an org sees ONLY the products it has
|
||||
@@ -3898,9 +3866,8 @@ export const visibleCatalog = (
|
||||
export const visibleCatalogByCategory = (
|
||||
showAdmin: boolean,
|
||||
enabled?: string[] | null,
|
||||
showBeta = false,
|
||||
): { category: ProductCategory; entries: CatalogEntry[] }[] => {
|
||||
const visible = visibleCatalog(showAdmin, enabled, showBeta)
|
||||
const visible = visibleCatalog(showAdmin, enabled)
|
||||
// In a product-shell face the root module IS the whole catalog — surface it as a
|
||||
// single group regardless of the brand's category order (its category may be
|
||||
// outside the brand's normal set). ONE branch for EVERY face.
|
||||
|
||||
@@ -87,14 +87,14 @@ function scoreDestination(q: string, d: Destination): number {
|
||||
* deep sub-page jumps ("queues" → Compute › Tasks › Queues). `showAdmin` gates
|
||||
* admin-only surfaces so a customer can't jump to what they can't see.
|
||||
*/
|
||||
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null, showBeta = false): Destination[] {
|
||||
export function searchDestinations(query: string, showAdmin = true, enabled?: string[] | null): Destination[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
// Scope to the visible catalog (brand + billing-only shell + entitlements), then
|
||||
// gate admin — so ⌘K jumps match exactly what the nav shows (billing-only offers
|
||||
// only billing; a customer only what their org has enabled). A TYPED query is
|
||||
// DISCOVERY: the entitlement scope opens to the whole catalog — searching is
|
||||
// for finding what you do not have yet — while admin and beta keep holding.
|
||||
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled, showBeta), showAdmin)
|
||||
// for finding what you do not have yet — while the admin gate keeps holding.
|
||||
const all = destinationsFor(visibleCatalog(showAdmin, q ? null : enabled), showAdmin)
|
||||
if (!q) return all.filter((d) => d.kind === 'product')
|
||||
return all
|
||||
.map((d) => ({ d, s: scoreDestination(q, d) }))
|
||||
|
||||
@@ -246,6 +246,17 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
'gpus',
|
||||
'fleet',
|
||||
'clusters',
|
||||
// Sandboxes (cloud apps/sandbox): the org's leased gVisor pods — lease/list/get/
|
||||
// end, exec, fs, and the ticket that opens an interactive terminal
|
||||
// (/v1/sandboxes[/:id[/exec|/fs|/terminal]]). Same gate as the rest: the handler
|
||||
// resolves the org from the Bearer owner and answers 403 without one, and an id
|
||||
// belonging to another org is a 404.
|
||||
//
|
||||
// The terminal's SOCKET does not come through here and cannot: a Next route
|
||||
// handler proxies requests, not upgrades. The browser dials the API host
|
||||
// directly, carrying the single-use ticket this proxy fetched for it — which is
|
||||
// the whole reason the ticket exists.
|
||||
'sandboxes',
|
||||
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
|
||||
// (/v1/vpcs[/:id], /v1/balancers[/:id]).
|
||||
'vpcs',
|
||||
|
||||
Reference in New Issue
Block a user