@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,203 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overview — the ONE reusable, videogame-like dashboard driver. Given an
|
|
5
|
+
* `OverviewConfig` (a product's tiles + its REAL data loader + live/motion
|
|
6
|
+
* settings) it renders the board and keeps it alive: a single throttled poll loop
|
|
7
|
+
* refetches on an interval (paused on a hidden tab), the range selector re-scopes,
|
|
8
|
+
* and every number counts up on change (unless reduced motion / count-up off).
|
|
9
|
+
*
|
|
10
|
+
* DRY + orthogonal: this file knows nothing about any specific product or endpoint.
|
|
11
|
+
* The tiles read their slice out of the normalized `OverviewData` the loader returns;
|
|
12
|
+
* a product is added by writing a config, never overview UI.
|
|
13
|
+
*
|
|
14
|
+
* Honest by construction: the FIRST load shows a spinner; a failure shows the error
|
|
15
|
+
* card; thereafter a background refetch never blanks the board (the last real data
|
|
16
|
+
* stays until new real data lands), and any tile whose slice is absent renders its
|
|
17
|
+
* own skeleton/empty. Style props are the @hanzo/gui v5 shorthand set.
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
20
|
+
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
|
21
|
+
|
|
22
|
+
import { effectiveInterval } from '../motion/motion'
|
|
23
|
+
import { usePageHidden, usePoll } from '../motion/hooks'
|
|
24
|
+
import { type Loadable } from './logic'
|
|
25
|
+
import { PanelSpinner } from './primitives'
|
|
26
|
+
import { Tile } from './tiles'
|
|
27
|
+
import type { OverviewConfig, OverviewData, OverviewRange } from './config'
|
|
28
|
+
|
|
29
|
+
/** Throttle floor: a config can ask for faster, but never faster than this. */
|
|
30
|
+
const POLL_FLOOR_MS = 5000
|
|
31
|
+
|
|
32
|
+
const RANGES: { key: OverviewRange; label: string }[] = [
|
|
33
|
+
{ key: '24h', label: '24H' },
|
|
34
|
+
{ key: '7d', label: '7D' },
|
|
35
|
+
{ key: '30d', label: '30D' },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export function Overview({ config, allOrgs, isGlobalAdmin = false }: { config: OverviewConfig; allOrgs?: boolean; isGlobalAdmin?: boolean }) {
|
|
39
|
+
const [range, setRange] = useState<OverviewRange>('24h')
|
|
40
|
+
const [state, setState] = useState<Loadable<OverviewData>>({ s: 'loading' })
|
|
41
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
42
|
+
const reqRef = useRef(0)
|
|
43
|
+
|
|
44
|
+
const hidden = usePageHidden()
|
|
45
|
+
// Pause polling while the tab is hidden OR the board is in its error state — an
|
|
46
|
+
// errored source should not be hammered on a loop; the user retries explicitly.
|
|
47
|
+
const pollMs = effectiveInterval(config.live?.pollMs ?? 0, POLL_FLOOR_MS, hidden || state.s === 'error')
|
|
48
|
+
const { tick, bump } = usePoll(pollMs)
|
|
49
|
+
const live = (config.live?.pollMs ?? 0) > 0
|
|
50
|
+
const countUp = config.live?.countUp !== false
|
|
51
|
+
|
|
52
|
+
// One load path for the first paint, the range change, and every poll tick. The
|
|
53
|
+
// reqRef guard drops a stale in-flight response so a fast range toggle can't
|
|
54
|
+
// render older data over newer.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const id = ++reqRef.current
|
|
57
|
+
const first = state.s === 'loading'
|
|
58
|
+
if (!first) setRefreshing(true)
|
|
59
|
+
config
|
|
60
|
+
.load({ range, allOrgs, isGlobalAdmin })
|
|
61
|
+
.then((v) => {
|
|
62
|
+
if (id === reqRef.current) setState({ s: 'ready', v })
|
|
63
|
+
})
|
|
64
|
+
.catch((e) => {
|
|
65
|
+
// A background refetch that fails must NOT blank a board that already has
|
|
66
|
+
// real data — keep the last data, surface the error only on the first load.
|
|
67
|
+
if (id !== reqRef.current) return
|
|
68
|
+
if (first) {
|
|
69
|
+
const err = asLoadError(e)
|
|
70
|
+
setState({ s: 'error', message: err.message, status: err.status })
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
.finally(() => {
|
|
74
|
+
if (id === reqRef.current) setRefreshing(false)
|
|
75
|
+
})
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, [range, allOrgs, isGlobalAdmin, tick, config])
|
|
78
|
+
|
|
79
|
+
const onRange = (k: OverviewRange) => {
|
|
80
|
+
setRange(k)
|
|
81
|
+
bump()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const data = state.s === 'ready' ? state.v : null
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<YStack gap="$4">
|
|
88
|
+
<Header
|
|
89
|
+
title={allOrgs ? `${config.title} — all orgs` : config.title}
|
|
90
|
+
subtitle={config.subtitle}
|
|
91
|
+
actions={
|
|
92
|
+
<XStack gap="$2" items="center">
|
|
93
|
+
{refreshing ? <Spinner size="small" color="$color10" /> : null}
|
|
94
|
+
{config.ranged === false ? null : <RangeSelector value={range} onChange={onRange} />}
|
|
95
|
+
</XStack>
|
|
96
|
+
}
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
{state.s === 'error' ? (
|
|
100
|
+
<ErrorState
|
|
101
|
+
message={state.message}
|
|
102
|
+
status={state.status}
|
|
103
|
+
onRetry={() => {
|
|
104
|
+
setState({ s: 'loading' })
|
|
105
|
+
bump()
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
) : state.s === 'loading' ? (
|
|
109
|
+
<PanelSpinner />
|
|
110
|
+
) : (
|
|
111
|
+
<YStack gap="$4">
|
|
112
|
+
{config.rows.map((row, ri) => (
|
|
113
|
+
<FadeIn key={ri} index={ri} step={60}>
|
|
114
|
+
<XStack flexWrap="wrap" gap="$3">
|
|
115
|
+
{row.map((tile, ti) => (
|
|
116
|
+
<Tile key={ti} tile={tile} data={data} loading={false} live={live && countUp} index={ti} />
|
|
117
|
+
))}
|
|
118
|
+
</XStack>
|
|
119
|
+
</FadeIn>
|
|
120
|
+
))}
|
|
121
|
+
</YStack>
|
|
122
|
+
)}
|
|
123
|
+
</YStack>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── chrome (self-contained — no host-app coupling) ───────────────────────────
|
|
128
|
+
|
|
129
|
+
/** Extract an honest message + status from an unknown thrown value. */
|
|
130
|
+
function asLoadError(e: unknown): { message: string; status: number } {
|
|
131
|
+
if (e && typeof e === 'object') {
|
|
132
|
+
const anyE = e as { message?: unknown; status?: unknown }
|
|
133
|
+
return {
|
|
134
|
+
message: typeof anyE.message === 'string' ? anyE.message : 'Something went wrong.',
|
|
135
|
+
status: typeof anyE.status === 'number' ? anyE.status : 0,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { message: typeof e === 'string' ? e : 'Something went wrong.', status: 0 }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Section title + optional subtitle and right-aligned actions. */
|
|
142
|
+
function Header({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
|
|
143
|
+
return (
|
|
144
|
+
<XStack justify="space-between" items="flex-start" gap="$3" flexWrap="wrap">
|
|
145
|
+
<YStack gap="$1" flex={1} minW={200}>
|
|
146
|
+
<Text fontSize="$7" fontWeight="500" letterSpacing={-0.4}>
|
|
147
|
+
{title}
|
|
148
|
+
</Text>
|
|
149
|
+
{subtitle ? (
|
|
150
|
+
<Text fontSize="$3" color="$color11">
|
|
151
|
+
{subtitle}
|
|
152
|
+
</Text>
|
|
153
|
+
) : null}
|
|
154
|
+
</YStack>
|
|
155
|
+
{actions ? <XStack gap="$2" items="center">{actions}</XStack> : null}
|
|
156
|
+
</XStack>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A fade-up entrance (staggered by index). Honors reduced motion via the CSS. */
|
|
161
|
+
function FadeIn({ children, index = 0, step = 50 }: { children: ReactNode; index?: number; step?: number }) {
|
|
162
|
+
return (
|
|
163
|
+
<div className="fade-up" style={{ animationDelay: `${index * step}ms` }}>
|
|
164
|
+
{children}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The first-load / retry error card (honest, with a retry). */
|
|
170
|
+
function ErrorState({ message, status, onRetry }: { message: string; status: number; onRetry: () => void }) {
|
|
171
|
+
const note =
|
|
172
|
+
status === 404
|
|
173
|
+
? 'This overview’s data source is not routed on this deployment yet — it appears automatically once the backend is reachable for your org.'
|
|
174
|
+
: message
|
|
175
|
+
return (
|
|
176
|
+
<Card borderWidth={1} borderColor="$borderColor" p="$5" gap="$3" items="flex-start">
|
|
177
|
+
<Text fontSize="$5" fontWeight="600" color="$color12">
|
|
178
|
+
Could not load
|
|
179
|
+
</Text>
|
|
180
|
+
<Text fontSize="$3" color="$color11" maxW={620}>
|
|
181
|
+
{note}
|
|
182
|
+
</Text>
|
|
183
|
+
<Button onPress={onRetry}>Retry</Button>
|
|
184
|
+
</Card>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function RangeSelector({ value, onChange }: { value: OverviewRange; onChange: (k: OverviewRange) => void }) {
|
|
189
|
+
return (
|
|
190
|
+
<XStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
|
|
191
|
+
{RANGES.map((r) => {
|
|
192
|
+
const active = r.key === value
|
|
193
|
+
return (
|
|
194
|
+
<Button key={r.key} size="$2" chromeless={!active} bg={active ? '$color5' : 'transparent'} rounded="$0" onPress={() => onChange(r.key)}>
|
|
195
|
+
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
|
|
196
|
+
{r.label}
|
|
197
|
+
</Text>
|
|
198
|
+
</Button>
|
|
199
|
+
)
|
|
200
|
+
})}
|
|
201
|
+
</XStack>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overview config — the DECLARATIVE contract that makes one overview
|
|
3
|
+
* component serve every product. A product declares WHICH tiles it has and WHERE
|
|
4
|
+
* their data comes from; `Overview` renders + animates them. No product
|
|
5
|
+
* writes overview UI — it writes a `OverviewConfig`.
|
|
6
|
+
*
|
|
7
|
+
* Orthogonal by design:
|
|
8
|
+
* - a `LoadOverview` is the ONE async that fetches a product's REAL data and
|
|
9
|
+
* returns a normalized `OverviewData` (KPIs, series, distribution, activity,
|
|
10
|
+
* alerts, health). It owns the endpoint; the tiles know nothing about it.
|
|
11
|
+
* - each tile is a small value object naming a slice of that data by key
|
|
12
|
+
* (`kpi.tokens`, `series.spend`, …). A missing slice renders its honest empty
|
|
13
|
+
* state — the config can over-declare tiles a slow backend hasn't filled yet.
|
|
14
|
+
* - `live` is the videogame layer: a poll interval (throttled, hidden-tab-paused)
|
|
15
|
+
* and whether numbers count up. Off → a static overview, same component.
|
|
16
|
+
*
|
|
17
|
+
* The consumer supplies its own adapter that maps each real source (usage ledger,
|
|
18
|
+
* admin aggregate, functions metrics, …) onto `OverviewData`, so wiring a product =
|
|
19
|
+
* one adapter + one config, never new UI.
|
|
20
|
+
*/
|
|
21
|
+
import type { IconComponent } from '../types'
|
|
22
|
+
|
|
23
|
+
/** A KPI datum for an animated metric tile. Raw numbers; the tile formats them. */
|
|
24
|
+
export type OverviewKpi = {
|
|
25
|
+
value: number
|
|
26
|
+
/** Prior-period value for the ±% delta; omit when there is no basis (→ honest "—"). */
|
|
27
|
+
prior?: number
|
|
28
|
+
/** A recent dense series for the tile's live sparkline (oldest→newest). */
|
|
29
|
+
series?: number[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A point on a temporal series. */
|
|
33
|
+
export type OverviewPoint = { t: string; value: number }
|
|
34
|
+
|
|
35
|
+
/** A named temporal series with its bucket interval (drives the x-axis labels). */
|
|
36
|
+
export type OverviewSeries = { interval: 'hour' | 'day' | (string & {}); points: OverviewPoint[] }
|
|
37
|
+
|
|
38
|
+
/** A slice of a distribution (spend by model, revenue by product). */
|
|
39
|
+
export type OverviewSlice = { label: string; value: number; sub?: string }
|
|
40
|
+
|
|
41
|
+
/** A live activity/event row. */
|
|
42
|
+
export type OverviewEvent = {
|
|
43
|
+
id: string
|
|
44
|
+
time: string
|
|
45
|
+
title: string
|
|
46
|
+
subtitle?: string
|
|
47
|
+
/** `success` | `error` | `warn` | '' — drives the status dot. */
|
|
48
|
+
status: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An open alert. */
|
|
52
|
+
export type OverviewAlert = { id: string; severity: 'critical' | 'warning' | 'info' | (string & {}); title: string; detail?: string }
|
|
53
|
+
|
|
54
|
+
/** A service-health row. */
|
|
55
|
+
export type OverviewHealth = { service: string; health: 'green' | 'yellow' | 'red' | (string & {}); detail?: string }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The normalized data every tile reads. Keyed maps so a tile names its slice by a
|
|
59
|
+
* stable key; every map may be partial (a slice with no data → honest empty tile).
|
|
60
|
+
*/
|
|
61
|
+
export type OverviewData = {
|
|
62
|
+
kpi: Record<string, OverviewKpi>
|
|
63
|
+
series: Record<string, OverviewSeries>
|
|
64
|
+
distribution: Record<string, OverviewSlice[]>
|
|
65
|
+
activity: OverviewEvent[]
|
|
66
|
+
alerts: OverviewAlert[]
|
|
67
|
+
health: OverviewHealth[]
|
|
68
|
+
/** Total count behind `activity` for pagination, when the source knows it. */
|
|
69
|
+
activityTotal?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** How a number is displayed. `cents`→USD, `count`→compacted, `ms`→duration, `pct`→percent. */
|
|
73
|
+
export type MetricUnit = 'count' | 'cents' | 'ms' | 'pct'
|
|
74
|
+
|
|
75
|
+
/** An animated KPI tile: a count-up number + optional live sparkline + delta. */
|
|
76
|
+
export type MetricTile = {
|
|
77
|
+
tile: 'metric'
|
|
78
|
+
/** Key into `OverviewData.kpi`. */
|
|
79
|
+
key: string
|
|
80
|
+
label: string
|
|
81
|
+
icon: IconComponent
|
|
82
|
+
unit?: MetricUnit
|
|
83
|
+
/** Static caption under the value (e.g. "across 3 providers"). */
|
|
84
|
+
caption?: string
|
|
85
|
+
/** Accent color for the sparkline (defaults to the palette by position). */
|
|
86
|
+
color?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A timeseries panel (line or bars) over `OverviewData.series[key]`. */
|
|
90
|
+
export type TimeseriesTile = {
|
|
91
|
+
tile: 'timeseries'
|
|
92
|
+
key: string
|
|
93
|
+
title: string
|
|
94
|
+
kind?: 'line' | 'bar'
|
|
95
|
+
unit?: MetricUnit
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A distribution panel (donut + legend) over `OverviewData.distribution[key]`. */
|
|
99
|
+
export type DistributionTile = {
|
|
100
|
+
tile: 'distribution'
|
|
101
|
+
key: string
|
|
102
|
+
title: string
|
|
103
|
+
/** Center caption under the total. */
|
|
104
|
+
centerLabel?: string
|
|
105
|
+
unit?: MetricUnit
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The live activity stream (virtualized, streaming/polled). */
|
|
109
|
+
export type ActivityTile = {
|
|
110
|
+
tile: 'activity'
|
|
111
|
+
title?: string
|
|
112
|
+
/** Empty-state copy when there is no activity in range. */
|
|
113
|
+
empty?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The open-alerts panel. */
|
|
117
|
+
export type AlertsTile = { tile: 'alerts'; title?: string }
|
|
118
|
+
|
|
119
|
+
/** The service-health board. */
|
|
120
|
+
export type HealthTile = { tile: 'health'; title?: string; empty?: string }
|
|
121
|
+
|
|
122
|
+
/** Any tile. Discriminated on `tile` so the renderer branches exhaustively. */
|
|
123
|
+
export type OverviewTile =
|
|
124
|
+
| MetricTile
|
|
125
|
+
| TimeseriesTile
|
|
126
|
+
| DistributionTile
|
|
127
|
+
| ActivityTile
|
|
128
|
+
| AlertsTile
|
|
129
|
+
| HealthTile
|
|
130
|
+
|
|
131
|
+
/** The "videogame" layer — live updates + motion, all off-able for a static view. */
|
|
132
|
+
export type LiveConfig = {
|
|
133
|
+
/**
|
|
134
|
+
* Poll the loader every `pollMs` (throttled to a floor, paused when the tab is
|
|
135
|
+
* hidden). 0/undefined → no polling (a one-shot static overview).
|
|
136
|
+
*/
|
|
137
|
+
pollMs?: number
|
|
138
|
+
/** Count numbers up on change (respects prefers-reduced-motion). Default true. */
|
|
139
|
+
countUp?: boolean
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The context passed to a loader (range + org scope). */
|
|
143
|
+
export type OverviewContext = {
|
|
144
|
+
range: OverviewRange
|
|
145
|
+
/** Global-admin all-orgs god view. */
|
|
146
|
+
allOrgs?: boolean
|
|
147
|
+
/**
|
|
148
|
+
* True when the viewer is a global (cross-tenant) admin. Loaders use this to
|
|
149
|
+
* SKIP admin-only aggregates that a tenant user's browser would only ever get a
|
|
150
|
+
* 403 from, going straight to the org-scoped source instead — the board renders
|
|
151
|
+
* the same, minus the console noise.
|
|
152
|
+
*/
|
|
153
|
+
isGlobalAdmin?: boolean
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type OverviewRange = '24h' | '7d' | '30d'
|
|
157
|
+
|
|
158
|
+
/** The ONE async that fetches a product's REAL data, normalized to `OverviewData`. */
|
|
159
|
+
export type LoadOverview = (ctx: OverviewContext) => Promise<OverviewData>
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A product's complete overview — its header, its tiles (in render order), its
|
|
163
|
+
* real data loader, and its live/motion settings. This IS the reusable unit: a
|
|
164
|
+
* consumer builds one of these; `Overview` renders it.
|
|
165
|
+
*/
|
|
166
|
+
export type OverviewConfig = {
|
|
167
|
+
id: string
|
|
168
|
+
title: string
|
|
169
|
+
subtitle?: string
|
|
170
|
+
/** Ordered rows of tiles (each row is a responsive wrap group). */
|
|
171
|
+
rows: OverviewTile[][]
|
|
172
|
+
load: LoadOverview
|
|
173
|
+
live?: LiveConfig
|
|
174
|
+
/** Show the range selector (24H/7D/30D). Default true. */
|
|
175
|
+
ranged?: boolean
|
|
176
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overview tile logic — the PURE decisions behind every tile, so the honest
|
|
3
|
+
* empty/loading/error/reduced-motion behavior is unit-testable in the node env and
|
|
4
|
+
* the `.tsx` tiles stay thin.
|
|
5
|
+
*
|
|
6
|
+
* Every function here answers ONE question a tile needs: how to format a value for
|
|
7
|
+
* its unit, whether a live sparkline has enough real points to draw, what a delta
|
|
8
|
+
* reads (or the honest "—" when there's no basis), what color a status/health dot
|
|
9
|
+
* is, and — critically — how to SELECT a tile's slice out of `OverviewData` so a
|
|
10
|
+
* missing slice becomes an honest empty tile instead of a crash.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here fabricates data: a formatter turns a real number into a string, a
|
|
13
|
+
* selector returns `undefined`/`[]` for absent data, and the sparkline gate returns
|
|
14
|
+
* false for <2 points (the tile then renders no trend).
|
|
15
|
+
*/
|
|
16
|
+
import type {
|
|
17
|
+
MetricUnit,
|
|
18
|
+
OverviewData,
|
|
19
|
+
OverviewKpi,
|
|
20
|
+
OverviewSlice,
|
|
21
|
+
OverviewEvent,
|
|
22
|
+
OverviewHealth,
|
|
23
|
+
OverviewSeries,
|
|
24
|
+
} from './config'
|
|
25
|
+
|
|
26
|
+
// ── value formatting ─────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const compact = (n: number): string =>
|
|
29
|
+
new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(n)
|
|
30
|
+
|
|
31
|
+
const usd = (cents: number): string => {
|
|
32
|
+
const d = cents / 100
|
|
33
|
+
if (Math.abs(d) >= 1000) return '$' + new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(d)
|
|
34
|
+
return '$' + d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const duration = (ms: number): string => {
|
|
38
|
+
if (ms >= 1000) return (ms / 1000).toFixed(ms >= 10_000 ? 0 : 1) + 's'
|
|
39
|
+
return Math.round(ms) + 'ms'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Format a raw metric value for its unit. The ONE place units become strings. */
|
|
43
|
+
export function formatMetric(value: number, unit: MetricUnit = 'count'): string {
|
|
44
|
+
if (!Number.isFinite(value)) return '—'
|
|
45
|
+
switch (unit) {
|
|
46
|
+
case 'cents':
|
|
47
|
+
return usd(value)
|
|
48
|
+
case 'ms':
|
|
49
|
+
return duration(value)
|
|
50
|
+
case 'pct':
|
|
51
|
+
return `${Math.round(value)}%`
|
|
52
|
+
default:
|
|
53
|
+
return compact(value)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The number the count-up animation should target for a unit. Money counts up in
|
|
59
|
+
* DOLLARS (not cents) so the animated digits read naturally; everything else counts
|
|
60
|
+
* up in its own value. The tile formats the interpolated number back via
|
|
61
|
+
* `formatMetric` on the same unit. This returns the raw value; the divisor is in
|
|
62
|
+
* `formatMetric`.
|
|
63
|
+
*/
|
|
64
|
+
export function metricTarget(value: number): number {
|
|
65
|
+
return Number.isFinite(value) ? value : 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A delta descriptor for a KPI, or null when there's no prior basis (honest "—"). */
|
|
69
|
+
export function deltaOf(kpi: OverviewKpi | undefined): { pct: number; up: boolean } | null {
|
|
70
|
+
if (!kpi || kpi.prior === undefined || !Number.isFinite(kpi.prior) || kpi.prior === 0) return null
|
|
71
|
+
const pct = Math.round(((kpi.value - kpi.prior) / kpi.prior) * 100)
|
|
72
|
+
return { pct, up: pct >= 0 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when a live sparkline has ≥2 finite points to draw (else the tile shows no trend). */
|
|
76
|
+
export function hasTrend(series: readonly number[] | undefined): boolean {
|
|
77
|
+
if (!series) return false
|
|
78
|
+
return series.filter((v) => Number.isFinite(v)).length >= 2
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── status / health colors (the ONE mapping) ─────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export const OK = '#23c562'
|
|
84
|
+
export const WARN = '#f0a868'
|
|
85
|
+
export const BAD = '#ff5d8f'
|
|
86
|
+
export const MUTED = '#64748b'
|
|
87
|
+
|
|
88
|
+
/** Dot color for an activity/event status. */
|
|
89
|
+
export function statusColor(status: string): string {
|
|
90
|
+
const s = status.toLowerCase()
|
|
91
|
+
if (s === 'error' || s === 'failed' || s === 'fail') return BAD
|
|
92
|
+
if (s === 'warn' || s === 'warning' || s === 'pending') return WARN
|
|
93
|
+
return OK // success or unknown/'' reads as nominal
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Dot color for a service-health verdict. */
|
|
97
|
+
export function healthColor(health: string): string {
|
|
98
|
+
const h = health.toLowerCase()
|
|
99
|
+
if (h === 'green' || h === 'healthy' || h === 'ok') return OK
|
|
100
|
+
if (h === 'yellow' || h === 'degraded' || h === 'warn') return WARN
|
|
101
|
+
if (h === 'red' || h === 'down' || h === 'unhealthy') return BAD
|
|
102
|
+
return MUTED
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Dot color for an alert severity. */
|
|
106
|
+
export function severityColor(severity: string): string {
|
|
107
|
+
const s = severity.toLowerCase()
|
|
108
|
+
if (s === 'critical' || s === 'error') return BAD
|
|
109
|
+
if (s === 'warning' || s === 'warn') return WARN
|
|
110
|
+
return MUTED
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── overall health verdict for the board header ──────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** The worst verdict across health rows: red > yellow > green; '' when no rows. */
|
|
116
|
+
export function worstHealth(rows: readonly OverviewHealth[]): '' | 'green' | 'yellow' | 'red' {
|
|
117
|
+
let sawYellow = false
|
|
118
|
+
let sawGreen = false
|
|
119
|
+
for (const r of rows) {
|
|
120
|
+
const h = r.health.toLowerCase()
|
|
121
|
+
if (h === 'red' || h === 'down' || h === 'unhealthy') return 'red' // worst — short-circuit
|
|
122
|
+
if (h === 'yellow' || h === 'degraded') sawYellow = true
|
|
123
|
+
else if (h === 'green' || h === 'healthy' || h === 'ok') sawGreen = true
|
|
124
|
+
}
|
|
125
|
+
if (sawYellow) return 'yellow'
|
|
126
|
+
if (sawGreen) return 'green'
|
|
127
|
+
return ''
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Count healthy rows / total (for the "12/14 healthy" header). */
|
|
131
|
+
export function healthTally(rows: readonly OverviewHealth[]): { healthy: number; total: number } {
|
|
132
|
+
const total = rows.length
|
|
133
|
+
const healthy = rows.filter((r) => healthColor(r.health) === OK).length
|
|
134
|
+
return { healthy, total }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── slice selectors (a missing slice → honest empty, never a crash) ──────────
|
|
138
|
+
|
|
139
|
+
export const selectKpi = (data: OverviewData, key: string): OverviewKpi | undefined => data.kpi[key]
|
|
140
|
+
export const selectSeries = (data: OverviewData, key: string): OverviewSeries | undefined => data.series[key]
|
|
141
|
+
export const selectDistribution = (data: OverviewData, key: string): OverviewSlice[] => data.distribution[key] ?? []
|
|
142
|
+
|
|
143
|
+
/** Sum a distribution's positive values (the donut center total). */
|
|
144
|
+
export function distributionTotal(slices: readonly OverviewSlice[]): number {
|
|
145
|
+
return slices.reduce((sum, s) => sum + Math.max(0, s.value), 0)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Merge freshly-polled activity into the current list, newest-first, de-duped by
|
|
150
|
+
* id, capped. This is what lets the live stream GROW smoothly across polls without
|
|
151
|
+
* duplicating a row already on screen or re-ordering the feed. Rows with an empty
|
|
152
|
+
* id are kept (can't dedupe them) but still ordered by time.
|
|
153
|
+
*/
|
|
154
|
+
export function mergeActivity(current: readonly OverviewEvent[], incoming: readonly OverviewEvent[], cap: number): OverviewEvent[] {
|
|
155
|
+
const seen = new Set<string>()
|
|
156
|
+
const out: OverviewEvent[] = []
|
|
157
|
+
for (const e of [...incoming, ...current]) {
|
|
158
|
+
if (e.id && seen.has(e.id)) continue
|
|
159
|
+
if (e.id) seen.add(e.id)
|
|
160
|
+
out.push(e)
|
|
161
|
+
}
|
|
162
|
+
out.sort((a, b) => tOf(b.time) - tOf(a.time))
|
|
163
|
+
return out.slice(0, cap)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tOf = (iso: string): number => {
|
|
167
|
+
const t = Date.parse(iso)
|
|
168
|
+
return Number.isNaN(t) ? 0 : t
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Which rows of a virtualized list are visible for a scroll offset + viewport. */
|
|
172
|
+
export function windowRows<T>(rows: readonly T[], scrollTop: number, rowH: number, viewportH: number, overscan = 4): { start: number; end: number; padTop: number; padBottom: number; slice: T[] } {
|
|
173
|
+
if (rows.length === 0 || rowH <= 0) return { start: 0, end: 0, padTop: 0, padBottom: 0, slice: [] }
|
|
174
|
+
const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan)
|
|
175
|
+
const visible = Math.ceil(viewportH / rowH) + overscan * 2
|
|
176
|
+
const end = Math.min(rows.length, start + visible)
|
|
177
|
+
return {
|
|
178
|
+
start,
|
|
179
|
+
end,
|
|
180
|
+
padTop: start * rowH,
|
|
181
|
+
padBottom: Math.max(0, (rows.length - end) * rowH),
|
|
182
|
+
slice: rows.slice(start, end),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── async state (the repo idiom, shared by the driver + tests) ───────────────
|
|
187
|
+
|
|
188
|
+
export type Loadable<T> = { s: 'loading' } | { s: 'error'; message: string; status: number } | { s: 'ready'; v: T }
|
|
189
|
+
|
|
190
|
+
/** True when a tile should render its SKELETON (first load, no data yet). */
|
|
191
|
+
export const isSkeleton = <T>(state: Loadable<T>): boolean => state.s === 'loading'
|
|
192
|
+
|
|
193
|
+
/** Relative-time label for an event/activity timestamp (honest for a bad stamp). */
|
|
194
|
+
export function ago(iso: string, now: number): string {
|
|
195
|
+
const t = Date.parse(iso)
|
|
196
|
+
if (Number.isNaN(t)) return '—'
|
|
197
|
+
const s = Math.max(0, Math.round((now - t) / 1000))
|
|
198
|
+
if (s < 60) return `${s}s ago`
|
|
199
|
+
const m = Math.round(s / 60)
|
|
200
|
+
if (m < 60) return `${m}m ago`
|
|
201
|
+
const h = Math.round(m / 60)
|
|
202
|
+
if (h < 24) return `${h}h ago`
|
|
203
|
+
return `${Math.round(h / 24)}d ago`
|
|
204
|
+
}
|