Compare commits

...
Author SHA1 Message Date
hanzo-dev 3d5b6f16c0 refactor(console): Base = the org's embedded /v1/base — retire the tenants orchestrator
The org IS the tenant (a user's personal/default org included): each IAM org gets
one physically-isolated Base, served natively same-origin by the ONE cloud binary
at /v1/base, scoped by the validated IAM principal (CLOUD_BASE_EMBED). Delete the
incidental complexity — the SuperBase 'tenants' collection + <slug>.base.hanzo.ai
orchestrator (BasesManager, tenants.ts, bases-logic). BaseModule now shows the
current org's Base (its content types); switching tenant = switching org (the
sidebar org switcher). RecordsModule transport -> /v1/base (was /v1/superbase, 404
on cloud.hanzo.ai). Verified: /v1/base/collections = 200 on cloud.hanzo.ai.
2026-07-22 19:17:18 -07:00
hanzo-dev 0290255379 fix(agents): Status/Logs/Metrics render the agents' OWN runs, not the empty subpage
/agents showed empty Status/Logs/Metrics despite agents + runs existing: the product
only owned the '' (Overview) route, so those base slugs fell to the shared subpage system
which reads o11y (not wired for agents) + the usage ledger (no product:agents-tagged spend)
→ honest-empty. But the data IS there in /v1/agents (invocations, health, activity).

Agents now OWNS Status/Logs/Metrics (registry subpages + :tab route, same pattern as
Inference). AgentsModule renders a focused slice per tab from its OWN derived data:
Metrics = counts + invocation trend + resource usage; Status = health donut + agents table;
Logs = the invocation activity feed; Overview = all. Settings stays the shared subpage.
tsc clean (my files); the AppLauncher/brands tsc errors are pre-existing main, not this.
2026-07-22 18:39:48 -07:00
8 changed files with 176 additions and 940 deletions
+29 -2
View File
@@ -134,7 +134,7 @@ function useAgents() {
return { agents, loading, error, live, activity, reload, setAgents }
}
export function AgentsModule(_props: { params: Record<string, string> }) {
export function AgentsModule(props: { params: Record<string, string> }) {
const detail = useDetailPane()
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
@@ -395,11 +395,21 @@ export function AgentsModule(_props: { params: Record<string, string> }) {
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
// Status = health donut + agents table; Logs = the invocation activity feed.
const routeTab = props.params?.tab ?? ''
const showMetrics = routeTab === '' || routeTab === 'metrics'
const showStatus = routeTab === '' || routeTab === 'status'
const showLogs = routeTab === '' || routeTab === 'logs'
return (
<>
{header}
{/* Row 1 — five headline stat cards (real / derived; spark+delta from series) */}
{showMetrics && (
<XStack flexWrap="wrap" gap="$3" items="stretch">
<MetricCard icon={Bot} label="Total agents" value={fmtInt(stats.total)} sub="registered" />
<MetricCard icon={Activity} label="Active" value={fmtInt(stats.active)} sub={`${stats.idle} idle · ${stats.error} error`} />
@@ -414,9 +424,12 @@ export function AgentsModule(_props: { params: Record<string, string> }) {
/>
<MetricCard icon={Timer} label="Avg latency" value={fmtDuration(stats.avgLatencyMs)} sub="per invocation" />
</XStack>
)}
{/* Row 2 — invocations over time + agent health donut */}
{(showMetrics || showStatus) && (
<XStack flexWrap="wrap" gap="$3" items="stretch">
{showMetrics && (
<Panel
title="Invocations over time"
flex={2}
@@ -453,13 +466,18 @@ export function AgentsModule(_props: { params: Record<string, string> }) {
</YStack>
)}
</Panel>
)}
{showStatus && (
<Panel title="Agent health" flex={1} minW={240}>
<HealthDonut breakdown={health} />
</Panel>
)}
</XStack>
)}
{/* Row 3 — agents table (status tabs + pagination) */}
{showStatus && (
<Panel title="Agents" minW={320}>
<XStack gap="$1" flexWrap="wrap">
{tabs.map((t) => (
@@ -503,22 +521,31 @@ export function AgentsModule(_props: { params: Record<string, string> }) {
</XStack>
) : null}
</Panel>
)}
{/* Row 4 — recent activity · top agents · resource usage */}
{/* Row 4 — recent activity (logs) · top agents · resource usage (metrics) */}
{(showLogs || showMetrics) && (
<XStack flexWrap="wrap" gap="$3" items="stretch">
{showLogs && (
<Panel title="Recent activity" flex={1} minW={280}>
<ActivityFeed events={activity} now={now} />
</Panel>
)}
{showMetrics && (
<Panel title="Top agents by invocations" flex={1} minW={280}>
<TopAgents agents={top} />
</Panel>
)}
{showMetrics && (
<Panel title="Resource usage · 30d" flex={1} minW={260}>
<ResourceUsagePanel
usage={metrics?.resource ?? { cpuVcpuHours: null, memGbHours: null, storageIoBytes: null, costCents: null }}
connected={metricsConnected}
/>
</Panel>
)}
</XStack>
)}
</>
)
}
+129 -16
View File
@@ -1,24 +1,137 @@
'use client'
/**
* Base — manage your organization's Hanzo Base instances.
* Base — your organization's realtime backend (hanzoai/base), embedded in the ONE
* cloud binary and served natively, same-origin, at /v1/base.
*
* Hanzo Base is a realtime backend (hanzoai/base): content types, records, and
* auth, per Base. This module is a thin route adapter; the whole surface — the
* Bases list, the New Base create flow, and per-Base configuration — lives in the
* reusable `BasesManager`. There is ONE Base binding: console2's OWN `/superbase`
* proxy, which mints the user's IAM bearer and stamps `X-Org-Id` from the JWT
* owner, so every read/write is scoped to this org. Each Base is a row in the
* SuperBase orchestrator's `tenants` collection; we drive its real API, we do not
* re-implement Base. (A Base's own data — collections + records — is the sibling
* `Records` product.)
* THE ORG IS THE TENANT. Each IAM org — including a user's personal/default org —
* gets its own physically-isolated Base, scoped by the validated IAM principal
* (X-Org-Id from the JWT owner). There is no `tenants` collection, no orchestrator,
* no per-Base `<slug>.base.hanzo.ai` workload: that was incidental complexity. One
* IAM org ↔ one Base. Switching tenant = switching org (the sidebar org switcher).
*
* Routes (declared in the registry, resolved by segment):
* /base · /base/new · /base/:base
* This surface is the org's Base overview — its content types (collections).
* Browsing and editing records is the sibling `Records` product (also on /v1/base).
*/
import { BasesManager } from './base/BasesManager'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useOrganizations } from '@hanzo/iam/react'
import { Button, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Boxes, Database, Table2 } from '@hanzogui/lucide-icons-2'
export function BaseModule({ params }: { params: Record<string, string> }) {
return <BasesManager params={params} />
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BaseDataApi } from '~/lib/base-data/api'
/** The org's Base is served natively same-origin by the cloud binary at /v1/base
* (per-org, org resolved from the validated IAM principal — CLOUD_BASE_EMBED). */
const BASE_ROOT = '/v1/base'
type Collection = { name: string }
type State =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; collections: Collection[] }
export function BaseModule(_props: { params: Record<string, string> }) {
const router = useRouter()
const { currentOrg, currentOrgId } = useOrganizations()
const api = useMemo(() => new BaseDataApi({ baseUrl: BASE_ROOT }), [])
const [state, setState] = useState<State>({ phase: 'loading' })
useEffect(() => {
let cancelled = false
setState({ phase: 'loading' })
api
.listCollections()
.then((cols) => {
// Hide the engine's internal collections (_superusers, _views, …).
const visible = cols.filter((c): c is Collection => typeof c.name === 'string' && !c.name.startsWith('_'))
if (!cancelled) setState({ phase: 'ready', collections: visible })
})
.catch((e) => {
if (!cancelled) setState({ phase: 'error', error: classifyBackend(e) })
})
return () => {
cancelled = true
}
}, [api, currentOrgId])
const orgName = currentOrg?.name ?? 'your organization'
return (
<YStack gap="$4">
<PageHeader
title="Base"
subtitle={`${orgName}'s realtime backend — content types, records, and auth, isolated to this organization.`}
actions={
<PrimaryButton size="$3" icon={<Table2 size={16} />} onPress={() => router.push('/records')}>
Open Records
</PrimaryButton>
}
/>
{state.phase === 'error' ? (
<BackendStateCard state={state.error} onRetry={() => router.refresh()} hint="base · GET /v1/base/collections" />
) : state.phase === 'loading' ? (
<XStack p="$4" gap="$2" items="center">
<Spinner />
<Text color="$color11">Loading your Base</Text>
</XStack>
) : state.collections.length === 0 ? (
<EmptyState
icon={Boxes}
title="No content types yet"
description={`${orgName}'s Base is ready. Create your first content type in Records to start storing data.`}
bullets={[
'One isolated Base per organization — scoped by your IAM org',
'Model content types (collections) and store records',
'Every read/write is scoped to this org automatically',
]}
primary={{ label: 'Open Records', onPress: () => router.push('/records'), icon: <Table2 size={16} /> }}
/>
) : (
<YStack gap="$2" maxW={860}>
<Text fontSize="$3" color="$color10">
{state.collections.length} content type{state.collections.length === 1 ? '' : 's'}
</Text>
{state.collections.map((c) => (
<XStack
key={c.name}
items="center"
justify="space-between"
gap="$3"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
px="$4"
py="$3"
cursor="pointer"
hoverStyle={{ bg: '$color3', borderColor: '$color7' }}
onPress={() => router.push('/records')}
>
<XStack items="center" gap="$3" flex={1}>
<Database size={16} />
<Text fontSize="$4" fontWeight="700">
{c.name}
</Text>
</XStack>
<Button
size="$2"
chromeless
onPress={(e) => {
e.stopPropagation?.()
router.push('/records')
}}
>
Records
</Button>
</XStack>
))}
</YStack>
)}
</YStack>
)
}
+4 -1
View File
@@ -33,7 +33,10 @@ import { baseCollectionToFields, type BaseCollection } from '~/lib/base-data/fie
/** Product base path — must match the registry entry id (`records`). */
const RECORDS_PATH = '/records'
/** Same-origin Base proxy prefix (the proxy injects the user bearer server-side). */
const BASE_PROXY = '/v1/superbase'
// The org's Base is served natively, same-origin, by the ONE cloud binary's
// embedded engine at /v1/base (per-org, org resolved from the validated IAM
// principal — CLOUD_BASE_EMBED). No /superbase proxy, no orchestrator hop.
const BASE_PROXY = '/v1/base'
export function RecordsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
@@ -1,577 +0,0 @@
'use client'
/**
* Base — manage your organization's Hanzo Base INSTANCES ("Bases").
*
* A Base is a full realtime backend (hanzoai/base): content types + records +
* auth, on its own `<slug>.base.hanzo.ai`. This module is the instance manager —
* SEE all your Bases, CREATE a new one, and CONFIGURE one (name, size, status,
* delete). Each Base is a row in the SuperBase orchestrator's `tenants` collection;
* we drive its real `/v1/collections/tenants/records` API through console2's OWN
* `/superbase` proxy, which mints the user's IAM bearer and stamps `X-Org-Id` from
* the JWT owner — so the list/create/configure are scoped to THIS org. We do not
* re-implement Base; a Base is a tenants record. (Browsing a Base's data — its
* collections + records — is the sibling `Records` product.)
*
* Routes (declared in the registry, resolved by segment):
* /base — your Bases (+ New Base)
* /base/new — create a Base
* /base/:base — configure one Base (`:base` = its record id)
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Boxes, ExternalLink, Plus, Server, Trash2 } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { StatusTag } from '~/components/ui/StatusTag'
import { FieldRow, FieldText, FieldSelect } from '~/components/ui/Field'
import { BaseTenantsApi, type BaseInstance } from '~/lib/base-data/tenants'
import { ApiError } from '~/lib/api'
import {
slugify,
validateBase,
SIZE_PRESETS,
DEFAULT_SIZE,
specForSize,
sizeForSpec,
specSummary,
statusOf,
baseHref,
} from './bases-logic'
/** Product base path — must match the registry entry id (`base`). */
const BASE_PATH = '/base'
/** Same-origin Base proxy prefix (injects the user bearer + org server-side). */
const BASE_PROXY = '/v1/superbase'
const SIZE_LABELS = SIZE_PRESETS.map((p) => p.label)
const labelToSize = (label: string): string => SIZE_PRESETS.find((p) => p.label === label)?.id ?? DEFAULT_SIZE
const sizeToLabel = (id: string): string => SIZE_PRESETS.find((p) => p.id === id)?.label ?? ''
export function BasesManager({ params }: { params: Record<string, string> }) {
const router = useRouter()
const pathname = usePathname()
const api = useMemo(() => new BaseTenantsApi(BASE_PROXY), [])
const baseId = params.base
const isNew = baseId === undefined && (pathname?.endsWith('/new') ?? false)
const nav = useMemo(
() => ({
toList: () => router.push(BASE_PATH),
toNew: () => router.push(`${BASE_PATH}/new`),
toBase: (id: string) => router.push(`${BASE_PATH}/${encodeURIComponent(id)}`),
}),
[router],
)
if (baseId) return <BaseConfig api={api} id={baseId} nav={nav} />
if (isNew) return <NewBase api={api} nav={nav} />
return <BasesList api={api} nav={nav} />
}
type Nav = { toList: () => void; toNew: () => void; toBase: (id: string) => void }
// ── List ────────────────────────────────────────────────────────────────────
type ListState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; bases: BaseInstance[] }
/** All the org's Bases + the "New Base" affordance. */
function BasesList({ api, nav }: { api: BaseTenantsApi; nav: Nav }) {
const [state, setState] = useState<ListState>({ phase: 'loading' })
const load = useCallback(
async (signal: { cancelled: boolean }) => {
setState({ phase: 'loading' })
try {
const bases = (await api.list()).sort((a, b) => a.name.localeCompare(b.name))
if (!signal.cancelled) setState({ phase: 'ready', bases })
} catch (e) {
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
}
},
[api],
)
useEffect(() => {
const signal = { cancelled: false }
void load(signal)
return () => {
signal.cancelled = true
}
}, [load])
const reload = useCallback(() => void load({ cancelled: false }), [load])
return (
<YStack gap="$4">
<PageHeader
title="Base"
subtitle="Your organization's Hanzo Base instances — a realtime backend per Base, with content types, records, and auth."
actions={
<XStack gap="$2">
<Button size="$3" onPress={reload}>
Refresh
</Button>
<PrimaryButton size="$3" icon={<Plus size={16} />} onPress={nav.toNew}>
New Base
</PrimaryButton>
</XStack>
}
/>
{state.phase === 'error' ? (
<BackendStateCard state={state.error} onRetry={reload} hint="base · GET /v1/collections/tenants/records" />
) : state.phase === 'loading' ? (
<XStack p="$4" gap="$2" items="center">
<Spinner />
<Text color="$color11">Loading your Bases</Text>
</XStack>
) : state.bases.length === 0 ? (
<EmptyState
icon={Boxes}
title="No Bases yet"
description="Create your first Base — a realtime backend for your organization. Model content types, store records, and add auth, all on its own subdomain."
bullets={['Each Base is isolated and provisioned on its own <slug>.base.hanzo.ai', 'Configure size (replicas + storage) per Base', 'Browse and edit a Bases data in Records']}
primary={{ label: 'New Base', onPress: nav.toNew, icon: <Plus size={16} /> }}
/>
) : (
<YStack gap="$2" maxW={860}>
{state.bases.map((b) => (
<BaseRow key={b.id || b.slug} base={b} onOpen={() => nav.toBase(b.id)} />
))}
</YStack>
)}
</YStack>
)
}
/** One Base in the list — name, slug, status, size, and (when ready) its live URL. */
function BaseRow({ base, onOpen }: { base: BaseInstance; onOpen: () => void }) {
const status = statusOf(base)
const href = baseHref(base)
return (
<XStack
items="center"
justify="space-between"
gap="$3"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
px="$4"
py="$3"
cursor="pointer"
hoverStyle={{ bg: '$color3', borderColor: '$color7' }}
onPress={onOpen}
>
<XStack items="center" gap="$3" flex={1}>
<Server size={16} />
<YStack flex={1} gap="$0.5">
<XStack items="center" gap="$2">
<Text fontSize="$4" fontWeight="700">
{base.name}
</Text>
<StatusTag status={status.label} />
</XStack>
<Text fontSize="$2" color="$color10">
{base.slug} · {specSummary(base.spec)}
</Text>
</YStack>
</XStack>
{href ? (
<Button
size="$2"
chromeless
icon={<ExternalLink size={14} />}
onPress={(e) => {
e.stopPropagation?.()
window.open(href, '_blank', 'noopener,noreferrer')
}}
>
Open
</Button>
) : null}
</XStack>
)
}
// ── Create ──────────────────────────────────────────────────────────────────
type Submit = { phase: 'idle' } | { phase: 'saving' } | { phase: 'error'; message: string }
/** Map a mutation error to an honest message (superuser/balance gate → clear ask). */
function mutationMessage(e: unknown, fallback: string): string {
const status = e instanceof ApiError ? e.status : 0
if (status === 401 || status === 403) {
return 'Creating and configuring Bases requires an organization admin. Ask an org admin, or sign in with an admin account.'
}
if (status === 402) return 'This organization needs available credit to provision a new Base. Add credit and try again.'
return e instanceof Error ? e.message : fallback
}
/** Create a Base — name → slug + a size preset → real POST to the tenants API. */
function NewBase({ api, nav }: { api: BaseTenantsApi; nav: Nav }) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
const [size, setSize] = useState(DEFAULT_SIZE)
const [existingSlugs, setExistingSlugs] = useState<string[]>([])
const [submit, setSubmit] = useState<Submit>({ phase: 'idle' })
const [showErrors, setShowErrors] = useState(false)
// Load existing slugs for the uniqueness check (best-effort; never blocks create).
useEffect(() => {
let cancelled = false
api
.list()
.then((bases) => {
if (!cancelled) setExistingSlugs(bases.map((b) => b.slug).filter(Boolean))
})
.catch(() => {
/* create still validates server-side */
})
return () => {
cancelled = true
}
}, [api])
const effectiveSlug = slugEdited ? slug.trim() : slugify(name)
const validation = useMemo(
() => validateBase(name, effectiveSlug, existingSlugs),
[name, effectiveSlug, existingSlugs],
)
const create = async () => {
setShowErrors(true)
if (!validation.ok) return
setSubmit({ phase: 'saving' })
try {
const created = await api.create({ name: name.trim(), slug: effectiveSlug, spec: specForSize(size) })
if (created.id) nav.toBase(created.id)
else nav.toList()
} catch (e) {
setSubmit({ phase: 'error', message: mutationMessage(e, 'Could not create the Base.') })
}
}
const disabled = submit.phase === 'saving'
return (
<YStack gap="$4" maxW={720}>
<PageHeader
title="New Base"
subtitle="Provision a realtime backend for your organization — it gets its own subdomain."
actions={
<XStack gap="$2">
<Button size="$3" icon={<ArrowLeft size={15} />} onPress={nav.toList} disabled={disabled}>
All Bases
</Button>
<PrimaryButton size="$3" icon={<Boxes size={16} />} onPress={create} disabled={disabled}>
{submit.phase === 'saving' ? 'Creating…' : 'Create Base'}
</PrimaryButton>
</XStack>
}
/>
<Card p="$4" gap="$4" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Name">
<FieldText
value={name}
onChange={setName}
disabled={disabled}
placeholder="Production, Staging, Blog…"
/>
</FieldRow>
<FieldRow label="Slug">
<YStack gap="$1.5">
<FieldText
value={effectiveSlug}
onChange={(v) => {
setSlugEdited(true)
setSlug(v)
}}
disabled={disabled}
placeholder="my-base"
/>
{showErrors && validation.slugError ? (
<Text fontSize="$2" color="$red10">
{validation.slugError}
</Text>
) : (
<Text fontSize="$2" color="$color10">
Its subdomain: {effectiveSlug || 'my-base'}.base.hanzo.ai · lowercase letters, numbers, hyphens.
</Text>
)}
{showErrors && validation.nameError ? (
<Text fontSize="$2" color="$red10">
{validation.nameError}
</Text>
) : null}
</YStack>
</FieldRow>
<FieldRow label="Size">
<YStack gap="$1.5">
<FieldSelect
value={sizeToLabel(size)}
options={SIZE_LABELS}
onChange={(label) => setSize(labelToSize(label))}
disabled={disabled}
/>
<Text fontSize="$2" color="$color10">
{SIZE_PRESETS.find((p) => p.id === size)?.hint ?? ''}
</Text>
</YStack>
</FieldRow>
</Card>
{submit.phase === 'error' ? (
<Card p="$3.5" gap="$1" borderWidth={1} borderColor="$red7" bg="$red2" maxW={720}>
<Text fontSize="$3" fontWeight="700" color="$red11">
Couldnt create the Base
</Text>
<Text fontSize="$2" color="$red11">
{submit.message}
</Text>
</Card>
) : null}
</YStack>
)
}
// ── Configure ─────────────────────────────────────────────────────────────────
type ConfigState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; base: BaseInstance }
/** Configure ONE Base — edit name + size, see live status, open it, or delete it. */
function BaseConfig({ api, id, nav }: { api: BaseTenantsApi; id: string; nav: Nav }) {
const [state, setState] = useState<ConfigState>({ phase: 'loading' })
const [name, setName] = useState('')
const [size, setSize] = useState(DEFAULT_SIZE)
const [submit, setSubmit] = useState<Submit>({ phase: 'idle' })
const [confirmDelete, setConfirmDelete] = useState(false)
const load = useCallback(
async (signal: { cancelled: boolean }) => {
setState({ phase: 'loading' })
try {
const base = await api.get(id)
if (signal.cancelled) return
setName(base.name)
setSize(sizeForSpec(base.spec))
setState({ phase: 'ready', base })
} catch (e) {
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
}
},
[api, id],
)
useEffect(() => {
const signal = { cancelled: false }
void load(signal)
return () => {
signal.cancelled = true
}
}, [load])
if (state.phase === 'loading') {
return (
<XStack p="$4" gap="$2" items="center">
<Spinner />
<Text color="$color11">Loading Base</Text>
</XStack>
)
}
if (state.phase === 'error') {
return (
<YStack gap="$4">
<PageHeader
title="Base"
actions={
<Button size="$3" icon={<ArrowLeft size={15} />} onPress={nav.toList}>
All Bases
</Button>
}
/>
<BackendStateCard state={state.error} onRetry={() => void load({ cancelled: false })} hint="base · GET /v1/collections/tenants/records" />
</YStack>
)
}
const base = state.base
const status = statusOf(base)
const href = baseHref(base)
const disabled = submit.phase === 'saving'
const dirty = name.trim() !== base.name || (sizeForSpec(base.spec) !== size && size !== 'custom')
const save = async () => {
if (!name.trim()) {
setSubmit({ phase: 'error', message: 'Name is required.' })
return
}
setSubmit({ phase: 'saving' })
try {
const patch: { name?: string; spec?: ReturnType<typeof specForSize> } = { name: name.trim() }
if (size !== 'custom' && sizeForSpec(base.spec) !== size) patch.spec = specForSize(size)
const updated = await api.update(base.id, patch)
setState({ phase: 'ready', base: updated })
setName(updated.name)
setSize(sizeForSpec(updated.spec))
setSubmit({ phase: 'idle' })
} catch (e) {
setSubmit({ phase: 'error', message: mutationMessage(e, 'Could not save the Base.') })
}
}
const remove = async () => {
setSubmit({ phase: 'saving' })
try {
await api.remove(base.id)
nav.toList()
} catch (e) {
setSubmit({ phase: 'error', message: mutationMessage(e, 'Could not delete the Base.') })
setConfirmDelete(false)
}
}
const sizeOptions = size === 'custom' ? [...SIZE_LABELS, 'Custom'] : SIZE_LABELS
const sizeValue = size === 'custom' ? 'Custom' : sizeToLabel(size)
return (
<YStack gap="$4" maxW={720}>
<PageHeader
title={base.name}
subtitle={`${base.slug}.base.hanzo.ai`}
actions={
<XStack gap="$2" items="center">
<StatusTag status={status.label} />
{href ? (
<Button size="$3" icon={<ExternalLink size={15} />} onPress={() => window.open(href, '_blank', 'noopener,noreferrer')}>
Open
</Button>
) : null}
<Button size="$3" icon={<ArrowLeft size={15} />} onPress={nav.toList}>
All Bases
</Button>
</XStack>
}
/>
{base.lastError ? (
<Card p="$3.5" gap="$1" borderWidth={1} borderColor="$red7" bg="$red2">
<Text fontSize="$3" fontWeight="700" color="$red11">
Reconcile error
</Text>
<Text fontSize="$2" color="$red11">
{base.lastError}
</Text>
</Card>
) : null}
<Card p="$4" gap="$4" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Name">
<FieldText value={name} onChange={setName} disabled={disabled} />
</FieldRow>
<FieldRow label="Slug">
<YStack gap="$1.5">
<Text fontSize="$4" fontWeight="600">
{base.slug}
</Text>
<Text fontSize="$2" color="$color10">
The slug is fixed after creation (it names the subdomain and workload).
</Text>
</YStack>
</FieldRow>
<FieldRow label="Size">
<YStack gap="$1.5">
<FieldSelect
value={sizeValue}
options={sizeOptions}
onChange={(label) => setSize(labelToSize(label))}
disabled={disabled}
/>
<Text fontSize="$2" color="$color10">
{size === 'custom' ? `Current: ${specSummary(base.spec)}` : SIZE_PRESETS.find((p) => p.id === size)?.hint ?? ''}
</Text>
</YStack>
</FieldRow>
<FieldRow label="Status">
<XStack items="center" gap="$2" flexWrap="wrap">
<StatusTag status={status.label} />
<Text fontSize="$2" color="$color10">
{status.ready ? `Live at ${base.subdomain}` : 'Provisioning — its subdomain appears here once its ready.'}
</Text>
</XStack>
</FieldRow>
<XStack gap="$2" pt="$1">
<PrimaryButton size="$3" onPress={save} disabled={disabled || !dirty}>
{submit.phase === 'saving' ? 'Saving…' : 'Save changes'}
</PrimaryButton>
</XStack>
</Card>
{/* Data lives in the sibling Records product (a Base's collections + records). */}
<Card p="$3.5" gap="$1" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$3" fontWeight="700">
Data
</Text>
<Text fontSize="$2" color="$color11">
Model this Bases content types and browse its records in Records. Its own Base dashboard is available at{' '}
{href ? base.subdomain : `${base.slug}.base.hanzo.ai`} once provisioned.
</Text>
</Card>
{/* Danger zone. */}
<Card p="$3.5" gap="$3" borderWidth={1} borderColor="$red7">
<YStack gap="$1">
<Text fontSize="$3" fontWeight="700" color="$red11">
Delete this Base
</Text>
<Text fontSize="$2" color="$color11">
Deprovisions the Base and permanently removes its data. This cannot be undone.
</Text>
</YStack>
{confirmDelete ? (
<XStack gap="$2" items="center" flexWrap="wrap">
<Text fontSize="$2" color="$red11">
Delete {base.name}?
</Text>
<Button size="$2" theme="red" icon={<Trash2 size={14} />} onPress={remove} disabled={disabled}>
{submit.phase === 'saving' ? 'Deleting…' : 'Yes, delete'}
</Button>
<Button size="$2" onPress={() => setConfirmDelete(false)} disabled={disabled}>
Cancel
</Button>
</XStack>
) : (
<XStack>
<Button size="$2" icon={<Trash2 size={14} />} onPress={() => setConfirmDelete(true)} disabled={disabled}>
Delete Base
</Button>
</XStack>
)}
</Card>
{submit.phase === 'error' ? (
<Card p="$3.5" gap="$1" borderWidth={1} borderColor="$red7" bg="$red2">
<Text fontSize="$2" color="$red11">
{submit.message}
</Text>
</Card>
) : null}
</YStack>
)
}
@@ -1,117 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
slugify,
isValidSlug,
validateBase,
SIZE_PRESETS,
DEFAULT_SIZE,
specForSize,
sizeForSpec,
specSummary,
statusOf,
baseHref,
} from './bases-logic'
import { normalizeBase } from '~/lib/base-data/tenants'
describe('slugify — free text → a DNS-label slug', () => {
it('lowercases, hyphenates, and trims', () => {
expect(slugify(' My Blog Base! ')).toBe('my-blog-base')
expect(slugify('Acme CRM Test')).toBe('acme-crm-test')
expect(slugify('a__b--c')).toBe('a-b-c')
})
it('caps at 40 chars with no trailing hyphen', () => {
const s = slugify('x'.repeat(60))
expect(s.length).toBeLessThanOrEqual(40)
expect(s.endsWith('-')).toBe(false)
})
})
describe('isValidSlug', () => {
it('accepts DNS labels', () => {
expect(isValidSlug('my-base')).toBe(true)
expect(isValidSlug('a')).toBe(true)
expect(isValidSlug('base1')).toBe(true)
})
it('rejects invalid shapes', () => {
expect(isValidSlug('-lead')).toBe(false)
expect(isValidSlug('trail-')).toBe(false)
expect(isValidSlug('Upper')).toBe(false)
expect(isValidSlug('has space')).toBe(false)
expect(isValidSlug('under_score')).toBe(false)
expect(isValidSlug('')).toBe(false)
})
})
describe('validateBase', () => {
it('requires a name and a valid, unique, non-reserved slug', () => {
expect(validateBase('My Base', 'my-base').ok).toBe(true)
expect(validateBase('', 'my-base').nameError).toBeTruthy()
expect(validateBase('My Base', '').slugError).toBeTruthy()
expect(validateBase('My Base', 'Bad Slug').slugError).toBeTruthy()
expect(validateBase('My Base', 'new').slugError).toBeTruthy() // reserved route word
expect(validateBase('My Base', 'taken', ['taken']).slugError).toContain('already exists')
})
})
describe('size presets', () => {
it('round-trips a preset id through spec', () => {
for (const p of SIZE_PRESETS) {
expect(specForSize(p.id)).toEqual(p.spec)
expect(sizeForSpec(p.spec)).toBe(p.id)
}
})
it('default is a real preset; an unknown spec is custom', () => {
expect(SIZE_PRESETS.some((p) => p.id === DEFAULT_SIZE)).toBe(true)
expect(sizeForSpec({ replicas: 7, storage: '999Gi' })).toBe('custom')
})
it('summarizes a spec, honest — for empty', () => {
expect(specSummary({ replicas: 2, storage: '10Gi' })).toBe('2 replicas · 10Gi')
expect(specSummary({ replicas: 1 })).toBe('1 replica')
expect(specSummary({})).toBe('—')
})
})
describe('statusOf — provisioning lifecycle', () => {
it('error wins over everything', () => {
expect(statusOf({ status: 'Ready', subdomain: 'x.base.hanzo.ai', lastError: 'boom' }).tone).toBe('error')
expect(statusOf({ status: 'failed', subdomain: '', lastError: '' }).tone).toBe('error')
})
it('ready only once a subdomain exists', () => {
expect(statusOf({ status: '', subdomain: 'x.base.hanzo.ai', lastError: '' })).toMatchObject({ tone: 'ready', ready: true })
expect(statusOf({ status: 'Ready', subdomain: 'x.base.hanzo.ai', lastError: '' }).ready).toBe(true)
})
it('provisioning while there is no subdomain yet', () => {
expect(statusOf({ status: '', subdomain: '', lastError: '' })).toMatchObject({ label: 'Provisioning', tone: 'pending', ready: false })
expect(statusOf({ status: 'Pending', subdomain: '', lastError: '' })).toMatchObject({ tone: 'pending', ready: false })
})
})
describe('baseHref', () => {
it('builds a URL from a bare subdomain, passes through an absolute one, null while provisioning', () => {
expect(baseHref({ subdomain: 'acme.base.hanzo.ai' })).toBe('https://acme.base.hanzo.ai')
expect(baseHref({ subdomain: 'https://acme.base.hanzo.ai' })).toBe('https://acme.base.hanzo.ai')
expect(baseHref({ subdomain: '' })).toBeNull()
})
})
describe('normalizeBase — defensive over the tenants wire shape', () => {
it('reads the real record shape and folds spec', () => {
const b = normalizeBase({
id: 'yj1om5gz64endii',
name: 'Acme CRM Test',
slug: 'acme-crm-test',
spec: { replicas: 3, storage: '10Gi' },
status: '',
subdomain: '',
last_error: '',
})
expect(b).toMatchObject({ id: 'yj1om5gz64endii', name: 'Acme CRM Test', slug: 'acme-crm-test' })
expect(b.spec).toEqual({ replicas: 3, storage: '10Gi' })
})
it('falls back name→slug and tolerates missing/garbage fields', () => {
expect(normalizeBase({ slug: 'only-slug' }).name).toBe('only-slug')
expect(normalizeBase({ spec: 'nope' as unknown as object }).spec).toEqual({})
expect(normalizeBase(null)).toMatchObject({ id: '', name: '', slug: '' })
})
})
-107
View File
@@ -1,107 +0,0 @@
/**
* Pure logic for the Bases manager (Hanzo Base instances) — slug derivation,
* validation, size presets, and status presentation. No I/O, no React: data in,
* data out, unit-testable in plain Node. `BasesManager.tsx` is the thin shell.
*/
import type { BaseSpec } from '~/lib/base-data/tenants'
/** A DNS-label slug: lowercase alnum + internal hyphens, starts/ends alnum, ≤40. */
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/
/** Route words the Base product owns — a Base may not take a slug that shadows them. */
const RESERVED_SLUGS = new Set(['new'])
/** Normalize free text into a valid Base slug candidate. */
export function slugify(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '')
}
export function isValidSlug(slug: string): boolean {
return SLUG_RE.test(slug)
}
export interface BaseValidation {
ok: boolean
nameError?: string
slugError?: string
}
/** Validate a new Base. Pure — the form renders these messages inline. */
export function validateBase(name: string, slug: string, existingSlugs: string[] = []): BaseValidation {
let nameError: string | undefined
let slugError: string | undefined
if (!name.trim()) nameError = 'Name is required.'
const s = slug.trim()
if (!s) slugError = 'Slug is required.'
else if (!isValidSlug(s)) slugError = 'Use lowercase letters, numbers, and hyphens (e.g. my-base).'
else if (RESERVED_SLUGS.has(s)) slugError = `${s}” is reserved.`
else if (existingSlugs.includes(s)) slugError = 'A Base with this slug already exists.'
return { ok: !nameError && !slugError, nameError, slugError }
}
/** Size presets → a Base spec (replicas + storage). ONE control, not raw K8s. */
export interface SizePreset {
id: string
label: string
hint: string
spec: BaseSpec
}
export const SIZE_PRESETS: SizePreset[] = [
{ id: 'starter', label: 'Starter', hint: '1 replica · 1 GB storage', spec: { replicas: 1, storage: '1Gi' } },
{ id: 'standard', label: 'Standard', hint: '2 replicas · 10 GB storage', spec: { replicas: 2, storage: '10Gi' } },
{ id: 'ha', label: 'High availability', hint: '3 replicas · 50 GB storage', spec: { replicas: 3, storage: '50Gi' } },
]
export const DEFAULT_SIZE = 'standard'
/** The spec for a preset id (falls back to the default). */
export function specForSize(id: string): BaseSpec {
return (SIZE_PRESETS.find((p) => p.id === id) ?? SIZE_PRESETS[1]).spec
}
/** Which preset a spec matches, or `custom` when it matches none. */
export function sizeForSpec(spec: BaseSpec): string {
const m = SIZE_PRESETS.find((p) => p.spec.replicas === spec.replicas && p.spec.storage === spec.storage)
return m ? m.id : 'custom'
}
/** A human summary of a spec, for the list/detail rows (honest '—' when empty). */
export function specSummary(spec: BaseSpec): string {
const parts: string[] = []
if (spec.replicas !== undefined) parts.push(`${spec.replicas} ${spec.replicas === 1 ? 'replica' : 'replicas'}`)
if (spec.storage) parts.push(spec.storage)
return parts.length ? parts.join(' · ') : '—'
}
/** Status presentation — the provisioning lifecycle derived from controller fields. */
export type StatusTone = 'ready' | 'pending' | 'error'
export interface BaseStatus {
label: string
tone: StatusTone
ready: boolean
}
export function statusOf(base: { status: string; subdomain: string; lastError: string }): BaseStatus {
if (base.lastError.trim()) return { label: 'Error', tone: 'error', ready: false }
const s = base.status.trim()
const sl = s.toLowerCase()
if (sl === 'error' || sl === 'failed') return { label: s || 'Error', tone: 'error', ready: false }
if (base.subdomain.trim() && (sl === '' || sl === 'ready' || sl === 'running' || sl === 'active')) {
return { label: 'Ready', tone: 'ready', ready: true }
}
if (!s) return { label: 'Provisioning', tone: 'pending', ready: false }
return { label: s, tone: 'pending', ready: false }
}
/** The live URL for a ready Base (its own subdomain), or null while provisioning. */
export function baseHref(base: { subdomain: string }): string | null {
const sd = base.subdomain.trim()
if (!sd) return null
return /^https?:\/\//.test(sd) ? sd : `https://${sd}`
}
-119
View File
@@ -1,119 +0,0 @@
/**
* BaseTenantsApi — the org's Hanzo Base INSTANCES ("Bases").
*
* A "Base" is a row in the SuperBase orchestrator's `tenants` collection
* (base.hanzo.ai): a name + slug (which becomes its own `<slug>.base.hanzo.ai`
* subdomain and K8s workload), a spec (replicas + storage), and a
* controller-reconciled status/subdomain. Listing / creating / configuring Bases
* IS reading and writing `tenants` records — so this reuses the ONE Base client
* (`BaseDataApi`) against that collection; it does not re-implement Base.
*
* Transport is console2's OWN `/superbase` proxy, which mints the user's IAM
* bearer and stamps `X-Org-Id` from the JWT owner, so every call is scoped to the
* caller's org (the derive-once model — the org is the trusted owner, never a
* browser-supplied value).
*/
import { BaseDataApi, type BaseRecord } from './api'
/** The orchestrator collection whose records ARE the org's Base instances. */
export const TENANTS_COLLECTION = 'tenants'
/** A Base instance's declared shape (replicas + storage) — a small size, not raw K8s. */
export interface BaseSpec {
replicas?: number
storage?: string
}
/** One Hanzo Base instance (a normalized `tenants` record). */
export interface BaseInstance {
id: string
name: string
slug: string
/** Controller-set lifecycle status ('' while just-created / provisioning). */
status: string
/** Controller-set live subdomain (present once the Base is reconciled + ready). */
subdomain: string
spec: BaseSpec
/** Controller-set last reconcile error (empty when healthy). */
lastError: string
created?: string
updated?: string
}
const str = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined
/** Normalize a raw `tenants` record into a `BaseInstance` (defensive over the wire shape). */
export function normalizeBase(raw: BaseRecord | Record<string, unknown> | null | undefined): BaseInstance {
const o = (raw ?? {}) as Record<string, unknown>
const spec: BaseSpec = {}
const rawSpec = o.spec
if (rawSpec && typeof rawSpec === 'object') {
const s = rawSpec as Record<string, unknown>
const replicas = num(s.replicas)
if (replicas !== undefined) spec.replicas = replicas
if (typeof s.storage === 'string' && s.storage) spec.storage = s.storage
}
return {
id: str(o.id),
name: str(o.name) || str(o.slug),
slug: str(o.slug),
status: str(o.status),
subdomain: str(o.subdomain),
spec,
lastError: str(o.last_error),
created: str(o.created) || undefined,
updated: str(o.updated) || undefined,
}
}
export interface CreateBaseInput {
name: string
slug: string
spec?: BaseSpec
}
export class BaseTenantsApi {
private readonly api: BaseDataApi
constructor(baseUrl: string) {
this.api = new BaseDataApi({ baseUrl })
}
/** List the org's Bases (its `tenants` records), newest data first (sorted in the UI). */
async list(): Promise<BaseInstance[]> {
const res = await this.api.listRecords(TENANTS_COLLECTION, { perPage: 200 })
return res.items.map(normalizeBase)
}
/** One Base by record id. */
async get(id: string): Promise<BaseInstance> {
return normalizeBase(await this.api.getRecord(TENANTS_COLLECTION, id))
}
/**
* Create a Base — `POST /v1/collections/tenants/records`. The orchestrator's
* plugin reconciles a real Base workload (`<slug>.base.hanzo.ai`) and stamps
* status/subdomain back on the record; Base scopes the write to the caller's org
* (the proxy stamps `X-Org-Id` from the JWT owner).
*/
async create(input: CreateBaseInput): Promise<BaseInstance> {
const body: Record<string, unknown> = { name: input.name, slug: input.slug }
if (input.spec && (input.spec.replicas !== undefined || input.spec.storage)) body.spec = input.spec
return normalizeBase(await this.api.createRecord(TENANTS_COLLECTION, body))
}
/** Reconfigure a Base's name and/or spec — `PATCH .../records/<id>`. */
async update(id: string, patch: { name?: string; spec?: BaseSpec }): Promise<BaseInstance> {
const body: Record<string, unknown> = {}
if (patch.name !== undefined) body.name = patch.name
if (patch.spec !== undefined) body.spec = patch.spec
return normalizeBase(await this.api.updateRecord(TENANTS_COLLECTION, id, body))
}
/** Delete a Base — `DELETE .../records/<id>` (deprovisions the workload). */
async remove(id: string): Promise<void> {
await this.api.deleteRecord(TENANTS_COLLECTION, id)
}
}
+14 -1
View File
@@ -1123,7 +1123,20 @@ export const catalog: CatalogEntry[] = [
repo: 'hanzoai/agent',
docs: `${DOCS}/agents`,
kind: 'module',
routes: [{ path: '', component: AgentsModule }],
// Agents OWNS Status/Logs/Metrics — they render the agent registry's OWN runs
// (health board, invocation activity, invocation/latency metrics from /v1/agents),
// NOT the generic o11y/usage-ledger subpage (which is empty for agents until the
// service emits OTel / the ledger tags spend product:agents). Same pattern as
// Inference owning Status/Logs. Settings stays the shared subpage.
routes: [
{ path: '', component: AgentsModule },
{ path: ':tab', component: AgentsModule },
],
subpages: [
{ slug: 'status', label: 'Status' },
{ slug: 'logs', label: 'Logs' },
{ slug: 'metrics', label: 'Metrics' },
],
},
{
// Mission Control — the mobile-first swipeable terminal-per-agent cockpit over the