@hanzo/dashboard 0.2.0 → 0.2.1
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/LICENSE.md +21 -0
- package/README.md +245 -0
- package/package.json +38 -50
- package/src/analytics/index.ts +20 -0
- package/src/analytics/parts.tsx +91 -0
- package/src/analytics/product.tsx +64 -0
- package/src/analytics/source.ts +141 -0
- package/src/analytics/web.tsx +83 -0
- package/src/charts/Charts.tsx +470 -0
- package/src/dashboard.css +157 -0
- package/src/index.ts +119 -0
- package/src/landing/Hero.tsx +115 -0
- package/src/landing/Landing.tsx +47 -0
- package/src/landing/Metrics.tsx +134 -0
- package/src/landing/Rail.tsx +61 -0
- package/src/landing/Samples.tsx +78 -0
- package/src/landing/logic.ts +47 -0
- package/src/landing/parts.tsx +116 -0
- package/src/landing/types.ts +98 -0
- package/src/motion/hooks.ts +98 -0
- package/src/motion/motion.ts +76 -0
- package/src/overview/Overview.tsx +203 -0
- package/src/overview/config.ts +176 -0
- package/src/overview/logic.ts +204 -0
- package/src/overview/primitives.tsx +306 -0
- package/src/overview/tiles.tsx +223 -0
- package/src/pipeline/Pipeline.tsx +178 -0
- package/src/pipeline/stages.ts +156 -0
- package/src/types.ts +12 -0
- package/readme.md +0 -57
- package/src/lib/api.ts +0 -155
- package/src/lib/components/ConfirmDialog.tsx +0 -53
- package/src/lib/components/TenantRow.tsx +0 -39
- package/src/lib/components/TenantStatusBadge.tsx +0 -33
- package/src/lib/index.ts +0 -30
- package/src/lib/nav.ts +0 -12
- package/src/lib/screens/NewTenantScreen.tsx +0 -137
- package/src/lib/screens/TenantsScreen.tsx +0 -229
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pipeline — the animated deploy-stage line. A horizontal track with N stages:
|
|
5
|
+
* completed stages fill in with a check, the current stage pulses, the active leg
|
|
6
|
+
* shows a flowing gradient, and a failure turns the reached stage red. Purely
|
|
7
|
+
* presentational — driven by a `stages` prop (each `{ name, state }`); the consumer
|
|
8
|
+
* derives those from a real status string with the pure `pipelineModel` and polls
|
|
9
|
+
* to pass fresh stages. Reduced motion → a static, filled line.
|
|
10
|
+
*
|
|
11
|
+
* All geometry derives from the stage states, so it renders whatever pipeline the
|
|
12
|
+
* caller supplies (the default four-stage deploy line, or a custom set).
|
|
13
|
+
*/
|
|
14
|
+
import { useContainerWidth } from '../charts/Charts'
|
|
15
|
+
import { useReducedMotion } from '../motion/hooks'
|
|
16
|
+
import type { PipelineStage, StageState } from './stages'
|
|
17
|
+
import { Text, XStack, YStack } from '@hanzo/gui'
|
|
18
|
+
|
|
19
|
+
const ACCENT = '#7c5cff'
|
|
20
|
+
const GREEN = '#23c562'
|
|
21
|
+
const RED = '#ff5d8f'
|
|
22
|
+
const MUTED = 'rgba(148,163,184,0.28)'
|
|
23
|
+
|
|
24
|
+
export interface PipelineProps {
|
|
25
|
+
/** The stages to render, in order (derive via `pipelineModel(status).stages`). */
|
|
26
|
+
stages: PipelineStage[]
|
|
27
|
+
/** A short status label shown above the track (e.g. `pipelineModel(...).label`). */
|
|
28
|
+
label?: string
|
|
29
|
+
/** Accent tone for the filled track + active stage. Defaults by state (green live / red error / purple). */
|
|
30
|
+
tone?: string
|
|
31
|
+
/** Animate the active leg + pulse. Default true; reduced motion still forces static. */
|
|
32
|
+
animate?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Pipeline({ stages, label, tone, animate = true }: PipelineProps) {
|
|
36
|
+
const reduced = useReducedMotion()
|
|
37
|
+
|
|
38
|
+
const n = stages.length
|
|
39
|
+
const errorIdx = stages.findIndex((s) => s.state === 'error')
|
|
40
|
+
const activeStateIdx = stages.findIndex((s) => s.state === 'active')
|
|
41
|
+
const doneCount = stages.filter((s) => s.state === 'done').length
|
|
42
|
+
const errored = errorIdx >= 0
|
|
43
|
+
const live = n > 0 && doneCount === n && !errored
|
|
44
|
+
const activeIndex = errored ? errorIdx : activeStateIdx >= 0 ? activeStateIdx : live ? n - 1 : -1
|
|
45
|
+
const inProgress = activeStateIdx >= 0 && !errored && !live
|
|
46
|
+
|
|
47
|
+
const accent = tone ?? (errored ? RED : live ? GREEN : ACCENT)
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<YStack gap="$3" aria-label={label ? `Deployment: ${label}` : 'Deployment pipeline'}>
|
|
51
|
+
{label ? (
|
|
52
|
+
<XStack items="center" gap="$2">
|
|
53
|
+
<YStack width={9} height={9} rounded="$10" style={{ backgroundColor: accent }} className={animate && !reduced && inProgress ? 'hz-pipe-dot' : undefined} />
|
|
54
|
+
<Text fontSize="$3" fontWeight="700" color="$color12">
|
|
55
|
+
{label}
|
|
56
|
+
</Text>
|
|
57
|
+
</XStack>
|
|
58
|
+
) : null}
|
|
59
|
+
<Track stages={stages} activeIndex={activeIndex} inProgress={inProgress} reduced={reduced || !animate} tone={accent} />
|
|
60
|
+
</YStack>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── The SVG track + aligned labels ───────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function Track({ stages, activeIndex, inProgress, reduced, tone }: { stages: PipelineStage[]; activeIndex: number; inProgress: boolean; reduced: boolean; tone: string }) {
|
|
67
|
+
const { ref, w } = useContainerWidth()
|
|
68
|
+
const n = stages.length
|
|
69
|
+
const H = 60
|
|
70
|
+
const padX = 24
|
|
71
|
+
const trackY = 28
|
|
72
|
+
const usable = Math.max(1, w - padX * 2)
|
|
73
|
+
const x = (i: number) => padX + (n <= 1 ? 0 : (i / (n - 1)) * usable)
|
|
74
|
+
|
|
75
|
+
const fillTo = activeIndex < 0 ? x(0) : x(activeIndex)
|
|
76
|
+
const flowing = !reduced && inProgress && activeIndex >= 0 && activeIndex < n - 1
|
|
77
|
+
const flowStart = x(Math.max(0, activeIndex))
|
|
78
|
+
const flowEnd = x(Math.min(activeIndex + 1, n - 1))
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div ref={ref} style={{ width: '100%' }}>
|
|
82
|
+
{w > 0 ? (
|
|
83
|
+
<>
|
|
84
|
+
<svg width={w} height={H} style={{ display: 'block', overflow: 'visible' }} role="presentation">
|
|
85
|
+
<defs>
|
|
86
|
+
<linearGradient id="hz-pipe-grad" x1="0" y1="0" x2="1" y2="0">
|
|
87
|
+
<stop offset="0%" stopColor={tone} stopOpacity="0.15" />
|
|
88
|
+
<stop offset="50%" stopColor={tone} stopOpacity="1" />
|
|
89
|
+
<stop offset="100%" stopColor={tone} stopOpacity="0.15" />
|
|
90
|
+
</linearGradient>
|
|
91
|
+
</defs>
|
|
92
|
+
|
|
93
|
+
{/* base track */}
|
|
94
|
+
<line x1={x(0)} y1={trackY} x2={x(n - 1)} y2={trackY} stroke={MUTED} strokeWidth={4} strokeLinecap="round" />
|
|
95
|
+
|
|
96
|
+
{/* filled (completed) track */}
|
|
97
|
+
{fillTo > x(0) ? <line x1={x(0)} y1={trackY} x2={fillTo} y2={trackY} stroke={tone} strokeWidth={4} strokeLinecap="round" /> : null}
|
|
98
|
+
|
|
99
|
+
{/* flowing gradient on the active leg */}
|
|
100
|
+
{flowing ? (
|
|
101
|
+
<line x1={flowStart} y1={trackY} x2={flowEnd} y2={trackY} stroke="url(#hz-pipe-grad)" strokeWidth={4} strokeLinecap="round" className="hz-pipe-flow" />
|
|
102
|
+
) : null}
|
|
103
|
+
|
|
104
|
+
{/* stages */}
|
|
105
|
+
{stages.map((st, i) => (
|
|
106
|
+
<Stage key={st.name} cx={x(i)} cy={trackY} state={st.state} tone={tone} reduced={reduced} />
|
|
107
|
+
))}
|
|
108
|
+
</svg>
|
|
109
|
+
|
|
110
|
+
{/* labels aligned under each stage */}
|
|
111
|
+
<div style={{ position: 'relative', width: w, height: 30, marginTop: 2 }}>
|
|
112
|
+
{stages.map((st, i) => (
|
|
113
|
+
<div
|
|
114
|
+
key={st.name}
|
|
115
|
+
style={{
|
|
116
|
+
position: 'absolute',
|
|
117
|
+
left: x(i),
|
|
118
|
+
transform: i === 0 ? 'translateX(0)' : i === n - 1 ? 'translateX(-100%)' : 'translateX(-50%)',
|
|
119
|
+
textAlign: i === 0 ? 'left' : i === n - 1 ? 'right' : 'center',
|
|
120
|
+
maxWidth: 110,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<StageLabel name={st.name} state={st.state} />
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</>
|
|
128
|
+
) : (
|
|
129
|
+
<div style={{ height: H + 30 }} />
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function StageLabel({ name, state }: { name: string; state: StageState }) {
|
|
136
|
+
const sub = state === 'active' ? 'in progress' : state === 'error' ? 'failed' : state === 'done' ? 'done' : ''
|
|
137
|
+
return (
|
|
138
|
+
<YStack gap="$0.5">
|
|
139
|
+
<Text fontSize="$1" fontWeight={state === 'active' || state === 'error' ? '800' : '600'} color={state === 'pending' ? '$color9' : '$color12'} numberOfLines={1}>
|
|
140
|
+
{name}
|
|
141
|
+
</Text>
|
|
142
|
+
<Text fontSize="$1" style={state === 'error' ? { color: RED } : undefined} color={state === 'error' ? undefined : '$color10'}>
|
|
143
|
+
{sub || ' '}
|
|
144
|
+
</Text>
|
|
145
|
+
</YStack>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function Stage({ cx, cy, state, tone, reduced }: { cx: number; cy: number; state: StageState; tone: string; reduced: boolean }) {
|
|
150
|
+
const r = 11
|
|
151
|
+
if (state === 'done') {
|
|
152
|
+
return (
|
|
153
|
+
<g>
|
|
154
|
+
<circle cx={cx} cy={cy} r={r} fill={tone} />
|
|
155
|
+
<path d={`M${cx - 4.5},${cy + 0.5} L${cx - 1.5},${cy + 3.5} L${cx + 4.5},${cy - 3.5}`} fill="none" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
156
|
+
</g>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
if (state === 'error') {
|
|
160
|
+
return (
|
|
161
|
+
<g>
|
|
162
|
+
<circle cx={cx} cy={cy} r={r} fill={RED} />
|
|
163
|
+
<path d={`M${cx - 3.5},${cy - 3.5} L${cx + 3.5},${cy + 3.5} M${cx + 3.5},${cy - 3.5} L${cx - 3.5},${cy + 3.5}`} stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
|
164
|
+
</g>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
if (state === 'active') {
|
|
168
|
+
return (
|
|
169
|
+
<g>
|
|
170
|
+
{!reduced ? <circle cx={cx} cy={cy} r={r} fill={tone} className="hz-pipe-pulse" /> : null}
|
|
171
|
+
<circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={tone} strokeWidth={3} />
|
|
172
|
+
<circle cx={cx} cy={cy} r={4} fill={tone} />
|
|
173
|
+
</g>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
// pending
|
|
177
|
+
return <circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={MUTED} strokeWidth={2} />
|
|
178
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-stage pipeline domain logic — PURE, no UI, no I/O, unit-testable.
|
|
3
|
+
*
|
|
4
|
+
* A deploy moves through four stages: Queued → Building → Deploying → Live. This
|
|
5
|
+
* maps a REAL app/deployment status string (building | deploying | live | error |
|
|
6
|
+
* queued | …) onto that pipeline and decides, per stage, whether it is done /
|
|
7
|
+
* active / pending / errored, plus an overall progress fraction and an honest
|
|
8
|
+
* status label.
|
|
9
|
+
*
|
|
10
|
+
* Honesty: this never fabricates progress. The stage is derived from the real
|
|
11
|
+
* status, and the caller passes the FURTHEST stage it has actually OBSERVED
|
|
12
|
+
* (monotonic across polls) so an `error` marks the stage the deploy truly reached —
|
|
13
|
+
* not a guess.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The four pipeline stages, in order. */
|
|
17
|
+
export const PIPELINE_STAGES = ['Queued', 'Building', 'Deploying', 'Live'] as const
|
|
18
|
+
export type PipelineStageName = (typeof PIPELINE_STAGES)[number]
|
|
19
|
+
|
|
20
|
+
/** Canonical pipeline phase, normalized from any backend status string. */
|
|
21
|
+
export type PipelinePhase = 'idle' | 'queued' | 'building' | 'deploying' | 'live' | 'error'
|
|
22
|
+
|
|
23
|
+
/** Per-stage render state. */
|
|
24
|
+
export type StageState = 'done' | 'active' | 'error' | 'pending'
|
|
25
|
+
|
|
26
|
+
/** One stage in the rendered pipeline. */
|
|
27
|
+
export type PipelineStage = { name: string; state: StageState }
|
|
28
|
+
|
|
29
|
+
/** The full derived model a `Pipeline` renders from. */
|
|
30
|
+
export type PipelineModel = {
|
|
31
|
+
phase: PipelinePhase
|
|
32
|
+
/** Index (0..3) of the current stage; -1 when idle (nothing deploying). */
|
|
33
|
+
activeIndex: number
|
|
34
|
+
/** Reached the final stage. */
|
|
35
|
+
live: boolean
|
|
36
|
+
/** The deploy failed. */
|
|
37
|
+
errored: boolean
|
|
38
|
+
/** True while actively progressing (has a stage to animate toward). */
|
|
39
|
+
inProgress: boolean
|
|
40
|
+
stages: PipelineStage[]
|
|
41
|
+
/** 0..1 fraction of the line filled (static progress / aria-valuenow). */
|
|
42
|
+
progress: number
|
|
43
|
+
/** A short, honest status label ("Building…", "Live", "Failed while deploying"). */
|
|
44
|
+
label: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const N = PIPELINE_STAGES.length
|
|
48
|
+
|
|
49
|
+
const QUEUED = new Set(['queued', 'queue', 'waiting', 'accepted', 'created', 'pending'])
|
|
50
|
+
const BUILDING = new Set(['building', 'build', 'in_progress', 'inprogress', 'compiling'])
|
|
51
|
+
const DEPLOYING = new Set(['deploying', 'deploy', 'rollout', 'rolling', 'releasing', 'provisioning'])
|
|
52
|
+
const LIVE = new Set(['live', 'ready', 'running', 'available', 'succeeded', 'success', 'active', 'healthy', 'superseded'])
|
|
53
|
+
const ERROR = new Set(['error', 'errored', 'failed', 'failure', 'crashed', 'canceled', 'cancelled', 'timeout'])
|
|
54
|
+
const IDLE = new Set(['', 'draft', 'stopped', 'paused', 'idle', 'unknown', 'none', 'new'])
|
|
55
|
+
|
|
56
|
+
/** Normalize any app/deployment status string to a canonical pipeline phase. */
|
|
57
|
+
export function pipelinePhase(status?: string | null): PipelinePhase {
|
|
58
|
+
const s = (status ?? '').trim().toLowerCase()
|
|
59
|
+
if (ERROR.has(s)) return 'error'
|
|
60
|
+
if (LIVE.has(s)) return 'live'
|
|
61
|
+
if (DEPLOYING.has(s)) return 'deploying'
|
|
62
|
+
if (BUILDING.has(s)) return 'building'
|
|
63
|
+
if (QUEUED.has(s)) return 'queued'
|
|
64
|
+
if (IDLE.has(s)) return 'idle'
|
|
65
|
+
return 'idle'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The stage index (0..3) a phase sits at; -1 for idle/error (error uses `furthest`). */
|
|
69
|
+
export function stageIndex(phase: PipelinePhase): number {
|
|
70
|
+
switch (phase) {
|
|
71
|
+
case 'queued':
|
|
72
|
+
return 0
|
|
73
|
+
case 'building':
|
|
74
|
+
return 1
|
|
75
|
+
case 'deploying':
|
|
76
|
+
return 2
|
|
77
|
+
case 'live':
|
|
78
|
+
return N - 1
|
|
79
|
+
default:
|
|
80
|
+
return -1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Terminal phases (live or error) — the poller stops here. */
|
|
85
|
+
export function isTerminalPhase(phase: PipelinePhase): boolean {
|
|
86
|
+
return phase === 'live' || phase === 'error'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A short, honest status label for the pipeline. */
|
|
90
|
+
function pipelineLabel(phase: PipelinePhase, activeIndex: number, status?: string | null): string {
|
|
91
|
+
switch (phase) {
|
|
92
|
+
case 'live':
|
|
93
|
+
return 'Live'
|
|
94
|
+
case 'queued':
|
|
95
|
+
return 'Queued'
|
|
96
|
+
case 'building':
|
|
97
|
+
return 'Building…'
|
|
98
|
+
case 'deploying':
|
|
99
|
+
return 'Deploying…'
|
|
100
|
+
case 'error': {
|
|
101
|
+
if (activeIndex <= 0) return 'Failed in queue'
|
|
102
|
+
const at = PIPELINE_STAGES[Math.min(activeIndex, N - 1)].toLowerCase()
|
|
103
|
+
return `Failed while ${at}`
|
|
104
|
+
}
|
|
105
|
+
default: {
|
|
106
|
+
const s = (status ?? '').trim()
|
|
107
|
+
if (!s || s.toLowerCase() === 'draft' || s.toLowerCase() === 'new') return 'Not deployed'
|
|
108
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the pipeline model from a status string and the FURTHEST stage index
|
|
115
|
+
* observed so far (monotonic across polls; default -1 = none observed). The furthest
|
|
116
|
+
* index makes an `error` mark the stage the deploy actually reached, and keeps
|
|
117
|
+
* completed stages filled even if a later poll returns a transient earlier status.
|
|
118
|
+
*/
|
|
119
|
+
export function pipelineModel(status?: string | null, furthestReached = -1): PipelineModel {
|
|
120
|
+
const phase = pipelinePhase(status)
|
|
121
|
+
const idx = stageIndex(phase)
|
|
122
|
+
const reached = Math.max(furthestReached, idx)
|
|
123
|
+
|
|
124
|
+
const live = phase === 'live'
|
|
125
|
+
const errored = phase === 'error'
|
|
126
|
+
|
|
127
|
+
let activeIndex: number
|
|
128
|
+
if (live) activeIndex = N - 1
|
|
129
|
+
else if (errored) activeIndex = reached >= 0 ? Math.min(reached, N - 1) : 0
|
|
130
|
+
else if (phase === 'idle') activeIndex = -1 // nothing deploying — a neutral pipeline
|
|
131
|
+
else activeIndex = Math.min(reached, N - 1) // active: monotonic (never backslides)
|
|
132
|
+
|
|
133
|
+
const stages: PipelineStage[] = PIPELINE_STAGES.map((name, i): PipelineStage => {
|
|
134
|
+
if (live) return { name, state: 'done' }
|
|
135
|
+
if (errored) {
|
|
136
|
+
if (i < activeIndex) return { name, state: 'done' }
|
|
137
|
+
if (i === activeIndex) return { name, state: 'error' }
|
|
138
|
+
return { name, state: 'pending' }
|
|
139
|
+
}
|
|
140
|
+
if (activeIndex < 0) return { name, state: 'pending' } // idle: nothing started
|
|
141
|
+
if (i < activeIndex) return { name, state: 'done' }
|
|
142
|
+
if (i === activeIndex) return { name, state: 'active' }
|
|
143
|
+
return { name, state: 'pending' }
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const progress = live ? 1 : activeIndex < 0 ? 0 : activeIndex / (N - 1)
|
|
147
|
+
const inProgress = !live && !errored && activeIndex >= 0
|
|
148
|
+
const label = pipelineLabel(phase, activeIndex, status)
|
|
149
|
+
|
|
150
|
+
return { phase, activeIndex, live, errored, inProgress, stages, progress, label }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The accent tone for a model: red on failure, green on live, else the accent. */
|
|
154
|
+
export function pipelineTone(model: Pick<PipelineModel, 'live' | 'errored'>, accent = '#7c5cff'): string {
|
|
155
|
+
return model.errored ? '#ff5d8f' : model.live ? '#23c562' : accent
|
|
156
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cross-module types for @hanzo/dashboard.
|
|
3
|
+
*/
|
|
4
|
+
import type { ComponentType } from 'react'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A lucide-style icon component — the shape the dashboard passes around for
|
|
8
|
+
* consumer-supplied product/tile/action icons. Structural (not tied to a specific
|
|
9
|
+
* icon package) so the config types stay pure and any `size`/`color` icon fits
|
|
10
|
+
* (e.g. `@hanzogui/lucide-icons-2` or `lucide-react` icons).
|
|
11
|
+
*/
|
|
12
|
+
export type IconComponent = ComponentType<{ size?: number | string; color?: string; opacity?: number }>
|
package/readme.md
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
}
|