@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 +65 -0
- package/readme.md +57 -0
- package/src/lib/api.ts +155 -0
- package/src/lib/components/ConfirmDialog.tsx +53 -0
- package/src/lib/components/TenantRow.tsx +39 -0
- package/src/lib/components/TenantStatusBadge.tsx +33 -0
- package/src/lib/index.ts +30 -0
- package/src/lib/nav.ts +12 -0
- package/src/lib/screens/NewTenantScreen.tsx +137 -0
- package/src/lib/screens/TenantsScreen.tsx +229 -0
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
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -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/<name>/records
|
|
225
|
+
</Text>
|
|
226
|
+
</YStack>
|
|
227
|
+
</YStack>
|
|
228
|
+
)
|
|
229
|
+
}
|