Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a33acca87f | ||
|
|
ff9d42e0ba | ||
|
|
a70a66b1cc | ||
|
|
344bb1cfc2 | ||
|
|
4d17b91b31 | ||
|
|
7b97b6752b | ||
|
|
139d7fc6f9 | ||
|
|
f6df104ec8 | ||
|
|
db164c8470 | ||
|
|
6976badf4e | ||
|
|
85b7e6accb | ||
|
|
f464508249 | ||
|
|
d37dc23580 | ||
|
|
38eb16f391 | ||
|
|
e1ff150012 | ||
|
|
a47de37ff5 |
+17
-1
@@ -33,6 +33,18 @@ ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# identically to one served from inside cloud.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
# The publishable ingest key, for SIGNED-OUT views only. A signed-in visitor is
|
||||
# still attributed by their own IAM bearer -- src/lib/event.ts feeds this through
|
||||
# getToken as `token ?? key`, never as `ingestKey`, so it can only fill the gap
|
||||
# where there is no token and can never displace one.
|
||||
#
|
||||
# PUBLISHABLE_KEY is the name in KMS (org hanzo, path deploy, env prod) and on the
|
||||
# --build-arg; NEXT_PUBLIC_ is what makes the bundler inline it and is a property
|
||||
# of THIS build, so it is applied here and the secret store keeps the one plain
|
||||
# name. No default: absent means signed-out views report nothing, exactly as
|
||||
# before, which is a degradation and not a break.
|
||||
ARG PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
|
||||
COPY . .
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
|
||||
@@ -44,7 +56,11 @@ RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
|
||||
# cannot change under a rebuild. (The console's OWN release is named by semver in
|
||||
# the values file — that is the version a human reads.)
|
||||
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
|
||||
#
|
||||
# v0.5.7: serves a directory's index IN PLACE. The prior pin 301'd `/` to
|
||||
# `/index.html`, so the address bar carried the internal filename and the
|
||||
# console's breadcrumb dutifully read "Home > index.html".
|
||||
FROM ghcr.io/hanzoai/static@sha256:46b9a9b359b24377e228d39fb3d4e485af594d55bf1034dcc7b7a1e858a0bba6
|
||||
COPY --from=build /console/out/ /srv/
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/static"]
|
||||
|
||||
@@ -119,7 +119,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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.63",
|
||||
"version": "8.5.78",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
@@ -126,9 +127,16 @@ export function ContextSwitcher() {
|
||||
) : null}
|
||||
</XStack>
|
||||
) : (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
// No uploaded logo: lead with the org's shared OrgMark (its monogram —
|
||||
// the SAME mark SidebarBrand and the account widget wear), so the switcher
|
||||
// is never a bare name. White-label safe: OrgMark is the tenant's OWN mark
|
||||
// (the org's IAM logo when set, else its monogram), never the house glyph.
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
<OrgMark org={org} size={20} />
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
</XStack>
|
||||
)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The cloud shell — a REAL terminal in the Developers dock.
|
||||
*
|
||||
* What was here before was an explorer: a prompt that took `GET /v1/models` and
|
||||
* printed the response. It looked like a shell and answered like a form, and the
|
||||
* gap between the two is the whole reason this exists — there was no way to run
|
||||
* anything, so the `$` in the dock was a promise the dock could not keep.
|
||||
*
|
||||
* THE SHELL IS A SANDBOX. Not a simulator, not a command allow-list: a login
|
||||
* shell on a pseudo-terminal inside the org's own gVisor pod. Whatever the image
|
||||
* carries — the hanzo CLI is on its PATH — is a command the user types, and
|
||||
* nothing here decides what may run. That decision belongs to the runtime
|
||||
* boundary the pod already has, and a second one in a browser tab would only be a
|
||||
* fiction.
|
||||
*
|
||||
* THE TERMINAL IS NOT BUILT HERE. Cloud serves it, whole, at the same address as
|
||||
* the socket, and this frames it. That is not laziness about an emulator — it is
|
||||
* that the console is one of several hosts that show a shell, and a terminal
|
||||
* built per host is a terminal that is subtly different in each of them. One
|
||||
* implementation, one place a fix lands, and this file is left with the only part
|
||||
* that is genuinely the console's: which sandbox, and what to say while it is
|
||||
* coming up.
|
||||
*
|
||||
* WHAT THIS STILL OWNS is the credential. A frame carries no Authorization
|
||||
* header any more than a socket does, so the ticket is fetched through the
|
||||
* same-origin `/v1` proxy — where identity lives — and handed to the page in its
|
||||
* URL. Single-use, thirty seconds, bound to one sandbox: that is what makes
|
||||
* putting it in a URL safe, and why nothing long-lived ever goes there.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { ApiError, cloudProxyV1Url, restGet, restPost } from '~/lib/api/client'
|
||||
import { config } from '~/config'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { terminalFor } from './logic'
|
||||
|
||||
/**
|
||||
* The project the dock's shell holds. A `dev` sandbox is attached to a project
|
||||
* and the project names the VOLUME, so this constant is what makes the shell the
|
||||
* same shell tomorrow: the checkout and the caches are still there when the lease
|
||||
* has long since ended. One live sandbox per project is the server's rule, which
|
||||
* is also why reopening the dock finds the running one instead of leasing a
|
||||
* second.
|
||||
*/
|
||||
const PROJECT = 'console'
|
||||
|
||||
/** The tmux session this dock attaches to, so reopening it finds the same shell. */
|
||||
const SESSION = 'dock'
|
||||
|
||||
/**
|
||||
* How long to wait for the terminal to say it is up.
|
||||
*
|
||||
* The page posts `{source:'hanzo-term'}` when its socket opens. Without a
|
||||
* deadline a frame that failed into something else — an expired ticket, an
|
||||
* origin that refused to be framed — is indistinguishable from one that is still
|
||||
* loading, and the dock would sit on "Starting…" forever rather than offering the
|
||||
* reconnect that fixes it.
|
||||
*/
|
||||
const READY_BY = 6000
|
||||
|
||||
type Phase = 'starting' | 'live' | 'gone'
|
||||
|
||||
type Sandbox = { id: string; status: string; project?: string }
|
||||
|
||||
/**
|
||||
* The org's running dock sandbox, or a freshly leased one.
|
||||
*
|
||||
* Asking the server which one is live is what makes the shell survive a reload
|
||||
* without remembering anything: there is exactly one live sandbox per project by
|
||||
* the server's own rule, so the answer to "which one is mine" is a query and
|
||||
* never a stored id that can go stale. The match is re-checked here rather than
|
||||
* trusted from the query string — a filter is the server's convenience, and the
|
||||
* sandbox this reattaches to had better be the right one.
|
||||
*/
|
||||
async function sandbox(): Promise<Sandbox> {
|
||||
const live = await restGet<{ sandboxes?: Sandbox[] }>(
|
||||
cloudProxyV1Url(`sandboxes?project=${PROJECT}&status=running`),
|
||||
)
|
||||
const held = live.sandboxes?.find((m) => m.status === 'running' && m.project === PROJECT)
|
||||
if (held) return held
|
||||
return restPost<Sandbox>(cloudProxyV1Url('sandboxes'), { class: 'dev', project: PROJECT })
|
||||
}
|
||||
|
||||
const reason = (err: unknown): string =>
|
||||
err instanceof ApiError
|
||||
? `${err.message}${err.status ? ` (${err.status})` : ''}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
|
||||
export function Terminal() {
|
||||
const [phase, setPhase] = useState<Phase>('starting')
|
||||
const [why, setWhy] = useState('')
|
||||
const [src, setSrc] = useState('')
|
||||
// A change to this is the ONE way a session restarts: the effect below owns the
|
||||
// whole lifetime — sandbox, ticket, frame — and reruns as a unit, so there is
|
||||
// no half-torn-down session to reason about. A ticket is spent once, so a
|
||||
// reconnect is a new ticket and never the old frame reloaded.
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const frame = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setPhase('starting')
|
||||
setWhy('')
|
||||
setSrc('')
|
||||
setAttempt((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// `alive` is the barrier for everything this effect started. React mounts an
|
||||
// effect twice in development, and a ticket fetched by the first pass would
|
||||
// otherwise land in a frame the second pass has replaced.
|
||||
let alive = true
|
||||
let waiting: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const end = (message: string) => {
|
||||
if (!alive) return
|
||||
setWhy(message)
|
||||
setPhase('gone')
|
||||
}
|
||||
|
||||
// The readiness handshake. Only the frame we opened may speak for it: the
|
||||
// origin is checked against the API host, so another page cannot post its way
|
||||
// into a terminal that is not there.
|
||||
const heard = (e: MessageEvent) => {
|
||||
if (!alive || e.source !== frame.current?.contentWindow) return
|
||||
if (new URL(config.apiUrl).origin !== e.origin) return
|
||||
const d = e.data as { source?: string } | null
|
||||
if (d && d.source === 'hanzo-term') {
|
||||
if (waiting) clearTimeout(waiting)
|
||||
setPhase('live')
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', heard)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const m = await sandbox()
|
||||
if (!alive) return
|
||||
const pass = await restPost<{ ticket: string }>(
|
||||
cloudProxyV1Url(`sandboxes/${m.id}/terminal/ticket`),
|
||||
)
|
||||
if (!alive) return
|
||||
setSrc(terminalFor(config.apiUrl, m.id, pass.ticket, SESSION))
|
||||
waiting = setTimeout(() => end('The terminal did not come up.'), READY_BY)
|
||||
} catch (err) {
|
||||
end(reason(err))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
if (waiting) clearTimeout(waiting)
|
||||
window.removeEventListener('message', heard)
|
||||
}
|
||||
}, [attempt])
|
||||
|
||||
// The frame is ALWAYS laid out and the status covers it, because a frame that
|
||||
// is display:none has no size — and a terminal sized to nothing measures 80x24
|
||||
// and never corrects.
|
||||
return (
|
||||
<YStack flex={1} minH={0} position="relative" bg="#000">
|
||||
{src ? (
|
||||
<iframe
|
||||
ref={frame}
|
||||
src={src}
|
||||
title="Cloud shell"
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
{phase === 'live' ? null : (
|
||||
<YStack position="absolute" t={0} l={0} r={0} b={0} items="center" justify="center" gap="$2" p="$4" bg="$color1">
|
||||
{phase === 'starting' ? (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Starting your cloud shell…
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$2" color={toneColor('critical')}>
|
||||
Disconnected
|
||||
</Text>
|
||||
<Button size="$2" onPress={retry} aria-label="Reconnect the cloud shell">
|
||||
Reconnect
|
||||
</Button>
|
||||
</XStack>
|
||||
{why ? (
|
||||
<Text fontSize="$1" color="$color10" className="hz-mono">
|
||||
{why}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -31,9 +31,8 @@ import { useRouter } from 'next/navigation'
|
||||
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
Activity,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Maximize2,
|
||||
Mic,
|
||||
Minimize2,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
@@ -43,6 +42,9 @@ import {
|
||||
|
||||
import { fetchUsageRecords, type UsageRecord } from '~/lib/api/aimetrics'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { useFloatingChat } from '~/components/FloatingChat'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { voiceSupported } from '~/lib/voice'
|
||||
import {
|
||||
EventsTab,
|
||||
HealthTab,
|
||||
@@ -73,6 +75,10 @@ const LEDGER_TABS: ReadonlySet<TabId> = new Set<TabId>(['overview', 'logs', 'eve
|
||||
export function WorkbenchDock() {
|
||||
const router = useRouter()
|
||||
const { get, set } = usePreferences()
|
||||
// The assistant lives in this bar on desktop (the floating bubble is suppressed
|
||||
// at lg+). The mic starts it listening; the brand mark opens it.
|
||||
const { openChat, startVoice } = useFloatingChat()
|
||||
const [voiceOk] = useState(() => voiceSupported())
|
||||
const open = get<boolean>('workbenchOpen', false)
|
||||
const [tab, setTab] = useState<TabId>('overview')
|
||||
// Freely resizable dock height (px), persisted per-user — drag the top handle to
|
||||
@@ -283,7 +289,7 @@ export function WorkbenchDock() {
|
||||
$
|
||||
</Text>
|
||||
<Text flex={1} fontSize="$2" color="$color10" className="hz-mono" numberOfLines={1}>
|
||||
Run a /v1 command — models, agents, logs…
|
||||
Open a cloud shell — a real terminal in your sandbox
|
||||
</Text>
|
||||
</XStack>
|
||||
) : (
|
||||
@@ -291,13 +297,24 @@ export function WorkbenchDock() {
|
||||
)}
|
||||
<Button size="$2" chromeless icon={<Activity size={16} />} onPress={() => openTo('overview')} aria-label="API activity" />
|
||||
<Button size="$2" chromeless icon={<ScrollText size={16} />} onPress={() => openTo('logs')} aria-label="Recent API logs" />
|
||||
{/* The assistant's home on desktop — mic starts it listening, the brand mark
|
||||
opens it. The floating bubble is hidden at lg+ (this is where it lives);
|
||||
it stays on phones, where this dock is not shown. */}
|
||||
{voiceOk ? (
|
||||
<Button size="$2" chromeless icon={<Mic size={16} />} onPress={startVoice} aria-label="Talk to Hanzo" />
|
||||
) : null}
|
||||
<Button
|
||||
size="$2"
|
||||
icon={open ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
onPress={() => set('workbenchOpen', !open)}
|
||||
aria-label={open ? 'Collapse the workbench' : 'Open the workbench'}
|
||||
bg="$color4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<BrandMark size={16} />}
|
||||
onPress={openChat}
|
||||
aria-label="Ask Hanzo"
|
||||
>
|
||||
{open ? 'Hide' : 'Open'}
|
||||
<Text fontSize="$2" fontWeight="700" color="$color12">
|
||||
AI
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput } from './logic'
|
||||
|
||||
describe('parseCommand', () => {
|
||||
it('accepts every documented form and normalizes to a /v1-relative path', () => {
|
||||
expect(parseCommand('GET /v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('get v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('/v1/models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand('models')).toEqual({ path: 'models' })
|
||||
expect(parseCommand(' agents?limit=5 ')).toEqual({ path: 'agents?limit=5' })
|
||||
})
|
||||
|
||||
it('is read-only — every mutating method is refused', () => {
|
||||
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
expect(parseCommand(`${m} /v1/agents`)).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses empty, multi-path, traversal, and smuggled inputs', () => {
|
||||
expect(parseCommand('')).toHaveProperty('error')
|
||||
expect(parseCommand('GET')).toHaveProperty('error')
|
||||
expect(parseCommand('/v1')).toHaveProperty('error')
|
||||
expect(parseCommand('GET /v1/a /v1/b')).toHaveProperty('error')
|
||||
expect(parseCommand('/v1/../admin')).toHaveProperty('error')
|
||||
expect(parseCommand('//evil.com/x')).toHaveProperty('error')
|
||||
expect(parseCommand('https://evil.com/x')).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
import { curlFor, eventsFrom, inspectorRoute, renderOutput, terminalFor } from './logic'
|
||||
|
||||
describe('renderOutput', () => {
|
||||
it('pretty-prints JSON and passes strings through', () => {
|
||||
@@ -77,10 +51,6 @@ describe('show code', () => {
|
||||
expect(curlFor('models')).toBe('curl https://api.hanzo.ai/v1/models \\\n -H "Authorization: Bearer $HANZO_API_KEY"')
|
||||
expect(curlFor('/v1/agents')).toContain('https://api.hanzo.ai/v1/agents')
|
||||
})
|
||||
it('builds the Hanzo CLI form', () => {
|
||||
expect(hanzoCli('/v1/models')).toBe('hanzo api get /v1/models')
|
||||
expect(hanzoCli('agents')).toBe('hanzo api get /v1/agents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventsFrom', () => {
|
||||
@@ -105,3 +75,26 @@ describe('eventsFrom', () => {
|
||||
expect(eventsFrom([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud shell — where the terminal is', () => {
|
||||
it('addresses the API host, not the console origin — a frame cannot ride the /v1 proxy', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai', 'm_abc', 'tok', 'dock')).toBe(
|
||||
'https://api.hanzo.ai/v1/sandboxes/m_abc/terminal?ticket=tok&arg=dock',
|
||||
)
|
||||
})
|
||||
|
||||
it('tolerates a trailing slash and escapes what it interpolates', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai/', 'm_1', 't', 'dock')).toContain(
|
||||
'api.hanzo.ai/v1/sandboxes/m_1/terminal',
|
||||
)
|
||||
// An id is hex and a ticket is base64url, but neither is trusted to be: an
|
||||
// unescaped `&` would silently truncate the credential.
|
||||
expect(terminalFor('https://api.hanzo.ai', 'a/b', 'x&y=z', 'p 1')).toBe(
|
||||
'https://api.hanzo.ai/v1/sandboxes/a%2Fb/terminal?ticket=x%26y%3Dz&arg=p%201',
|
||||
)
|
||||
})
|
||||
|
||||
it('names a session, so reopening the dock reattaches instead of starting over', () => {
|
||||
expect(terminalFor('https://api.hanzo.ai', 'm_1', 't', 'dock')).toContain('arg=dock')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
/**
|
||||
* Workbench logic — the PURE half of the bottom Developers dock (parse a shell
|
||||
* command into a safe same-origin `/v1` read; render a response for the terminal).
|
||||
* No React/Gui/registry imports so it is node-testable in isolation.
|
||||
* Workbench logic — the PURE half of the bottom Developers dock: where the cloud
|
||||
* shell's socket lives and what goes over it, how a response renders, and how an
|
||||
* id routes to the `/v1` read that explains it. No React/Gui/registry imports, so
|
||||
* every decision here is node-testable on its own.
|
||||
*/
|
||||
|
||||
export type Command = { path: string } | { error: string }
|
||||
|
||||
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
|
||||
/** Path charset — segments, query string; nothing that can smuggle a scheme/host. */
|
||||
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
|
||||
|
||||
/**
|
||||
* Parse a workbench shell line into a `/v1`-relative GET path. Accepted forms:
|
||||
* `GET /v1/models` · `/v1/models` · `v1/models` · `models` (a bare head). The
|
||||
* shell is deliberately READ-ONLY (GET) — a mutation belongs in the product UI,
|
||||
* where it gets its confirm/undo affordances — and `/v1`-only (the one API root).
|
||||
*/
|
||||
export function parseCommand(input: string): Command {
|
||||
const words = input.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return { error: 'Enter a path — e.g. GET /v1/models' }
|
||||
let rest = words
|
||||
const head = words[0].toUpperCase()
|
||||
if (METHODS.has(head)) {
|
||||
if (head !== 'GET') return { error: 'The workbench shell is read-only — only GET is supported.' }
|
||||
rest = words.slice(1)
|
||||
}
|
||||
if (rest.length !== 1) return { error: 'One path per command — e.g. GET /v1/models' }
|
||||
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(rest[0])) return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
|
||||
let path = rest[0].replace(/^\/+/, '')
|
||||
if (path === 'v1' || path === 'v1/') return { error: 'Name a resource — e.g. /v1/models' }
|
||||
if (path.startsWith('v1/')) path = path.slice(3)
|
||||
if (!path || !PATH_OK.test(path) || path.includes('..') || path.includes('//')) {
|
||||
return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
|
||||
}
|
||||
return { path }
|
||||
}
|
||||
|
||||
/** Pretty-print a shell response, bounded so a huge payload never wedges the DOM. */
|
||||
export function renderOutput(value: unknown, maxChars = 20000): string {
|
||||
let text: string
|
||||
@@ -50,6 +19,9 @@ export function renderOutput(value: unknown, maxChars = 20000): string {
|
||||
|
||||
// ── Inspector — route an object id to its `/v1` GET ───────────────────────────
|
||||
|
||||
/** Path charset — segments and a query string; nothing that can smuggle a scheme/host. */
|
||||
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
|
||||
|
||||
/** A resolved inspect target (a same-origin `/v1` GET) or an honest parse error. */
|
||||
export type InspectTarget = { path: string; kind: string; label: string } | { error: string }
|
||||
|
||||
@@ -110,11 +82,6 @@ export function curlFor(path: string, origin = 'https://api.hanzo.ai'): string {
|
||||
return `curl ${origin}/v1/${bareResource(path)} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`
|
||||
}
|
||||
|
||||
/** The Hanzo CLI form of the same `/v1` GET. */
|
||||
export function hanzoCli(path: string): string {
|
||||
return `hanzo api get /v1/${bareResource(path)}`
|
||||
}
|
||||
|
||||
// ── Events — project the usage ledger into a platform-event stream ────────────
|
||||
|
||||
/** One platform event, projected from a real charged ledger row (never fabricated). */
|
||||
@@ -152,3 +119,27 @@ export function eventsFrom(records: EventSource[]): PlatformEvent[] {
|
||||
})
|
||||
.sort((a, b) => (b.at ?? -Infinity) - (a.at ?? -Infinity))
|
||||
}
|
||||
|
||||
// ── Cloud shell — where the terminal is ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The terminal's address on the API host.
|
||||
*
|
||||
* Cloud SERVES the terminal — emulator, socket, resize and reconnect, one
|
||||
* self-contained page — so a host that wants a shell frames this rather than
|
||||
* building one. It is the one address in the console that is not same-origin, and
|
||||
* that is forced rather than chosen: the same-origin `/v1` proxy is a Next route
|
||||
* handler, and a route handler forwards requests, not sockets and not frames.
|
||||
* What crosses instead of the session is the single-use ticket the proxy just
|
||||
* fetched, which is the only credential a URL can safely hold.
|
||||
*
|
||||
* `arg` names a tmux session, so reopening the dock reattaches to the shell it
|
||||
* left instead of opening a fresh one over the user's work.
|
||||
*/
|
||||
export function terminalFor(apiBase: string, id: string, ticket: string, session: string): string {
|
||||
const base = apiBase.trim().replace(/\/+$/, '')
|
||||
return (
|
||||
`${base}/v1/sandboxes/${encodeURIComponent(id)}/terminal` +
|
||||
`?ticket=${encodeURIComponent(ticket)}&arg=${encodeURIComponent(session)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ import { config } from '~/config'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { RuntimeNotice } from '~/components/products/observability/RuntimeNotice'
|
||||
import { TracesModule } from '~/components/products/TracesModule'
|
||||
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput, type PlatformEvent } from './logic'
|
||||
import { curlFor, eventsFrom, inspectorRoute, renderOutput, type PlatformEvent } from './logic'
|
||||
import { Terminal } from './Terminal'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
|
||||
const usd = (cents: number, dp = 2): string => `$${(cents / 100).toFixed(dp)}`
|
||||
@@ -903,119 +904,13 @@ export function TracesTab() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shell / API Explorer ─────────────────────────────────────────────────────────
|
||||
|
||||
type ShellEntry = { cmd: string; path: string; output: string; ok: boolean }
|
||||
|
||||
/** Quick GET resources — the common `/v1` reads, run with one tap. */
|
||||
const SHELL_RESOURCES = ['models', 'agents', 'prompts', 'functions', 'automations/flows', 'billing/usage'] as const
|
||||
// ── Shell — the cloud shell ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Shell tab IS a terminal. The component that carries it lives on its own
|
||||
* (Terminal.tsx) because it loads xterm in the browser and owns a socket, and
|
||||
* neither belongs in a file of render-only tabs.
|
||||
*/
|
||||
export function ShellTab() {
|
||||
const [entries, setEntries] = useState<ShellEntry[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
}, [entries])
|
||||
|
||||
const run = useCallback(async (line: string) => {
|
||||
const cmd = line.trim()
|
||||
if (!cmd || running) return
|
||||
setInput('')
|
||||
const parsed = parseCommand(cmd)
|
||||
if ('error' in parsed) {
|
||||
setEntries((e) => [...e, { cmd, path: '', output: parsed.error, ok: false }])
|
||||
return
|
||||
}
|
||||
setRunning(true)
|
||||
try {
|
||||
const data = await restGet<unknown>(cloudProxyV1Url(parsed.path))
|
||||
setEntries((e) => [...e, { cmd, path: parsed.path, output: renderOutput(data), ok: true }])
|
||||
} catch (err) {
|
||||
const output =
|
||||
err instanceof ApiError ? `HTTP ${err.status} — ${err.message}` : err instanceof Error ? err.message : String(err)
|
||||
setEntries((e) => [...e, { cmd, path: parsed.path, output, ok: false }])
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
return (
|
||||
<YStack flex={1} minH={0}>
|
||||
{/* Resource picker — run a common /v1 GET with one tap. */}
|
||||
<XStack items="center" gap="$1" px="$2" height={34} borderBottomWidth={1} borderColor="$borderColor" flexWrap="wrap">
|
||||
<Text fontSize="$1" color="$color10">
|
||||
GET
|
||||
</Text>
|
||||
{SHELL_RESOURCES.map((r) => (
|
||||
<Button
|
||||
key={r}
|
||||
size="$1"
|
||||
chromeless
|
||||
bg="$color3"
|
||||
rounded="$4"
|
||||
px="$2"
|
||||
onPress={() => void run(`GET /v1/${r}`)}
|
||||
aria-label={`Run GET /v1/${r}`}
|
||||
>
|
||||
<Text fontSize="$1" color="$color11" className="hz-mono">
|
||||
{r}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack p="$3" gap="$2">
|
||||
{entries.length === 0 ? (
|
||||
<Text fontSize="$1" color="$color10" className="hz-mono">
|
||||
Read-only /v1 explorer — pick a resource above or type `GET /v1/models`. Runs as you, in your org.
|
||||
</Text>
|
||||
) : null}
|
||||
{entries.map((e, i) => (
|
||||
<YStack key={i} gap="$1">
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$1" color="$color11" className="hz-mono" flex={1} numberOfLines={1}>
|
||||
$ {e.cmd}
|
||||
</Text>
|
||||
{e.ok && e.path ? (
|
||||
<>
|
||||
<CopyBtn value={curlFor(e.path)} label="curl" />
|
||||
<CopyBtn value={hanzoCli(e.path)} label="CLI" />
|
||||
</>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$1" color={e.ok ? '$color12' : toneColor('critical')} className="hz-mono" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{e.output}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
<XStack items="center" gap="$2" px="$3" height={44} borderTopWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" color="$color10" className="hz-mono">
|
||||
$
|
||||
</Text>
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
autoFocus
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={() => void run(input)}
|
||||
placeholder={running ? 'Running…' : 'Enter a /v1 command…'}
|
||||
fontSize="$2"
|
||||
color="$color12"
|
||||
className="hz-mono"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
aria-label="Workbench shell command"
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
return <Terminal />
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('./client', async () => {
|
||||
})
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: vi.fn(),
|
||||
iamHasSession: vi.fn(() => false),
|
||||
iamUserInfo: vi.fn(),
|
||||
iamExpiresInSeconds: vi.fn(),
|
||||
iamSignOut: vi.fn(),
|
||||
|
||||
@@ -6,6 +6,7 @@ const token = vi.hoisted(() => ({ value: null as string | null }))
|
||||
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: async () => token.value,
|
||||
iamHasSession: () => token.value != null,
|
||||
iamUserInfo: async () => null,
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamSignOut: () => {},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// The IAM seam, both halves. The access TOKEN answers who you are; USERINFO is the
|
||||
// only place IAM publishes the avatar (`picture`) — deliberately, so a bounded data
|
||||
// URI never rides every JWT. `userinfoCalls` counts the round trips so the cache can
|
||||
// be asserted rather than assumed.
|
||||
const token = vi.hoisted(() => ({ value: null as string | null }))
|
||||
const userinfo = vi.hoisted(() => ({
|
||||
value: null as Record<string, unknown> | null,
|
||||
calls: 0,
|
||||
throws: false,
|
||||
}))
|
||||
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: async () => token.value,
|
||||
iamUserInfo: async () => {
|
||||
userinfo.calls++
|
||||
if (userinfo.throws) throw new Error('userinfo unreachable')
|
||||
return userinfo.value
|
||||
},
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamSignOut: () => {},
|
||||
}))
|
||||
|
||||
const { AccountApi } = await import('~/lib/api/account')
|
||||
|
||||
function jwt(claims: Record<string, unknown>): string {
|
||||
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
||||
return `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims)}.`
|
||||
}
|
||||
|
||||
// The claim set hanzo.id issues. Note what is NOT here: no `avatar`, no `picture`.
|
||||
// That absence IS the contract — reading only these is why the console showed
|
||||
// initials for every user who had uploaded a photo.
|
||||
const CLAIMS = { iss: 'https://hanzo.id', sub: 'u-1', owner: 'hanzo', name: 'z', email: 'z@hanzo.ai' }
|
||||
const PHOTO = 'data:image/webp;base64,UklGRhoAAABXRUJQ'
|
||||
|
||||
describe('the profile photo the token does not carry', () => {
|
||||
beforeEach(async () => {
|
||||
token.value = null
|
||||
userinfo.value = null
|
||||
userinfo.calls = 0
|
||||
userinfo.throws = false
|
||||
await AccountApi.signout() // drops the cached photo between cases
|
||||
})
|
||||
|
||||
it('reads the photo from userinfo when the token has none', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.value = { picture: PHOTO }
|
||||
const { account } = await AccountApi.session()
|
||||
expect(account?.avatar).toBe(PHOTO)
|
||||
})
|
||||
|
||||
it('prefers the token when it DOES carry one, and asks userinfo nothing', async () => {
|
||||
token.value = jwt({ ...CLAIMS, avatar: 'data:image/png;base64,FROMTOKEN' })
|
||||
userinfo.value = { picture: PHOTO }
|
||||
const { account } = await AccountApi.session()
|
||||
expect(account?.avatar).toBe('data:image/png;base64,FROMTOKEN')
|
||||
expect(userinfo.calls).toBe(0)
|
||||
})
|
||||
|
||||
it('costs ONE round trip across repeated loads — including for a user with no photo', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.value = { picture: PHOTO }
|
||||
await AccountApi.session()
|
||||
await AccountApi.session()
|
||||
await AccountApi.session()
|
||||
expect(userinfo.calls).toBe(1)
|
||||
|
||||
await AccountApi.signout()
|
||||
userinfo.value = {} // no photo — the MISS must cache too, or it refetches forever
|
||||
userinfo.calls = 0
|
||||
await AccountApi.session()
|
||||
await AccountApi.session()
|
||||
expect(userinfo.calls).toBe(1)
|
||||
})
|
||||
|
||||
it('never shows the previous account’s face after a switch', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.value = { picture: PHOTO }
|
||||
expect((await AccountApi.session()).account?.avatar).toBe(PHOTO)
|
||||
|
||||
// A different principal, same tab, no sign-out in between.
|
||||
token.value = jwt({ ...CLAIMS, owner: 'maxpower', name: 'dave' })
|
||||
userinfo.value = {}
|
||||
expect((await AccountApi.session()).account?.avatar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a failing userinfo costs the photo, never the session', async () => {
|
||||
token.value = jwt(CLAIMS)
|
||||
userinfo.throws = true
|
||||
const { account } = await AccountApi.session()
|
||||
expect(account?.owner).toBe('hanzo')
|
||||
expect(account?.name).toBe('z')
|
||||
expect(account?.avatar).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ const iam = vi.hoisted(() => ({ token: 'live-access-token' as string | null }))
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamAccessToken: () => iam.token,
|
||||
iamValidAccessToken: async () => iam.token,
|
||||
iamHasSession: () => iam.token != null,
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamUserInfo: async () => null,
|
||||
iamSignOut: () => {},
|
||||
|
||||
+62
-2
@@ -11,8 +11,10 @@ import {
|
||||
iamValidAccessToken,
|
||||
iamUserInfo,
|
||||
iamExpiresInSeconds,
|
||||
iamHasSession,
|
||||
iamSignOut,
|
||||
} from '~/lib/auth/iam'
|
||||
import { refreshSession } from '~/lib/auth/refresh'
|
||||
import { config } from '~/config'
|
||||
import { type Account } from './types'
|
||||
|
||||
@@ -92,6 +94,53 @@ function decodeJwtClaims(token: string): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile photo, which the access token does not carry and never will.
|
||||
*
|
||||
* Identity above is read from the token's own claims — self-contained, no round
|
||||
* trip, and immune to `getUserInfo()` answering null on a 200, which once
|
||||
* dead-ended the session into a /signin loop. That is the right source and this
|
||||
* does not move it. But IAM puts the avatar on `picture` in the USERINFO
|
||||
* response ONLY (`internal/oidc/userinfo.go`), deliberately: an avatar is a
|
||||
* bounded data URI, so carrying it would add kilobytes to every JWT on every
|
||||
* request for a value almost nothing reads. hanzoai/app's `lib/profile.ts`
|
||||
* states the same contract from the writing side.
|
||||
*
|
||||
* So the token is authoritative for WHO you are and silent about your picture,
|
||||
* and reading only the token meant `account.avatar` was undefined for every
|
||||
* user, forever — the account menu fell back to initials no matter what anyone
|
||||
* uploaded. One fetch fills exactly that gap.
|
||||
*
|
||||
* Best-effort and cached by design. A failure, a signed-out 401, or a user with
|
||||
* no photo all resolve to "no avatar" — never a broken session, because a
|
||||
* missing picture must not cost anyone their sign-in. The cache is keyed by
|
||||
* identity so switching accounts cannot show the previous face, and it holds
|
||||
* the miss too: a user with no photo should cost one request, not one per load.
|
||||
*/
|
||||
let avatarCache: { key: string; url: string | undefined } | null = null
|
||||
|
||||
async function withAvatar(account: Account): Promise<Account> {
|
||||
if (account.avatar) return account
|
||||
const key = `${account.owner}/${account.name}`
|
||||
if (avatarCache?.key !== key) {
|
||||
let url: string | undefined
|
||||
try {
|
||||
const info = await iamUserInfo()
|
||||
const pic = info?.['picture'] ?? info?.['avatar']
|
||||
if (typeof pic === 'string' && pic) url = pic
|
||||
} catch {
|
||||
/* no photo is not a session failure */
|
||||
}
|
||||
avatarCache = { key, url }
|
||||
}
|
||||
return avatarCache.url ? { ...account, avatar: avatarCache.url } : account
|
||||
}
|
||||
|
||||
/** Drop the cached photo — sign-out, so the next account never inherits this face. */
|
||||
function forgetAvatar(): void {
|
||||
avatarCache = null
|
||||
}
|
||||
|
||||
export const AccountApi = {
|
||||
/**
|
||||
* Resolve the current session from IAM: a valid access token (refreshed if
|
||||
@@ -100,14 +149,24 @@ export const AccountApi = {
|
||||
* silently refreshed by the SDK before the claims are read.
|
||||
*/
|
||||
session: async (): Promise<SessionResult> => {
|
||||
const token = await iamValidAccessToken()
|
||||
let token = await iamValidAccessToken()
|
||||
// A transient IAM blip (network / a 5xx from the token endpoint) yields a null
|
||||
// token even though the browser still holds a session — don't boot the user to the
|
||||
// sign-in card on a hiccup at load. Retry via the ONE resilient, single-flight
|
||||
// refresh before concluding signed-out; an anonymous visitor (no stored token)
|
||||
// skips it (iamHasSession is false) and resolves signed-out immediately.
|
||||
if (!token && iamHasSession() && (await refreshSession())) {
|
||||
token = await iamValidAccessToken()
|
||||
}
|
||||
if (!token) return { account: null, expiresIn: null }
|
||||
// Resolve identity from the access-token JWT claims directly — self-contained
|
||||
// and immune to the SDK's getUserInfo() returning null on a 200 (which dead-ended
|
||||
// the session and looped /signin). Fall back to userinfo only if not a JWT.
|
||||
const claims = decodeJwtClaims(token) ?? (await iamUserInfo())
|
||||
if (!claims) return { account: null, expiresIn: null }
|
||||
return { account: accountFromClaims(claims), expiresIn: iamExpiresInSeconds() }
|
||||
const account = accountFromClaims(claims)
|
||||
if (!account) return { account: null, expiresIn: null }
|
||||
return { account: await withAvatar(account), expiresIn: iamExpiresInSeconds() }
|
||||
},
|
||||
|
||||
/** The current signed-in account, or null. */
|
||||
@@ -115,6 +174,7 @@ export const AccountApi = {
|
||||
|
||||
/** Sign out: clear the IAM tokens (client) and best-effort the casibase session. */
|
||||
signout: async (): Promise<void> => {
|
||||
forgetAvatar()
|
||||
iamSignOut()
|
||||
try {
|
||||
await post('signout')
|
||||
|
||||
@@ -8,6 +8,7 @@ const iam = vi.hoisted(() => ({ token: null as string | null }))
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamAccessToken: () => iam.token,
|
||||
iamValidAccessToken: async () => iam.token,
|
||||
iamHasSession: () => iam.token != null,
|
||||
iamExpiresInSeconds: () => (iam.token ? 3600 : null),
|
||||
iamUserInfo: async () => null,
|
||||
iamSignOut: () => {},
|
||||
|
||||
@@ -17,48 +17,55 @@ vi.mock('./client', () => ({
|
||||
|
||||
import { ApmApi, apmWindow } from './apm'
|
||||
|
||||
type Body = { compositeQuery: { builderQueries: { A: { filters: { items: { op: string; value: string; key: { key: string } }[] } } } } }
|
||||
type Body = {
|
||||
requestType: string
|
||||
compositeQuery: { queries: { type: string; spec: { signal: string; limit: number } & Record<string, unknown> }[] }
|
||||
}
|
||||
|
||||
describe('ApmApi.logs — the per-product o11y query builds with the service filter + maps real rows', () => {
|
||||
describe('ApmApi.logs — the per-product o11y v5 query builds + maps real rows, service-scoped', () => {
|
||||
beforeEach(() => restPost.mockReset())
|
||||
|
||||
it('sends a service.name filter and normalizes a real O11y logs response to service-scoped rows', async () => {
|
||||
it('asks the v5 raw surface for a deep page and normalizes rows to the ONE service', async () => {
|
||||
const nsTs = String(Date.parse('2026-07-03T00:00:00Z') * 1_000_000) // O11y ns epoch
|
||||
restPost.mockResolvedValueOnce({
|
||||
data: { result: [{ list: [{ timestamp: nsTs, data: { id: 'l1', severity_text: 'INFO', 'service.name': 'iam', body: 'signed in' } }] }] },
|
||||
data: { data: { results: [{ rows: [{ timestamp: nsTs, data: { id: 'l1', severity_text: 'INFO', resources_string: { 'service.name': 'iam' }, body: 'signed in' } }] }] } },
|
||||
})
|
||||
|
||||
const rows = await ApmApi.logs(apmWindow(3600), 500, 'iam')
|
||||
|
||||
// 1) the outgoing query carried the per-service filter (serviceFilterItem)
|
||||
// 1) the outgoing query is the v5 raw shape on the version-less canonical
|
||||
// surface, addressed via the /v1 bearer BFF. Service scoping is client-side
|
||||
// (the runtime's key resolution is down — see rawQueryPayload), so a scoped
|
||||
// read asks for the deepest page instead of a server filter.
|
||||
const [url, body] = restPost.mock.calls[0] as [string, Body]
|
||||
// Version-less canonical o11y surface, addressed via the /v1 bearer BFF.
|
||||
expect(url).toBe('/v1/o11y/query_range')
|
||||
const items = body.compositeQuery.builderQueries.A.filters.items
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({ op: '=', value: 'iam', key: { key: 'service.name' } })
|
||||
expect(body.requestType).toBe('raw')
|
||||
const q = body.compositeQuery.queries[0]
|
||||
expect(q.type).toBe('builder_query')
|
||||
expect(q.spec).toMatchObject({ signal: 'logs', limit: 1000 })
|
||||
expect('filter' in q.spec).toBe(false)
|
||||
|
||||
// 2) the real response maps to real, normalized rows
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ service: 'iam', severity: 'info', body: 'signed in' })
|
||||
})
|
||||
|
||||
it('re-filters client-side so a runtime that ignored the filter cannot leak another service onto the page', async () => {
|
||||
it('re-filters client-side so another service can never leak onto the page', async () => {
|
||||
restPost.mockResolvedValueOnce({
|
||||
data: { result: [{ list: [
|
||||
{ timestamp: '2', data: { body: 'mine', 'service.name': 'iam' } },
|
||||
{ timestamp: '1', data: { body: 'not mine', 'service.name': 'kms' } },
|
||||
] }] },
|
||||
data: { data: { results: [{ rows: [
|
||||
{ timestamp: '2', data: { body: 'mine', resources_string: { 'service.name': 'iam' } } },
|
||||
{ timestamp: '1', data: { body: 'not mine', resources_string: { 'service.name': 'kms' } } },
|
||||
] }] } },
|
||||
})
|
||||
const rows = await ApmApi.logs(apmWindow(3600), 500, 'iam')
|
||||
expect(rows.map((r) => r.body)).toEqual(['mine'])
|
||||
})
|
||||
|
||||
it('sends NO filter for the org-wide stream (back-compat with the Observe Logs board)', async () => {
|
||||
restPost.mockResolvedValueOnce({ data: { result: [] } })
|
||||
it('keeps the caller limit for the org-wide stream (no service, no deep page)', async () => {
|
||||
restPost.mockResolvedValueOnce({ data: { data: { results: [] } } })
|
||||
await ApmApi.logs(apmWindow(3600), 500)
|
||||
const [, body] = restPost.mock.calls[0] as [string, Body]
|
||||
expect(body.compositeQuery.builderQueries.A.filters.items).toEqual([])
|
||||
expect(body.compositeQuery.queries[0].spec.limit).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+82
-68
@@ -14,11 +14,10 @@ import {
|
||||
normalizeExceptions,
|
||||
normalizeDashboard,
|
||||
normalizeDashboards,
|
||||
listQueryPayload,
|
||||
serviceFilterItem,
|
||||
rawQueryPayload,
|
||||
pickService,
|
||||
serviceHealthOf,
|
||||
parseListRows,
|
||||
parseRawRows,
|
||||
toIso,
|
||||
normalizeLogRow,
|
||||
normalizeLogs,
|
||||
@@ -234,65 +233,55 @@ describe('normalizeDashboard / normalizeDashboards', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('listQueryPayload (O11y v3 query_range LIST)', () => {
|
||||
describe('rawQueryPayload (O11y v5 query_range RAW)', () => {
|
||||
const w = apmWindow(3600)
|
||||
type V5 = {
|
||||
schemaVersion: string
|
||||
start: number
|
||||
end: number
|
||||
requestType: string
|
||||
compositeQuery: { queries: { type: string; spec: Record<string, unknown> }[] }
|
||||
}
|
||||
|
||||
it('builds a noop list builder query over the given dataSource with ms bounds', () => {
|
||||
const p = listQueryPayload('logs', w, 100) as {
|
||||
start: number
|
||||
end: number
|
||||
compositeQuery: { queryType: string; panelType: string; builderQueries: Record<string, Record<string, unknown>> }
|
||||
}
|
||||
it('builds ONE builder_query envelope over the given signal with ms bounds', () => {
|
||||
const p = rawQueryPayload('logs', w, 100) as V5
|
||||
expect(p.schemaVersion).toBe('v1')
|
||||
expect(p.start).toBe(w.startMs)
|
||||
expect(p.end).toBe(w.endMs)
|
||||
expect(p.compositeQuery.queryType).toBe('builder')
|
||||
expect(p.compositeQuery.panelType).toBe('list')
|
||||
const A = p.compositeQuery.builderQueries.A
|
||||
expect(A.dataSource).toBe('logs')
|
||||
expect(A.aggregateOperator).toBe('noop')
|
||||
expect(A.expression).toBe('A')
|
||||
expect(A.pageSize).toBe(100)
|
||||
// newest-first
|
||||
expect(A.orderBy).toEqual([{ columnName: 'timestamp', order: 'desc' }])
|
||||
expect(p.requestType).toBe('raw')
|
||||
expect(p.compositeQuery.queries).toHaveLength(1)
|
||||
const q = p.compositeQuery.queries[0]
|
||||
expect(q.type).toBe('builder_query')
|
||||
expect(q.spec).toMatchObject({ name: 'A', signal: 'logs', disabled: false, limit: 100, offset: 0 })
|
||||
})
|
||||
|
||||
it('carries dataSource=traces for a span search', () => {
|
||||
const p = listQueryPayload('traces', w, 50) as { compositeQuery: { builderQueries: { A: { dataSource: string } } } }
|
||||
expect(p.compositeQuery.builderQueries.A.dataSource).toBe('traces')
|
||||
it('carries signal=traces for a span search', () => {
|
||||
const p = rawQueryPayload('traces', w, 50) as V5
|
||||
expect(p.compositeQuery.queries[0].spec.signal).toBe('traces')
|
||||
})
|
||||
|
||||
it('clamps pageSize into [1,1000] and floors it', () => {
|
||||
const big = listQueryPayload('logs', w, 99999) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
|
||||
const zero = listQueryPayload('logs', w, 0) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
|
||||
const frac = listQueryPayload('logs', w, 12.9) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
|
||||
expect(big.compositeQuery.builderQueries.A.pageSize).toBe(1000)
|
||||
expect(zero.compositeQuery.builderQueries.A.pageSize).toBe(1)
|
||||
expect(frac.compositeQuery.builderQueries.A.pageSize).toBe(12)
|
||||
it('clamps limit into [1,1000] and floors it', () => {
|
||||
const lim = (n: number) => (rawQueryPayload('logs', w, n) as V5).compositeQuery.queries[0].spec.limit
|
||||
expect(lim(99999)).toBe(1000)
|
||||
expect(lim(0)).toBe(1)
|
||||
expect(lim(12.9)).toBe(12)
|
||||
})
|
||||
|
||||
it('defaults to NO filters (whole-org stream) when none are given — back-compat', () => {
|
||||
const p = listQueryPayload('logs', w, 100) as { compositeQuery: { builderQueries: { A: { filters: { items: unknown[]; op: string } } } } }
|
||||
expect(p.compositeQuery.builderQueries.A.filters).toEqual({ items: [], op: 'AND' })
|
||||
it('keeps order and filter OFF the wire — the runtime 500s resolving any key today', () => {
|
||||
// Both spec features route through telemetry-metadata key resolution, which the
|
||||
// deployed runtime cannot serve ("failed to get logs keys"). Raw is newest-first
|
||||
// by default, and service scoping is the client-side re-filter in ApmApi.
|
||||
const spec = (rawQueryPayload('logs', w, 100) as V5).compositeQuery.queries[0].spec
|
||||
expect('order' in spec).toBe(false)
|
||||
expect('filter' in spec).toBe(false)
|
||||
})
|
||||
|
||||
it('carries a per-service filter into the builder query when given (per-product scope)', () => {
|
||||
const item = serviceFilterItem('logs', 'iam')
|
||||
const p = listQueryPayload('logs', w, 100, [item]) as { compositeQuery: { builderQueries: { A: { filters: { items: unknown[]; op: string } } } } }
|
||||
expect(p.compositeQuery.builderQueries.A.filters).toEqual({ items: [item], op: 'AND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('serviceFilterItem (scope a logs/traces query to one OTel service.name)', () => {
|
||||
it('builds the service.name resource-attribute equality O11y expects', () => {
|
||||
const f = serviceFilterItem('logs', 'vector')
|
||||
expect(f.op).toBe('=')
|
||||
expect(f.value).toBe('vector')
|
||||
expect(f.key.key).toBe('service.name')
|
||||
expect(f.key.type).toBe('resource')
|
||||
expect(f.key.isColumn).toBe(false) // logs: resource attribute, not an indexed column
|
||||
})
|
||||
it('marks service.name as an indexed column for traces (where it is materialized)', () => {
|
||||
expect(serviceFilterItem('traces', 'gateway').key.isColumn).toBe(true)
|
||||
it('never emits a v3 field the strict v5 decoder refuses', () => {
|
||||
const p = rawQueryPayload('logs', w, 100) as Record<string, unknown>
|
||||
const composite = p.compositeQuery as Record<string, unknown>
|
||||
expect('builderQueries' in composite).toBe(false)
|
||||
expect('queryType' in composite).toBe(false)
|
||||
expect('panelType' in composite).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -338,21 +327,40 @@ describe('serviceHealthOf (RED verdict for one service)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseListRows', () => {
|
||||
it('reads rows from data.result[].list', () => {
|
||||
const body = { data: { result: [{ list: [{ timestamp: '1', data: { body: 'a' } }, { timestamp: '2', data: { body: 'b' } }] }] } }
|
||||
expect(parseListRows(body)).toHaveLength(2)
|
||||
describe('parseRawRows', () => {
|
||||
it('reads rows from the {status,data:{data:{results:[{rows}]}}} envelope', () => {
|
||||
const body = {
|
||||
status: 'success',
|
||||
data: { type: 'raw', data: { results: [{ queryName: 'A', rows: [{ timestamp: '1', data: { body: 'a' } }, { timestamp: '2', data: { body: 'b' } }] }] } },
|
||||
}
|
||||
expect(parseRawRows(body)).toHaveLength(2)
|
||||
})
|
||||
it('reads rows from the nested data.newResult.data.result[].list mirror', () => {
|
||||
const body = { data: { newResult: { data: { result: [{ list: [{ timestamp: '1', data: {} }] }] } } } }
|
||||
expect(parseListRows(body)).toHaveLength(1)
|
||||
it('reads rows from a bare {data:{results}} response', () => {
|
||||
const body = { data: { results: [{ rows: [{ timestamp: '1', data: {} }] }] } }
|
||||
expect(parseRawRows(body)).toHaveLength(1)
|
||||
})
|
||||
it('flattens the OTel attribute maps over the row scalars into ONE namespace', () => {
|
||||
const body = {
|
||||
data: { data: { results: [{ rows: [{
|
||||
timestamp: '2026-08-06T23:00:03Z',
|
||||
data: {
|
||||
body: 'request',
|
||||
severity_text: 'info',
|
||||
resources_string: { 'service.name': 'cloud' },
|
||||
attributes_string: { 'http.method': 'GET' },
|
||||
attributes_number: { 'http.status_code': 200 },
|
||||
},
|
||||
}] }] } },
|
||||
}
|
||||
const [row] = parseRawRows(body)
|
||||
expect(row.data).toMatchObject({ body: 'request', 'service.name': 'cloud', 'http.method': 'GET', 'http.status_code': 200 })
|
||||
})
|
||||
it('returns [] for empty/garbage/missing shapes (never throws)', () => {
|
||||
expect(parseListRows(null)).toEqual([])
|
||||
expect(parseListRows({})).toEqual([])
|
||||
expect(parseListRows({ data: { result: null } })).toEqual([])
|
||||
expect(parseListRows({ data: { result: [{ list: null }] } })).toEqual([])
|
||||
expect(parseListRows('nope')).toEqual([])
|
||||
expect(parseRawRows(null)).toEqual([])
|
||||
expect(parseRawRows({})).toEqual([])
|
||||
expect(parseRawRows({ data: { results: null } })).toEqual([])
|
||||
expect(parseRawRows({ data: { results: [{ rows: null }] } })).toEqual([])
|
||||
expect(parseRawRows('nope')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -402,15 +410,21 @@ describe('normalizeLogRow / normalizeLogs', () => {
|
||||
expect(l.body).toBe('hi')
|
||||
expect(l.id).toBe('5-3') // ts-idx fallback
|
||||
})
|
||||
it('maps a full query_range logs response, newest-first order preserved', () => {
|
||||
it('maps a full v5 query_range logs response, newest-first order preserved', () => {
|
||||
const body = {
|
||||
data: { result: [{ list: [{ timestamp: '2', data: { body: 'newer' } }, { timestamp: '1', data: { body: 'older' } }] }] },
|
||||
data: { data: { results: [{ rows: [{ timestamp: '2', data: { body: 'newer' } }, { timestamp: '1', data: { body: 'older' } }] }] } },
|
||||
}
|
||||
const rows = normalizeLogs(body)
|
||||
expect(rows.map((r) => r.body)).toEqual(['newer', 'older'])
|
||||
})
|
||||
it('empty result → empty list (honest empty, not a throw)', () => {
|
||||
expect(normalizeLogs({ data: { result: [] } })).toEqual([])
|
||||
it('reads the service from the nested resources_string map (as the runtime emits it)', () => {
|
||||
const body = {
|
||||
data: { data: { results: [{ rows: [{ timestamp: '1', data: { body: 'hi', severity_text: 'INFO', resources_string: { 'service.name': 'iam' } } }] }] } },
|
||||
}
|
||||
expect(normalizeLogs(body)[0]).toMatchObject({ service: 'iam', severity: 'info', body: 'hi' })
|
||||
})
|
||||
it('empty results → empty list (honest empty, not a throw)', () => {
|
||||
expect(normalizeLogs({ data: { data: { results: [] } } })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,8 +446,8 @@ describe('normalizeTraceSpan / normalizeSpans', () => {
|
||||
expect(normalizeTraceSpan({ data: { spanID: 's' } }, 0).durationNano).toBeNull()
|
||||
expect(normalizeTraceSpan({ data: { spanID: 's', durationNano: '' } }, 0).durationNano).toBeNull()
|
||||
})
|
||||
it('maps a full query_range traces response', () => {
|
||||
const body = { data: { result: [{ list: [{ timestamp: '1', data: { traceID: 't', name: 'op' } }] }] } }
|
||||
it('maps a full v5 query_range traces response', () => {
|
||||
const body = { data: { data: { results: [{ rows: [{ timestamp: '1', data: { trace_id: 't', name: 'op' } }] }] } } }
|
||||
expect(normalizeSpans(body)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
+78
-114
@@ -532,122 +532,86 @@ export function normalizeDashboards(body: unknown): Dashboard[] {
|
||||
return rows.map(normalizeDashboard).filter((d) => d.uuid !== '')
|
||||
}
|
||||
|
||||
// ── Logs + Traces (O11y composite query_range) ──────────────────────────────
|
||||
// ── Logs + Traces (O11y v5 composite query_range) ────────────────────────────
|
||||
//
|
||||
// The universal `POST /v1/o11y/query_range` builder query (version-less canonical
|
||||
// surface). A `list`-panel `noop` query over `dataSource: logs | traces` returns RAW
|
||||
// rows (recent log lines / spans), newest first — the one true logs/traces read.
|
||||
// Time is epoch MILLISECONDS = `ApmWindow.startMs/endMs`. Every helper is pure (JSON
|
||||
// in, view-model out) so it unit-tests without a live runtime.
|
||||
// The universal `POST /v1/o11y/query_range` builder query. The runtime behind the
|
||||
// flat path is the module's V5 querier — its composite is `{queries: [{type,
|
||||
// spec}]}` and its decoder is STRICT, so the old v3 `{queryType, panelType,
|
||||
// builderQueries}` envelope is refused outright ("unknown field \"builderQueries\"
|
||||
// in composite query"), which the overview panel rendered as "Could not reach
|
||||
// observability". A `requestType: "raw"` query over `signal: logs | traces`
|
||||
// returns raw rows (recent log lines / spans), newest first — the server's own
|
||||
// default order for raw.
|
||||
//
|
||||
// Two v5 spec features are deliberately NOT sent, verified against the live
|
||||
// runtime: an `order` clause and a `filter` expression both route through
|
||||
// telemetry-metadata key resolution, which currently fails server-side ("failed
|
||||
// to get logs keys", 500) — the runtime's key tables are not reachable from the
|
||||
// embedded store. Raw already returns newest-first without `order`, and service
|
||||
// scoping is enforced by the client-side re-filter below (which these readers
|
||||
// always did as their leak-proofing). When the metadata store heals, the filter
|
||||
// expression (`service.name = '<svc>'`) is the one-line addition.
|
||||
//
|
||||
// Time is epoch MILLISECONDS = `ApmWindow.startMs/endMs`. Every helper is pure
|
||||
// (JSON in, view-model out) so it unit-tests without a live runtime.
|
||||
|
||||
/** The telemetry signal a builder query reads. */
|
||||
export type O11yDataSource = 'logs' | 'traces' | 'metrics'
|
||||
|
||||
/**
|
||||
* One O11y builder-query filter item — the `{key, op, value}` shape the explorer
|
||||
* sends. `key` carries the attribute's name + type so the runtime resolves it
|
||||
* correctly (a resource attribute vs an indexed column).
|
||||
* The v5 `query_range` RAW payload — ONE `builder_query` envelope keyed `A`.
|
||||
* `limit` is clamped into [1,1000] (the page a reader shows). No `order`, no
|
||||
* `filter` — see the section note above for why both stay off the wire today.
|
||||
*/
|
||||
export type QueryFilterItem = {
|
||||
key: { key: string; dataType: 'string'; type: string; isColumn: boolean }
|
||||
op: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a logs/traces list query to ONE OpenTelemetry `service.name`. `service.name`
|
||||
* is a RESOURCE attribute on both signals (on traces it is also materialized as an
|
||||
* indexed column, so `isColumn` is set there) — this is the exact filter item O11y's
|
||||
* own explorer emits for a service-scoped list, so the runtime resolves it and never
|
||||
* 400s. The caller ALSO re-filters the normalized rows client-side (belt-and-suspenders),
|
||||
* so a runtime that ignores the item can never leak another service's rows onto a
|
||||
* per-product page.
|
||||
*/
|
||||
export function serviceFilterItem(dataSource: O11yDataSource, service: string): QueryFilterItem {
|
||||
return {
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource', isColumn: dataSource === 'traces' },
|
||||
op: '=',
|
||||
value: service,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `list`-panel `selectColumns` per signal. The traces v4 list builder HARD-fails
|
||||
* (`select columns cannot be empty for panelType list`, → 500) when a noop list query
|
||||
* carries no `selectColumns`; the query already emits timestamp/spanID/traceID, so
|
||||
* these ADD the display fields `normalizeTraceSpan` reads. Each name is a materialized
|
||||
* static trace column (o11y `StaticFieldsTraces`), so the runtime resolves it verbatim.
|
||||
* Logs list does NOT require selectColumns (its noop path returns the default row set),
|
||||
* so it stays empty — adding trace columns there would reference non-existent log columns.
|
||||
*/
|
||||
const listSelectColumns: Record<O11yDataSource, Array<Record<string, unknown>>> = {
|
||||
traces: [
|
||||
{ key: 'name', dataType: 'string', type: 'tag', isColumn: true },
|
||||
{ key: 'duration_nano', dataType: 'float64', type: 'tag', isColumn: true },
|
||||
{ key: 'response_status_code', dataType: 'string', type: 'tag', isColumn: true },
|
||||
],
|
||||
logs: [],
|
||||
metrics: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact v3 `query_range` LIST payload — ONE `noop` builder query keyed `A`,
|
||||
* newest-first, paged by `offset`/`pageSize`. Mirrors what O11y's own explorer
|
||||
* sends (verified against the frontend + the server `BuilderQuery` struct), so the
|
||||
* runtime never 400s on shape. `filters` (default none) scopes the query — e.g. a
|
||||
* `serviceFilterItem` restricts it to one product's OTel service. `selectColumns`
|
||||
* is REQUIRED by the traces list builder (empty → 500); see `listSelectColumns`.
|
||||
*/
|
||||
export function listQueryPayload(
|
||||
dataSource: O11yDataSource,
|
||||
w: ApmWindow,
|
||||
limit: number,
|
||||
filters: QueryFilterItem[] = [],
|
||||
): Record<string, unknown> {
|
||||
const pageSize = Math.max(1, Math.min(1000, Math.floor(limit)))
|
||||
export function rawQueryPayload(signal: O11yDataSource, w: ApmWindow, limit: number): Record<string, unknown> {
|
||||
const capped = Math.max(1, Math.min(1000, Math.floor(limit)))
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
start: w.startMs,
|
||||
end: w.endMs,
|
||||
step: 60,
|
||||
requestType: 'raw',
|
||||
compositeQuery: {
|
||||
queryType: 'builder',
|
||||
panelType: 'list',
|
||||
builderQueries: {
|
||||
A: {
|
||||
queryName: 'A',
|
||||
dataSource,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
filters: { items: filters, op: 'AND' },
|
||||
selectColumns: listSelectColumns[dataSource],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
limit: null,
|
||||
offset: 0,
|
||||
pageSize,
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', signal, disabled: false, limit: capped, offset: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A `list`-panel query returns rows under `data.result[].list` (newer runtimes
|
||||
* mirror them under `data.newResult.data.result[].list`); each is `{timestamp,data}`. */
|
||||
/** One raw row, its attribute maps flattened so `pick` reads one namespace. */
|
||||
type ListRow = { timestamp?: string | number; data?: Record<string, unknown> | null }
|
||||
|
||||
/** Pull the flat list rows out of either result location, never throwing on shape. */
|
||||
export function parseListRows(body: unknown): ListRow[] {
|
||||
const r = (body ?? {}) as { data?: { result?: unknown; newResult?: { data?: { result?: unknown } } } }
|
||||
const direct = r?.data?.result
|
||||
const nested = r?.data?.newResult?.data?.result
|
||||
const results = (Array.isArray(direct) ? direct : Array.isArray(nested) ? nested : []) as { list?: unknown }[]
|
||||
/**
|
||||
* Pull the raw rows out of a v5 `query_range` response, never throwing on shape.
|
||||
* The envelope is `{status, data: {type: "raw", data: {results: [{queryName,
|
||||
* rows: [{timestamp, data}]}]}}}` (a bare `{data: {results}}` is also accepted).
|
||||
* Each row's `data` nests the OTel attribute maps (`resources_string`,
|
||||
* `attributes_string`, `attributes_number`, `attributes_bool`); they are
|
||||
* flattened over the row's own scalars so downstream readers keep addressing one
|
||||
* flat namespace (`body`, `severity_text`, `service.name`, …) exactly as the v3
|
||||
* list rows carried it.
|
||||
*/
|
||||
export function parseRawRows(body: unknown): ListRow[] {
|
||||
const r = (body ?? {}) as { data?: { data?: { results?: unknown }; results?: unknown } }
|
||||
const results = ([] as unknown[]).concat(
|
||||
(Array.isArray(r?.data?.data?.results) ? r.data.data.results : Array.isArray(r?.data?.results) ? r.data.results : []) as unknown[],
|
||||
) as { rows?: unknown }[]
|
||||
const out: ListRow[] = []
|
||||
for (const res of results) {
|
||||
if (Array.isArray(res?.list)) out.push(...(res.list as ListRow[]).filter((x): x is ListRow => x != null))
|
||||
if (!Array.isArray(res?.rows)) continue
|
||||
for (const raw of res.rows as ListRow[]) {
|
||||
if (raw == null) continue
|
||||
const d = (raw.data ?? {}) as Record<string, unknown>
|
||||
const flat: Record<string, unknown> = { ...d }
|
||||
for (const mapKey of ['resources_string', 'attributes_string', 'attributes_number', 'attributes_bool']) {
|
||||
const m = d[mapKey]
|
||||
if (m && typeof m === 'object' && !Array.isArray(m)) Object.assign(flat, m as Record<string, unknown>)
|
||||
}
|
||||
out.push({ timestamp: raw.timestamp, data: flat })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -691,7 +655,7 @@ export function normalizeLogRow(row: ListRow, idx: number): LogRow {
|
||||
|
||||
/** Normalize a logs `query_range` response → LogRow[] (newest first). */
|
||||
export function normalizeLogs(body: unknown): LogRow[] {
|
||||
return parseListRows(body).map(normalizeLogRow)
|
||||
return parseRawRows(body).map(normalizeLogRow)
|
||||
}
|
||||
|
||||
/** One trace/span row from a `dataSource: traces` list query. */
|
||||
@@ -726,7 +690,7 @@ export function normalizeTraceSpan(row: ListRow, idx: number): TraceSpan {
|
||||
|
||||
/** Normalize a traces `query_range` response → TraceSpan[] (newest first). */
|
||||
export function normalizeSpans(body: unknown): TraceSpan[] {
|
||||
return parseListRows(body).map(normalizeTraceSpan)
|
||||
return parseRawRows(body).map(normalizeTraceSpan)
|
||||
}
|
||||
|
||||
// ── Transport ─────────────────────────────────────────────────────────────────
|
||||
@@ -734,12 +698,11 @@ export function normalizeSpans(body: unknown): TraceSpan[] {
|
||||
const u = (path: string): string => cloudProxyV1Url(`o11y/${path}`)
|
||||
|
||||
// The composite builder query rides the FLAT public path `/v1/o11y/query_range` (one
|
||||
// /v1/, no nested /api/vN). `listQueryPayload` + `parseListRows` are a matched v3 pair
|
||||
// (`compositeQuery.{queryType,builderQueries}` → `data.result[].list`); the cloud
|
||||
// clients/o11y flat route (query.go) resolves this flat path to the v3 engine handler
|
||||
// SERVER-SIDE. (The upstream module's version-less alias would instead resolve to the
|
||||
// HIGHEST engine version (v5), whose composite accepts only `{queries:[…]}` and 400s
|
||||
// the v3 shape — which is exactly why the mapping is pinned in cloud, not here.)
|
||||
// /v1/, no nested /api/vN). The version-less address resolves to the module's HIGHEST
|
||||
// engine — the v5 querier — so `rawQueryPayload` + `parseRawRows` are its matched pair
|
||||
// (`compositeQuery.queries[]` → `data.data.results[].rows`). The v3 pin this comment
|
||||
// used to cite (cloud's query.go) is deleted; a v3 envelope sent here is refused by
|
||||
// the strict v5 decoder, not quietly served.
|
||||
const COMPOSITE_QUERY_RANGE = 'query_range'
|
||||
|
||||
/** The APM POST body — a start/end window + optional tags filter (O11y shape). */
|
||||
@@ -782,19 +745,20 @@ export const ApmApi = {
|
||||
dashboards: async (): Promise<Dashboard[]> => normalizeDashboards(await restGet<unknown>(u('dashboards'))),
|
||||
dashboard: (uuid: string): Promise<unknown> => restGet<unknown>(u(`dashboards/${encodeURIComponent(uuid)}`)),
|
||||
|
||||
// ── Logs + Traces (composite query_range; `/v1/o11y/logs` is a stub) ──
|
||||
// A `service` scopes the query to ONE product's OTel `service.name` (the per-product
|
||||
// Logs sub-page); omit it for the org-wide stream. The rows are re-filtered client-side
|
||||
// to the same service so a runtime ignoring the item can never leak other services' lines.
|
||||
// ── Logs + Traces (v5 raw query_range; `/v1/o11y/logs` is a stub) ──
|
||||
// A `service` scopes the read to ONE product's OTel `service.name` (the
|
||||
// per-product Logs sub-page); omit it for the org-wide stream. Scoping is the
|
||||
// CLIENT-SIDE re-filter — the server-side filter expression is off the wire
|
||||
// while the runtime's key resolution is down (see rawQueryPayload) — so a
|
||||
// scoped read asks for a deeper page (up to the 1000 cap) and keeps what
|
||||
// matches. A row with no service survives the filter, as it always did here.
|
||||
logs: async (w: ApmWindow, limit = 200, service?: string): Promise<LogRow[]> => {
|
||||
const filters = service ? [serviceFilterItem('logs', service)] : []
|
||||
const rows = normalizeLogs(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), listQueryPayload('logs', w, limit, filters)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service) : rows
|
||||
const rows = normalizeLogs(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), rawQueryPayload('logs', w, service ? 1000 : limit)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service).slice(0, limit) : rows
|
||||
},
|
||||
traceSearch: async (w: ApmWindow, limit = 200, service?: string): Promise<TraceSpan[]> => {
|
||||
const filters = service ? [serviceFilterItem('traces', service)] : []
|
||||
const rows = normalizeSpans(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), listQueryPayload('traces', w, limit, filters)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service) : rows
|
||||
const rows = normalizeSpans(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), rawQueryPayload('traces', w, service ? 1000 : limit)))
|
||||
return service ? rows.filter((r) => !r.service || r.service === service).slice(0, limit) : rows
|
||||
},
|
||||
|
||||
// ── Per-product service health (RED metrics for ONE product's OTel service) ──
|
||||
|
||||
@@ -283,8 +283,8 @@ export {
|
||||
normalizeIssueDetail,
|
||||
normalizeDashboard,
|
||||
normalizeDashboards,
|
||||
listQueryPayload,
|
||||
parseListRows,
|
||||
rawQueryPayload,
|
||||
parseRawRows,
|
||||
toIso,
|
||||
normalizeLogRow,
|
||||
normalizeLogs,
|
||||
|
||||
@@ -80,6 +80,18 @@ export function iamAccessToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the browser still holds an IAM session to refresh — an access token is
|
||||
* stored, even if the SDK considers it expired (`getAccessToken` returns the raw
|
||||
* stored token). Cheap + synchronous, no network. Used to BOUND the refresh retry:
|
||||
* an anonymous visitor (no stored token) never waits through the backoff, and a
|
||||
* retry stops the moment the session is gone from storage (a revoked token the SDK
|
||||
* cleared cannot be brought back by retrying).
|
||||
*/
|
||||
export function iamHasSession(): boolean {
|
||||
return iamAccessToken() != null
|
||||
}
|
||||
|
||||
/** A valid (auto-refreshed if needed) access token, or null. */
|
||||
export async function iamValidAccessToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
@@ -132,6 +144,47 @@ export function iamSignOut(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to send the browser to END THE SESSION AT THE ISSUER.
|
||||
*
|
||||
* `iamSignOut()` above drops this tab's tokens and nothing else, which is only
|
||||
* half of signing out and is the half nobody notices. The other half is the
|
||||
* `iam_session_id` cookie at hanzo.id: leave it and signing out does not stick,
|
||||
* because the very next thing the app does is bounce to `/signin`, and sign-in
|
||||
* asks the issuer for a code from the EXISTING session. Measured against prod —
|
||||
* clear the tokens the way this module did and silent SSO still answers
|
||||
* `status: ok` with a code, so the user lands straight back in the console they
|
||||
* just left. Clearing the whole session cookie jar is not an option either: it
|
||||
* is set on hanzo.id, and this origin cannot touch it.
|
||||
*
|
||||
* Only the issuer can end the issuer's session, so sign-out has to be a
|
||||
* NAVIGATION there. RP-initiated logout returns the browser to
|
||||
* `post_logout_redirect_uri`, which is why this replaces the `/signin` assign
|
||||
* rather than racing it: one navigation, and it lands where the old one did.
|
||||
*
|
||||
* Verified: after this URL, the same silent-SSO probe answers
|
||||
* "please sign in first" and mints nothing.
|
||||
*/
|
||||
export function iamSignOutUrl(origin: string, returnPath = '/signin'): string {
|
||||
// The origin is a PARAMETER, not `window.location.origin` read in here. Every
|
||||
// other function in this module guards `typeof window === 'undefined'` because
|
||||
// this file is imported by server-rendered code; one that reads `window`
|
||||
// unguarded throws the moment anything touches it during SSR. Taking it as an
|
||||
// argument removes the hazard instead of guarding it, and makes the URL a pure
|
||||
// function of its inputs — testable with no DOM, which is what this repo's
|
||||
// suite runs.
|
||||
const url = new URL('/v1/iam/oauth/logout', config.iamUrl)
|
||||
// Resolve the return against OUR origin, then require it to have stayed there.
|
||||
// An absolute URL wins over a base in `new URL`, so a caller passing a foreign
|
||||
// one would otherwise hand the IdP an open redirect to hand back. A return leg
|
||||
// that left this origin is never what sign-out meant, so it falls back to the
|
||||
// sign-in page rather than being honored.
|
||||
const back = new URL(returnPath, origin)
|
||||
const safe = back.origin === origin ? back : new URL('/signin', origin)
|
||||
url.searchParams.set('post_logout_redirect_uri', safe.toString())
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// -- Graceful re-auth: return the user to their task after re-signing in --------
|
||||
// A mid-task session expiry (a 401) should not dump the user on the home page — we
|
||||
// stash where they were and the callback lands them back there.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// refreshSession is browser-only and delegates to the IAM SDK; mock the SDK wrapper so
|
||||
// the single-flight wiring is exercised in the node test env. resilientRefresh below is
|
||||
// pure over injected deps, so it needs no mock (it never touches iam).
|
||||
vi.mock('./iam', () => ({
|
||||
iamValidAccessToken: vi.fn(),
|
||||
iamHasSession: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
import { resilientRefresh, refreshSession, REFRESH_RETRY_MS } from './refresh'
|
||||
import { iamValidAccessToken } from './iam'
|
||||
|
||||
const noSleep = (_ms: number) => Promise.resolve()
|
||||
|
||||
// The FIX itself — the exact `resilientFetch` injected-deps idiom the API client uses.
|
||||
describe('resilientRefresh — a transient blip self-heals; a dead session does not spin', () => {
|
||||
it('returns true on the first attempt, no retry, no sleep', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue('tok')
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
|
||||
expect(attempt).toHaveBeenCalledTimes(1)
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers a TRANSIENT failure: null then a token → true (the whole point of the fix)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValueOnce(null).mockResolvedValue('tok')
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
|
||||
expect(attempt).toHaveBeenCalledTimes(2)
|
||||
expect(sleep).toHaveBeenCalledTimes(1)
|
||||
expect(sleep).toHaveBeenCalledWith(REFRESH_RETRY_MS[0])
|
||||
})
|
||||
|
||||
it('a genuinely-dead session resolves false only after exhausting the bounded retries', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(false)
|
||||
// one initial attempt + one per backoff slot
|
||||
expect(attempt).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length + 1)
|
||||
expect(sleep).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length)
|
||||
expect(sleep.mock.calls.map((c) => c[0])).toEqual(REFRESH_RETRY_MS)
|
||||
})
|
||||
|
||||
it('never waits through the backoff when there is no session to refresh (anonymous / revoked)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
expect(await resilientRefresh({ attempt, hasSession: () => false, sleep })).toBe(false)
|
||||
expect(attempt).toHaveBeenCalledTimes(1) // one try, then hasSession() false → stop
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops the moment the session disappears mid-retry (a revoked token the SDK cleared)', async () => {
|
||||
const attempt = vi.fn().mockResolvedValue(null)
|
||||
const sleep = vi.fn(noSleep)
|
||||
const hasSession = vi.fn().mockReturnValueOnce(true).mockReturnValue(false)
|
||||
expect(await resilientRefresh({ attempt, hasSession, sleep })).toBe(false)
|
||||
expect(attempt).toHaveBeenCalledTimes(2) // initial + one retry, then session gone
|
||||
expect(sleep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
const mockAttempt = iamValidAccessToken as ReturnType<typeof vi.fn>
|
||||
|
||||
// The wiring: browser-only + single-flight (concurrent callers share ONE rotation —
|
||||
// load-bearing for a one-time-use rotating refresh token).
|
||||
describe('refreshSession — browser-only, single-flight', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('window', {} as unknown as Window & typeof globalThis)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('is a no-op on the server (no window) — resolves false, never touches the SDK', async () => {
|
||||
vi.unstubAllGlobals() // remove the window stub → typeof window === 'undefined'
|
||||
expect(await refreshSession()).toBe(false)
|
||||
expect(mockAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses concurrent callers onto ONE rotation (the timer + N parallel 401s)', async () => {
|
||||
mockAttempt.mockResolvedValue('tok')
|
||||
const p1 = refreshSession()
|
||||
const p2 = refreshSession()
|
||||
expect(p1).toBe(p2) // same in-flight promise
|
||||
expect(await Promise.all([p1, p2])).toEqual([true, true])
|
||||
expect(mockAttempt).toHaveBeenCalledTimes(1) // one rotation, not two
|
||||
// Settled → a later caller starts a fresh rotation (inflight cleared).
|
||||
expect(await refreshSession()).toBe(true)
|
||||
expect(mockAttempt).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
+59
-15
@@ -11,8 +11,53 @@
|
||||
* the token, the second replays the now-invalid one and 400s, needlessly killing a
|
||||
* healthy session. Sharing ONE in-flight promise means every concurrent caller (the
|
||||
* timer + N parallel 401s) awaits the SAME single rotation.
|
||||
*
|
||||
* RESILIENT, not jumpy. One attempt used to be the whole story: a single transient
|
||||
* failure (a network blip, a 5xx from IAM, a lost rotation race) yielded a bare
|
||||
* `false`, and the caller took that one "no" as a definitive sign-out — the "session
|
||||
* expired, sign in again" card fired mid-task on a hiccup. A transient failure and a
|
||||
* genuinely-dead refresh token are indistinguishable at this layer (the SDK collapses
|
||||
* both to a null token), so we RETRY a bounded few times with short backoff WHILE a
|
||||
* session still exists: a blip self-heals (session preserved, no false eviction), and a
|
||||
* truly-expired session resolves `false` ~1s later — an acceptable delay before the
|
||||
* honest re-auth card. An anonymous visitor (no stored token) never waits through the
|
||||
* backoff (`hasSession` short-circuits on the first miss).
|
||||
*/
|
||||
import { iamValidAccessToken } from './iam'
|
||||
import { iamValidAccessToken, iamHasSession } from './iam'
|
||||
|
||||
/** Backoff before each retry AFTER the first attempt — transient recovery only. Worst
|
||||
* case added before an honest `false` when a stored token is dead: ~1.6s. */
|
||||
export const REFRESH_RETRY_MS = [400, 1200]
|
||||
|
||||
/** Injected dependencies for `resilientRefresh` — real ones in `refreshSession`, fakes
|
||||
* in tests. Mirrors the `ResilientDeps` idiom the API client uses for `resilientFetch`. */
|
||||
export interface RefreshDeps {
|
||||
/** One refresh attempt: a live access token, or null (a TRANSIENT failure OR a
|
||||
* genuinely signed-out state — this layer cannot tell them apart). Never throws. */
|
||||
attempt: () => Promise<string | null>
|
||||
/** True while the browser still holds a session to refresh (else retrying is futile). */
|
||||
hasSession: () => boolean
|
||||
sleep: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure refresh orchestration (over its injected deps): try once, then retry a bounded
|
||||
* few times with backoff — but ONLY while a session still exists. So a network blip /
|
||||
* lost rotation race self-heals (returns true on recovery), while a genuinely signed-out
|
||||
* state resolves false without wasted retries. Bounded by `REFRESH_RETRY_MS`.
|
||||
*/
|
||||
export async function resilientRefresh(deps: RefreshDeps): Promise<boolean> {
|
||||
for (let i = 0; ; i++) {
|
||||
if (await deps.attempt()) return true
|
||||
// First attempt failed. Stop if we've exhausted the budget OR the session is gone
|
||||
// from storage (an anonymous visitor, or a revoked token the SDK cleared — retrying
|
||||
// cannot bring it back). Otherwise wait and retry: the failure may be transient.
|
||||
if (i >= REFRESH_RETRY_MS.length || !deps.hasSession()) return false
|
||||
await deps.sleep(REFRESH_RETRY_MS[i])
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
let inflight: Promise<boolean> | null = null
|
||||
|
||||
@@ -24,19 +69,18 @@ let inflight: Promise<boolean> | null = null
|
||||
export function refreshSession(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return Promise.resolve(false)
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
try {
|
||||
// getValidAccessToken() returns the current token, or transparently runs the
|
||||
// refresh grant when it is expired — the SDK's own single rotation.
|
||||
const token = await iamValidAccessToken()
|
||||
return !!token
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
|
||||
// rotation and one arriving after it starts a fresh one.
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
inflight = resilientRefresh({
|
||||
// getValidAccessToken() returns the current token, or transparently runs the refresh
|
||||
// grant when it is expired — the SDK's own single rotation. iamValidAccessToken wraps
|
||||
// it browser-safe and never throws (a failure is a null token).
|
||||
attempt: iamValidAccessToken,
|
||||
hasSession: iamHasSession,
|
||||
sleep,
|
||||
})
|
||||
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
|
||||
// rotation and one arriving after it starts a fresh one.
|
||||
void inflight.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty
|
||||
|
||||
import { AccountApi, type Account } from '~/lib/api'
|
||||
import { withTimeout } from '~/lib/with-timeout'
|
||||
import { signinRedirect, stashReturnTo, iamSignOut } from './iam'
|
||||
import { signinRedirect, stashReturnTo, iamSignOut, iamSignOutUrl } from './iam'
|
||||
import { refreshSession } from './refresh'
|
||||
import { setCurrentActor } from '~/lib/actor-scope'
|
||||
import { claimReferralOnce, stashReferralCode } from '~/lib/referrals/claim'
|
||||
@@ -133,10 +133,21 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
await AccountApi.signout()
|
||||
iamSignOut()
|
||||
applyAccount(null)
|
||||
// Redirect DETERMINISTICALLY to /signin. A hard navigation is the single source of
|
||||
// truth for "signed out -> /signin" and clears all in-memory state (org scope,
|
||||
// caches, balances) — the same `window.location.assign` the sign-IN path uses.
|
||||
if (typeof window !== 'undefined') window.location.assign('/signin')
|
||||
// Redirect DETERMINISTICALLY, and through the ISSUER. A hard navigation is
|
||||
// still the single source of truth for "signed out -> /signin" — it clears
|
||||
// all in-memory state (org scope, caches, balances) exactly as the sign-IN
|
||||
// path does — but it has to go via RP-initiated logout, which returns here
|
||||
// through post_logout_redirect_uri.
|
||||
//
|
||||
// Assigning '/signin' directly is what made sign-out not stick: the two
|
||||
// calls above end the session HERE, and the `iam_session_id` cookie at the
|
||||
// issuer survives, so /signin's silent SSO immediately mints a code from it
|
||||
// and puts the user back in the console they just left. Measured: same
|
||||
// probe, `status: ok` with a code before this change, "please sign in
|
||||
// first" after.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.assign(iamSignOutUrl(window.location.origin, '/signin'))
|
||||
}
|
||||
}, [applyAccount])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Only the issuer origin matters here; the rest of `config` is irrelevant to the
|
||||
// URL under test, so the mock stays the size of the dependency.
|
||||
vi.mock('~/config', () => ({ config: { iamUrl: 'https://hanzo.id' } }))
|
||||
|
||||
const { iamSignOutUrl } = await import('~/lib/auth/iam')
|
||||
|
||||
const HERE = 'https://console.hanzo.ai'
|
||||
const back = (u: string) => new URL(u).searchParams.get('post_logout_redirect_uri')!
|
||||
|
||||
/**
|
||||
* Signing out has to end the session at the ISSUER, not just in this tab.
|
||||
*
|
||||
* `iamSignOut()` drops this browser's tokens and nothing else. That is half of it,
|
||||
* and the half nobody notices: the `iam_session_id` cookie lives on hanzo.id, this
|
||||
* origin cannot touch it, and the very next thing the app does is bounce to
|
||||
* /signin — which asks the issuer for a code from the EXISTING session. Measured
|
||||
* against production before the fix: clear the tokens the way this module did, and
|
||||
* silent SSO still answered `status: ok` with a code, so the user landed straight
|
||||
* back in the console they had just left.
|
||||
*/
|
||||
describe('sign-out ends the session at the issuer', () => {
|
||||
it('points at RP-initiated logout on the ISSUER, not this origin', () => {
|
||||
const u = new URL(iamSignOutUrl(HERE))
|
||||
expect(u.origin).toBe('https://hanzo.id')
|
||||
expect(u.pathname).toBe('/v1/iam/oauth/logout')
|
||||
})
|
||||
|
||||
it('returns the browser to an absolute URL on THIS origin', () => {
|
||||
// RP-initiated logout redirects; a bare path would be resolved against the
|
||||
// ISSUER, landing the user on hanzo.id/signin instead of the console's.
|
||||
expect(back(iamSignOutUrl(HERE, '/signin'))).toBe(`${HERE}/signin`)
|
||||
})
|
||||
|
||||
it('defaults to /signin, so a caller cannot forget where to land', () => {
|
||||
expect(back(iamSignOutUrl(HERE))).toBe(`${HERE}/signin`)
|
||||
})
|
||||
|
||||
it('refuses a return leg that leaves this origin', () => {
|
||||
// An absolute URL beats a base in `new URL`, so without the check the IdP
|
||||
// would be handed an open redirect to hand back. A foreign return is never
|
||||
// what sign-out meant — fall back to our own sign-in page.
|
||||
expect(back(iamSignOutUrl(HERE, 'https://evil.example.com/steal'))).toBe(`${HERE}/signin`)
|
||||
expect(back(iamSignOutUrl(HERE, '//evil.example.com/steal'))).toBe(`${HERE}/signin`)
|
||||
})
|
||||
|
||||
it('is a pure function of its inputs — no window, so SSR cannot throw', () => {
|
||||
// This module is imported by server-rendered code. Every sibling guards
|
||||
// `typeof window === 'undefined'`; this one has nothing to guard, which is
|
||||
// why the test can run in the repo's node environment at all.
|
||||
expect(typeof globalThis.window).toBe('undefined')
|
||||
expect(() => iamSignOutUrl(HERE)).not.toThrow()
|
||||
})
|
||||
})
|
||||
+37
-32
@@ -19,9 +19,10 @@
|
||||
* Wiring for the console SPA:
|
||||
* - `host: ''` — SAME-ORIGIN. Events POST to the console's own `/v1/event`. The
|
||||
* client NEVER sends an org/tenant — Cloud stamps it from the validated bearer.
|
||||
* - `getToken` — the signed-in visitor's own Hanzo IAM access token. THIS is what
|
||||
* attributes the stream, and it is the only mechanism that is correct here; see
|
||||
* the note below for why no publishable key is passed.
|
||||
* - `getToken` — the signed-in visitor's own Hanzo IAM access token, falling back
|
||||
* to the publishable key when there is none. The token attributes the stream to
|
||||
* the visitor's own org on every brand host; the key exists so a SIGNED-OUT view
|
||||
* is admitted at all. Token first, key second — see the note below.
|
||||
* - `dsn` (`NEXT_PUBLIC_HANZO_EVENT_DSN`) — the error plane's own credential,
|
||||
* shaped `https://<key>@api.hanzo.ai/v1/sentry/<project>`. Publishable by design
|
||||
* (it ships in the client bundle). Unset → errors are captured and dropped.
|
||||
@@ -51,38 +52,42 @@ function consented(): boolean {
|
||||
return dnt !== '1' && dnt !== 'yes'
|
||||
}
|
||||
|
||||
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
|
||||
// ── The token FIRST, the publishable key only when there is no token ─────────
|
||||
//
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
|
||||
// here.
|
||||
// The signed-in path is unchanged and remains the point: cloud resolves the
|
||||
// tenant from the IAM bearer's OWN owner claim, so each visitor's events land in
|
||||
// THEIR org on every brand host — cloud.hanzo.ai, cloud.lux.cloud,
|
||||
// cloud.zoo.cloud — with no per-brand configuration. The JWT carries the org;
|
||||
// everything downstream is org-scoped by it.
|
||||
//
|
||||
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
|
||||
// this image is brand-agnostic: one build serves cloud.hanzo.ai, cloud.lux.cloud and
|
||||
// cloud.zoo.cloud, with the brand resolved at RUNTIME from the request hostname
|
||||
// (src/config). Baking a key would file every brand's — and every customer's —
|
||||
// traffic into whichever org the key belongs to, which is both wrong data and a
|
||||
// cross-tenant leak. It is the same reason Dockerfile bakes no NEXT_PUBLIC_*.
|
||||
// What changed is the SIGNED-OUT path, which reported nothing at all. A
|
||||
// credential-less POST is refused outright — `401 ingest_key_required`, measured
|
||||
// against the live door with and without a browser Origin, for pageviews and
|
||||
// exceptions alike. (An earlier version of this comment described an "anonymous
|
||||
// lane" that admitted pageview + error under a `$public` tenant and answered 200.
|
||||
// That lane is not implemented in the deployed cloud; the same claim was wrong in
|
||||
// four other repos and is why keyless surfaces were believed to be half-working.)
|
||||
//
|
||||
// Worse, it would be SILENT: @hanzo/event resolves the outgoing credential as
|
||||
// `ingestKey ?? token`, so a key takes PRECEDENCE over the bearer — setting one
|
||||
// would OVERRIDE each signed-in user's own identity rather than supplement it.
|
||||
// So the publishable key is a FALLBACK, never an override. That distinction is
|
||||
// load-bearing: @hanzo/event resolves the outgoing credential as
|
||||
// `ingestKey ?? token`, so passing `ingestKey` would REPLACE each signed-in
|
||||
// user's bearer and file their events under the key's org instead of their own —
|
||||
// on a multi-brand image, that is a cross-tenant defect. Supplying the key
|
||||
// through `getToken` inverts that precedence into `token ?? key`, which is the
|
||||
// order this product actually wants.
|
||||
//
|
||||
// THE COOKIE IS NOT A CREDENTIAL HERE. This file used to claim that posting
|
||||
// same-origin let the first-party session ride along, so signed-in traffic landed
|
||||
// correctly. It does not. That cookie is the casibase session, while cloud resolves
|
||||
// a tenant from a VALIDATED IAM bearer (SanitizeIdentity) — so a cookie-only POST
|
||||
// carries no principal. It is not refused: it silently takes the ANONYMOUS lane,
|
||||
// which files every row under the `$public` tenant (a partition no org can read) and
|
||||
// drops `identify` with a 200 receipt. Production proved it — 498 console rows, all
|
||||
// `$public`, zero identified users.
|
||||
//
|
||||
// `getToken` below is the fix, and it has neither problem: cloud resolves the tenant
|
||||
// from the token's OWN owner claim, so each visitor's events land in THEIR org, on
|
||||
// every brand host, with no per-brand configuration. Logged-out views carry no token
|
||||
// and stay anonymous — the honest outcome for a visitor who has not identified
|
||||
// themselves, and still the open question for the public/marketing faces (closing
|
||||
// that needs a PER-HOST key resolved at RUNTIME, e.g. the `GET /v1/brand?host=`
|
||||
// shape src/config already anticipates; a module-scope const cannot receive it).
|
||||
// THE COOKIE IS NOT A CREDENTIAL HERE. This file once claimed that posting
|
||||
// same-origin let the first-party session ride along. It does not: that cookie is
|
||||
// the casibase session, while cloud resolves a tenant from a VALIDATED IAM bearer
|
||||
// (SanitizeIdentity), so a cookie-only POST carries no principal.
|
||||
|
||||
/**
|
||||
* Publishable ingest key — the SIGNED-OUT credential only. Org-scoped and
|
||||
* write-only by construction, so it is safe in the bundle. Unset → signed-out
|
||||
* views go back to reporting nothing, which is the previous behaviour and not a
|
||||
* crash.
|
||||
*/
|
||||
const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_PUBLISHABLE_KEY?.trim() || undefined
|
||||
|
||||
/** Error-plane credential. Unset → captureError is inert (fail-safe). */
|
||||
const dsn = process.env.NEXT_PUBLIC_HANZO_EVENT_DSN?.trim() || undefined
|
||||
@@ -100,7 +105,7 @@ export const eventClient: Analytics = createAnalytics({
|
||||
// The client calls this at flush time, so a sign-in — and every silent refresh
|
||||
// after it — is picked up with no rebuild. Returns undefined on the server and
|
||||
// when signed out, which is the anonymous path.
|
||||
getToken: () => iamAccessToken() ?? undefined,
|
||||
getToken: () => iamAccessToken() ?? PUBLISHABLE_KEY,
|
||||
dsn,
|
||||
enabled: consented(),
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
canonicalSlug,
|
||||
SLUG_ALIASES,
|
||||
BASE_SUBPAGES,
|
||||
baseSubpagesFor,
|
||||
subpageSlug,
|
||||
subpageHref,
|
||||
activeSubpage,
|
||||
@@ -183,24 +184,46 @@ describe('productSubpages — Overview + specifics + uniform base set', () => {
|
||||
const slugs = (e: CatalogEntry, showAdmin = true) => productSubpages(e, showAdmin).map((s) => s.slug)
|
||||
|
||||
it('auto-adds Overview + the base set to a single-screen product', () => {
|
||||
expect(slugs(vpc)).toEqual(['', 'settings', 'status', 'logs', 'metrics'])
|
||||
expect(slugs(vpc)).toEqual(['', 'settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('places a specific between Overview and the base set', () => {
|
||||
// models declares Routing (admin) — visible to an admin, before the base set.
|
||||
expect(slugs(models, true)).toEqual(['', 'routing', 'settings', 'status', 'logs', 'metrics'])
|
||||
expect(slugs(models, true)).toEqual(['', 'routing', 'settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('does NOT duplicate a base slug a product declares as a specific', () => {
|
||||
const withMetrics = mod('x', { subpages: [{ slug: 'metrics', label: 'Metrics' }] })
|
||||
expect(slugs(withMetrics)).toEqual(['', 'metrics', 'settings', 'status', 'logs'])
|
||||
expect(slugs(withMetrics)).toEqual(['', 'metrics', 'settings', 'logs', 'status'])
|
||||
})
|
||||
it('hides an admin-only specific from a customer', () => {
|
||||
expect(slugs(models, false)).toEqual(['', 'settings', 'status', 'logs', 'metrics'])
|
||||
expect(slugs(models, false)).toEqual(['', 'settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('drops a base slug that IS the product — Settings has no Settings child', () => {
|
||||
// The org-Settings product owns the `settings` concept; a base `settings`
|
||||
// sub-page beneath it is the `Settings › Settings` the rail used to show.
|
||||
expect(slugs(mod('settings'))).toEqual(['', 'logs', 'metrics', 'status'])
|
||||
// Same rule for the Observe products named after a base slug.
|
||||
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'metrics', 'status'])
|
||||
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'logs', 'status'])
|
||||
expect(slugs(mod('status'))).toEqual(['', 'settings', 'logs', 'metrics'])
|
||||
})
|
||||
it('fails closed (empty sub-pages) for a non-module entry', () => {
|
||||
expect(productSubpages(nonModule)).toEqual([])
|
||||
})
|
||||
it('BASE_SUBPAGES is exactly Settings · Status · Logs · Metrics', () => {
|
||||
expect(BASE_SUBPAGES.map((s) => s.slug)).toEqual(['settings', 'status', 'logs', 'metrics'])
|
||||
it('BASE_SUBPAGES is exactly Settings · Logs · Metrics · Status', () => {
|
||||
expect(BASE_SUBPAGES.map((s) => s.slug)).toEqual(['settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
it('a product that IS a base concern never gets a self-referential base tab', () => {
|
||||
// The Settings product: General (index) · Branding, then the base set MINUS
|
||||
// its own 'settings' — no second "Settings" tab of itself (the reported bug).
|
||||
const settings = mod('settings', { indexLabel: 'General', subpages: [{ slug: 'branding', label: 'Branding' }] })
|
||||
expect(slugs(settings)).toEqual(['', 'branding', 'logs', 'metrics', 'status'])
|
||||
// Same one rule for the other three Observe products named after a base slug.
|
||||
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'metrics', 'status'])
|
||||
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'logs', 'status'])
|
||||
expect(slugs(mod('status'))).toEqual(['', 'settings', 'logs', 'metrics'])
|
||||
// The rule is expressed once: baseSubpagesFor drops only the self-named slug.
|
||||
expect(baseSubpagesFor(mod('settings')).map((s) => s.slug)).toEqual(['logs', 'metrics', 'status'])
|
||||
expect(baseSubpagesFor(mod('vpc')).map((s) => s.slug)).toEqual(['settings', 'logs', 'metrics', 'status'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,7 +231,7 @@ describe('the ONE level-2 nav — one declaration, read by both the rail and the
|
||||
it('names the index after the product when it owns one (Models is a Catalog)', () => {
|
||||
const named = mod('models2', { indexLabel: 'Catalog', subpages: [{ slug: 'blend', label: 'Blend' }] })
|
||||
expect(productSubpages(named).map((s) => s.label)).toEqual([
|
||||
'Catalog', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics',
|
||||
'Catalog', 'Blend', 'Settings', 'Logs', 'Metrics', 'Status',
|
||||
])
|
||||
})
|
||||
it('falls back to Overview when the product does not name its index', () => {
|
||||
@@ -276,6 +299,31 @@ describe('resolveProductView — base sub-pages are the shared per-product view
|
||||
// …but an UNowned base slug on the same product is still the shared view.
|
||||
expect(resolveProductView(cat, mods, ['emb', 'status']).kind).toBe('subpage')
|
||||
})
|
||||
it('the router agrees with the nav: a product IS-that-concern URL is not a base subpage', () => {
|
||||
// Settings (a :tab product): /settings/settings is NOT the shared per-product
|
||||
// Settings view — it falls through to the module (which lands on the index),
|
||||
// so there is never a self-referential Settings screen. Its OTHER base slugs
|
||||
// still render the shared view.
|
||||
const settings = mod('settings', {
|
||||
indexLabel: 'General',
|
||||
subpages: [{ slug: 'branding', label: 'Branding' }],
|
||||
routes: [
|
||||
{ path: '', component: C },
|
||||
{ path: ':tab', component: C },
|
||||
],
|
||||
})
|
||||
const cat = [settings]
|
||||
const mods = cat.map((e) => e as unknown as ProductModule)
|
||||
expect(resolveProductView(cat, mods, ['settings', 'settings']).kind).not.toBe('subpage')
|
||||
expect(resolveProductView(cat, mods, ['settings', 'status']).kind).toBe('subpage')
|
||||
// A single-screen product named after a base slug: the self-URL is an honest
|
||||
// 404 (nothing links there), while its other base slugs render the shared view.
|
||||
const logs = mod('logs')
|
||||
const lcat = [logs]
|
||||
const lmods = lcat.map((e) => e as unknown as ProductModule)
|
||||
expect(resolveProductView(lcat, lmods, ['logs', 'logs']).kind).toBe('notfound')
|
||||
expect(resolveProductView(lcat, lmods, ['logs', 'metrics']).kind).toBe('subpage')
|
||||
})
|
||||
it('stubs a DECLARED non-base specific that has no route yet (Tasks › Queues)', () => {
|
||||
const v = view(['tasks', 'queues'])
|
||||
expect(v.kind).toBe('stub')
|
||||
|
||||
@@ -121,15 +121,30 @@ export const indexSubpage = (entry: CatalogEntry): ProductSubpage =>
|
||||
*/
|
||||
export const BASE_SUBPAGES: ProductSubpage[] = [
|
||||
{ slug: 'settings', label: 'Settings' },
|
||||
{ slug: 'status', label: 'Status' },
|
||||
// Observability trio reads raw → summary: Logs, then Metrics, then Status LAST
|
||||
// (the live-health verdict comes after the signals it is derived from).
|
||||
{ slug: 'logs', label: 'Logs' },
|
||||
{ slug: 'metrics', label: 'Metrics' },
|
||||
{ slug: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
/**
|
||||
* The base sub-pages a product actually gets: the uniform set minus any whose
|
||||
* slug IS the product's own id. A product that already IS one of these concerns
|
||||
* — Settings, Status, Logs, Metrics — must not also carry a base sub-tab bearing
|
||||
* its own name (that is a self-referential duplicate: the Settings product would
|
||||
* show a "Settings" tab of itself). One rule, read by both the nav and the
|
||||
* router, so the two never disagree on whether that tab exists.
|
||||
*/
|
||||
export const baseSubpagesFor = (entry: CatalogEntry): ProductSubpage[] =>
|
||||
BASE_SUBPAGES.filter((b) => b.slug !== entry.id)
|
||||
|
||||
/**
|
||||
* The full ordered level-2 nav for a product: Overview, then its declared
|
||||
* SPECIFIC sub-pages, then the uniform base set (a base slug the product already
|
||||
* declares as a specific is not duplicated). Non-module entries have none.
|
||||
* SPECIFIC sub-pages, then the uniform base set (`baseSubpagesFor` — the uniform
|
||||
* set minus any slug that IS the product's own id, so Settings has no "Settings"
|
||||
* child). A base slug the product already declares as a specific is not duplicated.
|
||||
* Non-module entries have none.
|
||||
*
|
||||
* `showAdmin` gates admin-only specifics (e.g. Models › Routing): a customer
|
||||
* never sees them in the sub-nav (default true keeps every existing caller
|
||||
@@ -140,7 +155,7 @@ export function productSubpages(entry: CatalogEntry, showAdmin = true): ProductS
|
||||
const specifics = (entry.subpages ?? []).filter((s) => s.slug !== '' && (showAdmin || !s.admin))
|
||||
const seen = new Set(specifics.map((s) => s.slug))
|
||||
const out: ProductSubpage[] = [indexSubpage(entry), ...specifics]
|
||||
for (const b of BASE_SUBPAGES) if (!seen.has(b.slug)) out.push(b)
|
||||
for (const b of baseSubpagesFor(entry)) if (!seen.has(b.slug)) out.push(b)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -293,7 +308,7 @@ export function resolveProductView(
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const ownsAsSpecific = (entry.subpages ?? []).some((s) => s.slug === seg)
|
||||
const base = BASE_SUBPAGES.find((s) => s.slug === seg)
|
||||
const base = baseSubpagesFor(entry).find((s) => s.slug === seg)
|
||||
if (base && !ownsAsSpecific) return { kind: 'subpage', entry, subpage: base }
|
||||
}
|
||||
}
|
||||
@@ -306,7 +321,7 @@ export function resolveProductView(
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const declared = (entry.subpages ?? []).find((s) => s.slug === seg)
|
||||
const base = BASE_SUBPAGES.find((s) => s.slug === seg)
|
||||
const base = baseSubpagesFor(entry).find((s) => s.slug === seg)
|
||||
const sp = declared ?? base
|
||||
if (sp) return { kind: 'stub', entry, subpage: sp }
|
||||
}
|
||||
|
||||
@@ -1279,7 +1279,6 @@ export const catalog: CatalogEntry[] = [
|
||||
status: 'enabled',
|
||||
brands: ['hanzo'],
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/automations`,
|
||||
kind: 'module',
|
||||
routes: [
|
||||
{ path: '', component: AutomationsModule },
|
||||
@@ -1380,7 +1379,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'AI',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/knowledge`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: KnowledgeModule }],
|
||||
},
|
||||
@@ -1713,7 +1711,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Network',
|
||||
status: 'enabled',
|
||||
repo: 'luxfi/node',
|
||||
docs: `${DOCS}/nodes`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: NodesModule }],
|
||||
},
|
||||
@@ -2472,7 +2469,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Observe',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/o11y',
|
||||
docs: `${DOCS}/apm`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: ServiceMapModule }],
|
||||
},
|
||||
@@ -2611,7 +2607,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Observe',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/finance',
|
||||
docs: `${DOCS}/finance`,
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: FinanceModule }],
|
||||
},
|
||||
@@ -3225,7 +3220,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Apps',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/cms`,
|
||||
kind: 'module',
|
||||
routes: [
|
||||
{ path: '', component: CmsModule },
|
||||
@@ -3266,7 +3260,6 @@ export const catalog: CatalogEntry[] = [
|
||||
category: 'Apps',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
docs: `${DOCS}/helpdesk`,
|
||||
kind: 'module',
|
||||
routes: [
|
||||
{ path: '', component: HelpModule },
|
||||
|
||||
@@ -246,6 +246,17 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
'gpus',
|
||||
'fleet',
|
||||
'clusters',
|
||||
// Sandboxes (cloud apps/sandbox): the org's leased gVisor pods — lease/list/get/
|
||||
// end, exec, fs, and the ticket that opens an interactive terminal
|
||||
// (/v1/sandboxes[/:id[/exec|/fs|/terminal]]). Same gate as the rest: the handler
|
||||
// resolves the org from the Bearer owner and answers 403 without one, and an id
|
||||
// belonging to another org is a 404.
|
||||
//
|
||||
// The terminal's SOCKET does not come through here and cannot: a Next route
|
||||
// handler proxies requests, not upgrades. The browser dials the API host
|
||||
// directly, carrying the single-use ticket this proxy fetched for it — which is
|
||||
// the whole reason the ticket exists.
|
||||
'sandboxes',
|
||||
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
|
||||
// (/v1/vpcs[/:id], /v1/balancers[/:id]).
|
||||
'vpcs',
|
||||
|
||||
Reference in New Issue
Block a user