Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37dc23580 | ||
|
|
38eb16f391 | ||
|
|
e1ff150012 |
+1
-1
@@ -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",
|
||||
|
||||
@@ -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't
|
||||
instrumented yet, no lines appear — never placeholder data.
|
||||
Application logs from the Hanzo o11y runtime (OTLP), scoped to{' '}
|
||||
{hasService ? (
|
||||
<>
|
||||
service <Text fontSize="$1" color="$color11" fontWeight="600">{o11yService}</Text> and your organization. Lines
|
||||
appear here as {entry.label} emits them.
|
||||
</>
|
||||
) : (
|
||||
<>your organization. Lines appear here as {entry.label} emits them.</>
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
@@ -169,20 +174,34 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Honest "connected · no logs in window" — o11y answered, this service ships no OTLP logs yet. */
|
||||
function NoLogs({ entry, range, service }: { entry: CatalogEntry; range: string; service: string }) {
|
||||
/**
|
||||
* Empty logs surface. Two shapes, one card:
|
||||
* - a product WITH a service, whose window came back empty → "no logs in the last <range>";
|
||||
* - a managed capability with no dedicated service → a calm "no logs yet".
|
||||
*/
|
||||
function NoLogs({ entry, range, service }: { entry: CatalogEntry; range: string | null; service: string | null }) {
|
||||
const connected = Boolean(service)
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={680}>
|
||||
<XStack gap="$2" items="center">
|
||||
<Server size={16} />
|
||||
<Server size={16} color={connected ? undefined : '$color10'} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Connected · no logs for {entry.label} in the last {range}
|
||||
{connected ? `No logs for ${entry.label} in the last ${range}` : 'No logs yet'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
The o11y log runtime answered, but service <Text fontSize="$3" color="$color12" fontWeight="600">{service}</Text> shipped no
|
||||
OpenTelemetry logs for your organization in this window. This is a real empty result, not placeholder data — lines appear
|
||||
here as {entry.label} emits OTLP logs. Try a wider range.
|
||||
{connected ? (
|
||||
<>
|
||||
The o11y runtime is connected, but service{' '}
|
||||
<Text fontSize="$3" color="$color12" fontWeight="600">{service}</Text> shipped no logs for your organization in this
|
||||
window. Lines appear here as {entry.label} emits them — try a wider range.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{entry.label} is a managed capability with no dedicated service. Application logs appear here as it emits them to the
|
||||
o11y runtime.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ function O11yHealthBand({ label, health }: { label: string; health: ServiceHealt
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Live RED metrics from the o11y runtime for service{' '}
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">{health.service}</Text>, scoped to your organization. {label} is
|
||||
serving traffic — this is real telemetry, not a fabricated status.
|
||||
serving traffic.
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
@@ -210,8 +210,8 @@ function ManagedCard({ entry, hasService }: { entry: CatalogEntry; hasService: b
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{hasService
|
||||
? `Neither the o11y runtime nor the control-plane inventory reports a running ${entry.label} service for your organization right now. It may be a shared managed service reported elsewhere, or idle in this window — its live health lights up automatically once it serves traffic or is reported. No status is fabricated.`
|
||||
: `${entry.label} is a managed Hanzo Cloud capability with no discrete service to report health for. It is available through the API; there is no fabricated status shown.`}
|
||||
? `Neither the o11y runtime nor the control-plane inventory reports a running ${entry.label} service for your organization right now. It may be a shared managed service reported elsewhere, or idle in this window — its live health lights up automatically once it serves traffic or is reported.`
|
||||
: `${entry.label} is a managed Hanzo Cloud capability with no discrete service to report health for. It is available through the API.`}
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Text, XStack } from '@hanzo/gui'
|
||||
import { ChevronRight } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { findEntry, categoryFromSlug } from '~/lib/products/registry'
|
||||
import { productSubpages } from '~/lib/products/match'
|
||||
|
||||
type Crumb = { label: string; href?: string }
|
||||
|
||||
@@ -38,9 +39,18 @@ function crumbsFor(pathname: string): Crumb[] {
|
||||
|
||||
const entry = findEntry(segs[0])
|
||||
if (entry) {
|
||||
crumbs.push({ label: entry.category })
|
||||
// Skip the category crumb when it just repeats the product's own name — the
|
||||
// `Settings` product lives in the `Settings` category, and `Home / Settings /
|
||||
// Settings / …` says the same word twice.
|
||||
if (entry.category !== entry.label) crumbs.push({ label: entry.category })
|
||||
crumbs.push({ label: entry.label, href: segs.length > 1 ? `/${entry.id}` : undefined })
|
||||
for (let i = 1; i < segs.length; i++) crumbs.push({ label: decodeURIComponent(segs[i]) })
|
||||
// Label trailing segments from the product's own sub-page list (so `/settings/logs`
|
||||
// reads `… / Logs`, not the raw slug); detail params that aren't sub-pages pass through.
|
||||
const subs = productSubpages(entry)
|
||||
for (let i = 1; i < segs.length; i++) {
|
||||
const sp = subs.find((s) => s.slug === segs[i])
|
||||
crumbs.push({ label: sp ? sp.label : decodeURIComponent(segs[i]) })
|
||||
}
|
||||
} else {
|
||||
for (const s of segs) crumbs.push({ label: decodeURIComponent(s) })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('./client', async () => {
|
||||
})
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: vi.fn(),
|
||||
iamHasSession: vi.fn(() => false),
|
||||
iamUserInfo: vi.fn(),
|
||||
iamExpiresInSeconds: vi.fn(),
|
||||
iamSignOut: vi.fn(),
|
||||
|
||||
@@ -6,6 +6,7 @@ const token = vi.hoisted(() => ({ value: null as string | null }))
|
||||
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: async () => token.value,
|
||||
iamHasSession: () => token.value != null,
|
||||
iamUserInfo: async () => null,
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamSignOut: () => {},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// refreshSession is browser-only and delegates to the IAM SDK; mock the SDK wrapper so
|
||||
// the single-flight wiring is exercised in the node test env. resilientRefresh below is
|
||||
// pure over injected deps, so it needs no mock (it never touches iam).
|
||||
vi.mock('./iam', () => ({
|
||||
iamValidAccessToken: vi.fn(),
|
||||
iamHasSession: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
import { resilientRefresh, refreshSession, REFRESH_RETRY_MS } from './refresh'
|
||||
import { iamValidAccessToken } from './iam'
|
||||
|
||||
const noSleep = (_ms: number) => Promise.resolve()
|
||||
|
||||
// The FIX itself — the exact `resilientFetch` injected-deps idiom the API client uses.
|
||||
describe('resilientRefresh — a transient blip self-heals; a dead session does not spin', () => {
|
||||
it('returns true on the first attempt, no retry, no sleep', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue('tok')
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
|
||||
expect(attempt).toHaveBeenCalledTimes(1)
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers a TRANSIENT failure: null then a token → true (the whole point of the fix)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValueOnce(null).mockResolvedValue('tok')
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
|
||||
expect(attempt).toHaveBeenCalledTimes(2)
|
||||
expect(sleep).toHaveBeenCalledTimes(1)
|
||||
expect(sleep).toHaveBeenCalledWith(REFRESH_RETRY_MS[0])
|
||||
})
|
||||
|
||||
it('a genuinely-dead session resolves false only after exhausting the bounded retries', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(false)
|
||||
// one initial attempt + one per backoff slot
|
||||
expect(attempt).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length + 1)
|
||||
expect(sleep).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length)
|
||||
expect(sleep.mock.calls.map((c) => c[0])).toEqual(REFRESH_RETRY_MS)
|
||||
})
|
||||
|
||||
it('never waits through the backoff when there is no session to refresh (anonymous / revoked)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => false, sleep })).toBe(false)
|
||||
expect(attempt).toHaveBeenCalledTimes(1) // one try, then hasSession() false → stop
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops the moment the session disappears mid-retry (a revoked token the SDK cleared)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
const hasSession = vi.fn().mockReturnValueOnce(true).mockReturnValue(false)
|
||||
expect(await resilientRefresh({ attempt, hasSession, sleep })).toBe(false)
|
||||
expect(attempt).toHaveBeenCalledTimes(2) // initial + one retry, then session gone
|
||||
expect(sleep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
const mockAttempt = iamValidAccessToken as ReturnType<typeof vi.fn>
|
||||
|
||||
// The wiring: browser-only + single-flight (concurrent callers share ONE rotation —
|
||||
// load-bearing for a one-time-use rotating refresh token).
|
||||
describe('refreshSession — browser-only, single-flight', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('window', {} as unknown as Window & typeof globalThis)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('is a no-op on the server (no window) — resolves false, never touches the SDK', async () => {
|
||||
vi.unstubAllGlobals() // remove the window stub → typeof window === 'undefined'
|
||||
expect(await refreshSession()).toBe(false)
|
||||
expect(mockAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses concurrent callers onto ONE rotation (the timer + N parallel 401s)', async () => {
|
||||
mockAttempt.mockResolvedValue('tok')
|
||||
const p1 = refreshSession()
|
||||
const p2 = refreshSession()
|
||||
expect(p1).toBe(p2) // same in-flight promise
|
||||
expect(await Promise.all([p1, p2])).toEqual([true, true])
|
||||
expect(mockAttempt).toHaveBeenCalledTimes(1) // one rotation, not two
|
||||
// Settled → a later caller starts a fresh rotation (inflight cleared).
|
||||
expect(await refreshSession()).toBe(true)
|
||||
expect(mockAttempt).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
+59
-15
@@ -11,8 +11,53 @@
|
||||
* the token, the second replays the now-invalid one and 400s, needlessly killing a
|
||||
* healthy session. Sharing ONE in-flight promise means every concurrent caller (the
|
||||
* timer + N parallel 401s) awaits the SAME single rotation.
|
||||
*
|
||||
* RESILIENT, not jumpy. One attempt used to be the whole story: a single transient
|
||||
* failure (a network blip, a 5xx from IAM, a lost rotation race) yielded a bare
|
||||
* `false`, and the caller took that one "no" as a definitive sign-out — the "session
|
||||
* expired, sign in again" card fired mid-task on a hiccup. A transient failure and a
|
||||
* genuinely-dead refresh token are indistinguishable at this layer (the SDK collapses
|
||||
* both to a null token), so we RETRY a bounded few times with short backoff WHILE a
|
||||
* session still exists: a blip self-heals (session preserved, no false eviction), and a
|
||||
* truly-expired session resolves `false` ~1s later — an acceptable delay before the
|
||||
* honest re-auth card. An anonymous visitor (no stored token) never waits through the
|
||||
* backoff (`hasSession` short-circuits on the first miss).
|
||||
*/
|
||||
import { iamValidAccessToken } from './iam'
|
||||
import { iamValidAccessToken, iamHasSession } from './iam'
|
||||
|
||||
/** Backoff before each retry AFTER the first attempt — transient recovery only. Worst
|
||||
* case added before an honest `false` when a stored token is dead: ~1.6s. */
|
||||
export const REFRESH_RETRY_MS = [400, 1200]
|
||||
|
||||
/** Injected dependencies for `resilientRefresh` — real ones in `refreshSession`, fakes
|
||||
* in tests. Mirrors the `ResilientDeps` idiom the API client uses for `resilientFetch`. */
|
||||
export interface RefreshDeps {
|
||||
/** One refresh attempt: a live access token, or null (a TRANSIENT failure OR a
|
||||
* genuinely signed-out state — this layer cannot tell them apart). Never throws. */
|
||||
attempt: () => Promise<string | null>
|
||||
/** True while the browser still holds a session to refresh (else retrying is futile). */
|
||||
hasSession: () => boolean
|
||||
sleep: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure refresh orchestration (over its injected deps): try once, then retry a bounded
|
||||
* few times with backoff — but ONLY while a session still exists. So a network blip /
|
||||
* lost rotation race self-heals (returns true on recovery), while a genuinely signed-out
|
||||
* state resolves false without wasted retries. Bounded by `REFRESH_RETRY_MS`.
|
||||
*/
|
||||
export async function resilientRefresh(deps: RefreshDeps): Promise<boolean> {
|
||||
for (let i = 0; ; i++) {
|
||||
if (await deps.attempt()) return true
|
||||
// First attempt failed. Stop if we've exhausted the budget OR the session is gone
|
||||
// from storage (an anonymous visitor, or a revoked token the SDK cleared — retrying
|
||||
// cannot bring it back). Otherwise wait and retry: the failure may be transient.
|
||||
if (i >= REFRESH_RETRY_MS.length || !deps.hasSession()) return false
|
||||
await deps.sleep(REFRESH_RETRY_MS[i])
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
let inflight: Promise<boolean> | null = null
|
||||
|
||||
@@ -24,19 +69,18 @@ let inflight: Promise<boolean> | null = null
|
||||
export function refreshSession(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return Promise.resolve(false)
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
try {
|
||||
// getValidAccessToken() returns the current token, or transparently runs the
|
||||
// refresh grant when it is expired — the SDK's own single rotation.
|
||||
const token = await iamValidAccessToken()
|
||||
return !!token
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
|
||||
// rotation and one arriving after it starts a fresh one.
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
inflight = resilientRefresh({
|
||||
// getValidAccessToken() returns the current token, or transparently runs the refresh
|
||||
// grant when it is expired — the SDK's own single rotation. iamValidAccessToken wraps
|
||||
// it browser-safe and never throws (a failure is a null token).
|
||||
attempt: iamValidAccessToken,
|
||||
hasSession: iamHasSession,
|
||||
sleep,
|
||||
})
|
||||
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
|
||||
// rotation and one arriving after it starts a fresh one.
|
||||
void inflight.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user