@hanzo/dashboard 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@hanzo/dashboard",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Hanzo Base tenant-management screens \u2014 host-agnostic @hanzo/gui v7 React surface, shared by the standalone dashboard (base.hanzo.ai) and the embedded console module (console.hanzo.ai).",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/lib/index.ts",
9
+ "default": "./src/lib/index.ts"
10
+ }
11
+ },
12
+ "main": "./src/lib/index.ts",
13
+ "module": "./src/lib/index.ts",
14
+ "types": "./src/lib/index.ts",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "src/lib"
18
+ ],
19
+ "scripts": {
20
+ "dev": "vite",
21
+ "build": "vite build",
22
+ "preview": "vite preview",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "peerDependencies": {
26
+ "@hanzo/gui": ">=7.2.2",
27
+ "react": ">=19",
28
+ "react-dom": ">=19"
29
+ },
30
+ "devDependencies": {
31
+ "@hanzo/gui": "^7.2.2",
32
+ "@hanzo/iam": "^0.9.4",
33
+ "@hanzogui/config": "^7.0.0",
34
+ "@hanzogui/vite-plugin": "7.0.0",
35
+ "@types/react": "^19.1.10",
36
+ "@types/react-dom": "^19.1.7",
37
+ "@vitejs/plugin-react": "^5.0.0",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0",
40
+ "react-native-web": "^0.21.0",
41
+ "react-router-dom": "^7.0.2",
42
+ "typescript": "^5.7.2",
43
+ "vite": "^7.0.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "overrides": {
52
+ "@hanzogui/vite-plugin": "7.0.0",
53
+ "@hanzogui/web": "102.0.0-rc.41-hanzoai.1",
54
+ "@hanzogui/core": "102.0.0-rc.41-hanzoai.1",
55
+ "@hanzogui/themes": "102.0.0-rc.41-hanzoai.1"
56
+ },
57
+ "resolutions": {
58
+ "@hanzogui/vite-plugin": "7.0.0",
59
+ "@hanzogui/web": "102.0.0-rc.41-hanzoai.1",
60
+ "@hanzogui/core": "102.0.0-rc.41-hanzoai.1",
61
+ "@hanzogui/themes": "102.0.0-rc.41-hanzoai.1"
62
+ },
63
+ "license": "BSD-3-Clause",
64
+ "author": "Hanzo AI <dev@hanzo.ai>"
65
+ }
package/readme.md ADDED
@@ -0,0 +1,57 @@
1
+ # SuperBase Dashboard
2
+
3
+ Static SPA that the superbase Go binary embeds via `go:embed` and serves
4
+ at `/dashboard/*` (with `/_/tenants/*` redirecting here as a legacy
5
+ alias).
6
+
7
+ - Vite + React 19 + React Router
8
+ - `@hanzo/gui` v7 for all UI (no shadcn, no Radix-direct)
9
+ - `@hanzo/iam` PKCE OIDC against `hanzo.id`
10
+ - Talks to the local Base instance at `/v1/collections/tenants/records`
11
+
12
+ No Svelte. No Express. No server-side. Pure static bundle.
13
+
14
+ ## Build
15
+
16
+ ```sh
17
+ bun install
18
+ bun run build
19
+ ```
20
+
21
+ The output lands in `dist/` — that's what the Go binary embeds. Each
22
+ asset is content-hashed; `dist/index.html` is the SPA shell. The Go
23
+ binary rewrites the `<meta name="hanzo-iam-*">` tags in `index.html`
24
+ at serve time to inject the right environment's IAM client id, so the
25
+ bundle has no env-specific configuration baked in.
26
+
27
+ ## Dev
28
+
29
+ ```sh
30
+ bun run dev
31
+ ```
32
+
33
+ Vite serves at `http://127.0.0.1:5180/dashboard/` and proxies `/v1/*`
34
+ to `http://127.0.0.1:8090` (a locally-running Base / superbase binary).
35
+
36
+ If you don't have a local IAM, the SPA still loads — `useIam()`
37
+ surfaces the sign-in screen and clicking it redirects to
38
+ `https://hanzo.id`. Override the IAM target with:
39
+
40
+ ```sh
41
+ VITE_IAM_SERVER_URL=https://iam-local.hanzo.ai \
42
+ VITE_IAM_CLIENT_ID=hanzo-superbase-local \
43
+ bun run dev
44
+ ```
45
+
46
+ ## What's where
47
+
48
+ - `src/main.tsx` — `HanzoguiProvider` → `IamProvider` → router
49
+ - `src/App.tsx` — header + auth gate
50
+ - `src/auth.ts` — IAM config (meta-tag → env → defaults)
51
+ - `src/api.ts` — typed Base client (list/get/create/update/delete)
52
+ - `src/routes/TenantsList.tsx` — table + drawer + 5s polling
53
+ - `src/routes/NewTenant.tsx` — create form with kebab-slug validation
54
+ - `src/routes/LoginCallback.tsx` — OIDC PKCE callback
55
+ - `src/components/` — TenantRow, TenantStatusBadge, ConfirmDialog
56
+ - `gui.config.ts` — @hanzogui/config v5 default + dark theme
57
+ - `vite.config.ts` — base `/dashboard/`, hanzogui dedupe + optimizeDeps
package/src/lib/api.ts ADDED
@@ -0,0 +1,155 @@
1
+ // Typed, host-agnostic Base client for the SuperBase `tenants` collection.
2
+ //
3
+ // ONE transport, injected by the host. The screens never see a token or a
4
+ // fetch — they call the returned `TenantsApi`. Two hosts build it differently:
5
+ //
6
+ // • standalone (base.hanzo.ai): same-origin, Bearer from @hanzo/iam.
7
+ // createTenantsApi({ getToken: () => accessToken })
8
+ // • embedded (console.hanzo.ai): calls console2's own `/superbase/*` proxy
9
+ // with the session cookie; the proxy mints + forwards the user's IAM
10
+ // bearer server-side, so no token reaches this browser.
11
+ // createTenantsApi({ baseUrl: '/superbase', credentials: 'include' })
12
+ //
13
+ // One and only one way: every mutation goes through a method on the api object.
14
+ // No raw fetch elsewhere in the screens.
15
+
16
+ export type TenantStatus =
17
+ | 'Pending'
18
+ | 'Creating'
19
+ | 'Running'
20
+ | 'Degraded'
21
+ | 'Deleting'
22
+
23
+ export interface TenantSpec {
24
+ replicas: number
25
+ storage: string
26
+ }
27
+
28
+ export interface TenantRecord {
29
+ id: string
30
+ slug: string
31
+ name: string
32
+ owner_iam_user: string
33
+ namespace: string
34
+ spec: TenantSpec
35
+ status: TenantStatus
36
+ subdomain: string
37
+ current_writer: string
38
+ term: number
39
+ created: string
40
+ updated: string
41
+ }
42
+
43
+ export interface CreateTenantInput {
44
+ slug: string
45
+ name: string
46
+ namespace?: string
47
+ spec: TenantSpec
48
+ }
49
+
50
+ export interface UpdateTenantInput {
51
+ name?: string
52
+ namespace?: string
53
+ spec?: Partial<TenantSpec>
54
+ }
55
+
56
+ export interface ListResponse<T> {
57
+ page: number
58
+ perPage: number
59
+ totalPages: number
60
+ totalItems: number
61
+ items: T[]
62
+ }
63
+
64
+ const BASE_PATH = '/v1/collections/tenants/records'
65
+
66
+ export class ApiError extends Error {
67
+ status: number
68
+ body: unknown
69
+ constructor(status: number, body: unknown, message?: string) {
70
+ super(message ?? `Base API error ${status}`)
71
+ this.status = status
72
+ this.body = body
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Host-supplied transport. `baseUrl` prefixes every request (''=same-origin for
78
+ * the standalone app, '/superbase'=console2's proxy). `getToken` provides the
79
+ * Bearer when the host holds the token in the browser (standalone); omit it when
80
+ * a same-origin proxy injects the credential server-side (console2), and set
81
+ * `credentials: 'include'` so the proxy receives the session cookie.
82
+ */
83
+ export interface TenantsTransport {
84
+ baseUrl?: string
85
+ getToken?: () => string | null
86
+ credentials?: RequestCredentials
87
+ }
88
+
89
+ /** The complete data surface the screens consume — five methods, no token. */
90
+ export interface TenantsApi {
91
+ list(params?: { page?: number; perPage?: number; sort?: string }): Promise<ListResponse<TenantRecord>>
92
+ get(idOrSlug: string): Promise<TenantRecord>
93
+ create(input: CreateTenantInput): Promise<TenantRecord>
94
+ update(id: string, patch: UpdateTenantInput): Promise<TenantRecord>
95
+ remove(id: string): Promise<void>
96
+ }
97
+
98
+ export function createTenantsApi(t: TenantsTransport = {}): TenantsApi {
99
+ const baseUrl = (t.baseUrl ?? '').replace(/\/+$/, '')
100
+ const credentials: RequestCredentials = t.credentials ?? 'same-origin'
101
+
102
+ async function request<T>(path: string, init: RequestInit): Promise<T> {
103
+ const headers: Record<string, string> = {
104
+ 'Content-Type': 'application/json',
105
+ ...((init.headers as Record<string, string>) ?? {}),
106
+ }
107
+ const token = t.getToken?.() ?? null
108
+ if (token) headers.Authorization = `Bearer ${token}`
109
+
110
+ const res = await fetch(`${baseUrl}${path}`, { ...init, headers, credentials })
111
+ if (!res.ok) {
112
+ let body: unknown = null
113
+ try { body = await res.json() } catch { /* not JSON */ }
114
+ throw new ApiError(res.status, body, `Base API ${res.status} ${res.statusText}`)
115
+ }
116
+ if (res.status === 204) return undefined as T
117
+ return (await res.json()) as T
118
+ }
119
+
120
+ return {
121
+ list(params) {
122
+ const q = new URLSearchParams()
123
+ if (params?.page) q.set('page', String(params.page))
124
+ if (params?.perPage) q.set('perPage', String(params.perPage))
125
+ // The tenants collection has no `created`/`createdAt` autodate field in
126
+ // the deployed Base schema, so `sort=-created` 400s. `-id` is always
127
+ // present and id is monotonic, giving newest-first ordering.
128
+ q.set('sort', params?.sort ?? '-id')
129
+ return request<ListResponse<TenantRecord>>(`${BASE_PATH}?${q}`, { method: 'GET' })
130
+ },
131
+ get(idOrSlug) {
132
+ // Base supports lookup by id or by a unique field via filter; slug is unique.
133
+ if (idOrSlug.match(/^[a-z0-9]{15}$/)) {
134
+ return request<TenantRecord>(`${BASE_PATH}/${encodeURIComponent(idOrSlug)}`, { method: 'GET' })
135
+ }
136
+ const q = new URLSearchParams({ filter: `slug='${idOrSlug.replace(/'/g, "\\'")}'`, perPage: '1' })
137
+ return request<ListResponse<TenantRecord>>(`${BASE_PATH}?${q}`, { method: 'GET' }).then((r) => {
138
+ if (r.items.length === 0) throw new ApiError(404, null, `tenant not found: ${idOrSlug}`)
139
+ return r.items[0]
140
+ })
141
+ },
142
+ create(input) {
143
+ return request<TenantRecord>(BASE_PATH, { method: 'POST', body: JSON.stringify(input) })
144
+ },
145
+ update(id, patch) {
146
+ return request<TenantRecord>(`${BASE_PATH}/${encodeURIComponent(id)}`, {
147
+ method: 'PATCH',
148
+ body: JSON.stringify(patch),
149
+ })
150
+ },
151
+ remove(id) {
152
+ return request<void>(`${BASE_PATH}/${encodeURIComponent(id)}`, { method: 'DELETE' })
153
+ },
154
+ }
155
+ }
@@ -0,0 +1,53 @@
1
+ import { Button, Dialog, Text, XStack, YStack } from '@hanzo/gui'
2
+
3
+ export function ConfirmDialog({
4
+ open,
5
+ title,
6
+ message,
7
+ confirmLabel = 'Confirm',
8
+ destructive = false,
9
+ onConfirm,
10
+ onCancel,
11
+ }: {
12
+ open: boolean
13
+ title: string
14
+ message: string
15
+ confirmLabel?: string
16
+ destructive?: boolean
17
+ onConfirm: () => void
18
+ onCancel: () => void
19
+ }) {
20
+ return (
21
+ <Dialog modal open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
22
+ <Dialog.Portal>
23
+ <Dialog.Overlay bg="rgba(0,0,0,0.6)" />
24
+ <Dialog.Content
25
+ bg="#0a0a0a"
26
+ borderColor="#27272a"
27
+ borderWidth={1}
28
+ p={20}
29
+ width={420}
30
+ gap={16}
31
+ >
32
+ <Dialog.Title>
33
+ <Text fontSize={18} fontWeight="700" color="#f4f4f5">{title}</Text>
34
+ </Dialog.Title>
35
+ <Dialog.Description>
36
+ <Text color="#a1a1aa">{message}</Text>
37
+ </Dialog.Description>
38
+ <YStack gap={12}>
39
+ <XStack gap={8} justify="flex-end">
40
+ <Button onPress={onCancel}>Cancel</Button>
41
+ <Button
42
+ theme={destructive ? 'red' : 'blue'}
43
+ onPress={onConfirm}
44
+ >
45
+ {confirmLabel}
46
+ </Button>
47
+ </XStack>
48
+ </YStack>
49
+ </Dialog.Content>
50
+ </Dialog.Portal>
51
+ </Dialog>
52
+ )
53
+ }
@@ -0,0 +1,39 @@
1
+ import { Text, XStack } from '@hanzo/gui'
2
+ import { TenantStatusBadge } from './TenantStatusBadge'
3
+ import type { TenantRecord } from '../api'
4
+
5
+ // One row in the tenants list. Click anywhere on the row to open the
6
+ // detail drawer — selection is parent-controlled.
7
+ export function TenantRow({
8
+ tenant,
9
+ onClick,
10
+ }: {
11
+ tenant: TenantRecord
12
+ onClick: (t: TenantRecord) => void
13
+ }) {
14
+ return (
15
+ <XStack
16
+ px={16}
17
+ py={12}
18
+ borderBottomWidth={1}
19
+ borderBottomColor="#27272a"
20
+ hoverStyle={{ bg: '#18181b' }}
21
+ cursor="pointer"
22
+ onPress={() => onClick(tenant)}
23
+ items="center"
24
+ gap={16}
25
+ >
26
+ <Text flex={1} color="#f4f4f5" fontWeight="600">{tenant.slug}</Text>
27
+ <Text flex={2} color="#a1a1aa" numberOfLines={1}>{tenant.subdomain || '—'}</Text>
28
+ <XStack width={120} justify="flex-start">
29
+ <TenantStatusBadge status={tenant.status} />
30
+ </XStack>
31
+ <Text flex={2} color="#a1a1aa" numberOfLines={1}>
32
+ {tenant.current_writer || '—'}
33
+ </Text>
34
+ <Text width={80} color="#a1a1aa" style={{ textAlign: 'right' }}>
35
+ term {tenant.term}
36
+ </Text>
37
+ </XStack>
38
+ )
39
+ }
@@ -0,0 +1,33 @@
1
+ import { Text, XStack } from '@hanzo/gui'
2
+ import type { TenantStatus } from '../api'
3
+
4
+ // Color map mirrors the BaseApp.Status.Phase semantics — we read the
5
+ // Status verbatim from the operator (see plugin/tenant/types.go), so
6
+ // any new value will fall through to the neutral default rather than
7
+ // silently rendering wrong. `as const` keeps the hex literals narrow so
8
+ // they satisfy the Gui `bg`/`color` token unions (a widened `string` won't).
9
+ const NEUTRAL = { bg: '#3f3f46', fg: '#fafafa' } as const // zinc 700 / 50
10
+ const COLORS = {
11
+ Pending: NEUTRAL,
12
+ Creating: { bg: '#1e40af', fg: '#dbeafe' }, // blue 800 / 100
13
+ Running: { bg: '#166534', fg: '#dcfce7' }, // green 800 / 100
14
+ Degraded: { bg: '#92400e', fg: '#fef3c7' }, // amber 800 / 100
15
+ Deleting: { bg: '#991b1b', fg: '#fee2e2' }, // red 800 / 100
16
+ } as const satisfies Record<TenantStatus, { bg: string; fg: string }>
17
+
18
+ export function TenantStatusBadge({ status }: { status: TenantStatus }) {
19
+ const c = COLORS[status] ?? NEUTRAL
20
+ return (
21
+ <XStack
22
+ bg={c.bg}
23
+ px={8}
24
+ py={2}
25
+ rounded={4}
26
+ items="center"
27
+ >
28
+ <Text fontSize={12} color={c.fg} fontWeight="600">
29
+ {status}
30
+ </Text>
31
+ </XStack>
32
+ )
33
+ }
@@ -0,0 +1,30 @@
1
+ // @hanzo/superbase-dashboard — the Hanzo Base tenant-management screens.
2
+ //
3
+ // Host-agnostic surface consumed by BOTH the standalone dashboard (base.hanzo.ai,
4
+ // the superbase Go binary) and the embedded console module (console.hanzo.ai,
5
+ // console2). ONE source of screens; each host supplies a transport + navigation.
6
+ //
7
+ // import { createTenantsApi, TenantsScreen, NewTenantScreen } from '@hanzo/superbase-dashboard'
8
+ //
9
+ // See ./api (createTenantsApi) and ./nav (TenantsNav) for the two injection
10
+ // points. The screens themselves never touch a token, a router, or a fetch.
11
+
12
+ export { createTenantsApi, ApiError } from './api'
13
+ export type {
14
+ TenantsApi,
15
+ TenantsTransport,
16
+ TenantRecord,
17
+ TenantStatus,
18
+ TenantSpec,
19
+ CreateTenantInput,
20
+ UpdateTenantInput,
21
+ ListResponse,
22
+ } from './api'
23
+ export type { TenantsNav } from './nav'
24
+
25
+ export { TenantsScreen } from './screens/TenantsScreen'
26
+ export { NewTenantScreen } from './screens/NewTenantScreen'
27
+
28
+ export { TenantStatusBadge } from './components/TenantStatusBadge'
29
+ export { TenantRow } from './components/TenantRow'
30
+ export { ConfirmDialog } from './components/ConfirmDialog'
package/src/lib/nav.ts ADDED
@@ -0,0 +1,12 @@
1
+ // Host-agnostic navigation surface for the Base screens.
2
+ //
3
+ // The screens never import a router. Each host maps these intents to its own
4
+ // routing: the standalone app to react-router `navigate`, console2 to the Next
5
+ // `useRouter().push` under the `/base` product path. Tenant DETAIL is a
6
+ // client-state drawer inside TenantsScreen — not a route — so it needs no nav.
7
+ export interface TenantsNav {
8
+ /** Return to the tenants list, optionally re-selecting a tenant by id. */
9
+ toList(opts?: { selected?: string }): void
10
+ /** Open the new-tenant form. */
11
+ toNew(): void
12
+ }
@@ -0,0 +1,137 @@
1
+ import { useState } from 'react'
2
+ import { Button, Card, Input, Label, Text, XStack, YStack } from '@hanzo/gui'
3
+ import type { TenantsApi } from '../api'
4
+ import type { TenantsNav } from '../nav'
5
+
6
+ // Slug validation mirrors the Base collection constraint (lowercase
7
+ // kebab-case, must start with a letter, 2-31 chars). Keep this in
8
+ // sync with the slug regex in plugin/tenant/collection.go.
9
+ const SLUG_RE = /^[a-z][a-z0-9-]{1,30}$/
10
+
11
+ const REPLICAS = [3, 5, 7] as const
12
+ const STORAGE = ['10Gi', '25Gi', '50Gi'] as const
13
+
14
+ /**
15
+ * The create-tenant form. Host-agnostic: submits through the injected `api`,
16
+ * returns to the list (re-selecting the new tenant) or cancels via `nav`.
17
+ */
18
+ export function NewTenantScreen({ api, nav }: { api: TenantsApi; nav: TenantsNav }) {
19
+ const [slug, setSlug] = useState('')
20
+ const [name, setName] = useState('')
21
+ const [replicas, setReplicas] = useState<(typeof REPLICAS)[number]>(3)
22
+ const [storage, setStorage] = useState<(typeof STORAGE)[number]>('10Gi')
23
+ const [submitting, setSubmitting] = useState(false)
24
+ const [error, setError] = useState<string | null>(null)
25
+
26
+ const slugError =
27
+ slug.length === 0 ? null :
28
+ SLUG_RE.test(slug) ? null :
29
+ 'Lowercase letters, digits and dashes only; must start with a letter; 2-31 chars.'
30
+
31
+ const canSubmit = slug.length > 0 && !slugError && name.length > 0 && !submitting
32
+
33
+ const onSubmit = async () => {
34
+ if (!canSubmit) return
35
+ setSubmitting(true)
36
+ setError(null)
37
+ try {
38
+ const created = await api.create({ slug, name, spec: { replicas, storage } })
39
+ nav.toList({ selected: created.id })
40
+ } catch (e) {
41
+ setError(e instanceof Error ? e.message : String(e))
42
+ setSubmitting(false)
43
+ }
44
+ }
45
+
46
+ return (
47
+ <YStack flex={1} p={24} gap={16} maxW={560}>
48
+ <XStack items="center" gap={12}>
49
+ <Text fontSize={24} fontWeight="700" color="#f4f4f5" flex={1}>New tenant</Text>
50
+ <Button onPress={() => nav.toList()}>Cancel</Button>
51
+ </XStack>
52
+
53
+ <Card bg="#09090b" borderColor="#27272a" borderWidth={1} p={20} gap={16}>
54
+ <YStack gap={6}>
55
+ <Label htmlFor="slug">
56
+ <Text color="#d4d4d8" fontWeight="600">Slug</Text>
57
+ </Label>
58
+ <Input
59
+ id="slug"
60
+ value={slug}
61
+ onChangeText={(v) => setSlug(v.toLowerCase())}
62
+ placeholder="acme-prod"
63
+ autoCapitalize="none"
64
+ />
65
+ {slugError && <Text color="#fca5a5" fontSize={12}>{slugError}</Text>}
66
+ {!slugError && slug.length > 0 && (
67
+ <Text color="#71717a" fontSize={12}>
68
+ Subdomain will be <Text color="#a1a1aa" fontWeight="600">{slug}.base.hanzo.ai</Text>
69
+ </Text>
70
+ )}
71
+ </YStack>
72
+
73
+ <YStack gap={6}>
74
+ <Label htmlFor="name">
75
+ <Text color="#d4d4d8" fontWeight="600">Display name</Text>
76
+ </Label>
77
+ <Input
78
+ id="name"
79
+ value={name}
80
+ onChangeText={setName}
81
+ placeholder="Acme Production"
82
+ />
83
+ </YStack>
84
+
85
+ <YStack gap={6}>
86
+ <Text color="#d4d4d8" fontWeight="600">Replicas</Text>
87
+ <XStack gap={8}>
88
+ {REPLICAS.map((n) => (
89
+ <Button
90
+ key={n}
91
+ theme={replicas === n ? 'blue' : undefined}
92
+ onPress={() => setReplicas(n)}
93
+ >
94
+ {n}
95
+ </Button>
96
+ ))}
97
+ </XStack>
98
+ <Text color="#71717a" fontSize={12}>
99
+ Odd-count replica set for the writer-election quorum.
100
+ </Text>
101
+ </YStack>
102
+
103
+ <YStack gap={6}>
104
+ <Text color="#d4d4d8" fontWeight="600">Storage</Text>
105
+ <XStack gap={8}>
106
+ {STORAGE.map((s) => (
107
+ <Button
108
+ key={s}
109
+ theme={storage === s ? 'blue' : undefined}
110
+ onPress={() => setStorage(s)}
111
+ >
112
+ {s}
113
+ </Button>
114
+ ))}
115
+ </XStack>
116
+ <Text color="#71717a" fontSize={12}>PVC size per replica.</Text>
117
+ </YStack>
118
+
119
+ {error && (
120
+ <Card p={12} bg="#450a0a" borderColor="#7f1d1d" borderWidth={1}>
121
+ <Text color="#fecaca">{error}</Text>
122
+ </Card>
123
+ )}
124
+
125
+ <XStack justify="flex-end">
126
+ <Button
127
+ theme="blue"
128
+ disabled={!canSubmit}
129
+ onPress={onSubmit}
130
+ >
131
+ {submitting ? 'Creating…' : 'Create tenant'}
132
+ </Button>
133
+ </XStack>
134
+ </Card>
135
+ </YStack>
136
+ )
137
+ }
@@ -0,0 +1,229 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
3
+ import { TenantRow } from '../components/TenantRow'
4
+ import { TenantStatusBadge } from '../components/TenantStatusBadge'
5
+ import { ConfirmDialog } from '../components/ConfirmDialog'
6
+ import type { TenantsApi, TenantRecord } from '../api'
7
+ import type { TenantsNav } from '../nav'
8
+
9
+ const POLL_MS = 5000
10
+
11
+ /**
12
+ * The tenants list + detail drawer. Host-agnostic: data comes from the injected
13
+ * `api`, navigation to the new-tenant form from `nav`. Detail is a client-state
14
+ * drawer (not a route), so it works identically standalone and embedded.
15
+ */
16
+ export function TenantsScreen({ api, nav }: { api: TenantsApi; nav: TenantsNav }) {
17
+ const [tenants, setTenants] = useState<TenantRecord[]>([])
18
+ const [loading, setLoading] = useState(false)
19
+ const [error, setError] = useState<string | null>(null)
20
+ const [selected, setSelected] = useState<TenantRecord | null>(null)
21
+ const [confirmDelete, setConfirmDelete] = useState(false)
22
+
23
+ const refresh = useCallback(async () => {
24
+ setLoading(true)
25
+ setError(null)
26
+ try {
27
+ const r = await api.list({ perPage: 200 })
28
+ setTenants(r.items)
29
+ // Re-resolve selected from refreshed list so drawer reflects latest status.
30
+ setSelected((s) => (s ? r.items.find((x) => x.id === s.id) ?? null : null))
31
+ } catch (e) {
32
+ setError(e instanceof Error ? e.message : String(e))
33
+ } finally {
34
+ setLoading(false)
35
+ }
36
+ }, [api])
37
+
38
+ useEffect(() => {
39
+ refresh()
40
+ const id = setInterval(refresh, POLL_MS)
41
+ return () => clearInterval(id)
42
+ }, [refresh])
43
+
44
+ const onDelete = async () => {
45
+ if (!selected) return
46
+ try {
47
+ await api.remove(selected.id)
48
+ setConfirmDelete(false)
49
+ setSelected(null)
50
+ await refresh()
51
+ } catch (e) {
52
+ setError(e instanceof Error ? e.message : String(e))
53
+ }
54
+ }
55
+
56
+ return (
57
+ <XStack flex={1}>
58
+ <YStack flex={1} p={24} gap={16}>
59
+ <XStack items="center" gap={12}>
60
+ <Text fontSize={24} fontWeight="700" color="#f4f4f5" flex={1}>Tenants</Text>
61
+ <Button onPress={refresh} disabled={loading}>Refresh</Button>
62
+ <Button theme="blue" onPress={() => nav.toNew()}>New tenant</Button>
63
+ </XStack>
64
+
65
+ {error && (
66
+ <Card p={12} bg="#450a0a" borderColor="#7f1d1d" borderWidth={1}>
67
+ <Text color="#fecaca">{error}</Text>
68
+ </Card>
69
+ )}
70
+
71
+ <Card bg="#09090b" borderColor="#27272a" borderWidth={1} p={0}>
72
+ <XStack
73
+ px={16}
74
+ py={10}
75
+ borderBottomWidth={1}
76
+ borderBottomColor="#27272a"
77
+ bg="#0a0a0a"
78
+ gap={16}
79
+ >
80
+ <Text flex={1} color="#71717a" fontSize={12} fontWeight="600">SLUG</Text>
81
+ <Text flex={2} color="#71717a" fontSize={12} fontWeight="600">SUBDOMAIN</Text>
82
+ <Text width={120} color="#71717a" fontSize={12} fontWeight="600">STATUS</Text>
83
+ <Text flex={2} color="#71717a" fontSize={12} fontWeight="600">CURRENT WRITER</Text>
84
+ <Text width={80} color="#71717a" fontSize={12} fontWeight="600" style={{ textAlign: 'right' }}>TERM</Text>
85
+ </XStack>
86
+ {tenants.length === 0 ? (
87
+ <YStack p={32} items="center">
88
+ <Text color="#71717a">No tenants yet. Create one to get started.</Text>
89
+ </YStack>
90
+ ) : (
91
+ tenants.map((t) => (
92
+ <TenantRow key={t.id} tenant={t} onClick={setSelected} />
93
+ ))
94
+ )}
95
+ </Card>
96
+ </YStack>
97
+
98
+ {selected && (
99
+ <YStack
100
+ width={420}
101
+ bg="#0a0a0a"
102
+ borderLeftWidth={1}
103
+ borderLeftColor="#27272a"
104
+ p={24}
105
+ gap={16}
106
+ >
107
+ <XStack items="center" gap={12}>
108
+ <Text fontSize={20} fontWeight="700" color="#f4f4f5" flex={1}>{selected.slug}</Text>
109
+ <Button size="$2" onPress={() => setSelected(null)}>Close</Button>
110
+ </XStack>
111
+ <YStack gap={4}>
112
+ <Text color="#71717a" fontSize={12}>NAME</Text>
113
+ <Text color="#f4f4f5">{selected.name}</Text>
114
+ </YStack>
115
+ <YStack gap={4}>
116
+ <Text color="#71717a" fontSize={12}>STATUS</Text>
117
+ <XStack><TenantStatusBadge status={selected.status} /></XStack>
118
+ </YStack>
119
+ <YStack gap={4}>
120
+ <Text color="#71717a" fontSize={12}>SUBDOMAIN</Text>
121
+ <Text color="#f4f4f5">{selected.subdomain || '—'}</Text>
122
+ </YStack>
123
+ <YStack gap={4}>
124
+ <Text color="#71717a" fontSize={12}>NAMESPACE</Text>
125
+ <Text color="#f4f4f5">{selected.namespace}</Text>
126
+ </YStack>
127
+ <YStack gap={4}>
128
+ <Text color="#71717a" fontSize={12}>SPEC</Text>
129
+ <Text color="#f4f4f5">
130
+ {selected.spec.replicas} replicas · {selected.spec.storage}
131
+ </Text>
132
+ </YStack>
133
+ <YStack gap={4}>
134
+ <Text color="#71717a" fontSize={12}>CURRENT WRITER</Text>
135
+ <Text color="#f4f4f5">{selected.current_writer || '—'}</Text>
136
+ </YStack>
137
+ <YStack gap={4}>
138
+ <Text color="#71717a" fontSize={12}>TERM</Text>
139
+ <Text color="#f4f4f5">{selected.term}</Text>
140
+ </YStack>
141
+ <YStack gap={4}>
142
+ <Text color="#71717a" fontSize={12}>OWNER</Text>
143
+ <Text color="#f4f4f5">{selected.owner_iam_user || '—'}</Text>
144
+ </YStack>
145
+
146
+ {selected.status === 'Running' && selected.subdomain ? (
147
+ <DbAccess tenant={selected} />
148
+ ) : (
149
+ <YStack gap={4}>
150
+ <Text color="#71717a" fontSize={12}>ENDPOINT</Text>
151
+ <Text color="#52525b">Available once the tenant is Running.</Text>
152
+ </YStack>
153
+ )}
154
+
155
+ <XStack flex={1} />
156
+ <Button theme="red" onPress={() => setConfirmDelete(true)}>Delete tenant</Button>
157
+ </YStack>
158
+ )}
159
+
160
+ <ConfirmDialog
161
+ open={confirmDelete}
162
+ title="Delete tenant?"
163
+ message={`This deletes the "${selected?.slug ?? ''}" tenant record. The operator will tear down the BaseApp and all associated state. This cannot be undone.`}
164
+ confirmLabel="Delete"
165
+ destructive
166
+ onConfirm={onDelete}
167
+ onCancel={() => setConfirmDelete(false)}
168
+ />
169
+ </XStack>
170
+ )
171
+ }
172
+
173
+ // What makes a tenant "usable as a database backend": its live endpoints. A
174
+ // Running Base instance always serves the REST/realtime data API at `/v1`
175
+ // (collections, records, auth, files — usable from any app/SDK) and, when the
176
+ // image runs with BASE_ENABLE_ADMIN_UI=1, the visual admin (collections /
177
+ // records / auth / SQL) at `/_/`. We surface both straight off the tenant
178
+ // subdomain so a freshly-provisioned Base is immediately usable.
179
+ function DbAccess({ tenant }: { tenant: TenantRecord }) {
180
+ const [copied, setCopied] = useState(false)
181
+ const base = `https://${tenant.subdomain}`
182
+ const apiBase = `${base}/v1`
183
+
184
+ const open = (url: string) => {
185
+ if (typeof window !== 'undefined') window.open(url, '_blank', 'noopener,noreferrer')
186
+ }
187
+ const copy = async (text: string) => {
188
+ try {
189
+ await navigator.clipboard.writeText(text)
190
+ setCopied(true)
191
+ setTimeout(() => setCopied(false), 1200)
192
+ } catch {
193
+ /* clipboard unavailable (insecure context) — no-op */
194
+ }
195
+ }
196
+
197
+ return (
198
+ <YStack gap={10} p={12} bg="#09090b" borderColor="#27272a" borderWidth={1} rounded={8}>
199
+ <Text color="#71717a" fontSize={12} fontWeight="600">USE THIS DATABASE</Text>
200
+
201
+ <YStack gap={4}>
202
+ <Text color="#71717a" fontSize={11}>ENDPOINT</Text>
203
+ <XStack items="center" gap={8}>
204
+ <Text color="#f4f4f5" flex={1} numberOfLines={1}>{base}</Text>
205
+ <Button size="$2" onPress={() => open(base)}>Open ↗</Button>
206
+ </XStack>
207
+ </YStack>
208
+
209
+ <YStack gap={4}>
210
+ <Text color="#71717a" fontSize={11}>ADMIN — collections · records · auth · SQL</Text>
211
+ <XStack items="center" gap={8}>
212
+ <Text color="#a1a1aa" flex={1} numberOfLines={1}>{base}/_/</Text>
213
+ <Button size="$2" theme="blue" onPress={() => open(`${base}/_/`)}>Open admin ↗</Button>
214
+ </XStack>
215
+ </YStack>
216
+
217
+ <YStack gap={4}>
218
+ <Text color="#71717a" fontSize={11}>REST API</Text>
219
+ <XStack items="center" gap={8}>
220
+ <Text color="#a1a1aa" flex={1} numberOfLines={1}>{apiBase}</Text>
221
+ <Button size="$2" onPress={() => copy(apiBase)}>{copied ? 'Copied' : 'Copy'}</Button>
222
+ </XStack>
223
+ <Text color="#52525b" fontSize={11}>
224
+ e.g. GET {apiBase}/collections/&lt;name&gt;/records
225
+ </Text>
226
+ </YStack>
227
+ </YStack>
228
+ )
229
+ }