Compare commits

...
3 Commits
Author SHA1 Message Date
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
18 changed files with 348 additions and 76 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console",
"version": "8.5.68",
"version": "8.5.71",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "MIT OR Apache-2.0",
+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 }}
@@ -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) })
}
+23 -6
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
@@ -291,13 +297,24 @@ export function WorkbenchDock() {
)}
<Button size="$2" chromeless icon={<Activity size={16} />} onPress={() => openTo('overview')} aria-label="API activity" />
<Button size="$2" chromeless icon={<ScrollText size={16} />} onPress={() => openTo('logs')} aria-label="Recent API logs" />
{/* The assistant's home on desktop — mic starts it listening, the brand mark
opens it. The floating bubble is hidden at lg+ (this is where it lives);
it stays on phones, where this dock is not shown. */}
{voiceOk ? (
<Button size="$2" chromeless icon={<Mic size={16} />} onPress={startVoice} aria-label="Talk to Hanzo" />
) : null}
<Button
size="$2"
icon={open ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
onPress={() => set('workbenchOpen', !open)}
aria-label={open ? 'Collapse the workbench' : 'Open the workbench'}
bg="$color4"
borderWidth={1}
borderColor="$borderColor"
icon={<BrandMark size={16} />}
onPress={openChat}
aria-label="Ask Hanzo"
>
{open ? 'Hide' : 'Open'}
<Text fontSize="$2" fontWeight="700" color="$color12">
AI
</Text>
</Button>
</XStack>
</YStack>
+1
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: () => {},
+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: () => {},
+11 -1
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'
@@ -100,7 +102,15 @@ 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
+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: () => {},
+12
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
+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
}
+48
View File
@@ -11,6 +11,7 @@ import {
canonicalSlug,
SLUG_ALIASES,
BASE_SUBPAGES,
baseSubpagesFor,
subpageSlug,
subpageHref,
activeSubpage,
@@ -196,12 +197,34 @@ describe('productSubpages — Overview + specifics + uniform base set', () => {
it('hides an admin-only specific from a customer', () => {
expect(slugs(models, false)).toEqual(['', 'settings', 'status', 'logs', 'metrics'])
})
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(['', 'status', 'logs', 'metrics'])
// Same rule for the Observe products named after a base slug.
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'status', 'metrics'])
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'status', 'logs'])
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('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', 'status', 'logs', 'metrics'])
// Same one rule for the other three Observe products named after a base slug.
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'status', 'metrics'])
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'status', 'logs'])
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(['status', 'logs', 'metrics'])
expect(baseSubpagesFor(mod('vpc')).map((s) => s.slug)).toEqual(['settings', 'status', 'logs', 'metrics'])
})
})
describe('the ONE level-2 nav — one declaration, read by both the rail and the strip', () => {
@@ -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')
+18 -5
View File
@@ -126,10 +126,23 @@ export const BASE_SUBPAGES: ProductSubpage[] = [
{ slug: 'metrics', label: 'Metrics' },
]
/**
* 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 +153,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 +306,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 +319,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 }
}