@astryxdesign/cli 0.4.5 → 0.4.6-canary.cea9ecd
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/CHANGELOG.md +26 -0
- package/api/component/_adapter.d.mts +22 -5
- package/api/component/_adapter.mjs +51 -3
- package/api/docs/_adapter.d.mts +37 -8
- package/api/docs/_adapter.mjs +70 -41
- package/api/docs/detail/detail.d.mts +2 -0
- package/api/docs/detail/detail.mjs +12 -10
- package/api/docs/detail/section/section.d.mts +2 -0
- package/api/docs/detail/section/section.mjs +1 -0
- package/api/docs/docs.d.mts +2 -0
- package/api/docs/docs.doc.mjs +11 -2
- package/api/docs/docs.mjs +2 -1
- package/api/docs/docs.type.d.mts +15 -0
- package/api/docs/docs.type.mjs +6 -0
- package/api/docs/integrationDocs.test.mjs +208 -0
- package/api/docs/list/list.d.mts +5 -1
- package/api/docs/list/list.mjs +30 -14
- package/api/init/run/run.mjs +9 -4
- package/api/integration/validate-integration.mjs +2 -1
- package/api/integration/validate-integration.type.d.mts +1 -0
- package/api/integration/validate-integration.type.mjs +1 -0
- package/api/search/search.mjs +26 -15
- package/api/upgrade/_adapter.d.mts +2 -2
- package/api/upgrade/_adapter.mjs +7 -3
- package/api/upgrade/run/run.mjs +1 -1
- package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
- package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
- package/assets/docs/cli-integrations.doc.mjs +41 -3
- package/assets/docs/layout.doc.dense.mjs +275 -30
- package/assets/docs/layout.doc.mjs +412 -78
- package/assets/docs/theme.doc.mjs +3 -3
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.doc.mjs +15 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.tsx +47 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Breadcrumbs/BreadcrumbsCustomSeparator.tsx +18 -5
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
- package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
- package/assets/templates/pages/dashboard-data/page.tsx +894 -0
- package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
- package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
- package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
- package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
- package/authoring/doctypes/_schema.d.mts +2 -0
- package/authoring/doctypes/_schema.mjs +5 -0
- package/authoring/doctypes/reference/reference.doc.mjs +14 -0
- package/authoring/doctypes/reference/type.ts +12 -0
- package/authoring/doctypes/template/type.ts +2 -0
- package/authoring/integration/integration.doc.mjs +10 -1
- package/authoring/integration/parse.d.mts +1 -0
- package/authoring/integration/parse.mjs +1 -0
- package/authoring/integration/parse.test.mjs +1 -0
- package/authoring/integration/type.ts +5 -0
- package/clients/cli/commands/component-ownership.test.mjs +63 -3
- package/clients/cli/commands/theme-build.doc.mjs +1 -1
- package/foundation/agent-docs/agent-docs.d.mts +19 -3
- package/foundation/agent-docs/agent-docs.mjs +30 -11
- package/foundation/agent-docs/agent-docs.test.mjs +38 -0
- package/foundation/config/project.d.mts +16 -0
- package/foundation/config/project.mjs +65 -4
- package/foundation/config/project.test.mjs +66 -0
- package/foundation/discovery/docs-discovery.d.mts +185 -0
- package/foundation/discovery/docs-discovery.mjs +544 -0
- package/foundation/discovery/docs-discovery.test.mjs +341 -0
- package/foundation/integrations/integrations.d.mts +8 -6
- package/foundation/integrations/integrations.mjs +6 -4
- package/foundation/integrations/validate-contributions.mjs +30 -2
- package/package.json +9 -9
|
@@ -0,0 +1,1590 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Service Health Monitor — a live-ops / SRE dashboard.
|
|
7
|
+
*
|
|
8
|
+
* Content-only (root `Layout`); the host supplies the app shell. Frame:
|
|
9
|
+
* header (global controls) | content (KPIs, charts, uptime) | triage rail 400
|
|
10
|
+
*
|
|
11
|
+
* The page is anchored to the viewport height, so the header stays put while
|
|
12
|
+
* the content column and the rail scroll within it — never the window.
|
|
13
|
+
*
|
|
14
|
+
* The rail is one scroll region holding two sections — active alerts over a
|
|
15
|
+
* per-service breakdown — each with a header that pins while its list scrolls
|
|
16
|
+
* under it. Below 1024px the rail folds into the content column as two cards.
|
|
17
|
+
*
|
|
18
|
+
* The time-window control (1h/1d/7d), environment, and region selectors are
|
|
19
|
+
* real: they reshape the charts, KPI sparklines, and the rail's service rows.
|
|
20
|
+
* All data is deterministic (fixed fixtures, no clocks/random) so previews
|
|
21
|
+
* stay stable.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
Fragment,
|
|
26
|
+
useMemo,
|
|
27
|
+
useState,
|
|
28
|
+
type CSSProperties,
|
|
29
|
+
type ReactNode,
|
|
30
|
+
} from 'react';
|
|
31
|
+
import * as stylex from '@stylexjs/stylex';
|
|
32
|
+
import {
|
|
33
|
+
VStack,
|
|
34
|
+
HStack,
|
|
35
|
+
StackItem,
|
|
36
|
+
Layout,
|
|
37
|
+
LayoutContent,
|
|
38
|
+
LayoutHeader,
|
|
39
|
+
LayoutPanel,
|
|
40
|
+
} from '@astryxdesign/core/Layout';
|
|
41
|
+
import {Grid} from '@astryxdesign/core/Grid';
|
|
42
|
+
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
43
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
44
|
+
import {Icon} from '@astryxdesign/core/Icon';
|
|
45
|
+
import type {IconType} from '@astryxdesign/core/Icon';
|
|
46
|
+
import {Divider} from '@astryxdesign/core/Divider';
|
|
47
|
+
import {Badge} from '@astryxdesign/core/Badge';
|
|
48
|
+
import {Token} from '@astryxdesign/core/Token';
|
|
49
|
+
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
|
50
|
+
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
|
|
51
|
+
import {Selector} from '@astryxdesign/core/Selector';
|
|
52
|
+
import {
|
|
53
|
+
SegmentedControl,
|
|
54
|
+
SegmentedControlItem,
|
|
55
|
+
} from '@astryxdesign/core/SegmentedControl';
|
|
56
|
+
import {List, ListItem} from '@astryxdesign/core/List';
|
|
57
|
+
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
|
58
|
+
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
|
59
|
+
import {useMediaQuery} from '@astryxdesign/core/hooks';
|
|
60
|
+
import {
|
|
61
|
+
LineChart,
|
|
62
|
+
Line,
|
|
63
|
+
XAxis,
|
|
64
|
+
YAxis,
|
|
65
|
+
CartesianGrid,
|
|
66
|
+
Tooltip,
|
|
67
|
+
ResponsiveContainer,
|
|
68
|
+
} from 'recharts';
|
|
69
|
+
import {
|
|
70
|
+
ArrowUpIcon,
|
|
71
|
+
ArrowDownIcon,
|
|
72
|
+
BellAlertIcon,
|
|
73
|
+
CheckCircleIcon,
|
|
74
|
+
ServerStackIcon,
|
|
75
|
+
} from '@heroicons/react/24/outline';
|
|
76
|
+
import {StopIcon} from '@heroicons/react/24/solid';
|
|
77
|
+
|
|
78
|
+
// ============= TYPES =============
|
|
79
|
+
|
|
80
|
+
type TimeWindow = '1h' | '1d' | '7d';
|
|
81
|
+
type Environment = 'production' | 'staging' | 'development';
|
|
82
|
+
type HealthStatus = 'healthy' | 'warning' | 'critical';
|
|
83
|
+
type MetricKey = 'p50' | 'p95' | 'p99' | 'useast' | 'uswest' | 'euwest';
|
|
84
|
+
|
|
85
|
+
type SeriesPoint = Record<MetricKey, number> & {
|
|
86
|
+
t: number;
|
|
87
|
+
label: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
interface WindowSeries {
|
|
91
|
+
points: SeriesPoint[];
|
|
92
|
+
ticks: number[];
|
|
93
|
+
tickLabels: Record<number, string>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const STATUS_STROKE: Record<HealthStatus, string> = {
|
|
97
|
+
healthy: 'var(--color-success, #0B991F)',
|
|
98
|
+
warning: 'var(--color-warning, #B25000)',
|
|
99
|
+
critical: 'var(--color-error, #D6002A)',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const STATUS_DOT: Record<HealthStatus, StatusDotVariant> = {
|
|
103
|
+
healthy: 'success',
|
|
104
|
+
warning: 'warning',
|
|
105
|
+
critical: 'error',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Accessible label wherever a StatusDot is the only carrier of state.
|
|
109
|
+
const STATUS_LABEL: Record<HealthStatus, string> = {
|
|
110
|
+
healthy: 'Healthy',
|
|
111
|
+
warning: 'Degraded',
|
|
112
|
+
critical: 'Down',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const SEVERITY_RANK: Record<HealthStatus, number> = {
|
|
116
|
+
critical: 0,
|
|
117
|
+
warning: 1,
|
|
118
|
+
healthy: 2,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ============= DETERMINISTIC TIME-SERIES =============
|
|
122
|
+
|
|
123
|
+
// Staging and dev carry a fraction of prod's load, and run leaner with it.
|
|
124
|
+
const ENV_FACTOR: Record<Environment, number> = {
|
|
125
|
+
production: 1,
|
|
126
|
+
staging: 0.16,
|
|
127
|
+
development: 0.04,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const ENV_LATENCY_FACTOR: Record<Environment, number> = {
|
|
131
|
+
production: 1,
|
|
132
|
+
staging: 0.82,
|
|
133
|
+
development: 0.7,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
137
|
+
|
|
138
|
+
function pad2(n: number): string {
|
|
139
|
+
return n < 10 ? `0${n}` : `${n}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Pseudo-noise in [0, 1) — a hash, not a PRNG, so every render is identical.
|
|
143
|
+
function hashNoise(i: number, seed: number): number {
|
|
144
|
+
const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453;
|
|
145
|
+
return x - Math.floor(x);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Signed jitter in [-1, 1) — the per-sample wobble every real metric carries.
|
|
149
|
+
function jitter(i: number, seed: number): number {
|
|
150
|
+
return hashNoise(i, seed) * 2 - 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sparse heavy-tail burst in [0, 1], zero most of the time: metrics sit near
|
|
154
|
+
// baseline and occasionally kick rather than wandering smoothly.
|
|
155
|
+
function burstAt(i: number, seed: number, rate: number): number {
|
|
156
|
+
const gate = hashNoise(i, seed);
|
|
157
|
+
if (gate < 1 - rate) {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
const t = (gate - (1 - rate)) / rate;
|
|
161
|
+
return t * t;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A diurnal baseline under constant jitter, punctuated by sparse bursts, with a
|
|
165
|
+
// sustained latency spike mid-window to match the SEV2 alert in the rail.
|
|
166
|
+
function buildWindow(kind: TimeWindow): WindowSeries {
|
|
167
|
+
const config: Record<
|
|
168
|
+
TimeWindow,
|
|
169
|
+
{n: number; stepSec: number; cycles: number}
|
|
170
|
+
> = {
|
|
171
|
+
'1h': {n: 120, stepSec: 30, cycles: 1.2},
|
|
172
|
+
'1d': {n: 144, stepSec: 600, cycles: 2},
|
|
173
|
+
'7d': {n: 168, stepSec: 3600, cycles: 7},
|
|
174
|
+
};
|
|
175
|
+
const {n, stepSec, cycles} = config[kind];
|
|
176
|
+
const points: SeriesPoint[] = [];
|
|
177
|
+
const spikeCenter = Math.round(n * 0.62);
|
|
178
|
+
const spikeWidth = Math.max(3, Math.round(n * 0.05));
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < n; i++) {
|
|
181
|
+
const phase = (i / n) * Math.PI * 2 * cycles;
|
|
182
|
+
const diurnal = Math.sin(phase);
|
|
183
|
+
const diurnal2 = Math.sin(phase + 1.1);
|
|
184
|
+
const diurnal3 = Math.sin(phase + 2.3);
|
|
185
|
+
|
|
186
|
+
const dist = Math.abs(i - spikeCenter);
|
|
187
|
+
const spike = dist <= spikeWidth ? (1 - dist / (spikeWidth + 1)) * 170 : 0;
|
|
188
|
+
|
|
189
|
+
// A real slowdown hits the whole distribution, hardest at the tail.
|
|
190
|
+
const burst = burstAt(i, 3.1, 0.14);
|
|
191
|
+
const p50 = 46 + 9 * diurnal + spike * 0.15 + burst * 11 + jitter(i, 1) * 4;
|
|
192
|
+
const p95 =
|
|
193
|
+
158 + 38 * diurnal + spike * 0.6 + burst * 58 + jitter(i, 2) * 15;
|
|
194
|
+
const p99 =
|
|
195
|
+
312 + 88 * diurnal + spike * 1.6 + burst * 195 + jitter(i, 3) * 38;
|
|
196
|
+
|
|
197
|
+
// Deploys and outages move every region together.
|
|
198
|
+
const pulse = burstAt(i, 7.3, 0.18) - burstAt(i, 9.7, 0.12);
|
|
199
|
+
const useast = 9200 + 2600 * diurnal + pulse * 1500 + jitter(i, 11) * 520;
|
|
200
|
+
const uswest = 6100 + 1500 * diurnal2 + pulse * 900 + jitter(i, 13) * 380;
|
|
201
|
+
const euwest = 3900 + 1200 * diurnal3 + pulse * 620 + jitter(i, 17) * 260;
|
|
202
|
+
|
|
203
|
+
const totalMin = Math.floor((i * stepSec) / 60);
|
|
204
|
+
let label: string;
|
|
205
|
+
if (kind === '7d') {
|
|
206
|
+
label = DAY_LABELS[Math.floor(totalMin / (60 * 24)) % 7];
|
|
207
|
+
} else {
|
|
208
|
+
const hh = Math.floor(totalMin / 60) % 24;
|
|
209
|
+
const mm = totalMin % 60;
|
|
210
|
+
label = `${pad2(hh)}:${pad2(mm)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
points.push({
|
|
214
|
+
t: i,
|
|
215
|
+
label,
|
|
216
|
+
p50: Math.round(p50),
|
|
217
|
+
p95: Math.round(p95),
|
|
218
|
+
p99: Math.round(p99),
|
|
219
|
+
useast: Math.round(useast),
|
|
220
|
+
uswest: Math.round(uswest),
|
|
221
|
+
euwest: Math.round(euwest),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A fixed handful of ticks, so dense sampling can't crowd the axis.
|
|
226
|
+
const ticks: number[] = [];
|
|
227
|
+
if (kind === '7d') {
|
|
228
|
+
const perDay = Math.round((60 * 60 * 24) / stepSec);
|
|
229
|
+
for (let d = 0; d < 7; d++) {
|
|
230
|
+
ticks.push(d * perDay);
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
for (let k = 0; k < 6; k++) {
|
|
234
|
+
ticks.push(Math.round((k / 5) * (n - 1)));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const tickLabels: Record<number, string> = {};
|
|
238
|
+
for (const tk of ticks) {
|
|
239
|
+
tickLabels[tk] = points[Math.min(tk, points.length - 1)]?.label ?? '';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {points, ticks, tickLabels};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const SERIES: Record<TimeWindow, WindowSeries> = {
|
|
246
|
+
'1h': buildWindow('1h'),
|
|
247
|
+
'1d': buildWindow('1d'),
|
|
248
|
+
'7d': buildWindow('7d'),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// ============= KPI DATA (per environment) =============
|
|
252
|
+
|
|
253
|
+
interface Kpi {
|
|
254
|
+
key: string;
|
|
255
|
+
label: string;
|
|
256
|
+
value: string;
|
|
257
|
+
status: HealthStatus;
|
|
258
|
+
statusLabel: string;
|
|
259
|
+
delta: string;
|
|
260
|
+
/** Whether the delta direction is a good thing — drives the arrow + color. */
|
|
261
|
+
deltaPositive: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const KPI_BY_ENV: Record<Environment, Kpi[]> = {
|
|
265
|
+
production: [
|
|
266
|
+
{
|
|
267
|
+
key: 'health',
|
|
268
|
+
label: 'Overall health',
|
|
269
|
+
value: '98.6%',
|
|
270
|
+
status: 'healthy',
|
|
271
|
+
statusLabel: 'Healthy',
|
|
272
|
+
delta: '+0.3%',
|
|
273
|
+
deltaPositive: true,
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
key: 'error',
|
|
277
|
+
label: 'Error rate',
|
|
278
|
+
value: '1.84%',
|
|
279
|
+
status: 'warning',
|
|
280
|
+
statusLabel: 'Elevated',
|
|
281
|
+
delta: '+0.9%',
|
|
282
|
+
deltaPositive: false,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
key: 'latency',
|
|
286
|
+
label: 'p99 latency',
|
|
287
|
+
value: '512 ms',
|
|
288
|
+
status: 'warning',
|
|
289
|
+
statusLabel: 'Above target',
|
|
290
|
+
delta: '+84 ms',
|
|
291
|
+
deltaPositive: false,
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
key: 'availability',
|
|
295
|
+
label: 'Availability',
|
|
296
|
+
value: '99.98%',
|
|
297
|
+
status: 'healthy',
|
|
298
|
+
statusLabel: 'Operational',
|
|
299
|
+
delta: '+0.01%',
|
|
300
|
+
deltaPositive: true,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
staging: [
|
|
304
|
+
{
|
|
305
|
+
key: 'health',
|
|
306
|
+
label: 'Overall health',
|
|
307
|
+
value: '99.4%',
|
|
308
|
+
status: 'healthy',
|
|
309
|
+
statusLabel: 'Healthy',
|
|
310
|
+
delta: '+0.1%',
|
|
311
|
+
deltaPositive: true,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
key: 'error',
|
|
315
|
+
label: 'Error rate',
|
|
316
|
+
value: '0.42%',
|
|
317
|
+
status: 'healthy',
|
|
318
|
+
statusLabel: 'Nominal',
|
|
319
|
+
delta: '-0.2%',
|
|
320
|
+
deltaPositive: true,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
key: 'latency',
|
|
324
|
+
label: 'p99 latency',
|
|
325
|
+
value: '338 ms',
|
|
326
|
+
status: 'healthy',
|
|
327
|
+
statusLabel: 'Within target',
|
|
328
|
+
delta: '-12 ms',
|
|
329
|
+
deltaPositive: true,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
key: 'availability',
|
|
333
|
+
label: 'Availability',
|
|
334
|
+
value: '99.95%',
|
|
335
|
+
status: 'healthy',
|
|
336
|
+
statusLabel: 'Operational',
|
|
337
|
+
delta: '0.00%',
|
|
338
|
+
deltaPositive: true,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
development: [
|
|
342
|
+
{
|
|
343
|
+
key: 'health',
|
|
344
|
+
label: 'Overall health',
|
|
345
|
+
value: '96.1%',
|
|
346
|
+
status: 'warning',
|
|
347
|
+
statusLabel: 'Flaky',
|
|
348
|
+
delta: '-1.8%',
|
|
349
|
+
deltaPositive: false,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
key: 'error',
|
|
353
|
+
label: 'Error rate',
|
|
354
|
+
value: '3.10%',
|
|
355
|
+
status: 'critical',
|
|
356
|
+
statusLabel: 'High',
|
|
357
|
+
delta: '+1.4%',
|
|
358
|
+
deltaPositive: false,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
key: 'latency',
|
|
362
|
+
label: 'p99 latency',
|
|
363
|
+
value: '274 ms',
|
|
364
|
+
status: 'healthy',
|
|
365
|
+
statusLabel: 'Within target',
|
|
366
|
+
delta: '-30 ms',
|
|
367
|
+
deltaPositive: true,
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
key: 'availability',
|
|
371
|
+
label: 'Availability',
|
|
372
|
+
value: '99.20%',
|
|
373
|
+
status: 'warning',
|
|
374
|
+
statusLabel: 'Degraded',
|
|
375
|
+
delta: '-0.6%',
|
|
376
|
+
deltaPositive: false,
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// 120 samples per KPI, tail-sliced to the active window so it ends "now".
|
|
382
|
+
const SPARKLINES: Record<string, number[]> = {
|
|
383
|
+
// prettier-ignore
|
|
384
|
+
health: [97.6, 97.6, 97.7, 97.6, 98, 97.4, 96.5, 97.3, 97.3, 97, 97, 97.6, 96.8, 97.2, 91.7, 92.2, 95, 96.3, 96.5, 96.4, 96.8, 95.9, 96.7, 96.7, 96.4, 97.1, 97, 97, 96.1, 96.7, 96, 95.7, 96.9, 97.3, 96.1, 95.8, 96.9, 96.2, 96.6, 96.7, 97.2, 92.7, 96.5, 97.2, 96.5, 95.8, 96, 97.4, 96.8, 96.9, 96.4, 96.9, 97.3, 96.2, 97.3, 96.2, 97, 97.3, 89.5, 90.1, 91.8, 95.1, 96.4, 97.7, 97.2, 97.8, 97, 97.5, 97.1, 98, 96.8, 96.8, 97.7, 97.7, 96.8, 97.2, 93.2, 95.3, 97.2, 97.7, 97.3, 98.2, 97.6, 97.3, 97, 97.4, 96.5, 98, 96.9, 97.1, 96.5, 96.4, 97.6, 87.4, 90, 94.1, 97.1, 97.1, 97.5, 97.6, 96.9, 96.9, 96.8, 96.5, 95.8, 96.7, 96.7, 97.2, 91.7, 91.5, 92.3, 95.3, 96.9, 97.4, 96.8, 97.9, 97.5, 98.3, 97.9, 98.6],
|
|
385
|
+
// prettier-ignore
|
|
386
|
+
error: [0.62, 0.64, 0.98, 0.82, 0.86, 0.83, 1.02, 1.29, 1.24, 2.41, 1.57, 0.96, 0.91, 1.43, 1.36, 1.16, 1.33, 0.95, 1.18, 1.3, 1.48, 1.05, 1.41, 1.06, 1.03, 1.36, 1, 3.71, 2.85, 1.7, 1.41, 1.29, 1.35, 1.35, 1.19, 1.27, 1.2, 0.91, 0.95, 1.06, 1.26, 1.28, 1.19, 1.09, 2.21, 1.52, 0.83, 0.94, 1.17, 0.81, 1.11, 1.24, 1.18, 0.76, 1.09, 1.08, 1.22, 1.02, 1, 5.18, 4.39, 3.74, 2.23, 1, 1.3, 1.53, 1.42, 1.16, 1.18, 1.52, 1.45, 1.34, 3.13, 2.1, 1.66, 1.74, 1.69, 1.33, 1.43, 1.73, 1.73, 1.51, 1.63, 1.55, 1.56, 1.74, 2, 1.46, 5.44, 4.33, 2.3, 1.99, 1.77, 1.82, 1.98, 1.86, 1.88, 1.51, 1.42, 1.82, 1.71, 3.88, 2.22, 1.72, 1.65, 1.64, 1.42, 1.69, 1.39, 1.81, 1.66, 1.3, 1.32, 1.75, 6.22, 5.03, 2.35, 1.39, 1.25, 1.84],
|
|
387
|
+
// prettier-ignore
|
|
388
|
+
latency: [322, 371, 287, 357, 313, 353, 320, 617, 421, 363, 377, 399, 350, 401, 421, 420, 430, 365, 401, 797, 635, 477, 365, 412, 380, 421, 380, 411, 393, 401, 436, 378, 376, 569, 389, 419, 417, 342, 347, 376, 385, 347, 393, 328, 328, 367, 304, 871, 812, 707, 411, 317, 316, 367, 331, 320, 354, 349, 326, 309, 323, 631, 396, 306, 295, 293, 355, 334, 324, 372, 1012, 850, 489, 355, 324, 346, 353, 406, 365, 362, 377, 348, 437, 423, 664, 437, 397, 413, 402, 427, 404, 438, 491, 479, 447, 500, 440, 903, 776, 555, 476, 506, 439, 461, 502, 496, 485, 474, 470, 503, 1198, 1080, 930, 609, 456, 507, 426, 451, 427, 512],
|
|
389
|
+
// prettier-ignore
|
|
390
|
+
availability: [99.891, 99.956, 99.885, 99.964, 99.882, 99.92, 99.696, 99.897, 99.913, 99.948, 99.918, 99.942, 99.921, 99.928, 99.94, 99.902, 99.551, 99.853, 99.853, 99.891, 99.887, 99.926, 99.912, 99.911, 99.739, 99.873, 99.877, 99.85, 99.844, 99.895, 99.854, 99.851, 99.912, 99.426, 99.593, 99.821, 99.851, 99.928, 99.767, 99.852, 99.854, 99.866, 99.87, 99.929, 99.856, 99.925, 99.672, 99.882, 99.926, 99.865, 99.944, 99.941, 99.807, 99.91, 99.892, 99.922, 99.937, 99.338, 99.491, 99.743, 99.925, 99.871, 99.95, 99.909, 99.949, 99.687, 99.844, 99.911, 99.93, 99.882, 99.894, 99.536, 99.833, 99.867, 99.927, 99.88, 99.883, 99.926, 99.895, 99.653, 99.827, 99.906, 99.941, 99.893, 99.863, 99.916, 99.781, 99.885, 99.846, 99.896, 99.876, 99.485, 99.534, 99.738, 99.832, 99.241, 99.321, 99.422, 99.698, 99.828, 99.824, 99.837, 99.828, 99.61, 99.761, 99.878, 99.843, 99.845, 99.529, 99.75, 99.838, 99.83, 99.423, 99.728, 99.817, 99.827, 99.811, 99.871, 99.925, 99.98],
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// ============= DRILL-DOWN ROWS =============
|
|
394
|
+
|
|
395
|
+
interface HostRow {
|
|
396
|
+
id: string;
|
|
397
|
+
service: string;
|
|
398
|
+
environment: Environment;
|
|
399
|
+
region: string;
|
|
400
|
+
instances: number;
|
|
401
|
+
rpm: number;
|
|
402
|
+
errorRate: number;
|
|
403
|
+
p99: number;
|
|
404
|
+
status: HealthStatus;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const HOST_ROWS: HostRow[] = [
|
|
408
|
+
{
|
|
409
|
+
id: '1',
|
|
410
|
+
service: 'checkout-api',
|
|
411
|
+
environment: 'production',
|
|
412
|
+
region: 'us-east-1',
|
|
413
|
+
instances: 24,
|
|
414
|
+
rpm: 9240,
|
|
415
|
+
errorRate: 2.1,
|
|
416
|
+
p99: 512,
|
|
417
|
+
status: 'warning',
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: '2',
|
|
421
|
+
service: 'checkout-api',
|
|
422
|
+
environment: 'production',
|
|
423
|
+
region: 'us-west-2',
|
|
424
|
+
instances: 18,
|
|
425
|
+
rpm: 6110,
|
|
426
|
+
errorRate: 0.3,
|
|
427
|
+
p99: 214,
|
|
428
|
+
status: 'healthy',
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
id: '3',
|
|
432
|
+
service: 'checkout-api',
|
|
433
|
+
environment: 'production',
|
|
434
|
+
region: 'eu-west-1',
|
|
435
|
+
instances: 12,
|
|
436
|
+
rpm: 3980,
|
|
437
|
+
errorRate: 0.4,
|
|
438
|
+
p99: 236,
|
|
439
|
+
status: 'healthy',
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: '4',
|
|
443
|
+
service: 'search-indexer',
|
|
444
|
+
environment: 'production',
|
|
445
|
+
region: 'us-east-1',
|
|
446
|
+
instances: 16,
|
|
447
|
+
rpm: 4120,
|
|
448
|
+
errorRate: 5.8,
|
|
449
|
+
p99: 1180,
|
|
450
|
+
status: 'critical',
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: '5',
|
|
454
|
+
service: 'search-indexer',
|
|
455
|
+
environment: 'production',
|
|
456
|
+
region: 'ap-southeast-1',
|
|
457
|
+
instances: 8,
|
|
458
|
+
rpm: 2040,
|
|
459
|
+
errorRate: 0.9,
|
|
460
|
+
p99: 342,
|
|
461
|
+
status: 'healthy',
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
id: '6',
|
|
465
|
+
service: 'payments',
|
|
466
|
+
environment: 'production',
|
|
467
|
+
region: 'us-east-1',
|
|
468
|
+
instances: 20,
|
|
469
|
+
rpm: 5310,
|
|
470
|
+
errorRate: 0.2,
|
|
471
|
+
p99: 188,
|
|
472
|
+
status: 'healthy',
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
id: '7',
|
|
476
|
+
service: 'payments',
|
|
477
|
+
environment: 'production',
|
|
478
|
+
region: 'eu-west-1',
|
|
479
|
+
instances: 10,
|
|
480
|
+
rpm: 2760,
|
|
481
|
+
errorRate: 0.2,
|
|
482
|
+
p99: 205,
|
|
483
|
+
status: 'healthy',
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: '8',
|
|
487
|
+
service: 'edge-cdn',
|
|
488
|
+
environment: 'production',
|
|
489
|
+
region: 'eu-west-1',
|
|
490
|
+
instances: 30,
|
|
491
|
+
rpm: 14820,
|
|
492
|
+
errorRate: 1.6,
|
|
493
|
+
p99: 402,
|
|
494
|
+
status: 'warning',
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: '9',
|
|
498
|
+
service: 'auth',
|
|
499
|
+
environment: 'production',
|
|
500
|
+
region: 'us-west-2',
|
|
501
|
+
instances: 14,
|
|
502
|
+
rpm: 4530,
|
|
503
|
+
errorRate: 0.1,
|
|
504
|
+
p99: 96,
|
|
505
|
+
status: 'healthy',
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
id: '10',
|
|
509
|
+
service: 'notifications',
|
|
510
|
+
environment: 'production',
|
|
511
|
+
region: 'ap-southeast-1',
|
|
512
|
+
instances: 6,
|
|
513
|
+
rpm: 1210,
|
|
514
|
+
errorRate: 0.5,
|
|
515
|
+
p99: 268,
|
|
516
|
+
status: 'healthy',
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
id: '11',
|
|
520
|
+
service: 'checkout-api',
|
|
521
|
+
environment: 'staging',
|
|
522
|
+
region: 'us-east-1',
|
|
523
|
+
instances: 3,
|
|
524
|
+
rpm: 420,
|
|
525
|
+
errorRate: 0.4,
|
|
526
|
+
p99: 198,
|
|
527
|
+
status: 'healthy',
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
id: '12',
|
|
531
|
+
service: 'search-indexer',
|
|
532
|
+
environment: 'staging',
|
|
533
|
+
region: 'us-east-1',
|
|
534
|
+
instances: 2,
|
|
535
|
+
rpm: 180,
|
|
536
|
+
errorRate: 1.1,
|
|
537
|
+
p99: 356,
|
|
538
|
+
status: 'warning',
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
id: '13',
|
|
542
|
+
service: 'checkout-api',
|
|
543
|
+
environment: 'development',
|
|
544
|
+
region: 'us-west-2',
|
|
545
|
+
instances: 1,
|
|
546
|
+
rpm: 44,
|
|
547
|
+
errorRate: 3.2,
|
|
548
|
+
p99: 274,
|
|
549
|
+
status: 'warning',
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
id: '14',
|
|
553
|
+
service: 'auth',
|
|
554
|
+
environment: 'development',
|
|
555
|
+
region: 'us-west-2',
|
|
556
|
+
instances: 1,
|
|
557
|
+
rpm: 38,
|
|
558
|
+
errorRate: 6.4,
|
|
559
|
+
p99: 512,
|
|
560
|
+
status: 'critical',
|
|
561
|
+
},
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
// ============= ALERTS =============
|
|
565
|
+
|
|
566
|
+
type AlertState = 'firing' | 'acknowledged' | 'resolved';
|
|
567
|
+
|
|
568
|
+
interface Alert {
|
|
569
|
+
id: string;
|
|
570
|
+
severity: HealthStatus;
|
|
571
|
+
title: string;
|
|
572
|
+
service: string;
|
|
573
|
+
at: string;
|
|
574
|
+
state: AlertState;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const ALERT_STATE_TOKEN: Record<
|
|
578
|
+
AlertState,
|
|
579
|
+
{label: string; color: 'red' | 'yellow' | 'green'}
|
|
580
|
+
> = {
|
|
581
|
+
firing: {label: 'Firing', color: 'red'},
|
|
582
|
+
acknowledged: {label: 'Ack', color: 'yellow'},
|
|
583
|
+
resolved: {label: 'Resolved', color: 'green'},
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const ALERTS: Alert[] = [
|
|
587
|
+
{
|
|
588
|
+
id: 'AL-8842',
|
|
589
|
+
severity: 'critical',
|
|
590
|
+
title: 'search-indexer 5xx rate above 5%',
|
|
591
|
+
service: 'search-indexer · us-east-1',
|
|
592
|
+
at: '2026-06-30T21:14:00Z',
|
|
593
|
+
state: 'firing',
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
id: 'AL-8841',
|
|
597
|
+
severity: 'warning',
|
|
598
|
+
title: 'checkout-api p99 latency above 500ms',
|
|
599
|
+
service: 'checkout-api · us-east-1',
|
|
600
|
+
at: '2026-06-30T21:02:00Z',
|
|
601
|
+
state: 'firing',
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
id: 'AL-8840',
|
|
605
|
+
severity: 'warning',
|
|
606
|
+
title: 'edge-cdn cache hit ratio degraded',
|
|
607
|
+
service: 'edge-cdn · eu-west-1',
|
|
608
|
+
at: '2026-06-30T20:41:00Z',
|
|
609
|
+
state: 'acknowledged',
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
id: 'AL-8839',
|
|
613
|
+
severity: 'critical',
|
|
614
|
+
title: 'auth error rate spike in dev cluster',
|
|
615
|
+
service: 'auth · us-west-2',
|
|
616
|
+
at: '2026-06-30T20:18:00Z',
|
|
617
|
+
state: 'acknowledged',
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: 'AL-8838',
|
|
621
|
+
severity: 'warning',
|
|
622
|
+
title: 'notifications queue depth climbing',
|
|
623
|
+
service: 'notifications · ap-southeast-1',
|
|
624
|
+
at: '2026-06-30T19:55:00Z',
|
|
625
|
+
state: 'firing',
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
id: 'AL-8837',
|
|
629
|
+
severity: 'warning',
|
|
630
|
+
title: 'payments settlement job heartbeat missed',
|
|
631
|
+
service: 'payments · us-east-1',
|
|
632
|
+
at: '2026-06-30T18:30:00Z',
|
|
633
|
+
state: 'resolved',
|
|
634
|
+
},
|
|
635
|
+
];
|
|
636
|
+
|
|
637
|
+
const ALERT_ORDER: AlertState[] = ['firing', 'acknowledged', 'resolved'];
|
|
638
|
+
|
|
639
|
+
// ============= SHARED CHART PIECES =============
|
|
640
|
+
|
|
641
|
+
// Inline color — Icon's `color` prop only takes semantic names.
|
|
642
|
+
function LegendDot({color, label}: {color: string; label: string}) {
|
|
643
|
+
const dotStyle: CSSProperties = {color};
|
|
644
|
+
return (
|
|
645
|
+
<HStack gap={2} vAlign="center">
|
|
646
|
+
<Icon icon={StopIcon} size="xsm" style={dotStyle} />
|
|
647
|
+
<Text type="supporting" color="secondary">
|
|
648
|
+
{label}
|
|
649
|
+
</Text>
|
|
650
|
+
</HStack>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
interface TooltipEntry {
|
|
655
|
+
name: string;
|
|
656
|
+
value: number;
|
|
657
|
+
color: string;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Recharts passes the point index as `label`; map it back to a time.
|
|
661
|
+
function ChartTooltip({
|
|
662
|
+
active,
|
|
663
|
+
payload,
|
|
664
|
+
label,
|
|
665
|
+
series,
|
|
666
|
+
unit,
|
|
667
|
+
}: {
|
|
668
|
+
active?: boolean;
|
|
669
|
+
payload?: TooltipEntry[];
|
|
670
|
+
label?: number;
|
|
671
|
+
series: WindowSeries;
|
|
672
|
+
unit: string;
|
|
673
|
+
}) {
|
|
674
|
+
if (!active || !payload?.length) {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
const timeLabel =
|
|
678
|
+
typeof label === 'number' ? (series.points[label]?.label ?? '') : '';
|
|
679
|
+
return (
|
|
680
|
+
<Card padding={3}>
|
|
681
|
+
<VStack gap={1}>
|
|
682
|
+
<Text type="supporting" color="secondary">
|
|
683
|
+
{timeLabel}
|
|
684
|
+
</Text>
|
|
685
|
+
{payload.map(entry => (
|
|
686
|
+
<LegendDot
|
|
687
|
+
key={entry.name}
|
|
688
|
+
color={entry.color}
|
|
689
|
+
label={`${entry.name}: ${entry.value.toLocaleString()}${unit}`}
|
|
690
|
+
/>
|
|
691
|
+
))}
|
|
692
|
+
</VStack>
|
|
693
|
+
</Card>
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const AXIS_TICK = {
|
|
698
|
+
fontSize: 'var(--font-size-sm, 12px)',
|
|
699
|
+
fill: 'var(--color-text-secondary, #4E606F)',
|
|
700
|
+
};
|
|
701
|
+
const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
|
|
702
|
+
|
|
703
|
+
// Shared so both cards in the grid row align. The margins reserve the overhang
|
|
704
|
+
// of centered axis ticks, without which the tallest y-label is clipped.
|
|
705
|
+
const CHART_HEIGHT = 280;
|
|
706
|
+
const CHART_MARGIN = {top: 12, right: 16, left: 0, bottom: 4};
|
|
707
|
+
const Y_AXIS_WIDTH = 56;
|
|
708
|
+
|
|
709
|
+
// `linear` + miter joins keep every sample a straight segment — `monotone`
|
|
710
|
+
// would round the spikes into a curve no real metric produces.
|
|
711
|
+
const LINE_PROPS = {
|
|
712
|
+
type: 'linear',
|
|
713
|
+
strokeLinejoin: 'miter',
|
|
714
|
+
strokeLinecap: 'butt',
|
|
715
|
+
dot: false,
|
|
716
|
+
isAnimationActive: false,
|
|
717
|
+
} as const;
|
|
718
|
+
|
|
719
|
+
// ============= CHART CARDS =============
|
|
720
|
+
|
|
721
|
+
interface ChartLine {
|
|
722
|
+
key: MetricKey;
|
|
723
|
+
name: string;
|
|
724
|
+
color: string;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const BLUE = 'var(--color-data-categorical-blue, #0171E3)';
|
|
728
|
+
const ORANGE = 'var(--color-data-categorical-orange, #EB6E00)';
|
|
729
|
+
const PURPLE = 'var(--color-data-categorical-purple, #6B1EFD)';
|
|
730
|
+
const TEAL = 'var(--color-data-categorical-teal, #008E80)';
|
|
731
|
+
|
|
732
|
+
const LATENCY_LINES: ChartLine[] = [
|
|
733
|
+
{key: 'p50', name: 'p50', color: BLUE},
|
|
734
|
+
{key: 'p95', name: 'p95', color: ORANGE},
|
|
735
|
+
{key: 'p99', name: 'p99', color: PURPLE},
|
|
736
|
+
];
|
|
737
|
+
|
|
738
|
+
const TRAFFIC_LINES: ChartLine[] = [
|
|
739
|
+
{key: 'useast', name: 'us-east-1', color: BLUE},
|
|
740
|
+
{key: 'uswest', name: 'us-west-2', color: TEAL},
|
|
741
|
+
{key: 'euwest', name: 'eu-west-1', color: ORANGE},
|
|
742
|
+
];
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* One time-series card — title, plot, legend — driven by a line config. `scale`
|
|
746
|
+
* applies the active environment's multiplier; `hasAxisUnit` is opt-in because
|
|
747
|
+
* rpm ticks are already wide enough without a suffix.
|
|
748
|
+
*/
|
|
749
|
+
function MetricChart({
|
|
750
|
+
title,
|
|
751
|
+
subtitle,
|
|
752
|
+
series,
|
|
753
|
+
lines,
|
|
754
|
+
scale,
|
|
755
|
+
unit,
|
|
756
|
+
hasAxisUnit = false,
|
|
757
|
+
}: {
|
|
758
|
+
title: string;
|
|
759
|
+
subtitle: string;
|
|
760
|
+
series: WindowSeries;
|
|
761
|
+
lines: ChartLine[];
|
|
762
|
+
scale: number;
|
|
763
|
+
unit: string;
|
|
764
|
+
hasAxisUnit?: boolean;
|
|
765
|
+
}) {
|
|
766
|
+
const data = useMemo(
|
|
767
|
+
() =>
|
|
768
|
+
series.points.map(point => {
|
|
769
|
+
const row: Record<string, number> = {t: point.t};
|
|
770
|
+
for (const line of lines) {
|
|
771
|
+
row[line.key] = Math.round(point[line.key] * scale);
|
|
772
|
+
}
|
|
773
|
+
return row;
|
|
774
|
+
}),
|
|
775
|
+
[series, lines, scale],
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<Card padding={6}>
|
|
780
|
+
<VStack gap={4}>
|
|
781
|
+
<VStack gap={1}>
|
|
782
|
+
<Heading level={3}>{title}</Heading>
|
|
783
|
+
<Text type="supporting" color="secondary">
|
|
784
|
+
{subtitle}
|
|
785
|
+
</Text>
|
|
786
|
+
</VStack>
|
|
787
|
+
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
|
788
|
+
<LineChart data={data} margin={CHART_MARGIN}>
|
|
789
|
+
<CartesianGrid horizontal vertical={false} stroke={GRID_STROKE} />
|
|
790
|
+
<XAxis
|
|
791
|
+
dataKey="t"
|
|
792
|
+
type="number"
|
|
793
|
+
domain={[0, series.points.length - 1]}
|
|
794
|
+
ticks={series.ticks}
|
|
795
|
+
tickFormatter={(v: number) => series.tickLabels[v] ?? ''}
|
|
796
|
+
tick={AXIS_TICK}
|
|
797
|
+
axisLine={false}
|
|
798
|
+
tickLine={false}
|
|
799
|
+
/>
|
|
800
|
+
<YAxis
|
|
801
|
+
tick={AXIS_TICK}
|
|
802
|
+
axisLine={false}
|
|
803
|
+
tickLine={false}
|
|
804
|
+
width={Y_AXIS_WIDTH}
|
|
805
|
+
unit={hasAxisUnit ? unit : undefined}
|
|
806
|
+
/>
|
|
807
|
+
<Tooltip
|
|
808
|
+
content={<ChartTooltip series={series} unit={unit} />}
|
|
809
|
+
cursor={{stroke: GRID_STROKE}}
|
|
810
|
+
/>
|
|
811
|
+
{lines.map(line => (
|
|
812
|
+
<Line
|
|
813
|
+
{...LINE_PROPS}
|
|
814
|
+
key={line.key}
|
|
815
|
+
dataKey={line.key}
|
|
816
|
+
name={line.name}
|
|
817
|
+
stroke={line.color}
|
|
818
|
+
strokeWidth={1.5}
|
|
819
|
+
/>
|
|
820
|
+
))}
|
|
821
|
+
</LineChart>
|
|
822
|
+
</ResponsiveContainer>
|
|
823
|
+
<HStack gap={6} vAlign="center" wrap="wrap">
|
|
824
|
+
{lines.map(line => (
|
|
825
|
+
<LegendDot key={line.key} color={line.color} label={line.name} />
|
|
826
|
+
))}
|
|
827
|
+
</HStack>
|
|
828
|
+
</VStack>
|
|
829
|
+
</Card>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ============= KPI TILE =============
|
|
834
|
+
|
|
835
|
+
function Sparkline({data, color}: {data: number[]; color: string}) {
|
|
836
|
+
const chartData = data.map((v, i) => ({i, v}));
|
|
837
|
+
return (
|
|
838
|
+
<ResponsiveContainer width="100%" height={40}>
|
|
839
|
+
<LineChart
|
|
840
|
+
data={chartData}
|
|
841
|
+
margin={{top: 4, right: 0, left: 0, bottom: 2}}>
|
|
842
|
+
{/* Fit the scale to the data. Recharts' implicit axis anchors the
|
|
843
|
+
domain at 0, which flattens any metric living far from zero —
|
|
844
|
+
availability (99.2–100%) collapses to a quarter-pixel of travel. */}
|
|
845
|
+
<YAxis hide domain={['dataMin', 'dataMax']} />
|
|
846
|
+
<Line {...LINE_PROPS} dataKey="v" stroke={color} strokeWidth={1.25} />
|
|
847
|
+
</LineChart>
|
|
848
|
+
</ResponsiveContainer>
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function KpiTile({kpi, spark}: {kpi: Kpi; spark: number[]}) {
|
|
853
|
+
return (
|
|
854
|
+
<Card padding={4}>
|
|
855
|
+
<VStack gap={2}>
|
|
856
|
+
<HStack gap={2} vAlign="center">
|
|
857
|
+
<StatusDot
|
|
858
|
+
variant={STATUS_DOT[kpi.status]}
|
|
859
|
+
label={kpi.statusLabel}
|
|
860
|
+
tooltip={kpi.statusLabel}
|
|
861
|
+
isPulsing={kpi.status === 'critical'}
|
|
862
|
+
/>
|
|
863
|
+
<Text type="label" color="secondary">
|
|
864
|
+
{kpi.label}
|
|
865
|
+
</Text>
|
|
866
|
+
</HStack>
|
|
867
|
+
<HStack gap={2} vAlign="center">
|
|
868
|
+
<Heading level={2}>{kpi.value}</Heading>
|
|
869
|
+
<HStack gap={1} vAlign="center">
|
|
870
|
+
<Icon
|
|
871
|
+
icon={kpi.deltaPositive ? ArrowUpIcon : ArrowDownIcon}
|
|
872
|
+
size="xsm"
|
|
873
|
+
color={kpi.deltaPositive ? 'success' : 'error'}
|
|
874
|
+
/>
|
|
875
|
+
<Text type="supporting" color="secondary">
|
|
876
|
+
{kpi.delta}
|
|
877
|
+
</Text>
|
|
878
|
+
</HStack>
|
|
879
|
+
</HStack>
|
|
880
|
+
<Sparkline data={spark} color={STATUS_STROKE[kpi.status]} />
|
|
881
|
+
</VStack>
|
|
882
|
+
</Card>
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ============= RAIL SECTIONS =============
|
|
887
|
+
|
|
888
|
+
const railStyles = stylex.create({
|
|
889
|
+
// Translucent, so the band reads the same on a card and on the panel.
|
|
890
|
+
header: {
|
|
891
|
+
backgroundColor: 'var(--color-background-muted)',
|
|
892
|
+
},
|
|
893
|
+
// The opaque layer the translucent band needs; without it the rows read
|
|
894
|
+
// through the header as they scroll past.
|
|
895
|
+
stickyHeader: {
|
|
896
|
+
position: 'sticky',
|
|
897
|
+
insetBlockStart: 0,
|
|
898
|
+
zIndex: 1,
|
|
899
|
+
backgroundColor: 'var(--color-background-surface)',
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* One labelled region of the triage rail. `hasStickyHeader` is the rail form:
|
|
905
|
+
* the panel is a single scroll region shared with the sibling section, so the
|
|
906
|
+
* header pins to its top. Folded inline, each section gets its own card.
|
|
907
|
+
*/
|
|
908
|
+
function RailSection({
|
|
909
|
+
icon,
|
|
910
|
+
iconColor,
|
|
911
|
+
title,
|
|
912
|
+
subtitle,
|
|
913
|
+
badge,
|
|
914
|
+
hasStickyHeader = false,
|
|
915
|
+
children,
|
|
916
|
+
}: {
|
|
917
|
+
icon: IconType;
|
|
918
|
+
iconColor: 'error' | 'secondary';
|
|
919
|
+
title: string;
|
|
920
|
+
subtitle: string;
|
|
921
|
+
badge: ReactNode;
|
|
922
|
+
hasStickyHeader?: boolean;
|
|
923
|
+
children: ReactNode;
|
|
924
|
+
}) {
|
|
925
|
+
return (
|
|
926
|
+
<VStack gap={0}>
|
|
927
|
+
<VStack
|
|
928
|
+
gap={0}
|
|
929
|
+
xstyle={hasStickyHeader ? railStyles.stickyHeader : undefined}>
|
|
930
|
+
<VStack gap={1} padding={4} xstyle={railStyles.header}>
|
|
931
|
+
<HStack gap={2} vAlign="center">
|
|
932
|
+
<Icon icon={icon} size="sm" color={iconColor} />
|
|
933
|
+
<StackItem size="fill">
|
|
934
|
+
<Heading level={3}>{title}</Heading>
|
|
935
|
+
</StackItem>
|
|
936
|
+
{badge}
|
|
937
|
+
</HStack>
|
|
938
|
+
<Text type="supporting" color="secondary">
|
|
939
|
+
{subtitle}
|
|
940
|
+
</Text>
|
|
941
|
+
</VStack>
|
|
942
|
+
<Divider />
|
|
943
|
+
</VStack>
|
|
944
|
+
{children}
|
|
945
|
+
</VStack>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ============= ALERTS FEED =============
|
|
950
|
+
|
|
951
|
+
function AlertsFeed({hasStickyHeader = false}: {hasStickyHeader?: boolean}) {
|
|
952
|
+
const groups = ALERT_ORDER.map(state => ({
|
|
953
|
+
state,
|
|
954
|
+
items: ALERTS.filter(a => a.state === state),
|
|
955
|
+
})).filter(g => g.items.length > 0);
|
|
956
|
+
|
|
957
|
+
const firing = ALERTS.filter(a => a.state === 'firing').length;
|
|
958
|
+
|
|
959
|
+
return (
|
|
960
|
+
<RailSection
|
|
961
|
+
icon={BellAlertIcon}
|
|
962
|
+
iconColor={firing > 0 ? 'error' : 'secondary'}
|
|
963
|
+
title="Active alerts"
|
|
964
|
+
subtitle={`${ALERTS.length} in the last 24h · newest first`}
|
|
965
|
+
badge={
|
|
966
|
+
<Badge
|
|
967
|
+
label={`${firing} firing`}
|
|
968
|
+
variant={firing > 0 ? 'error' : 'neutral'}
|
|
969
|
+
/>
|
|
970
|
+
}
|
|
971
|
+
hasStickyHeader={hasStickyHeader}>
|
|
972
|
+
{groups.length === 0 ? (
|
|
973
|
+
<EmptyState
|
|
974
|
+
title="All clear"
|
|
975
|
+
description="No active alerts across the selected environment."
|
|
976
|
+
icon={<Icon icon={CheckCircleIcon} size="lg" color="success" />}
|
|
977
|
+
isCompact
|
|
978
|
+
/>
|
|
979
|
+
) : (
|
|
980
|
+
groups.map(group => (
|
|
981
|
+
<List key={group.state} density="compact" hasDividers>
|
|
982
|
+
{group.items.map(alert => (
|
|
983
|
+
<ListItem
|
|
984
|
+
key={alert.id}
|
|
985
|
+
label={alert.title}
|
|
986
|
+
description={alert.service}
|
|
987
|
+
startContent={
|
|
988
|
+
<StatusDot
|
|
989
|
+
variant={STATUS_DOT[alert.severity]}
|
|
990
|
+
label={alert.severity}
|
|
991
|
+
isPulsing={
|
|
992
|
+
alert.state === 'firing' && alert.severity === 'critical'
|
|
993
|
+
}
|
|
994
|
+
/>
|
|
995
|
+
}
|
|
996
|
+
endContent={
|
|
997
|
+
<VStack gap={1} hAlign="end">
|
|
998
|
+
<Token
|
|
999
|
+
size="sm"
|
|
1000
|
+
color={ALERT_STATE_TOKEN[alert.state].color}
|
|
1001
|
+
label={ALERT_STATE_TOKEN[alert.state].label}
|
|
1002
|
+
/>
|
|
1003
|
+
<Timestamp
|
|
1004
|
+
value={alert.at}
|
|
1005
|
+
format="relative"
|
|
1006
|
+
color="secondary"
|
|
1007
|
+
/>
|
|
1008
|
+
</VStack>
|
|
1009
|
+
}
|
|
1010
|
+
/>
|
|
1011
|
+
))}
|
|
1012
|
+
</List>
|
|
1013
|
+
))
|
|
1014
|
+
)}
|
|
1015
|
+
</RailSection>
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ============= SERVICE BREAKDOWN =============
|
|
1020
|
+
|
|
1021
|
+
function errorColor(rate: number): 'red' | 'yellow' | 'green' {
|
|
1022
|
+
if (rate >= 5) {
|
|
1023
|
+
return 'red';
|
|
1024
|
+
}
|
|
1025
|
+
return rate >= 1.5 ? 'yellow' : 'green';
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Abbreviated — the traffic chart carries the exact figures.
|
|
1029
|
+
function formatRpm(rpm: number): string {
|
|
1030
|
+
return rpm >= 1000 ? `${(rpm / 1000).toFixed(1)}k rpm` : `${rpm} rpm`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function NoServices() {
|
|
1034
|
+
return (
|
|
1035
|
+
<EmptyState
|
|
1036
|
+
title="No services in this view"
|
|
1037
|
+
description="Nothing reports in the selected environment and region. Try widening the region filter."
|
|
1038
|
+
icon={<Icon icon={ServerStackIcon} size="lg" />}
|
|
1039
|
+
isCompact
|
|
1040
|
+
/>
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Per-service drill-down as rail rows rather than a wide table: a rail has room
|
|
1046
|
+
* for about two of the seven columns this data would need. Rows sort
|
|
1047
|
+
* worst-first, since a scrolling rail can't be scanned like a grid.
|
|
1048
|
+
*/
|
|
1049
|
+
function ServiceBreakdown({
|
|
1050
|
+
rows,
|
|
1051
|
+
contextLabel,
|
|
1052
|
+
hasStickyHeader = false,
|
|
1053
|
+
}: {
|
|
1054
|
+
rows: HostRow[];
|
|
1055
|
+
contextLabel: string;
|
|
1056
|
+
hasStickyHeader?: boolean;
|
|
1057
|
+
}) {
|
|
1058
|
+
const sorted = useMemo(
|
|
1059
|
+
() =>
|
|
1060
|
+
[...rows].sort(
|
|
1061
|
+
(a, b) =>
|
|
1062
|
+
SEVERITY_RANK[a.status] - SEVERITY_RANK[b.status] ||
|
|
1063
|
+
b.errorRate - a.errorRate,
|
|
1064
|
+
),
|
|
1065
|
+
[rows],
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
return (
|
|
1069
|
+
<RailSection
|
|
1070
|
+
icon={ServerStackIcon}
|
|
1071
|
+
iconColor="secondary"
|
|
1072
|
+
title="Service breakdown"
|
|
1073
|
+
subtitle={contextLabel}
|
|
1074
|
+
badge={<Badge label={`${rows.length}`} variant="neutral" />}
|
|
1075
|
+
hasStickyHeader={hasStickyHeader}>
|
|
1076
|
+
{sorted.length === 0 ? (
|
|
1077
|
+
<NoServices />
|
|
1078
|
+
) : (
|
|
1079
|
+
<List density="compact" hasDividers>
|
|
1080
|
+
{sorted.map(row => (
|
|
1081
|
+
<ListItem
|
|
1082
|
+
key={row.id}
|
|
1083
|
+
label={row.service}
|
|
1084
|
+
description={`${row.region} · ${row.instances} hosts · ${formatRpm(row.rpm)}`}
|
|
1085
|
+
startContent={
|
|
1086
|
+
<StatusDot
|
|
1087
|
+
variant={STATUS_DOT[row.status]}
|
|
1088
|
+
label={STATUS_LABEL[row.status]}
|
|
1089
|
+
isPulsing={row.status === 'critical'}
|
|
1090
|
+
/>
|
|
1091
|
+
}
|
|
1092
|
+
endContent={
|
|
1093
|
+
<VStack gap={1} hAlign="end">
|
|
1094
|
+
<HStack gap={1} vAlign="center">
|
|
1095
|
+
<Text type="supporting" color="secondary">
|
|
1096
|
+
p99
|
|
1097
|
+
</Text>
|
|
1098
|
+
<Text type="body" weight="semibold">
|
|
1099
|
+
{row.p99} ms
|
|
1100
|
+
</Text>
|
|
1101
|
+
</HStack>
|
|
1102
|
+
<Token
|
|
1103
|
+
size="sm"
|
|
1104
|
+
color={errorColor(row.errorRate)}
|
|
1105
|
+
label={`${row.errorRate.toFixed(1)}% err`}
|
|
1106
|
+
/>
|
|
1107
|
+
</VStack>
|
|
1108
|
+
}
|
|
1109
|
+
/>
|
|
1110
|
+
))}
|
|
1111
|
+
</List>
|
|
1112
|
+
)}
|
|
1113
|
+
</RailSection>
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ============= UPTIME HISTORY =============
|
|
1118
|
+
|
|
1119
|
+
// Deliberately on its own time base, not the header's 1h/1d/7d control: uptime
|
|
1120
|
+
// is measured in days. Environment and region still filter the roster.
|
|
1121
|
+
const UPTIME_DAYS = 90;
|
|
1122
|
+
|
|
1123
|
+
type UptimeStatus = 'operational' | 'degraded' | 'down';
|
|
1124
|
+
|
|
1125
|
+
// Mirrors `uptimeStyles` below — the legend needs values, the bars need static
|
|
1126
|
+
// StyleX rules. Keep the two in sync by hand.
|
|
1127
|
+
const UPTIME_COLOR: Record<UptimeStatus, string> = {
|
|
1128
|
+
operational: 'var(--color-success, #0D8626)',
|
|
1129
|
+
degraded: 'var(--color-warning, #E9AF08)',
|
|
1130
|
+
down: 'var(--color-error, #E3193B)',
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
const UPTIME_LABEL: Record<UptimeStatus, string> = {
|
|
1134
|
+
operational: 'Operational',
|
|
1135
|
+
degraded: 'Partial degradation',
|
|
1136
|
+
down: 'Downtime',
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
const UPTIME_ORDER: UptimeStatus[] = ['operational', 'degraded', 'down'];
|
|
1140
|
+
|
|
1141
|
+
// Percentage of the day served — a down day is a partial outage, not 24h lost.
|
|
1142
|
+
const DAY_UPTIME: Record<UptimeStatus, number> = {
|
|
1143
|
+
operational: 100,
|
|
1144
|
+
degraded: 98.4,
|
|
1145
|
+
down: 82,
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
// How much rougher each environment's history runs than production's.
|
|
1149
|
+
const UPTIME_ENV_RISK: Record<Environment, number> = {
|
|
1150
|
+
production: 1,
|
|
1151
|
+
staging: 1.6,
|
|
1152
|
+
development: 3,
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
// An unhealthy service didn't get there in a day; its record reads worse.
|
|
1156
|
+
const UPTIME_STATUS_RISK: Record<HealthStatus, number> = {
|
|
1157
|
+
healthy: 1,
|
|
1158
|
+
warning: 3.5,
|
|
1159
|
+
critical: 7,
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// The strip's last bar has to agree with what the rest of the page reports.
|
|
1163
|
+
const UPTIME_TODAY: Record<HealthStatus, UptimeStatus> = {
|
|
1164
|
+
healthy: 'operational',
|
|
1165
|
+
warning: 'degraded',
|
|
1166
|
+
critical: 'down',
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
interface ServiceUptime {
|
|
1170
|
+
service: string;
|
|
1171
|
+
status: HealthStatus;
|
|
1172
|
+
days: UptimeStatus[];
|
|
1173
|
+
uptime: number;
|
|
1174
|
+
degradedDays: number;
|
|
1175
|
+
downDays: number;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Stable per service, so a history doesn't reshuffle as the roster is filtered.
|
|
1179
|
+
function seedForService(service: string): number {
|
|
1180
|
+
let h = 7;
|
|
1181
|
+
for (let i = 0; i < service.length; i++) {
|
|
1182
|
+
h = (h * 31 + service.charCodeAt(i)) % 9973;
|
|
1183
|
+
}
|
|
1184
|
+
return h;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function buildUptime(
|
|
1188
|
+
service: string,
|
|
1189
|
+
environment: Environment,
|
|
1190
|
+
status: HealthStatus,
|
|
1191
|
+
): ServiceUptime {
|
|
1192
|
+
const seed = seedForService(service);
|
|
1193
|
+
const risk = UPTIME_ENV_RISK[environment] * UPTIME_STATUS_RISK[status];
|
|
1194
|
+
|
|
1195
|
+
// Fixed quotas rather than a per-day coin flip: over 90 draws the variance of
|
|
1196
|
+
// independent rolls swamps the risk signal. The ±40% wobble keeps same-status
|
|
1197
|
+
// peers from rendering identically without reordering the bands.
|
|
1198
|
+
const downWobble = 0.6 + 0.8 * hashNoise(seed, 3.7);
|
|
1199
|
+
const degradedWobble = 0.6 + 0.8 * hashNoise(seed, 5.9);
|
|
1200
|
+
const downCount = Math.round(
|
|
1201
|
+
UPTIME_DAYS * Math.min(0.09, 0.005 * risk) * downWobble,
|
|
1202
|
+
);
|
|
1203
|
+
const degradedCount = Math.round(
|
|
1204
|
+
UPTIME_DAYS * Math.min(0.32, 0.02 * risk) * degradedWobble,
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
const ranked = [...Array(UPTIME_DAYS).keys()].sort(
|
|
1208
|
+
(a, b) => hashNoise(b, seed) - hashNoise(a, seed),
|
|
1209
|
+
);
|
|
1210
|
+
const days: UptimeStatus[] = new Array(UPTIME_DAYS).fill('operational');
|
|
1211
|
+
for (let k = 0; k < downCount; k++) {
|
|
1212
|
+
days[ranked[k]] = 'down';
|
|
1213
|
+
}
|
|
1214
|
+
for (let k = downCount; k < downCount + degradedCount; k++) {
|
|
1215
|
+
days[ranked[k]] = 'degraded';
|
|
1216
|
+
}
|
|
1217
|
+
days[UPTIME_DAYS - 1] = UPTIME_TODAY[status];
|
|
1218
|
+
|
|
1219
|
+
// Per-service, not per-day: a per-day wobble averages out to the same figure
|
|
1220
|
+
// for every service, leaving equal-quota peers showing an identical number.
|
|
1221
|
+
const cleanDay = DAY_UPTIME.operational - 0.02 - hashNoise(seed, 11.3) * 0.06;
|
|
1222
|
+
|
|
1223
|
+
let served = 0;
|
|
1224
|
+
let degradedDays = 0;
|
|
1225
|
+
let downDays = 0;
|
|
1226
|
+
for (let i = 0; i < UPTIME_DAYS; i++) {
|
|
1227
|
+
const day = days[i];
|
|
1228
|
+
if (day === 'operational') {
|
|
1229
|
+
served += cleanDay - hashNoise(i, seed + 1) * 0.02;
|
|
1230
|
+
} else {
|
|
1231
|
+
served += DAY_UPTIME[day];
|
|
1232
|
+
if (day === 'degraded') {
|
|
1233
|
+
degradedDays++;
|
|
1234
|
+
} else {
|
|
1235
|
+
downDays++;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
return {
|
|
1241
|
+
service,
|
|
1242
|
+
status,
|
|
1243
|
+
days,
|
|
1244
|
+
uptime: served / UPTIME_DAYS,
|
|
1245
|
+
degradedDays,
|
|
1246
|
+
downDays,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Index 0 is the oldest day, UPTIME_DAYS - 1 is today.
|
|
1251
|
+
function dayLabel(i: number): string {
|
|
1252
|
+
const ago = UPTIME_DAYS - 1 - i;
|
|
1253
|
+
if (ago === 0) {
|
|
1254
|
+
return 'Today';
|
|
1255
|
+
}
|
|
1256
|
+
return ago === 1 ? 'Yesterday' : `${ago} days ago`;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const uptimeStyles = stylex.create({
|
|
1260
|
+
// Below ~420px the row can't honor the 3px bar floor, so it scrolls rather
|
|
1261
|
+
// than thinning bars — that would hide the outage days this strip shows.
|
|
1262
|
+
strip: {
|
|
1263
|
+
display: 'flex',
|
|
1264
|
+
gap: '2px',
|
|
1265
|
+
overflowX: 'auto',
|
|
1266
|
+
overscrollBehaviorX: 'contain',
|
|
1267
|
+
scrollbarWidth: 'thin',
|
|
1268
|
+
},
|
|
1269
|
+
// No radius: at 3–8px wide the smallest token (4px) rounds a bar away.
|
|
1270
|
+
bar: {
|
|
1271
|
+
flexBasis: 0,
|
|
1272
|
+
flexGrow: 1,
|
|
1273
|
+
minWidth: '3px',
|
|
1274
|
+
height: '30px',
|
|
1275
|
+
},
|
|
1276
|
+
// Mirrors UPTIME_COLOR above.
|
|
1277
|
+
operational: {backgroundColor: 'var(--color-success, #0D8626)'},
|
|
1278
|
+
degraded: {backgroundColor: 'var(--color-warning, #E9AF08)'},
|
|
1279
|
+
down: {backgroundColor: 'var(--color-error, #E3193B)'},
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* One service's 90-day strip — a single image to assistive tech, not 90
|
|
1284
|
+
* unlabelled boxes. Per-day detail rides on `title` rather than 90 Tooltips.
|
|
1285
|
+
*/
|
|
1286
|
+
function UptimeStrip({entry}: {entry: ServiceUptime}) {
|
|
1287
|
+
const summary =
|
|
1288
|
+
entry.downDays === 0 && entry.degradedDays === 0
|
|
1289
|
+
? `${UPTIME_DAYS}-day uptime for ${entry.service}: no incidents.`
|
|
1290
|
+
: `${UPTIME_DAYS}-day uptime for ${entry.service}: ${entry.degradedDays} degraded ${
|
|
1291
|
+
entry.degradedDays === 1 ? 'day' : 'days'
|
|
1292
|
+
}, ${entry.downDays} with downtime.`;
|
|
1293
|
+
|
|
1294
|
+
return (
|
|
1295
|
+
<div {...stylex.props(uptimeStyles.strip)} role="img" aria-label={summary}>
|
|
1296
|
+
{entry.days.map((day, i) => (
|
|
1297
|
+
<div
|
|
1298
|
+
key={i}
|
|
1299
|
+
{...stylex.props(uptimeStyles.bar, uptimeStyles[day])}
|
|
1300
|
+
title={`${dayLabel(i)} — ${UPTIME_LABEL[day]}`}
|
|
1301
|
+
/>
|
|
1302
|
+
))}
|
|
1303
|
+
</div>
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function UptimeRow({entry}: {entry: ServiceUptime}) {
|
|
1308
|
+
return (
|
|
1309
|
+
<VStack gap={2}>
|
|
1310
|
+
<HStack gap={3} vAlign="center" hAlign="between">
|
|
1311
|
+
<HStack gap={2} vAlign="center">
|
|
1312
|
+
<StatusDot
|
|
1313
|
+
variant={STATUS_DOT[entry.status]}
|
|
1314
|
+
label={STATUS_LABEL[entry.status]}
|
|
1315
|
+
isPulsing={entry.status === 'critical'}
|
|
1316
|
+
/>
|
|
1317
|
+
<Heading level={4}>{entry.service}</Heading>
|
|
1318
|
+
</HStack>
|
|
1319
|
+
<Text type="supporting" color="secondary">
|
|
1320
|
+
{entry.uptime.toFixed(2)}% uptime
|
|
1321
|
+
</Text>
|
|
1322
|
+
</HStack>
|
|
1323
|
+
<UptimeStrip entry={entry} />
|
|
1324
|
+
</VStack>
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* The "All services" roster — one strip per *service*, not per deployment. The
|
|
1330
|
+
* rail already lists service × region rows; each strip here takes the worst
|
|
1331
|
+
* status across a service's regions.
|
|
1332
|
+
*/
|
|
1333
|
+
function AllServicesUptime({
|
|
1334
|
+
rows,
|
|
1335
|
+
environment,
|
|
1336
|
+
contextLabel,
|
|
1337
|
+
}: {
|
|
1338
|
+
rows: HostRow[];
|
|
1339
|
+
environment: Environment;
|
|
1340
|
+
contextLabel: string;
|
|
1341
|
+
}) {
|
|
1342
|
+
const entries = useMemo(() => {
|
|
1343
|
+
const worst = new Map<string, HealthStatus>();
|
|
1344
|
+
for (const row of rows) {
|
|
1345
|
+
const prev = worst.get(row.service);
|
|
1346
|
+
if (
|
|
1347
|
+
prev === undefined ||
|
|
1348
|
+
SEVERITY_RANK[row.status] < SEVERITY_RANK[prev]
|
|
1349
|
+
) {
|
|
1350
|
+
worst.set(row.service, row.status);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// Alphabetical, not worst-first — the rail already triages by severity.
|
|
1354
|
+
return [...worst.entries()]
|
|
1355
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
1356
|
+
.map(([service, status]) => buildUptime(service, environment, status));
|
|
1357
|
+
}, [rows, environment]);
|
|
1358
|
+
|
|
1359
|
+
return (
|
|
1360
|
+
<VStack gap={6}>
|
|
1361
|
+
<HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
|
|
1362
|
+
<VStack>
|
|
1363
|
+
<Heading level={2}>All services</Heading>
|
|
1364
|
+
<Text type="supporting" color="secondary">
|
|
1365
|
+
Daily uptime over the last {UPTIME_DAYS} days · {contextLabel}
|
|
1366
|
+
</Text>
|
|
1367
|
+
</VStack>
|
|
1368
|
+
<HStack gap={4} vAlign="center" wrap="wrap">
|
|
1369
|
+
{UPTIME_ORDER.map(state => (
|
|
1370
|
+
<LegendDot
|
|
1371
|
+
key={state}
|
|
1372
|
+
color={UPTIME_COLOR[state]}
|
|
1373
|
+
label={UPTIME_LABEL[state]}
|
|
1374
|
+
/>
|
|
1375
|
+
))}
|
|
1376
|
+
</HStack>
|
|
1377
|
+
</HStack>
|
|
1378
|
+
<Card padding={6}>
|
|
1379
|
+
{entries.length === 0 ? (
|
|
1380
|
+
<NoServices />
|
|
1381
|
+
) : (
|
|
1382
|
+
<VStack gap={6}>
|
|
1383
|
+
{entries.map((entry, index) => (
|
|
1384
|
+
<Fragment key={entry.service}>
|
|
1385
|
+
{index > 0 && <Divider />}
|
|
1386
|
+
<UptimeRow entry={entry} />
|
|
1387
|
+
</Fragment>
|
|
1388
|
+
))}
|
|
1389
|
+
</VStack>
|
|
1390
|
+
)}
|
|
1391
|
+
</Card>
|
|
1392
|
+
</VStack>
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// ============= CONTROL OPTIONS =============
|
|
1397
|
+
|
|
1398
|
+
const ENV_OPTIONS = [
|
|
1399
|
+
{value: 'production', label: 'Production'},
|
|
1400
|
+
{value: 'staging', label: 'Staging'},
|
|
1401
|
+
{value: 'development', label: 'Development'},
|
|
1402
|
+
];
|
|
1403
|
+
|
|
1404
|
+
const REGION_OPTIONS = [
|
|
1405
|
+
{value: 'all', label: 'All regions'},
|
|
1406
|
+
{value: 'us-east-1', label: 'us-east-1'},
|
|
1407
|
+
{value: 'us-west-2', label: 'us-west-2'},
|
|
1408
|
+
{value: 'eu-west-1', label: 'eu-west-1'},
|
|
1409
|
+
{value: 'ap-southeast-1', label: 'ap-southeast-1'},
|
|
1410
|
+
];
|
|
1411
|
+
|
|
1412
|
+
// Layout height="fill" is height:100%, which needs a definite height the host's
|
|
1413
|
+
// <html>/<body> don't set. Without this the rail grows instead of scrolling.
|
|
1414
|
+
const pageStyle: CSSProperties = {height: '100dvh'};
|
|
1415
|
+
|
|
1416
|
+
// Every window stays densely sampled — a thinned sparkline smooths its spikes.
|
|
1417
|
+
const SPARK_LEN: Record<TimeWindow, number> = {'1h': 60, '1d': 90, '7d': 120};
|
|
1418
|
+
|
|
1419
|
+
// ============= MAIN =============
|
|
1420
|
+
|
|
1421
|
+
export default function ServiceHealthMonitorPage() {
|
|
1422
|
+
const [timeWindow, setTimeWindow] = useState<TimeWindow>('1d');
|
|
1423
|
+
const [environment, setEnvironment] = useState<Environment>('production');
|
|
1424
|
+
const [region, setRegion] = useState('all');
|
|
1425
|
+
|
|
1426
|
+
const isNarrow = useMediaQuery('(max-width: 1024px)');
|
|
1427
|
+
|
|
1428
|
+
const series = SERIES[timeWindow];
|
|
1429
|
+
const kpis = KPI_BY_ENV[environment];
|
|
1430
|
+
const sparkLen = SPARK_LEN[timeWindow];
|
|
1431
|
+
|
|
1432
|
+
const rows = useMemo(
|
|
1433
|
+
() =>
|
|
1434
|
+
HOST_ROWS.filter(
|
|
1435
|
+
r =>
|
|
1436
|
+
r.environment === environment &&
|
|
1437
|
+
(region === 'all' || r.region === region),
|
|
1438
|
+
),
|
|
1439
|
+
[environment, region],
|
|
1440
|
+
);
|
|
1441
|
+
|
|
1442
|
+
// Restates the active filters, since the rail rows carry no column headers.
|
|
1443
|
+
const breakdownContext = `${ENV_OPTIONS.find(e => e.value === environment)?.label} · ${REGION_OPTIONS.find(r => r.value === region)?.label}`;
|
|
1444
|
+
|
|
1445
|
+
return (
|
|
1446
|
+
<Layout
|
|
1447
|
+
height="fill"
|
|
1448
|
+
style={pageStyle}
|
|
1449
|
+
header={
|
|
1450
|
+
<LayoutHeader padding={6} hasDivider>
|
|
1451
|
+
<HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
|
|
1452
|
+
<VStack gap={0} vAlign="center">
|
|
1453
|
+
<Heading level={1}>Service Health Monitor</Heading>
|
|
1454
|
+
<HStack gap={2} vAlign="center">
|
|
1455
|
+
<StatusDot
|
|
1456
|
+
variant="success"
|
|
1457
|
+
label="Live"
|
|
1458
|
+
isPulsing
|
|
1459
|
+
tooltip="Auto-refreshing every 30s"
|
|
1460
|
+
/>
|
|
1461
|
+
<Text type="supporting" color="secondary">
|
|
1462
|
+
Updated{' '}
|
|
1463
|
+
<Timestamp
|
|
1464
|
+
value="2026-06-30T21:15:00Z"
|
|
1465
|
+
format="relative"
|
|
1466
|
+
type="inherit"
|
|
1467
|
+
color="inherit"
|
|
1468
|
+
/>
|
|
1469
|
+
</Text>
|
|
1470
|
+
</HStack>
|
|
1471
|
+
</VStack>
|
|
1472
|
+
<HStack gap={2} vAlign="center">
|
|
1473
|
+
<SegmentedControl
|
|
1474
|
+
label="Time window"
|
|
1475
|
+
value={timeWindow}
|
|
1476
|
+
onChange={value => setTimeWindow(value as TimeWindow)}
|
|
1477
|
+
size="lg">
|
|
1478
|
+
<SegmentedControlItem label="1h" value="1h" />
|
|
1479
|
+
<SegmentedControlItem label="1d" value="1d" />
|
|
1480
|
+
<SegmentedControlItem label="7d" value="7d" />
|
|
1481
|
+
</SegmentedControl>
|
|
1482
|
+
<Selector
|
|
1483
|
+
label="Environment"
|
|
1484
|
+
isLabelHidden
|
|
1485
|
+
size="lg"
|
|
1486
|
+
options={ENV_OPTIONS}
|
|
1487
|
+
value={environment}
|
|
1488
|
+
onChange={value => setEnvironment(value as Environment)}
|
|
1489
|
+
/>
|
|
1490
|
+
<Selector
|
|
1491
|
+
label="Region"
|
|
1492
|
+
isLabelHidden
|
|
1493
|
+
size="lg"
|
|
1494
|
+
options={REGION_OPTIONS}
|
|
1495
|
+
value={region}
|
|
1496
|
+
onChange={setRegion}
|
|
1497
|
+
/>
|
|
1498
|
+
</HStack>
|
|
1499
|
+
</HStack>
|
|
1500
|
+
</LayoutHeader>
|
|
1501
|
+
}
|
|
1502
|
+
content={
|
|
1503
|
+
<LayoutContent padding={6}>
|
|
1504
|
+
<VStack gap={10}>
|
|
1505
|
+
<VStack gap={6}>
|
|
1506
|
+
{/* Traffic-light KPI tiles */}
|
|
1507
|
+
<Grid columns={{minWidth: 240, repeat: 'fit'}} gap={3}>
|
|
1508
|
+
{kpis.map(kpi => (
|
|
1509
|
+
<KpiTile
|
|
1510
|
+
key={kpi.key}
|
|
1511
|
+
kpi={kpi}
|
|
1512
|
+
spark={SPARKLINES[kpi.key].slice(-sparkLen)}
|
|
1513
|
+
/>
|
|
1514
|
+
))}
|
|
1515
|
+
</Grid>
|
|
1516
|
+
|
|
1517
|
+
{/* Time-series charts */}
|
|
1518
|
+
<Grid columns={{minWidth: 320, repeat: 'fit'}} gap={3}>
|
|
1519
|
+
<MetricChart
|
|
1520
|
+
title="Response latency"
|
|
1521
|
+
subtitle="Percentile latency across all services (ms)"
|
|
1522
|
+
series={series}
|
|
1523
|
+
lines={LATENCY_LINES}
|
|
1524
|
+
scale={ENV_LATENCY_FACTOR[environment]}
|
|
1525
|
+
unit=" ms"
|
|
1526
|
+
hasAxisUnit
|
|
1527
|
+
/>
|
|
1528
|
+
<MetricChart
|
|
1529
|
+
title="Request volume by region"
|
|
1530
|
+
subtitle="Requests per minute (rpm)"
|
|
1531
|
+
series={series}
|
|
1532
|
+
lines={TRAFFIC_LINES}
|
|
1533
|
+
scale={ENV_FACTOR[environment]}
|
|
1534
|
+
unit=" rpm"
|
|
1535
|
+
/>
|
|
1536
|
+
</Grid>
|
|
1537
|
+
</VStack>
|
|
1538
|
+
|
|
1539
|
+
<Divider />
|
|
1540
|
+
|
|
1541
|
+
{/* 90-day uptime roster */}
|
|
1542
|
+
<AllServicesUptime
|
|
1543
|
+
rows={rows}
|
|
1544
|
+
environment={environment}
|
|
1545
|
+
contextLabel={breakdownContext}
|
|
1546
|
+
/>
|
|
1547
|
+
|
|
1548
|
+
{/* Below 1024px the rail folds in here, one card per section */}
|
|
1549
|
+
{isNarrow && (
|
|
1550
|
+
<VStack gap={4}>
|
|
1551
|
+
<Card padding={0}>
|
|
1552
|
+
<AlertsFeed />
|
|
1553
|
+
</Card>
|
|
1554
|
+
<Card padding={0}>
|
|
1555
|
+
<ServiceBreakdown
|
|
1556
|
+
rows={rows}
|
|
1557
|
+
contextLabel={breakdownContext}
|
|
1558
|
+
/>
|
|
1559
|
+
</Card>
|
|
1560
|
+
</VStack>
|
|
1561
|
+
)}
|
|
1562
|
+
</VStack>
|
|
1563
|
+
</LayoutContent>
|
|
1564
|
+
}
|
|
1565
|
+
end={
|
|
1566
|
+
isNarrow ? undefined : (
|
|
1567
|
+
<LayoutPanel
|
|
1568
|
+
width={400}
|
|
1569
|
+
padding={0}
|
|
1570
|
+
hasDivider
|
|
1571
|
+
role="complementary"
|
|
1572
|
+
label="Alerts and service breakdown"
|
|
1573
|
+
// One scroll region for the whole rail; the sticky section headers
|
|
1574
|
+
// keep the boundary between the two feeds legible.
|
|
1575
|
+
isScrollable>
|
|
1576
|
+
<VStack gap={0}>
|
|
1577
|
+
<AlertsFeed hasStickyHeader />
|
|
1578
|
+
<Divider />
|
|
1579
|
+
<ServiceBreakdown
|
|
1580
|
+
rows={rows}
|
|
1581
|
+
contextLabel={breakdownContext}
|
|
1582
|
+
hasStickyHeader
|
|
1583
|
+
/>
|
|
1584
|
+
</VStack>
|
|
1585
|
+
</LayoutPanel>
|
|
1586
|
+
)
|
|
1587
|
+
}
|
|
1588
|
+
/>
|
|
1589
|
+
);
|
|
1590
|
+
}
|