Compare commits

..
Author SHA1 Message Date
hanzo-dev e35aa8c693 the rail shows one level, and the catalog is not an allow-list
Hanzo CI/CD / cicd (push) Successful in 4m27s
CI/CD / cicd (push) Successful in 4m32s
Five defects, and each was one thing said twice.

LEVEL. A product's pages expanded indented beneath its row while the whole
catalog stayed painted below, so two levels shared the screen — and a pinned
product appeared twice, one copy carrying the pages and the other inert. The
level is a replacement now, and a function of the route alone: the category (the
way back up), the product as a heading, its pages flush, then its siblings.
Drops NAV_PRODUCT_OPEN_PREF, productIsOpen, toggleProduct, the rule that picked
which duplicate row was real, and the inert accordion. One SubRow serves the
full console and the product-shell face, which had its own copy and a private
icon map that was a subset of the shared one.

CATALOG. filterBeta kept only LAUNCH_PRODUCTS for anyone who was not a
superadmin: 13 of 185 products, 6 of 14 categories, behind a word ("beta") that
no entry actually carried. Gone, with isLaunchProduct, isBetaEntry,
CatalogEntry.beta, useAppsBeta and the showBeta argument threaded through the
catalog, the search, the rail, the palette and the panel. That left the panel
saying every product is always available while the rail still filtered by the
org's enabled set — one catalog, two sizes. navCatalog settles it: on, the rail
is everything the viewer may see; off, it narrows to the enabled set plus pins.
Permission holds either way. An org whose entitlement plane answers {enabled:[]}
now sees all eleven categories instead of collapsing to the essentials.

SEARCH. The rail's own text filter narrowed only the rows already drawn — a
second search beside the header palette and a third copy in the drawer. All
three are CommandSearchBox now, so the rail asks the whole catalog from either
level.

IDENTITY. The org's name was replaced by its logo whenever IAM carried one,
surviving only in the aria-label; the logo is the mark now and the name is the
label. The account menu was @hanzo/iam's UserMenu — a second rendering system in
one rail, raw DOM through an injected global stylesheet, a circle where the org
wore a square. Both ends are one Menu on @hanzo/gui. Identity still comes from
IAM. The balance leaves the menu: SidebarWallet shows the same number one row
below, with more. One orgLabel, so the trigger and the row for the same org
cannot read differently, and SidebarBrand stops wearing the house glyph for an
org that has no logo of its own.

paper declared bordered and left the width at zero, so every anchored sheet met
the page with no edge; the menu took no z-layer, so on a phone it opened inside
the account sheet unclickable, and upward off the top of the screen.

3340 unit tests, 22 browser tests across rail, account-menu, level-2-nav and
find-and-do. e2e/rail.spec.ts measures the flush left edge, the absent
neighbours, the single naming of the product, and the two triggers' shared box.
2026-08-07 09:04:46 -07:00
zooqueen 129599fe9e a card states the role it was granted, not one inferred from the account
Hanzo CI/CD / cicd (push) Successful in 6m9s
CI/CD / cicd (push) Successful in 6m10s
roleFor's last branch carried a comment calling itself unreachable —
'a non-global-admin only ever sees their own org' — and listing
memberships is exactly what made it reachable. Every joined org then
rendered 'Member', including ones the person administers: measured
live, dave is admin of maxpower and his maxpower card said Member.

The membership row is consulted first, because once a person can see
more than one org, inferring a role from their account is a guess.
2026-08-07 06:18:09 -07:00
zooqueen a28b0e796d restore the OrgMark import the two concurrent fixes cancelled
Hanzo CI/CD / cicd (push) Successful in 9m54s
CI/CD / cicd (push) Successful in 10m24s
Two sessions repaired the same duplicate import at once — one dropped
the first declaration, the other dropped the second — and rebasing
applied both, so the switcher used OrgMark twice while importing it
zero times. tsc says it plainly (TS2304 Cannot find name 'OrgMark');
the bundler would have said it again.

Verified AFTER the rebase this time, which is the actual lesson: a
check that ran before the merge proves nothing about what landed.
2026-08-06 21:23:59 -07:00
zooqueen a1a13cdc25 the switcher imports OrgMark once, so the console can build again
Hanzo CI/CD / cicd (push) Failing after 1m41s
CI/CD / cicd (push) Failing after 1m51s
2b5786685 added a second 'import { OrgMark, SearchInput }' beside the
existing OrgMark import in ContextSwitcher, and webpack refuses a
re-declared identifier: 'Module parse failed: Identifier OrgMark has
already been declared'. Every console image since has failed at
build:embed, so nothing has shipped — the tests and typecheck never saw
it because both run before the bundler does.

One import, both names.
2026-08-06 21:22:42 -07:00
hanzo-dev 60b906b448 console: one OrgMark import, so the bundle compiles
Hanzo CI/CD / cicd (push) Successful in 4m43s
CI/CD / cicd (push) Successful in 4m56s
The file already imported OrgMark for the trigger; adding it again for the rows
re-declared the identifier. tsc did not object, webpack did — the bundle is the
gate here.
2026-08-06 21:18:38 -07:00
zooqueen 49d11bb9bc the picker lists the orgs you belong to, and names them by the org
Hanzo CI/CD / cicd (push) Failing after 2m29s
CI/CD / cicd (push) Failing after 2m47s
A non-super caller got ONE card, synthesized from the session, titled
with account.displayName — the signed-in PERSON where the organization
belongs, with the person's initials as its mark. A customer who works
in a second workspace could not see it at all, because the list was
derived from the account's owner rather than from the memberships that
actually say which orgs a person may act in.

Now it reads the membership set (home org first, deduped — pure, so the
ordering is tested without a network) and loads each org's own record,
so a card carries the ORG's name and logo. IAM authorizes a member to
read the orgs they belong to as of v1.34.26, which is what makes the
per-org read answer instead of 403. A row that still cannot be read
degrades to its slug rather than dropping an org the person is entitled
to see.
2026-08-06 21:17:32 -07:00
hanzo-dev 2b5786685d console: the org switcher wears marks, and names the way into its own settings
Hanzo CI/CD / cicd (push) Failing after 1m18s
CI/CD / cicd (push) Failing after 1m27s
The switcher listed orgs as bare text while the account menu beside it wore an
avatar, so two rows of the same chrome read as two different products. Each org
now shows the ONE shared OrgMark the account widget already uses.

An org with no logo wore its initial. It now wears the HOST's brand — the Hanzo
mark on console.hanzo.ai, whatever a white-labelled host resolves to elsewhere —
so a workspace reads as its own from the first sign-in rather than as a letter.
Uploading a logo still overrides it.

Branding was reachable only by knowing the URL; the switcher now says where it
is.
2026-08-06 21:01:41 -07:00
zooqueen a33acca87f console 8.5.78 — re-release the terminal dock (8.5.77's image OOM'd in CI)
Hanzo CI/CD / cicd (push) Successful in 5m59s
CI/CD / cicd (push) Successful in 6m9s
No code change from ff9d42e0 (the dock that frames cloud's /v1/sandboxes/:id/terminal
page); the 8.5.77 build died on the ARC runner, so this re-triggers a clean build.
2026-08-06 20:50:11 -07:00
zooqueen ff9d42e0ba console 8.5.77 — the dock frames the terminal cloud serves
Hanzo CI/CD / cicd (push) Successful in 13m2s
CI/CD / cicd (push) Successful in 13m2s
Cloud now serves the terminal whole — emulator, socket, resize, reconnect, one
self-contained page at the same address as the socket — so the console frames it
instead of building one. That is not laziness about an emulator: 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 @xterm leaves this repo's dependencies entirely.

What is left here is the only part that was ever the console's: which sandbox,
and what to say while it comes up. The credential too — 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 and thirty seconds is what makes that safe.

The dock names its tmux session, so reopening it reattaches to the shell it left
rather than opening a fresh one over the user's work.

Readiness is a HANDSHAKE, not a guess: the page posts {source:'hanzo-term'} when
its socket opens, and only the frame we opened, from the API origin, is allowed
to speak for it. Without a deadline on that message a frame that failed into
something else is indistinguishable from one still loading, and the dock would
sit on "Starting…" forever instead of offering the reconnect that fixes it.

Ticket path follows cloud: /v1/sandboxes/:id/terminal/ticket.
2026-08-06 19:07:57 -07:00
zooqueen a70a66b1cc the level-2 spec asserted the sub-nav order the code stopped shipping
Hanzo CI/CD / cicd (push) Successful in 5m59s
CI/CD / cicd (push) Successful in 10m27s
f6df104ec8 moved Status to the end of BASE_SUBPAGES and updated match-core.test.ts,
but this browser spec carries its own copy of the expected labels and was missed. It
asserted Settings · Status · Logs · Metrics, so it failed against every build from
8.5.75 on — measured live on console.hanzo.ai, where the deployed bundle serves
Settings · Logs · Metrics · Status.

The spec is right to hold the whole list rather than just the tail: it is the one
place the ORDER a phone actually paints is checked, and a partial assertion is how
the order drifted in the first place.
2026-08-06 19:00:22 -07:00
zooqueen 344bb1cfc2 console 8.5.76 — cloud shell in the Developers dock
Hanzo CI/CD / cicd (push) Successful in 22m58s
CI/CD / cicd (push) Successful in 22m59s
The Shell tab was an explorer wearing a terminal's clothes: a `$` prompt that
took `GET /v1/models` and printed the response. Nothing could be RUN in it, so
the prompt was a promise the dock could not keep. It is a terminal now — a login
shell on a pty inside the org's own sandbox, over
/v1/sandboxes/:id/terminal/ws. Whatever the image carries, the hanzo CLI
included, is a command the user types; nothing here decides what may run, because
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 socket leaves the origin, once and deliberately. Every other read goes
through the same-origin /v1 proxy, which mints a user-bound bearer server-side —
and a WebSocket cannot use it, because that proxy is a route handler and a route
handler forwards requests, not upgrades. So the two halves split: the ticket is
fetched through the proxy exactly like every other call, and the socket dials the
API host carrying it. 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.

`sandboxes` joins CLOUD_HEADS so the ticket can be fetched at all. The socket is
not and cannot be proxied — see the note beside the entry.

Which sandbox is "mine" is a QUERY, not a stored id: one live sandbox per project
is the server's own rule, so reopening the dock finds the running one and a
reload has nothing to go stale. The `console` project names the volume, which is
what makes it the same shell tomorrow.

Honest states throughout — "Starting your cloud shell…", then the terminal, then
a calm Disconnected with the reason and a Reconnect that restarts the whole
session as a unit. Never a fake prompt.

xterm loads in the browser and not at import: it reads `document` as it
constructs and this component is server-rendered first. Its element is always
laid out and the status covers it, because an element that is display:none has no
size and the fit that runs when the socket opens would hand the shell an 80x24
window it never corrects.

The /v1 explorer's parser goes with it — `parseCommand` and `hanzoCli` had one
caller each and it was the tab this replaces.
2026-08-06 18:45:18 -07:00
zooqueen 4d17b91b31 console 8.5.75 — the release that carries the org mark, the sub-page icons and Status-last
Hanzo CI/CD / cicd (push) Successful in 21m15s
CI/CD / cicd (push) Successful in 21m15s
f6df104ec8 (org mark in the switcher, the seven billing sub-page icons, and the
Settings · Logs · Metrics · Status order) has been on main unreleased: the pin
still names 8.5.73, so none of it is serving.

.74 is skipped rather than reused. A v8.5.74 tag was already cut at a commit that
PREDATES f6df104ec8, and a version number is an identity — reusing it would make
one name mean two trees, which is the same class of mistake as `8.5.35` naming
both a git tag with no image and an image with no v. .75 is free in both.

The image publishes bare (`8.5.75`), matching what charts/app/values/hanzo/console.yaml
already reads; the git tag keeps the repo's v-prefix. Two namespaces, one number.
2026-08-06 18:42:32 -07:00
zooqueen 7b97b6752b console 8.5.73 — drop 7 dead Docs links (apm/automations/cms/finance/helpdesk/knowledge/nodes)
Hanzo CI/CD / cicd (push) Successful in 8m40s
CI/CD / cicd (push) Successful in 8m41s
Seven products' Docs button deep-linked to docs.hanzo.ai/docs/<slug> pages that
404 (verified live: baseline docs root + a known-good slug return 200, these seven
return 404). The docs-lane redirects v8.4.93 expected for apm/cms/helpdesk/nodes
never landed. Same dead-button class as the duplicate Settings tab.

Fix (registry data only, the v8.4.87 pattern): drop the docs: field from those
seven entries. The consumers read entry.docs ?? config.docsUrl, so the button now
opens the docs home (a real 200 page) instead of a dead deep link — zero 404. The
other 62 docs deep links are verified 200 and unchanged; no inline module link
targets the removed slugs.

tsc clean; vitest 3345 passed / 8 skipped.
2026-08-06 18:16:13 -07:00
zooqueenandhanzo-dev 139d7fc6f9 console: signed-out views can report now — token first, key second
Hanzo CI/CD / cicd (push) Successful in 6m11s
CI/CD / cicd (push) Successful in 6m12s
Signed-out console reported nothing. Not partially: a credential-less POST
to the ingest door is refused outright, `401 ingest_key_required`,
measured live with and without a browser Origin, for pageviews and
exceptions alike.

The signed-in path is untouched and is still the point. Cloud resolves the
tenant from the IAM bearer's own owner claim, so a 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
and everything downstream is scoped by it.

The key is supplied through `getToken`, NOT as `ingestKey`, and that is the
whole design. @hanzo/event resolves the outgoing credential as
`ingestKey ?? token`: passing `ingestKey` would REPLACE a signed-in user's
bearer and file their events under the key's org instead of their own,
which on a multi-brand image is a cross-tenant defect. Feeding it through
getToken inverts the precedence to `token ?? key`, so the key can only ever
fill the gap where no token exists and can never displace one.

This file also carried the claim that a keyless beacon degrades to an
"anonymous lane" admitting pageview and error under a $public tenant with a
200 receipt. That lane is not implemented in the deployed cloud. The same
claim appeared in four other repos and is why keyless surfaces were
believed to be half-working when they were reporting nothing at all;
corrected here too.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 18:15:07 -07:00
zooqueen f6df104ec8 console: org mark in the switcher, sub-page icons, Status-last nav order
Hanzo CI/CD / cicd (push) Successful in 5m58s
CI/CD / cicd (push) Successful in 5m58s
- Org switcher (ContextSwitcher) leads with the shared @hanzo/ui OrgMark — the
  same mark SidebarBrand and the account widget wear — so an org with no uploaded
  logo (e.g. Hanzo) shows its monogram instead of a bare name. White-label safe.
  (The user switcher, AccountMenu, already IS the shared @hanzo/iam UserMenu that
  hanzo.chat mounts — the two shared widgets are now aligned.)
- Every level-2 sub-page has a real icon: expanded the ONE shared SUBPAGE_ICON map
  with reports/accounts/budgets/invoices/subscriptions/payment-methods/credits, so
  Billing (and any product reusing those slugs) drops the quiet Circle fallback.
- Uniform sub-nav tail reordered Settings · Logs · Metrics · Status — Status last
  (the live-health verdict follows the signals it's derived from). One order, every
  product. Tests updated to the new order (196 pass).
2026-08-06 17:43:13 -07:00
zooqueen db164c8470 sign out ends the session at the issuer, not just in this tab
Hanzo CI/CD / cicd (push) Successful in 4m48s
CI/CD / cicd (push) Successful in 4m49s
Reported twice as "sign out is not working". The first fix was on hanzo.id's
portal; this is console, and it is the MIRROR IMAGE of that bug, which is why
the first fix changed nothing here.

hanzo.id called the IdP logout and forgot local storage. Console does the
opposite: iamSignOut() clears this browser's tokens and nothing else, then
assigns /signin. The iam_session_id cookie lives on hanzo.id and this origin
cannot touch it, so it survives -- and /signin's first act is to ask the issuer
for a code from the EXISTING session. Measured against production, same probe
either side of the sign-out:

    signed in                            status: ok, code minted
    after console-style sign-out         status: ok, code minted
    after a real RP-initiated logout     "please sign in first", nothing

So the user landed straight back in the console they had just left. The server
end was never broken; nothing ever asked it to end anything.

Sign-out is now ONE navigation, through RP-initiated logout, returning via
post_logout_redirect_uri to the same /signin the old assign targeted. The two
local calls stay -- they clear this tab immediately, which the redirect cannot.

Two details the shape depends on:

  - The origin is a PARAMETER, not window.location.origin read inside. This
    module is imported by server-rendered code and every sibling guards
    `typeof window === 'undefined'`; a function that reads window unguarded
    throws the moment SSR touches it. Passing it removes the hazard rather than
    guarding it, and makes the URL a pure function of its inputs -- testable in
    this repo's node environment, which has no DOM at all.

  - The return leg must not leave this origin. An absolute URL beats a base in
    `new URL`, so without the check a caller could hand the IdP an open redirect
    to hand back. A foreign return is never what sign-out meant, so it falls
    back to our own sign-in page.

Guard is known to bite: dropping the same-origin check fails that case by name
while the other four stay green.
2026-08-06 17:19:54 -07:00
zooqueen 6976badf4e the profile photo the access token was never going to carry
Reported as "I still don't see the profile photo in the Hanzo console". It was
not a stale deploy and not the upload: account.avatar was undefined for every
user, always, so the menu fell back to initials no matter what anyone uploaded.

session() resolves identity from the access token's own claims:

    const claims = decodeJwtClaims(token) ?? (await iamUserInfo())

The IAM access token IS a JWT, so decodeJwtClaims never returns null and the
userinfo arm is dead code. And IAM publishes the avatar on `picture` in the
USERINFO response ONLY (internal/oidc/userinfo.go putIf(info,"picture",u.Avatar),
under the profile scope the console already requests) -- deliberately, because
an avatar is a bounded data URI and carrying it would add kilobytes to every
JWT on every request. hanzoai/app's lib/profile.ts writes it under that same
contract from the other side: "surfaces on the picture claim in the USERINFO
response only, never in the ID token".

So the token is authoritative about who you are and silent about your picture,
and we were reading only the token. Both halves are right on their own; nothing
joined them.

The token stays the identity source -- it is self-contained, costs no round
trip, and is immune to getUserInfo() answering null on a 200, which once
dead-ended the session into a /signin loop. One extra fetch fills the one claim
it omits, best-effort: a failure, a 401, or a user with no photo all resolve to
"no avatar", never to a broken session. Cached by identity, and the MISS is
cached too -- a user with no photo should cost one request, not one per load --
and dropped on sign-out so the next account never wears the previous face.

Ruled out first, so the diagnosis is not a guess: CSP allows data: on all three
hosts (measured); the photo does reach IAM (hanzo.app writes user.avatar
through the confidential client, one store, not a second copy); and the
userinfo claim is gated only on the `profile` scope, which console requests.

Guard is known to bite: reverting to the token-only read fails 3 of the 5 by
name, including the neighbour cases (token-carried avatar still wins; a failing
userinfo still yields a session). 3268 pass, typecheck clean.
2026-08-06 17:19:54 -07:00
zooqueen 85b7e6accb 8.5.72 — the static base serves the index in place
Hanzo CI/CD / cicd (push) Successful in 7m50s
CI/CD / cicd (push) Successful in 7m51s
hanzoai/static v0.5.7 (digest 46b9a9b3…) answers / with the index bytes
instead of a 301 to /index.html, so the address bar stops carrying the
internal filename and the breadcrumb stops reading 'Home > index.html'.
2026-08-06 17:08:10 -07:00
zooqueen f464508249 o11y: the logs/traces read speaks v5 — builderQueries dies with the v3 pin it cited
Hanzo CI/CD / cicd (push) Successful in 9m4s
CI/CD / cicd (push) Successful in 9m6s
The flat /v1/o11y/query_range now resolves to the module's v5 querier,
whose strict composite refuses the v3 envelope outright — the overview's
observability panel rendered that 400 as 'Could not reach observability'.
rawQueryPayload/parseRawRows are the matched v5 pair: requestType raw,
one builder_query envelope, rows under data.data.results[].rows with the
OTel attribute maps flattened back into the one namespace the row
readers always addressed.

Order and filter stay OFF the wire deliberately: both route through
telemetry-metadata key resolution, which the deployed runtime cannot
serve ('failed to get logs keys'), while raw is newest-first by default
— verified live. Service scoping remains the client-side re-filter these
readers always ran; a scoped read asks for the deepest page instead.
When the metadata store heals, the filter expression is a one-line
return.
2026-08-06 17:04:23 -07:00
zooqueen d37dc23580 console 8.5.71 — honest empty logs, one Settings crumb, the assistant in the dock
Hanzo CI/CD / cicd (push) Successful in 7m54s
CI/CD / cicd (push) Successful in 7m55s
- Logs: a managed capability with no dedicated service shows the logs surface in
  its calm empty state ("no logs yet"), not a dead "No product log source" card.
  The "nothing is fabricated / not placeholder data / never placeholder" self-talk
  is removed from the Logs, Status, RuntimeNotice and observability copy — an empty
  state just says what it is.
- Breadcrumbs: drop the category crumb when it only repeats the product's own name
  (no more Home / Settings / Settings / logs), and label a trailing base sub-page
  from the product's own nav (… / Logs, not the raw `logs` slug).
- Developers dock: the mic + Ask-Hanzo launcher live in the bar on desktop; the
  floating bubble is hidden at lg+ (its home is the dock there) and kept on phones.
  The redundant Open/Hide chevron is gone.
2026-08-06 13:30:23 -07:00
zooqueen 38eb16f391 console 8.5.70 — silent refresh is resilient, no false 'session expired'
Hanzo CI/CD / cicd (push) Successful in 11m28s
CI/CD / cicd (push) Successful in 11m29s
The console signs you in with a short-lived IAM access token and silently
refreshes it (a proactive timer at ~80% of the token's life + a reactive 401 ->
refresh). Both paths funnel through the ONE single-flight refreshSession, which
ran exactly ONE refresh attempt: a single transient failure (a network blip, a
5xx from IAM, a lost rotation race) returned a bare false, and the caller took
that one 'no' as a definitive sign-out — so 'session expired, sign in again'
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 refreshSession now
retries a bounded few times with short backoff WHILE a session still exists
(new iamHasSession): a blip self-heals (session preserved, no false eviction),
and a truly-expired session resolves false ~1s later before the honest re-auth
card. An anonymous visitor (no stored token) never waits through the backoff.

The retry orchestration is the pure, injected-deps resilientRefresh — the same
idiom the API client's resilientFetch uses — so it is unit-testable in isolation.
AccountApi.session() reuses it at boot, so a transient IAM blip at load no longer
dumps a signed-in user on the sign-in card either. Single-flight preserved.

tsc clean; vitest 3334 passed / 8 skipped (+7 refresh: transient-recovery,
bounded-retry-exhaust, no-session-no-spin, mid-retry-stop, browser-only,
single-flight).
2026-08-06 13:16:25 -07:00
zooqueen e1ff150012 console 8.5.69 — one Settings, one Logs, one Metrics, one Status
Hanzo CI/CD / cicd (push) Successful in 13m54s
CI/CD / cicd (push) Successful in 13m55s
A product whose own id is a base sub-page concern (Settings, Status, Logs,
Metrics) was also getting a base sub-tab of that same name, so the Settings
product's level-2 nav read General · Branding · Settings · Status · Logs ·
Metrics — a self-referential Settings tab of itself. Same duplicate on the
Logs/Metrics/Status Observe products.

One rule, read by both the nav and the router (baseSubpagesFor): a product
never gets a base sub-tab bearing its own id. The nav drops the duplicate; the
router agrees (a /settings/settings URL lands on the index, never a second
Settings screen).
2026-08-06 13:04:02 -07:00
hanzo-dev a47de37ff5 console 8.5.68 — the release number the registry has left
Hanzo CI/CD / cicd (push) Successful in 5m35s
CI/CD / cicd (push) Successful in 5m35s
8.5.63 cannot name this build. It is already a tag in ghcr.io/hanzoai/console,
pushed against commit 8a7ae9be, and a v* tag build publishes the v-stripped
alias beside the tag it was given (hanzoai/ci build.yml: VER="${REL#v}"), so
cutting v8.5.63 would move `8.5.63` off those bytes and onto these. One name,
two digests, is the single thing imgver and pin.sh are both built to prevent —
pin.sh resolves tag and digest from ONE registry answer precisely so a name
cannot come to mean two images, and nodes run pullPolicy: IfNotPresent, so a
re-pointed tag reaches only the nodes that never pulled it.

The number is therefore the next one free above the registry, not the next one
free after the last release: main-push builds now mint a derived semver on
every push (imgver: max(declared, published) + a patch), so 8.5.60 through
8.5.67 are spent, 8.5.67 being this branch's own base. 8.5.68 is unused as both
`v8.5.68` and `8.5.68`, which is what a cut tag needs, and declaring it here
keeps the manifest and the release the same number.
2026-08-06 11:36:01 -07:00
62 changed files with 2537 additions and 1476 deletions
+17 -1
View File
@@ -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"]
+110
View File
@@ -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.
+6 -4
View File
@@ -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
View File
@@ -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)
}
+267
View File
@@ -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()
})
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+1 -3
View File
@@ -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.
+16 -7
View File
@@ -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
View File
@@ -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>
)
}
+7 -5
View File
@@ -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 }}
+61 -19
View File
@@ -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(
+14 -5
View File
@@ -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
+33
View File
@@ -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')
})
})
+19 -5
View File
@@ -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 inthat 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'
}
+29 -1
View File
@@ -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&apos;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 -2
View File
@@ -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) })
}
+120
View File
@@ -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" />
}
+16
View File
@@ -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. */
+3
View File
@@ -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
+202
View File
@@ -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>
)
}
+24 -7
View File
@@ -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>
+24 -31
View File
@@ -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')
})
})
+31 -40
View File
@@ -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)}`
)
}
+9 -114
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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
+1
View File
@@ -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(),
+1
View File
@@ -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: () => {},
+97
View File
@@ -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 accounts 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()
})
})
+1
View File
@@ -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
View File
@@ -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')
+1
View File
@@ -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: () => {},
+24 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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) ──
+3 -3
View File
@@ -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,
+43
View File
@@ -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([])
})
})
+40
View File
@@ -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>> =>
+53
View File
@@ -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.
+93
View File
@@ -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
View File
@@ -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
}
+16 -5
View File
@@ -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 (
+55
View File
@@ -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()
})
})
-57
View File
@@ -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
View File
@@ -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(),
})
-46
View File
@@ -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([])
})
})
-67
View File
@@ -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
}
+55 -7
View File
@@ -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')
+21 -6
View File
@@ -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 }
}
-116
View File
@@ -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)
})
})
-105
View File
@@ -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 }
}
+56
View File
@@ -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)
})
})
+68
View File
@@ -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'
+4 -37
View File
@@ -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.
+3 -3
View File
@@ -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) }))
+11
View File
@@ -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',