@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
@@ -0,0 +1,306 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Composable dashboard primitives — the small, honest building blocks a consumer
5
+ * mixes directly (without the full `Overview` driver): `Kpi` (an animated stat
6
+ * tile), `Feed` (a virtualized activity stream), `Board` (a service-health board),
7
+ * plus the shared `Panel`/`SkeletonBar`/`EmptyPanel`/`PanelSpinner` chrome.
8
+ *
9
+ * Every primitive is driven by direct props (data in, view out) and is honest by
10
+ * construction: a null/absent value renders an em-dash, a <2-point series draws no
11
+ * trend, reduced motion snaps to the end state. Style props are the @hanzo/gui v5
12
+ * shorthand set; raw/SVG colors go through inline `style`.
13
+ */
14
+ import { useMemo, useRef, useState, type ReactNode } from 'react'
15
+ import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
16
+ import { Clock, TrendingDown, TrendingUp } from '@hanzogui/lucide-icons-2'
17
+
18
+ import { CHART_PALETTE, Sparkline } from '../charts/Charts'
19
+ import { useCountUp, useReducedMotion } from '../motion/hooks'
20
+ import { ago, formatMetric, hasTrend, healthColor, healthTally, statusColor, windowRows, worstHealth, OK, MUTED } from './logic'
21
+ import type { MetricUnit, OverviewEvent, OverviewHealth } from './config'
22
+ import type { IconComponent } from '../types'
23
+
24
+ const UP = '#23c562'
25
+ const DOWN = '#ff5d8f'
26
+
27
+ // ── Kpi (count-up stat tile + optional live sparkline + delta) ───────────────
28
+
29
+ export interface KpiProps {
30
+ label: string
31
+ /** The value; `null`/`undefined` → an honest em-dash (backend doesn't report it). */
32
+ value: number | null | undefined
33
+ unit?: MetricUnit
34
+ /** Signed ±% vs the prior window; `null`/`undefined` → honest "— vs prior". */
35
+ deltaPct?: number | null
36
+ /** A recent dense series for the sparkline (≥2 finite points, else omitted). */
37
+ series?: number[]
38
+ icon?: IconComponent
39
+ /** Sparkline accent (defaults to the palette head). */
40
+ color?: string
41
+ /** A small caption under the value. */
42
+ caption?: string
43
+ /** Count the value up on change (respects reduced motion). Default true. */
44
+ animate?: boolean
45
+ /** Show the loading skeleton for the value + delta. */
46
+ loading?: boolean
47
+ }
48
+
49
+ /** An animated KPI / stat tile — the canonical dashboard number. */
50
+ export function Kpi({ label, value, unit, deltaPct, series, icon: Icon, color, caption, animate = true, loading = false }: KpiProps) {
51
+ const reduced = useReducedMotion()
52
+ const finite = value != null && Number.isFinite(value)
53
+ const target = finite ? (value as number) : 0
54
+ const shown = useCountUp(target, animate && !reduced && finite)
55
+ const accent = color ?? CHART_PALETTE[0]
56
+
57
+ return (
58
+ // Responsive KPI columns so a 4-card row balances instead of wrapping 3+1: one
59
+ // column on phones, two from md, four from xl. flexGrow equalizes the widths.
60
+ <Card
61
+ borderWidth={1}
62
+ borderColor="$borderColor"
63
+ p="$4"
64
+ gap="$2"
65
+ flexGrow={1}
66
+ flexShrink={1}
67
+ flexBasis="100%"
68
+ $md={{ flexBasis: '48%' }}
69
+ $xl={{ flexBasis: '22%' }}
70
+ minW={200}
71
+ >
72
+ <XStack items="center" gap="$2">
73
+ {Icon ? <Icon size={15} opacity={0.7} /> : null}
74
+ <Text fontSize="$2" color="$color10" numberOfLines={1}>
75
+ {label}
76
+ </Text>
77
+ </XStack>
78
+
79
+ {loading && !finite ? (
80
+ <SkeletonBar w={110} h={34} />
81
+ ) : (
82
+ <Text fontSize="$9" fontWeight="500" color={finite ? '$color12' : '$color10'} numberOfLines={1} className="tnum">
83
+ {finite ? formatMetric(shown, unit) : '—'}
84
+ </Text>
85
+ )}
86
+
87
+ <XStack justify="space-between" items="flex-end" gap="$2">
88
+ <YStack gap="$1">
89
+ {caption ? (
90
+ <Text fontSize="$1" color="$color10" numberOfLines={1}>
91
+ {caption}
92
+ </Text>
93
+ ) : null}
94
+ <DeltaPill pct={deltaPct ?? null} loading={loading && !finite} />
95
+ </YStack>
96
+ {hasTrend(series) ? <Sparkline values={series!} color={accent} /> : null}
97
+ </XStack>
98
+ </Card>
99
+ )
100
+ }
101
+
102
+ function DeltaPill({ pct, loading }: { pct: number | null; loading: boolean }) {
103
+ if (loading) return <SkeletonBar w={80} h={12} />
104
+ if (pct == null || !Number.isFinite(pct)) {
105
+ return (
106
+ <Text fontSize="$1" color="$color10">
107
+ — vs prior
108
+ </Text>
109
+ )
110
+ }
111
+ const up = pct >= 0
112
+ const Icon = up ? TrendingUp : TrendingDown
113
+ const color = up ? UP : DOWN
114
+ return (
115
+ <XStack items="center" gap="$1">
116
+ <Icon size={12} color={color} />
117
+ <Text fontSize="$1" fontWeight="700" style={{ color }}>
118
+ {`${up ? '+' : ''}${Math.round(pct)}%`}
119
+ </Text>
120
+ <Text fontSize="$1" color="$color10">
121
+ vs prior
122
+ </Text>
123
+ </XStack>
124
+ )
125
+ }
126
+
127
+ // ── Feed (virtualized live activity stream) ──────────────────────────────────
128
+
129
+ const ROW_H = 52
130
+ const VIEWPORT_H = 360
131
+
132
+ export interface FeedProps {
133
+ items: OverviewEvent[]
134
+ title?: string
135
+ empty?: string
136
+ loading?: boolean
137
+ }
138
+
139
+ /** A live activity stream — newest rows on top, virtualized past a viewport. */
140
+ export function Feed({ items, title = 'Live activity', empty = 'No activity in this range yet.', loading = false }: FeedProps) {
141
+ const [scrollTop, setScrollTop] = useState(0)
142
+ const nowRef = useRef(Date.now())
143
+ nowRef.current = Date.now()
144
+ const rows = items ?? []
145
+ // Virtualize only when the stream is long; small feeds render whole (no scroll jank).
146
+ const virtualize = rows.length > Math.ceil(VIEWPORT_H / ROW_H)
147
+ const win = useMemo(() => windowRows(rows, scrollTop, ROW_H, VIEWPORT_H), [rows, scrollTop])
148
+
149
+ return (
150
+ <Panel title={title} right={<LiveDot on={!loading && rows.length > 0} />}>
151
+ {loading && rows.length === 0 ? (
152
+ <YStack gap="$2">
153
+ {[0, 1, 2, 3].map((i) => (
154
+ <SkeletonBar key={i} w="100%" h={ROW_H - 12} />
155
+ ))}
156
+ </YStack>
157
+ ) : rows.length === 0 ? (
158
+ <EmptyPanel note={empty} />
159
+ ) : virtualize ? (
160
+ <div style={{ height: VIEWPORT_H, overflowY: 'auto' }} onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}>
161
+ <div style={{ height: win.padTop }} />
162
+ {win.slice.map((e) => (
163
+ <FeedRow key={e.id || `${e.time}-${e.title}`} title={e.title} subtitle={e.subtitle} status={e.status} when={ago(e.time, nowRef.current)} />
164
+ ))}
165
+ <div style={{ height: win.padBottom }} />
166
+ </div>
167
+ ) : (
168
+ <YStack>
169
+ {rows.map((e) => (
170
+ <FeedRow key={e.id || `${e.time}-${e.title}`} title={e.title} subtitle={e.subtitle} status={e.status} when={ago(e.time, nowRef.current)} />
171
+ ))}
172
+ </YStack>
173
+ )}
174
+ </Panel>
175
+ )
176
+ }
177
+
178
+ function FeedRow({ title, subtitle, status, when }: { title: string; subtitle?: string; status: string; when: string }) {
179
+ return (
180
+ <XStack items="center" gap="$3" py="$2.5" borderBottomWidth={1} borderColor="$borderColor" style={{ height: ROW_H }}>
181
+ <YStack width={8} height={8} rounded="$10" style={{ backgroundColor: statusColor(status) }} />
182
+ <YStack flex={1}>
183
+ <Text fontSize="$3" color="$color12" numberOfLines={1}>
184
+ {title || 'Event'}
185
+ </Text>
186
+ {subtitle ? (
187
+ <Text fontSize="$1" color="$color10" numberOfLines={1}>
188
+ {subtitle}
189
+ </Text>
190
+ ) : null}
191
+ </YStack>
192
+ <XStack items="center" gap="$1.5">
193
+ <Clock size={12} opacity={0.6} />
194
+ <Text fontSize="$2" color="$color11">
195
+ {when}
196
+ </Text>
197
+ </XStack>
198
+ </XStack>
199
+ )
200
+ }
201
+
202
+ // ── Board (service-health board) ─────────────────────────────────────────────
203
+
204
+ export interface BoardProps {
205
+ items: OverviewHealth[]
206
+ title?: string
207
+ empty?: string
208
+ loading?: boolean
209
+ }
210
+
211
+ /** A service-health board — a healthy/total header + per-service dots. */
212
+ export function Board({ items, title = 'System health', empty = 'Health is reported once the control plane is reachable.', loading = false }: BoardProps) {
213
+ const rows = items ?? []
214
+ const worst = worstHealth(rows)
215
+ const { healthy, total } = healthTally(rows)
216
+ const headColor = worst === 'red' ? DOWN : worst === 'yellow' ? '#f0a868' : worst === 'green' ? OK : MUTED
217
+
218
+ return (
219
+ <Panel
220
+ title={title}
221
+ right={
222
+ total > 0 ? (
223
+ <XStack items="center" gap="$2">
224
+ <YStack width={9} height={9} rounded="$10" style={{ backgroundColor: headColor }} />
225
+ <Text fontSize="$2" color="$color10">
226
+ {healthy}/{total} healthy
227
+ </Text>
228
+ </XStack>
229
+ ) : undefined
230
+ }
231
+ >
232
+ {loading && rows.length === 0 ? (
233
+ <SkeletonBar w="100%" h={40} />
234
+ ) : rows.length === 0 ? (
235
+ <EmptyPanel note={empty} />
236
+ ) : (
237
+ <XStack flexWrap="wrap" gap="$2">
238
+ {rows.map((r) => (
239
+ <XStack key={r.service} items="center" gap="$2" bg="$color2" px="$2.5" py="$1.5" rounded="$10" borderWidth={1} borderColor="$borderColor">
240
+ <YStack width={8} height={8} rounded="$10" style={{ backgroundColor: healthColor(r.health) }} />
241
+ <Text fontSize="$2" color="$color11" numberOfLines={1}>
242
+ {r.service}
243
+ </Text>
244
+ </XStack>
245
+ ))}
246
+ </XStack>
247
+ )}
248
+ </Panel>
249
+ )
250
+ }
251
+
252
+ // ── shared chrome ────────────────────────────────────────────────────────────
253
+
254
+ /** A titled panel shell — the ONE section container across the dashboard. */
255
+ export function Panel({ title, right, children, minW = 320, flex }: { title: string; right?: ReactNode; children: ReactNode; minW?: number; flex?: number }) {
256
+ return (
257
+ <Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" flex={flex} minW={minW}>
258
+ <XStack items="center" justify="space-between" gap="$2">
259
+ <Text fontSize="$4" fontWeight="500" color="$color12">
260
+ {title}
261
+ </Text>
262
+ {right}
263
+ </XStack>
264
+ {children}
265
+ </Card>
266
+ )
267
+ }
268
+
269
+ /** A pulsing "live" indicator (steady dot under reduced motion). */
270
+ export function LiveDot({ on }: { on: boolean }) {
271
+ const reduced = useReducedMotion()
272
+ if (!on) return null
273
+ return (
274
+ <XStack items="center" gap="$1.5">
275
+ <YStack width={7} height={7} rounded="$10" style={{ backgroundColor: OK, animation: reduced ? undefined : 'pulse 1.6s ease-in-out infinite' }} />
276
+ <Text fontSize="$1" color="$color10">
277
+ Live
278
+ </Text>
279
+ </XStack>
280
+ )
281
+ }
282
+
283
+ /** A shimmer skeleton block (honest "loading", not fabricated content). */
284
+ export function SkeletonBar({ w, h, rounded }: { w: number | string; h: number; rounded?: boolean }) {
285
+ return <div className="skeleton" style={{ width: w, height: h, borderRadius: rounded ? '50%' : 8 }} aria-hidden />
286
+ }
287
+
288
+ /** An honest empty-state note inside a panel. */
289
+ export function EmptyPanel({ note }: { note: string }) {
290
+ return (
291
+ <YStack p="$5" items="center" gap="$1">
292
+ <Text fontSize="$3" color="$color11">
293
+ {note}
294
+ </Text>
295
+ </YStack>
296
+ )
297
+ }
298
+
299
+ /** A loading spinner centered in a panel (used by the driver's first paint). */
300
+ export function PanelSpinner() {
301
+ return (
302
+ <XStack p="$6" justify="center">
303
+ <Spinner size="large" color="$color11" />
304
+ </XStack>
305
+ )
306
+ }
@@ -0,0 +1,36 @@
1
+ // The dashboard decides no authority. SuperAdmin is the HOST's verdict (the server's
2
+ // `isSuperAdmin`, or @hanzo/iam `superAdmin(claims)` — a person whose home org is the
3
+ // reserved `admin` org), and the overview only carries it to the product's loader
4
+ // under that one name, so a loader can skip a fleet aggregate the viewer would be
5
+ // refused. These hold the package to that: no predicate of its own, one name.
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ const SRC = join(import.meta.dir, '..')
11
+
12
+ function sources(dir: string): string[] {
13
+ return readdirSync(dir).flatMap((name) => {
14
+ const p = join(dir, name)
15
+ if (statSync(p).isDirectory()) return sources(p)
16
+ return /\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name) ? [p] : []
17
+ })
18
+ }
19
+
20
+ describe('SuperAdmin in @hanzo/dashboard', () => {
21
+ test('the overview hands the loader the host verdict as superAdmin', () => {
22
+ const overview = readFileSync(join(SRC, 'overview', 'Overview.tsx'), 'utf8')
23
+ expect(overview).toMatch(/superAdmin = false \}: \{ config: OverviewConfig; allOrgs\?: boolean; superAdmin\?: boolean \}/)
24
+ expect(overview).toContain('.load({ range, allOrgs, superAdmin })')
25
+ const config = readFileSync(join(SRC, 'overview', 'config.ts'), 'utf8')
26
+ expect(config).toMatch(/\n superAdmin\?: boolean\n/)
27
+ })
28
+
29
+ test('no source decides SuperAdmin itself or names it another way', () => {
30
+ for (const file of sources(SRC)) {
31
+ const text = readFileSync(file, 'utf8')
32
+ expect({ file, hit: /isGlobalAdmin|global[ -]admin/i.test(text) }).toEqual({ file, hit: false })
33
+ expect({ file, hit: /owner\s*===?\s*['"]admin['"]|orgs\?*\.?\[0\]/.test(text) }).toEqual({ file, hit: false })
34
+ }
35
+ })
36
+ })
@@ -0,0 +1,223 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Overview tiles — the config-tile adapters that select a slice out of the
5
+ * normalized `OverviewData` and render the matching primitive. Thin by design:
6
+ * the presentation lives in the composable primitives (`Kpi`/`Feed`/`Board`) and
7
+ * the charts (`Line`/`Columns`/`Donut`); these adapters only pick the slice and
8
+ * hand it over. `Tile` routes a declared tile spec to its adapter (exhaustive on
9
+ * the `tile` discriminant), which is what the `Overview` driver renders per row.
10
+ */
11
+ import { Text, XStack, YStack } from '@hanzo/gui'
12
+
13
+ import { CHART_OTHER, CHART_PALETTE, Columns, Donut, Line, type ChartPoint, type Slice } from '../charts/Charts'
14
+ import { Board, EmptyPanel, Feed, Kpi, Panel, SkeletonBar } from './primitives'
15
+ import {
16
+ deltaOf,
17
+ distributionTotal,
18
+ formatMetric,
19
+ selectDistribution,
20
+ selectKpi,
21
+ selectSeries,
22
+ severityColor,
23
+ OK,
24
+ } from './logic'
25
+ import type {
26
+ ActivityTile,
27
+ AlertsTile,
28
+ DistributionTile,
29
+ HealthTile,
30
+ MetricTile,
31
+ OverviewData,
32
+ OverviewTile,
33
+ TimeseriesTile,
34
+ } from './config'
35
+
36
+ /** Format an axis label for a series interval. */
37
+ const fmtAxis = (iso: string, interval: string): string => {
38
+ const d = new Date(iso)
39
+ if (Number.isNaN(d.getTime())) return iso
40
+ return interval === 'day'
41
+ ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
42
+ : d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
43
+ }
44
+
45
+ // ── Metric tile → Kpi ────────────────────────────────────────────────────────
46
+
47
+ export function MetricTileView({ tile, data, loading, live, index }: { tile: MetricTile; data: OverviewData | null; loading: boolean; live: boolean; index: number }) {
48
+ const kpi = data ? selectKpi(data, tile.key) : undefined
49
+ const delta = deltaOf(kpi)
50
+ return (
51
+ <Kpi
52
+ label={tile.label}
53
+ icon={tile.icon}
54
+ unit={tile.unit}
55
+ caption={tile.caption}
56
+ color={tile.color ?? CHART_PALETTE[index % CHART_PALETTE.length]}
57
+ value={kpi === undefined ? null : kpi.value}
58
+ deltaPct={delta ? delta.pct : null}
59
+ series={kpi?.series}
60
+ animate={live}
61
+ loading={loading && kpi === undefined}
62
+ />
63
+ )
64
+ }
65
+
66
+ // ── Timeseries tile → Line / Columns ─────────────────────────────────────────
67
+
68
+ export function TimeseriesTileView({ tile, data, loading }: { tile: TimeseriesTile; data: OverviewData | null; loading: boolean }) {
69
+ const series = data ? selectSeries(data, tile.key) : undefined
70
+ const points: ChartPoint[] = (series?.points ?? []).map((p) => ({ label: fmtAxis(p.t, series?.interval ?? 'day'), value: p.value }))
71
+ const fmt = (v: number) => formatMetric(v, tile.unit)
72
+ const total = points.reduce((s, p) => s + p.value, 0)
73
+
74
+ return (
75
+ <Panel title={tile.title} flex={1} right={points.length ? <Text fontSize="$2" color="$color10" className="tnum">{fmt(total)}</Text> : undefined}>
76
+ {loading && series === undefined ? (
77
+ <SkeletonBar w="100%" h={200} />
78
+ ) : points.length < 2 ? (
79
+ <EmptyPanel note="Not enough data in this range yet." />
80
+ ) : tile.kind === 'bar' ? (
81
+ <Columns data={points} color={CHART_PALETTE[1]} formatValue={fmt} />
82
+ ) : (
83
+ <Line data={points} color={CHART_PALETTE[0]} formatValue={fmt} />
84
+ )}
85
+ </Panel>
86
+ )
87
+ }
88
+
89
+ // ── Distribution tile → Donut + legend ───────────────────────────────────────
90
+
91
+ export function DistributionTileView({ tile, data, loading }: { tile: DistributionTile; data: OverviewData | null; loading: boolean }) {
92
+ const raw = data ? selectDistribution(data, tile.key) : []
93
+ const total = distributionTotal(raw)
94
+ const slices: Slice[] = raw
95
+ .filter((s) => s.value > 0)
96
+ .map((s, i) => ({ label: s.label, value: s.value, color: i < CHART_PALETTE.length ? CHART_PALETTE[i] : CHART_OTHER }))
97
+ const fmt = (v: number) => formatMetric(v, tile.unit)
98
+
99
+ return (
100
+ <Panel title={tile.title} flex={1} minW={340}>
101
+ {loading && raw.length === 0 ? (
102
+ <XStack p="$4" justify="center">
103
+ <SkeletonBar w={160} h={160} rounded />
104
+ </XStack>
105
+ ) : total <= 0 ? (
106
+ <EmptyPanel note="No breakdown recorded in this range." />
107
+ ) : (
108
+ <XStack flexWrap="wrap" gap="$4" items="center">
109
+ <Donut
110
+ slices={slices}
111
+ center={
112
+ <>
113
+ <Text fontSize="$5" fontWeight="500" color="$color12" className="tnum">
114
+ {fmt(total)}
115
+ </Text>
116
+ {tile.centerLabel ? (
117
+ <Text fontSize="$1" color="$color10">
118
+ {tile.centerLabel}
119
+ </Text>
120
+ ) : null}
121
+ </>
122
+ }
123
+ />
124
+ <YStack flex={1} minW={200} gap="$2">
125
+ {raw.filter((s) => s.value > 0).map((s, i) => (
126
+ <XStack key={s.label} items="center" gap="$2.5">
127
+ <YStack width={10} height={10} rounded="$10" style={{ backgroundColor: i < CHART_PALETTE.length ? CHART_PALETTE[i] : CHART_OTHER }} />
128
+ <YStack flex={1}>
129
+ <Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
130
+ {s.label}
131
+ </Text>
132
+ {s.sub ? (
133
+ <Text fontSize="$1" color="$color10" numberOfLines={1}>
134
+ {s.sub}
135
+ </Text>
136
+ ) : null}
137
+ </YStack>
138
+ <YStack items="flex-end">
139
+ <Text fontSize="$3" fontWeight="500" color="$color12" className="tnum">
140
+ {fmt(s.value)}
141
+ </Text>
142
+ <Text fontSize="$1" color="$color10" className="tnum">
143
+ {Math.round((s.value / total) * 100)}%
144
+ </Text>
145
+ </YStack>
146
+ </XStack>
147
+ ))}
148
+ </YStack>
149
+ </XStack>
150
+ )}
151
+ </Panel>
152
+ )
153
+ }
154
+
155
+ // ── Activity tile → Feed ─────────────────────────────────────────────────────
156
+
157
+ export function ActivityTileView({ tile, data, loading }: { tile: ActivityTile; data: OverviewData | null; loading: boolean }) {
158
+ return <Feed items={data?.activity ?? []} title={tile.title} empty={tile.empty} loading={loading} />
159
+ }
160
+
161
+ // ── Alerts tile ──────────────────────────────────────────────────────────────
162
+
163
+ export function AlertsTileView({ tile, data, loading }: { tile: AlertsTile; data: OverviewData | null; loading: boolean }) {
164
+ const alerts = data?.alerts ?? []
165
+ return (
166
+ <Panel title={tile.title ?? 'Alerts'}>
167
+ {loading && alerts.length === 0 ? (
168
+ <SkeletonBar w="100%" h={44} />
169
+ ) : alerts.length === 0 ? (
170
+ <XStack items="center" gap="$2">
171
+ <YStack width={9} height={9} rounded="$10" style={{ backgroundColor: OK }} />
172
+ <Text fontSize="$3" color="$color11">
173
+ No active alerts.
174
+ </Text>
175
+ </XStack>
176
+ ) : (
177
+ <YStack gap="$2.5">
178
+ {alerts.map((a) => (
179
+ <XStack key={a.id || a.title} items="flex-start" gap="$2.5">
180
+ <YStack width={9} height={9} rounded="$10" mt="$1.5" style={{ backgroundColor: severityColor(a.severity) }} />
181
+ <YStack flex={1}>
182
+ <Text fontSize="$3" fontWeight="600" color="$color12">
183
+ {a.title}
184
+ </Text>
185
+ {a.detail ? (
186
+ <Text fontSize="$1" color="$color10">
187
+ {a.detail}
188
+ </Text>
189
+ ) : null}
190
+ </YStack>
191
+ </XStack>
192
+ ))}
193
+ </YStack>
194
+ )}
195
+ </Panel>
196
+ )
197
+ }
198
+
199
+ // ── Health tile → Board ──────────────────────────────────────────────────────
200
+
201
+ export function HealthTileView({ tile, data, loading }: { tile: HealthTile; data: OverviewData | null; loading: boolean }) {
202
+ return <Board items={data?.health ?? []} title={tile.title} empty={tile.empty} loading={loading} />
203
+ }
204
+
205
+ // ── Tile router ──────────────────────────────────────────────────────────────
206
+
207
+ /** Render one declared tile spec against the normalized `OverviewData`. */
208
+ export function Tile({ tile, data, loading, live, index }: { tile: OverviewTile; data: OverviewData | null; loading: boolean; live: boolean; index: number }) {
209
+ switch (tile.tile) {
210
+ case 'metric':
211
+ return <MetricTileView tile={tile} data={data} loading={loading} live={live} index={index} />
212
+ case 'timeseries':
213
+ return <TimeseriesTileView tile={tile} data={data} loading={loading} />
214
+ case 'distribution':
215
+ return <DistributionTileView tile={tile} data={data} loading={loading} />
216
+ case 'activity':
217
+ return <ActivityTileView tile={tile} data={data} loading={loading} />
218
+ case 'alerts':
219
+ return <AlertsTileView tile={tile} data={data} loading={loading} />
220
+ case 'health':
221
+ return <HealthTileView tile={tile} data={data} loading={loading} />
222
+ }
223
+ }