Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d5b6f16c0 | ||
|
|
0290255379 |
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 Base’s 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">
|
||||
Couldn’t 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 it’s 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 Base’s 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: '' })
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user