@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
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 hanzo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# @hanzo/dashboard
|
|
2
|
+
|
|
3
|
+
The reusable **dashboard layer on [`@hanzo/gui`](https://github.com/hanzoai/gui)**.
|
|
4
|
+
A set of composable, honest, reduced-motion-guarded dashboard primitives extracted
|
|
5
|
+
from the Hanzo Cloud Console so every Hanzo app — console, admin surfaces,
|
|
6
|
+
hanzo.team, desktop dashboards — builds the **same** dashboard system instead of
|
|
7
|
+
re-implementing it.
|
|
8
|
+
|
|
9
|
+
Everything here is **pure**: data comes in via props or a loader you supply, actions
|
|
10
|
+
are callbacks, and `@hanzo/gui` is a peer dependency (this layer sits _on_ it, never
|
|
11
|
+
forks it). No app-specific API imports.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @hanzo/dashboard @hanzo/gui @hanzogui/lucide-icons-2
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import '@hanzo/dashboard/dashboard.css' // once — ships the motion keyframes
|
|
19
|
+
import { Overview, Kpi, Sparkline, Landing, Pipeline } from '@hanzo/dashboard'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
> `dashboard.css` carries the loading shimmer, live-feed pulse, fade-up entrance, and
|
|
23
|
+
> the deploy-pipeline animations — **every one reduced-motion-guarded** (→ static).
|
|
24
|
+
> The count-up + live sparkline motion is driven in JS and gated on
|
|
25
|
+
> `prefers-reduced-motion` in the hooks.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Charts — the ONE way to draw a trend / series / share / distribution
|
|
30
|
+
|
|
31
|
+
Monochrome SVG (dependency-free) that themes to the shell. Honest by construction: a
|
|
32
|
+
`Sparkline` with < 2 points renders nothing; `Donut`/`Bars` with no positive value
|
|
33
|
+
render an em-dash. Callers pass REAL series — these never fabricate data.
|
|
34
|
+
|
|
35
|
+
| Export | What it draws |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `Sparkline` | A fixed-size mini trend (for stat tiles). |
|
|
38
|
+
| `Line` | A value over time (line + hover tooltip). |
|
|
39
|
+
| `Columns` | A value over time (vertical bars + hover). |
|
|
40
|
+
| `Donut` | Categorical share (optional legend, center slot). |
|
|
41
|
+
| `Bars` | A ranked distribution (horizontal, token-native). |
|
|
42
|
+
| `useContainerWidth` | Measure a container for responsive SVG. |
|
|
43
|
+
| `CHART_PALETTE`, `CHART_OTHER` | The categorical hues. |
|
|
44
|
+
| types `ChartPoint`, `Slice` | Data shapes. |
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
<Line data={[{ label: 'Mon', value: 12 }, { label: 'Tue', value: 30 }]} formatValue={(v) => `${v}`} />
|
|
48
|
+
<Donut slices={[{ label: 'zen', value: 60 }, { label: 'gpt', value: 40 }]} legend />
|
|
49
|
+
<Sparkline values={[3, 5, 4, 9, 12]} />
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Motion — pure math + rAF/interval hooks
|
|
53
|
+
|
|
54
|
+
Unit-testable pure functions plus the thin browser hooks that drive them; every hook
|
|
55
|
+
self-cleans on unmount (no leaked frames/timers).
|
|
56
|
+
|
|
57
|
+
- Pure: `easeOutCubic`, `countUpValue`, `progress`, `pushSample`, `shouldTick`, `effectiveInterval`.
|
|
58
|
+
- Hooks: `useCountUp`, `usePoll`, `useReducedMotion`, `usePageHidden`.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
const value = useCountUp(target, /* enabled */ true) // snaps to target under reduced motion
|
|
62
|
+
const { tick, bump } = usePoll(15_000) // paused when interval ≤ 0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Overview — the videogame-like living dashboard
|
|
66
|
+
|
|
67
|
+
Declare a product's tiles + a REAL data loader; `Overview` renders + animates the
|
|
68
|
+
board and keeps it alive (one throttled poll loop, hidden-tab-paused, range selector,
|
|
69
|
+
count-up on change). A product is added by writing a config, never overview UI.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { Overview, type OverviewConfig } from '@hanzo/dashboard'
|
|
73
|
+
import { Cpu } from '@hanzogui/lucide-icons-2'
|
|
74
|
+
|
|
75
|
+
const config: OverviewConfig = {
|
|
76
|
+
id: 'gpus', title: 'GPUs',
|
|
77
|
+
live: { pollMs: 15_000, countUp: true },
|
|
78
|
+
rows: [
|
|
79
|
+
[{ tile: 'metric', key: 'active', label: 'Active', icon: Cpu }],
|
|
80
|
+
[{ tile: 'timeseries', key: 'usage', title: 'Usage over time' },
|
|
81
|
+
{ tile: 'distribution', key: 'byModel', title: 'By model' }],
|
|
82
|
+
[{ tile: 'activity' }, { tile: 'health' }],
|
|
83
|
+
],
|
|
84
|
+
load: async ({ range }) => fromMyApi(await MyApi.overview(range)), // → OverviewData
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
<Overview config={config} isGlobalAdmin={amAdmin} />
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The loader returns the normalized **`OverviewData`** (`kpi` / `series` / `distribution`
|
|
91
|
+
/ `activity` / `alerts` / `health` maps). A missing slice renders an honest empty tile
|
|
92
|
+
— you can over-declare tiles a slow backend hasn't filled yet. First load shows a
|
|
93
|
+
spinner; a failure shows a retry card; a background refetch never blanks a board that
|
|
94
|
+
already has real data.
|
|
95
|
+
|
|
96
|
+
### Composable primitives (use without the driver)
|
|
97
|
+
|
|
98
|
+
The tiles are also standalone, direct-props components — the valuable, reusable core:
|
|
99
|
+
|
|
100
|
+
| Export | Props (data in, view out) |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `Kpi` | `label`, `value` (null → "—"), `unit`, `deltaPct`, `series`, `icon`, `color`, `caption`, `animate`, `loading` |
|
|
103
|
+
| `Feed` | `items: OverviewEvent[]`, `title`, `empty`, `loading` (virtualizes past a viewport) |
|
|
104
|
+
| `Board` | `items: OverviewHealth[]`, `title`, `empty`, `loading` (health tally + dots) |
|
|
105
|
+
| `Tile` | Render one declared tile spec against `OverviewData`. |
|
|
106
|
+
| `Panel`, `SkeletonBar`, `EmptyPanel`, `PanelSpinner`, `LiveDot` | Shared chrome. |
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
<Kpi label="Requests" value={32_412} unit="count" deltaPct={12} series={[10,14,22,31]} icon={Zap} />
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Plus the pure tile logic (`formatMetric`, `deltaOf`, `hasTrend`, `mergeActivity`,
|
|
113
|
+
`windowRows`, `statusColor`/`healthColor`/`severityColor`, `selectKpi`/`selectSeries`/
|
|
114
|
+
`selectDistribution`, …) and the `OverviewConfig` / `OverviewData` / tile types.
|
|
115
|
+
|
|
116
|
+
## Landing — the polished product landing kit
|
|
117
|
+
|
|
118
|
+
Compose a `LandingConfig` and render `<Landing>` to get a hero + live count-up metrics
|
|
119
|
+
row + an interactive code-sample block + a resources rail. Parts are exported too
|
|
120
|
+
(`Hero`, `Metrics`, `Samples`, `Rail`).
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
import { Landing, type LandingConfig } from '@hanzo/dashboard'
|
|
124
|
+
|
|
125
|
+
const config: LandingConfig = {
|
|
126
|
+
productId: 'embeddings', title: 'Embeddings',
|
|
127
|
+
tagline: 'Vector search over your data.',
|
|
128
|
+
brandName: 'Hanzo Cloud', docsUrl: 'https://docs.hanzo.ai', docsProduct: 'embeddings',
|
|
129
|
+
primary: { label: 'Create collection', onPress: onCreate },
|
|
130
|
+
loadMetrics: async () => [{ key: 'vectors', label: 'Vectors', value: 1_240, series: [...] }],
|
|
131
|
+
samples: [{ lang: 'curl', label: 'cURL', code: 'curl https://api.hanzo.ai/v1/embeddings ...' }],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
<Landing config={config}>{/* the product's own real content */}</Landing>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
All links (docs / quickstart / examples / API / support) derive from `config.docsUrl`,
|
|
138
|
+
so a Lux/Zoo console links to ITS OWN surfaces. Brand + accent come from the config
|
|
139
|
+
(`brandName`, `accent`) — no hardcoded brand. Pure link helpers are exported
|
|
140
|
+
(`apexFromDocs`, `apiBaseFromDocs`, `landingDocsUrl`, `standardResources`, `supportMailto`).
|
|
141
|
+
|
|
142
|
+
## Pipeline — the deploy-stage line
|
|
143
|
+
|
|
144
|
+
A small, presentational animated pipeline (Queued → Building → Deploying → Live, or any
|
|
145
|
+
custom stages). Driven by a `stages` prop; derive it from a real status string with the
|
|
146
|
+
pure `pipelineModel`, and poll to pass fresh stages.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
import { Pipeline, pipelineModel } from '@hanzo/dashboard'
|
|
150
|
+
|
|
151
|
+
const model = pipelineModel(app.status, furthestReached) // furthest tracked across polls
|
|
152
|
+
<Pipeline stages={model.stages} label={model.label} />
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Pure helpers: `pipelineModel`, `pipelinePhase`, `stageIndex`, `isTerminalPhase`,
|
|
156
|
+
`pipelineTone`, `PIPELINE_STAGES`, and the `PipelineStage`/`PipelineModel`/`StageState`
|
|
157
|
+
types. Completed stages fill with a check, the current stage pulses, the active leg
|
|
158
|
+
flows, a failure turns the reached stage red — all reduced-motion-guarded.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Provenance — how each export maps back to the console
|
|
163
|
+
|
|
164
|
+
Extracted (and generalized: no `~/lib/*` / `~/config` imports) from
|
|
165
|
+
`~/work/hanzo/console2`:
|
|
166
|
+
|
|
167
|
+
| `@hanzo/dashboard` | console2 source |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `charts/Charts` (`Sparkline`/`Line`/`Columns`/`Donut`/`Bars`) | `src/components/ui/Charts.tsx` (`Sparkline`/`LineChart`/`BarChart`/`Donut`/`BarRows`) |
|
|
170
|
+
| `motion/motion` + `motion/hooks` | `src/components/products/overview/living/{motion,hooks}.ts` |
|
|
171
|
+
| `overview/{config,logic}` | `.../overview/living/{config,logic}.ts` |
|
|
172
|
+
| `overview/primitives` (`Kpi`/`Feed`/`Board`) + `overview/tiles` (`Tile`) | `.../overview/living/tiles.tsx` (the tile views) |
|
|
173
|
+
| `overview/Overview` | `.../overview/living/LivingOverview.tsx` |
|
|
174
|
+
| `landing/*` (`Landing`/`Hero`/`Metrics`/`Samples`/`Rail`) | `src/components/products/landing/*` (`ProductLanding`/`LandingHero`/`LandingMetrics`/`CodeSamples`/`ResourceRail`) |
|
|
175
|
+
| `pipeline/{pipeline,Pipeline}` | `src/components/products/paas/{railway.ts,RailwayDeploy.tsx}` |
|
|
176
|
+
|
|
177
|
+
### Generalizations made during extraction
|
|
178
|
+
|
|
179
|
+
- **Icons** are consumer-supplied (`IconComponent` = a `size`/`color` icon), replacing
|
|
180
|
+
the console's `ProductIcon` from its registry.
|
|
181
|
+
- **`Overview`** drops `~/lib/api` `ApiError`, `useIsGlobalAdmin`, `PageHeader`,
|
|
182
|
+
`FadeIn`, and `States` — `isGlobalAdmin` is now a prop and the header/fade/error
|
|
183
|
+
chrome is inlined (self-contained).
|
|
184
|
+
- **Landing** takes brand/docs/support/discord/accent from `LandingConfig` (was
|
|
185
|
+
`~/config`). `LandingHero` inlines the hero graphic + accent button (was
|
|
186
|
+
`../inference/parts`).
|
|
187
|
+
- **Pipeline** takes a `stages` prop and drops the console's `PaasApi.getApp`
|
|
188
|
+
self-poll — the consumer polls and passes fresh stages.
|
|
189
|
+
- Naming is de-branded and single-word: `LivingOverview` → `Overview`,
|
|
190
|
+
`RailwayDeploy` → `Pipeline`, `ProductLanding` → `Landing`, `LineChart`/`BarChart`/
|
|
191
|
+
`BarRows` → `Line`/`Columns`/`Bars`. CSS classes `hz-rail-*` → `hz-pipe-*`.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Follow-up: swapping console2 onto this package (a later pass)
|
|
196
|
+
|
|
197
|
+
console2 is **untouched** this pass. To consume `@hanzo/dashboard` back in console2,
|
|
198
|
+
per component:
|
|
199
|
+
|
|
200
|
+
1. **Charts** — delete `src/components/ui/Charts.tsx`; re-export from the package
|
|
201
|
+
(`export { Sparkline, Line as LineChart, Columns as BarChart, Bars as BarRows, Donut, useContainerWidth, CHART_PALETTE, CHART_OTHER } from '@hanzo/dashboard'`)
|
|
202
|
+
or update call sites to the new names. `~/components/products/inference/parts`,
|
|
203
|
+
`landing/*`, and `overview/living/tiles` import `Charts` — the alias keeps them working.
|
|
204
|
+
2. **Motion** — replace `overview/living/{motion,hooks}.ts` with re-exports from
|
|
205
|
+
`@hanzo/dashboard`; `RailwayDeploy` + `LandingMetrics` import these hooks.
|
|
206
|
+
3. **Overview** — replace `overview/living/{config,logic,tiles,LivingOverview}` with
|
|
207
|
+
thin wrappers over the package. The console's `LivingOverviewModule` keeps its
|
|
208
|
+
registry-router wrapper and passes `isGlobalAdmin={useIsGlobalAdmin()}` +
|
|
209
|
+
maps `ApiError` into the driver's caught error. `PageHeader`/`FadeIn`/`States`
|
|
210
|
+
stay in console (they're used elsewhere) — the driver now inlines its own.
|
|
211
|
+
4. **Landing** — replace `products/landing/*` with the package; the console builds a
|
|
212
|
+
`LandingConfig` with `docsUrl: config.docsUrl`, `brandName: config.brandName`,
|
|
213
|
+
`discordUrl: 'https://discord.gg/CJCyAsm9Vr'`. `LandingHero`'s console-specific
|
|
214
|
+
`HeroGraphic`/`AccentButton` (from `inference/parts`) are now inlined in the package.
|
|
215
|
+
5. **Pipeline** — swap `paas/RailwayDeploy.tsx` for `<Pipeline>`; the console owns the
|
|
216
|
+
`PaasApi.getApp` poll (`usePoll` + `PaasApi`) and the monotonic `furthest` state,
|
|
217
|
+
calling `pipelineModel(status, furthest)` and passing `.stages` + `.label`.
|
|
218
|
+
|
|
219
|
+
Once swapped, the console2 tests that already cover the pure logic
|
|
220
|
+
(`motion.test.ts`, `logic.test.ts`, `railway.test.ts`, `landing/logic.test.ts`) move
|
|
221
|
+
here (or re-run against the re-exports) so coverage follows the code.
|
|
222
|
+
|
|
223
|
+
## Honest about what didn't extract cleanly
|
|
224
|
+
|
|
225
|
+
- The console's **`config.ts` registry** (`LivingOverviewConfig.load` adapters:
|
|
226
|
+
`fromCloudUsage`, `fromAdminOverview`, `fromFunctions`, `healthFromApps`) is
|
|
227
|
+
**product/API-specific** and stays in console2 — it's the data half. This package is
|
|
228
|
+
the view half; the `OverviewData` contract is the seam between them.
|
|
229
|
+
- Console tile icons come from the console's product registry; here they're plain
|
|
230
|
+
`IconComponent` props (the consumer supplies them).
|
|
231
|
+
- The `Pipeline` self-poll was PaaS-coupled and is intentionally dropped — the small,
|
|
232
|
+
minor pipeline primitive is presentational only.
|
|
233
|
+
|
|
234
|
+
## Build
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
pnpm typecheck # tsc --noEmit (strict) — clean
|
|
238
|
+
pnpm build # tsc → dist (proof; the package ships src)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The package **ships `src`** (source-only, like `@hanzo/data`); the consumer's bundler
|
|
242
|
+
transpiles it, and the `@hanzo/gui` shorthand style props type-check against the real
|
|
243
|
+
token unions via `gui.config.ts` + `gui.d.ts`.
|
|
244
|
+
|
|
245
|
+
License: MIT · Hanzo AI, Inc. — see [LICENSE.md](../../LICENSE.md). HIP-0137 (`hanzoai/hips`).
|
package/package.json
CHANGED
|
@@ -1,65 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/dashboard",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Hanzo
|
|
5
|
+
"description": "Hanzo Dashboard — the reusable dashboard layer on @hanzo/gui: monochrome SVG charts, a videogame-like LivingOverview (count-up KPIs, live sparklines, streaming feed, health), the ProductLanding kit, and a Pipeline deploy animation. Pure (data via props/loaders), typed, reduced-motion-guarded. Extracted from the Hanzo Cloud Console so every Hanzo app composes ONE dashboard system.",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
|
-
"types": "./src/
|
|
9
|
-
"default": "./src/
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"./dashboard.css": "./src/dashboard.css"
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"module": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"sideEffects": [
|
|
17
|
+
"*.css"
|
|
18
|
+
],
|
|
16
19
|
"files": [
|
|
17
|
-
"src
|
|
20
|
+
"src"
|
|
18
21
|
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"dev": "vite",
|
|
21
|
-
"build": "vite build",
|
|
22
|
-
"preview": "vite preview",
|
|
23
|
-
"typecheck": "tsc --noEmit"
|
|
24
|
-
},
|
|
25
22
|
"peerDependencies": {
|
|
26
|
-
"@hanzo/gui": ">=
|
|
27
|
-
"
|
|
28
|
-
"react
|
|
23
|
+
"@hanzo/gui": ">=8.3.5",
|
|
24
|
+
"@hanzogui/lucide-icons-2": "^8.3.5",
|
|
25
|
+
"react": ">=19.2.8"
|
|
29
26
|
},
|
|
30
27
|
"devDependencies": {
|
|
31
|
-
"@hanzo/gui": "
|
|
32
|
-
"@
|
|
33
|
-
"@hanzogui/
|
|
34
|
-
"@hanzogui/
|
|
35
|
-
"@
|
|
36
|
-
"@types/react
|
|
37
|
-
"
|
|
38
|
-
"
|
|
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"
|
|
28
|
+
"@hanzo/gui": "8.3.5",
|
|
29
|
+
"@hanzogui/config": "8.3.5",
|
|
30
|
+
"@hanzogui/core": "8.3.5",
|
|
31
|
+
"@hanzogui/lucide-icons-2": "8.3.5",
|
|
32
|
+
"@hanzogui/web": "8.3.5",
|
|
33
|
+
"@types/react": "19.2.18",
|
|
34
|
+
"react": "19.2.8",
|
|
35
|
+
"typescript": "7.0.2"
|
|
47
36
|
},
|
|
48
37
|
"publishConfig": {
|
|
49
38
|
"access": "public"
|
|
50
39
|
},
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
"
|
|
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"
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"author": "Hanzo AI <dev@hanzo.ai>",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/hanzoai/ui.git",
|
|
45
|
+
"directory": "pkg/dashboard"
|
|
62
46
|
},
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
47
|
+
"scripts": {
|
|
48
|
+
"tc": "tsc --noEmit",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"build": "tsc --outDir dist --declaration --emitDeclarationOnly false --module ESNext --moduleResolution bundler --jsx react-jsx --esModuleInterop true --skipLibCheck true --sourceMap true",
|
|
51
|
+
"test": "bun test"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Analytics views, built once and mounted by analytics.hanzo.ai and by a project's
|
|
2
|
+
// page in the platform: the same components over the same source.
|
|
3
|
+
export { WebAnalytics } from './web'
|
|
4
|
+
export { ProductAnalytics, JOURNEY } from './product'
|
|
5
|
+
export {
|
|
6
|
+
analyticsSource,
|
|
7
|
+
windowOf,
|
|
8
|
+
RANGES,
|
|
9
|
+
ZONE,
|
|
10
|
+
SourceError,
|
|
11
|
+
type Source,
|
|
12
|
+
type Range,
|
|
13
|
+
type Stats,
|
|
14
|
+
type Series,
|
|
15
|
+
type Point,
|
|
16
|
+
type Dimension,
|
|
17
|
+
type Step,
|
|
18
|
+
type FunnelStep,
|
|
19
|
+
type Website,
|
|
20
|
+
} from './source'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
|
3
|
+
import { EmptyPanel, Panel, PanelSpinner } from '../overview/primitives'
|
|
4
|
+
import { RANGES, type Point, type Range } from './source'
|
|
5
|
+
|
|
6
|
+
/** What a load is: loading, its value, or why there is none. */
|
|
7
|
+
export type Load<T> = { state: 'loading' } | { state: 'ok'; value: T } | { state: 'error'; error: string }
|
|
8
|
+
|
|
9
|
+
/** Run `read` whenever `deps` change, and answer its Load. */
|
|
10
|
+
export function useLoad<T>(read: () => Promise<T>, deps: unknown[]): Load<T> {
|
|
11
|
+
const [load, setLoad] = useState<Load<T>>({ state: 'loading' })
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let live = true
|
|
14
|
+
setLoad({ state: 'loading' })
|
|
15
|
+
read()
|
|
16
|
+
.then((value) => live && setLoad({ state: 'ok', value }))
|
|
17
|
+
.catch((err: unknown) => live && setLoad({ state: 'error', error: err instanceof Error ? err.message : String(err) }))
|
|
18
|
+
return () => {
|
|
19
|
+
live = false
|
|
20
|
+
}
|
|
21
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
22
|
+
}, deps)
|
|
23
|
+
return load
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The range switch every view carries. */
|
|
27
|
+
export function RangeSwitch({ range, onRange }: { range: Range; onRange: (r: Range) => void }) {
|
|
28
|
+
return (
|
|
29
|
+
<XStack gap="$1" role="tablist" aria-label="Range">
|
|
30
|
+
{RANGES.map((r) => (
|
|
31
|
+
<Button
|
|
32
|
+
key={r}
|
|
33
|
+
size="$2"
|
|
34
|
+
role="tab"
|
|
35
|
+
aria-selected={r === range}
|
|
36
|
+
theme={r === range ? 'accent' : undefined}
|
|
37
|
+
chromeless={r !== range}
|
|
38
|
+
onPress={() => onRange(r)}
|
|
39
|
+
>
|
|
40
|
+
{r}
|
|
41
|
+
</Button>
|
|
42
|
+
))}
|
|
43
|
+
</XStack>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A panel whose body is a Load: spinner, error note, or the rendered value. */
|
|
48
|
+
export function Loaded<T>({ title, load, empty, children, right, minW = 300 }: {
|
|
49
|
+
title: string
|
|
50
|
+
load: Load<T>
|
|
51
|
+
empty?: (value: T) => boolean
|
|
52
|
+
children: (value: T) => ReactNode
|
|
53
|
+
right?: ReactNode
|
|
54
|
+
minW?: number
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<Panel title={title} right={right} minW={minW} flex={1}>
|
|
58
|
+
{load.state === 'loading' ? (
|
|
59
|
+
<PanelSpinner />
|
|
60
|
+
) : load.state === 'error' ? (
|
|
61
|
+
<EmptyPanel note={load.error} />
|
|
62
|
+
) : empty?.(load.value) ? (
|
|
63
|
+
<EmptyPanel note="Nothing in this range yet." />
|
|
64
|
+
) : (
|
|
65
|
+
children(load.value)
|
|
66
|
+
)}
|
|
67
|
+
</Panel>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A ranked list: label, a bar proportional to the leader, and the count. */
|
|
72
|
+
export function TopList({ rows, format = (x) => x || '(none)' }: { rows: Point[]; format?: (x: string) => string }) {
|
|
73
|
+
const max = Math.max(1, ...rows.map((r) => r.y))
|
|
74
|
+
return (
|
|
75
|
+
<YStack gap="$2">
|
|
76
|
+
{rows.map((r) => (
|
|
77
|
+
<XStack key={r.x} gap="$3" items="center">
|
|
78
|
+
<YStack flex={1} minW={0} position="relative" height={24} justify="center">
|
|
79
|
+
<YStack position="absolute" l={0} t={0} b={0} width={`${Math.max(2, (r.y / max) * 100)}%`} bg="$color3" rounded="$2" />
|
|
80
|
+
<Text px="$2" fontSize="$2" color="$color12" numberOfLines={1}>
|
|
81
|
+
{format(r.x)}
|
|
82
|
+
</Text>
|
|
83
|
+
</YStack>
|
|
84
|
+
<Text width={56} fontSize="$2" color="$color12" fontWeight="600" text="right">
|
|
85
|
+
{r.y.toLocaleString()}
|
|
86
|
+
</Text>
|
|
87
|
+
</XStack>
|
|
88
|
+
))}
|
|
89
|
+
</YStack>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Text, XStack, YStack } from '@hanzo/gui'
|
|
3
|
+
import { Loaded, RangeSwitch, TopList, useLoad } from './parts'
|
|
4
|
+
import type { FunnelStep, Range, Source, Step } from './source'
|
|
5
|
+
|
|
6
|
+
/** The sign-up journey, in @hanzo/events names (HIP-1190 §6.1). */
|
|
7
|
+
export const JOURNEY: Step[] = [
|
|
8
|
+
{ type: 'event', value: 'signup_viewed' },
|
|
9
|
+
{ type: 'event', value: 'signup_submitted' },
|
|
10
|
+
{ type: 'event', value: 'signup_completed' },
|
|
11
|
+
{ type: 'event', value: 'first_action' },
|
|
12
|
+
{ type: 'event', value: 'checkout_started' },
|
|
13
|
+
{ type: 'event', value: 'order_completed' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function Funnel({ steps }: { steps: FunnelStep[] }) {
|
|
17
|
+
const first = steps[0]?.visitors || 0
|
|
18
|
+
return (
|
|
19
|
+
<YStack gap="$2">
|
|
20
|
+
{steps.map((s, i) => (
|
|
21
|
+
<XStack key={s.value} gap="$3" items="center">
|
|
22
|
+
<Text width={150} fontSize="$2" color="$color11" numberOfLines={1}>
|
|
23
|
+
{i + 1}. {s.value}
|
|
24
|
+
</Text>
|
|
25
|
+
<YStack flex={1} height={12} bg="$color3" rounded="$2" overflow="hidden">
|
|
26
|
+
<YStack height={12} width={`${first ? Math.max(1, (s.visitors / first) * 100) : 0}%`} bg="$color9" rounded="$2" />
|
|
27
|
+
</YStack>
|
|
28
|
+
<Text width={64} fontSize="$2" color="$color12" fontWeight="600" text="right">
|
|
29
|
+
{s.visitors.toLocaleString()}
|
|
30
|
+
</Text>
|
|
31
|
+
<Text width={56} fontSize="$2" color="$color10" text="right">
|
|
32
|
+
{i === 0 || !steps[i - 1].visitors ? '' : `${Math.round((s.visitors / steps[i - 1].visitors) * 100)}%`}
|
|
33
|
+
</Text>
|
|
34
|
+
</XStack>
|
|
35
|
+
))}
|
|
36
|
+
</YStack>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Product analytics for one website: what people did (the named events) and how
|
|
42
|
+
* far they got through the sign-up journey, one visitor followed across steps.
|
|
43
|
+
*/
|
|
44
|
+
export function ProductAnalytics({ source, website, steps = JOURNEY, initial = '7d' }: { source: Source; website: string; steps?: Step[]; initial?: Range }) {
|
|
45
|
+
const [range, setRange] = useState<Range>(initial)
|
|
46
|
+
const funnel = useLoad(() => source.funnel(website, steps, range), [source, website, range, steps])
|
|
47
|
+
const events = useLoad(() => source.top(website, 'event', range, 20), [source, website, range])
|
|
48
|
+
return (
|
|
49
|
+
<YStack gap="$4" width="100%">
|
|
50
|
+
<XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
|
|
51
|
+
<Text fontSize="$6" fontWeight="600" color="$color12">
|
|
52
|
+
Product analytics
|
|
53
|
+
</Text>
|
|
54
|
+
<RangeSwitch range={range} onRange={setRange} />
|
|
55
|
+
</XStack>
|
|
56
|
+
<Loaded title="Sign-up journey" load={funnel} empty={(v) => !v.length || !v[0].visitors}>
|
|
57
|
+
{(v) => <Funnel steps={v} />}
|
|
58
|
+
</Loaded>
|
|
59
|
+
<Loaded title="Events" load={events} empty={(v) => !v.length}>
|
|
60
|
+
{(rows) => <TopList rows={rows} />}
|
|
61
|
+
</Loaded>
|
|
62
|
+
</YStack>
|
|
63
|
+
)
|
|
64
|
+
}
|