@hanzo/dashboard 0.2.0 → 0.2.2

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.
Files changed (39) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +245 -0
  3. package/package.json +38 -50
  4. package/src/analytics/index.ts +20 -0
  5. package/src/analytics/parts.tsx +91 -0
  6. package/src/analytics/product.tsx +64 -0
  7. package/src/analytics/source.ts +141 -0
  8. package/src/analytics/web.tsx +83 -0
  9. package/src/charts/Charts.tsx +470 -0
  10. package/src/dashboard.css +157 -0
  11. package/src/index.ts +119 -0
  12. package/src/landing/Hero.tsx +115 -0
  13. package/src/landing/Landing.tsx +47 -0
  14. package/src/landing/Metrics.tsx +134 -0
  15. package/src/landing/Rail.tsx +61 -0
  16. package/src/landing/Samples.tsx +78 -0
  17. package/src/landing/logic.ts +47 -0
  18. package/src/landing/parts.tsx +116 -0
  19. package/src/landing/types.ts +98 -0
  20. package/src/motion/hooks.ts +98 -0
  21. package/src/motion/motion.ts +76 -0
  22. package/src/overview/Overview.tsx +203 -0
  23. package/src/overview/config.ts +177 -0
  24. package/src/overview/logic.ts +204 -0
  25. package/src/overview/primitives.tsx +306 -0
  26. package/src/overview/superadmin.test.ts +36 -0
  27. package/src/overview/tiles.tsx +223 -0
  28. package/src/pipeline/Pipeline.tsx +178 -0
  29. package/src/pipeline/stages.ts +156 -0
  30. package/src/types.ts +12 -0
  31. package/readme.md +0 -57
  32. package/src/lib/api.ts +0 -155
  33. package/src/lib/components/ConfirmDialog.tsx +0 -53
  34. package/src/lib/components/TenantRow.tsx +0 -39
  35. package/src/lib/components/TenantStatusBadge.tsx +0 -33
  36. package/src/lib/index.ts +0 -30
  37. package/src/lib/nav.ts +0 -12
  38. package/src/lib/screens/NewTenantScreen.tsx +0 -137
  39. package/src/lib/screens/TenantsScreen.tsx +0 -229
@@ -1,33 +0,0 @@
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 DELETED
@@ -1,30 +0,0 @@
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 DELETED
@@ -1,12 +0,0 @@
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
- }
@@ -1,137 +0,0 @@
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
- }
@@ -1,229 +0,0 @@
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
- }