@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.
- 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 +177 -0
- package/src/overview/logic.ts +204 -0
- package/src/overview/primitives.tsx +306 -0
- package/src/overview/superadmin.test.ts +36 -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,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
|
+
}
|