@kopai/ui 0.0.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +137 -0
  2. package/dist/index.cjs +5069 -3
  3. package/dist/index.d.cts +301 -3
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +302 -3
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +5010 -3
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +25 -7
  10. package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +113 -0
  11. package/src/components/KeyboardShortcuts/ShortcutsHelpDialog.tsx +82 -0
  12. package/src/components/KeyboardShortcuts/context.ts +23 -0
  13. package/src/components/KeyboardShortcuts/index.ts +8 -0
  14. package/src/components/KeyboardShortcuts/types.ts +11 -0
  15. package/src/components/dashboard/Badge/Badge.stories.tsx +29 -0
  16. package/src/components/dashboard/Badge/index.tsx +32 -0
  17. package/src/components/dashboard/Button/Button.stories.tsx +107 -0
  18. package/src/components/dashboard/Button/index.tsx +63 -0
  19. package/src/components/dashboard/Card/Card.stories.tsx +81 -0
  20. package/src/components/dashboard/Card/index.tsx +58 -0
  21. package/src/components/dashboard/Chart/Chart.stories.tsx +48 -0
  22. package/src/components/dashboard/Chart/index.tsx +74 -0
  23. package/src/components/dashboard/DatePicker/DatePicker.stories.tsx +33 -0
  24. package/src/components/dashboard/DatePicker/index.tsx +41 -0
  25. package/src/components/dashboard/Divider/Divider.stories.tsx +17 -0
  26. package/src/components/dashboard/Divider/index.tsx +49 -0
  27. package/src/components/dashboard/Empty/Empty.stories.tsx +48 -0
  28. package/src/components/dashboard/Empty/index.tsx +46 -0
  29. package/src/components/dashboard/Grid/Grid.stories.tsx +52 -0
  30. package/src/components/dashboard/Grid/index.tsx +26 -0
  31. package/src/components/dashboard/Heading/Heading.stories.tsx +25 -0
  32. package/src/components/dashboard/Heading/index.tsx +27 -0
  33. package/src/components/dashboard/List/List.stories.tsx +37 -0
  34. package/src/components/dashboard/List/index.tsx +24 -0
  35. package/src/components/dashboard/Metric/Metric.stories.tsx +65 -0
  36. package/src/components/dashboard/Metric/index.tsx +36 -0
  37. package/src/components/dashboard/Stack/Stack.stories.tsx +61 -0
  38. package/src/components/dashboard/Stack/index.tsx +33 -0
  39. package/src/components/dashboard/Table/Table.stories.tsx +38 -0
  40. package/src/components/dashboard/Table/index.tsx +104 -0
  41. package/src/components/dashboard/Text/Text.stories.tsx +53 -0
  42. package/src/components/dashboard/Text/index.tsx +18 -0
  43. package/src/components/dashboard/index.ts +46 -0
  44. package/src/components/index.ts +17 -0
  45. package/src/components/observability/LogTimeline/LogDetailPane/AttributesTab.tsx +56 -0
  46. package/src/components/observability/LogTimeline/LogDetailPane/JsonTreeView.tsx +139 -0
  47. package/src/components/observability/LogTimeline/LogDetailPane/index.tsx +271 -0
  48. package/src/components/observability/LogTimeline/LogFilter.stories.tsx +66 -0
  49. package/src/components/observability/LogTimeline/LogFilter.test.tsx +696 -0
  50. package/src/components/observability/LogTimeline/LogFilter.tsx +674 -0
  51. package/src/components/observability/LogTimeline/LogRow.tsx +174 -0
  52. package/src/components/observability/LogTimeline/LogTimeline.stories.tsx +154 -0
  53. package/src/components/observability/LogTimeline/index.tsx +542 -0
  54. package/src/components/observability/LogTimeline/shortcuts.ts +18 -0
  55. package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +20 -0
  56. package/src/components/observability/MetricHistogram/index.tsx +303 -0
  57. package/src/components/observability/MetricStat/MetricStat.stories.tsx +30 -0
  58. package/src/components/observability/MetricStat/index.tsx +281 -0
  59. package/src/components/observability/MetricTable/MetricTable.stories.tsx +20 -0
  60. package/src/components/observability/MetricTable/index.tsx +194 -0
  61. package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +28 -0
  62. package/src/components/observability/MetricTimeSeries/index.tsx +462 -0
  63. package/src/components/observability/RawDataTable/RawDataTable.stories.tsx +27 -0
  64. package/src/components/observability/RawDataTable/index.tsx +131 -0
  65. package/src/components/observability/ServiceList/ServiceList.stories.tsx +20 -0
  66. package/src/components/observability/ServiceList/index.tsx +60 -0
  67. package/src/components/observability/ServiceList/shortcuts.ts +6 -0
  68. package/src/components/observability/TabBar/TabBar.stories.tsx +34 -0
  69. package/src/components/observability/TabBar/index.tsx +46 -0
  70. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +51 -0
  71. package/src/components/observability/TraceDetail/index.tsx +53 -0
  72. package/src/components/observability/TraceSearch/TraceSearch.stories.tsx +49 -0
  73. package/src/components/observability/TraceSearch/index.tsx +292 -0
  74. package/src/components/observability/TraceTimeline/DetailPane/AttributesTab.tsx +152 -0
  75. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +128 -0
  76. package/src/components/observability/TraceTimeline/DetailPane/LinksTab.tsx +210 -0
  77. package/src/components/observability/TraceTimeline/DetailPane/index.tsx +174 -0
  78. package/src/components/observability/TraceTimeline/SpanRow.tsx +173 -0
  79. package/src/components/observability/TraceTimeline/TimelineBar.tsx +41 -0
  80. package/src/components/observability/TraceTimeline/Tooltip.tsx +42 -0
  81. package/src/components/observability/TraceTimeline/TraceHeader.tsx +88 -0
  82. package/src/components/observability/TraceTimeline/TraceTimeline.stories.tsx +25 -0
  83. package/src/components/observability/TraceTimeline/index.tsx +478 -0
  84. package/src/components/observability/TraceTimeline/shortcuts.ts +16 -0
  85. package/src/components/observability/__fixtures__/logs.ts +476 -0
  86. package/src/components/observability/__fixtures__/metrics.ts +216 -0
  87. package/src/components/observability/__fixtures__/raw-table.ts +204 -0
  88. package/src/components/observability/__fixtures__/services.ts +8 -0
  89. package/src/components/observability/__fixtures__/trace-summaries.ts +81 -0
  90. package/src/components/observability/__fixtures__/traces.ts +396 -0
  91. package/src/components/observability/index.ts +66 -0
  92. package/src/components/observability/renderers/OtelMetricDiscovery.tsx +77 -0
  93. package/src/components/observability/renderers/OtelMetricHistogram.tsx +29 -0
  94. package/src/components/observability/renderers/OtelMetricStat.tsx +44 -0
  95. package/src/components/observability/renderers/OtelMetricTable.tsx +29 -0
  96. package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +30 -0
  97. package/src/components/observability/renderers/index.ts +5 -0
  98. package/src/components/observability/shared/LoadingSkeleton.tsx +43 -0
  99. package/src/components/observability/types.ts +113 -0
  100. package/src/components/observability/utils/attributes.ts +17 -0
  101. package/src/components/observability/utils/colors.ts +29 -0
  102. package/src/components/observability/utils/flatten-tree.ts +53 -0
  103. package/src/components/observability/utils/lttb.ts +121 -0
  104. package/src/components/observability/utils/time.ts +46 -0
  105. package/src/hooks/use-kopai-data.test.ts +296 -0
  106. package/src/hooks/use-kopai-data.ts +64 -0
  107. package/src/hooks/use-live-logs.test.ts +193 -0
  108. package/src/hooks/use-live-logs.ts +113 -0
  109. package/src/index.ts +15 -0
  110. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +33 -0
  111. package/src/lib/catalog.ts +165 -0
  112. package/src/lib/component-catalog.test.ts +357 -0
  113. package/src/lib/component-catalog.ts +171 -0
  114. package/src/lib/dashboard-datasource.ts +76 -0
  115. package/src/lib/generate-prompt-instructions.test.ts +27 -0
  116. package/src/lib/generate-prompt-instructions.ts +185 -0
  117. package/src/lib/log-buffer.test.ts +88 -0
  118. package/src/lib/log-buffer.ts +62 -0
  119. package/src/lib/observability-catalog.ts +143 -0
  120. package/src/lib/renderer.test.tsx +693 -0
  121. package/src/lib/renderer.tsx +276 -0
  122. package/src/pages/observability.tsx +825 -0
  123. package/src/providers/kopai-provider.tsx +51 -0
  124. package/src/styles/globals.css +46 -0
  125. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * MetricHistogram - Accepts OtelMetricsRow[] and renders histogram bar charts.
3
+ */
4
+
5
+ import { useMemo } from "react";
6
+ import {
7
+ BarChart,
8
+ Bar,
9
+ XAxis,
10
+ YAxis,
11
+ CartesianGrid,
12
+ Tooltip,
13
+ Legend,
14
+ ResponsiveContainer,
15
+ Cell,
16
+ } from "recharts";
17
+ import type { denormalizedSignals } from "@kopai/core";
18
+
19
+ type OtelMetricsRow = denormalizedSignals.OtelMetricsRow;
20
+
21
+ const COLORS = [
22
+ "#8884d8",
23
+ "#82ca9d",
24
+ "#ffc658",
25
+ "#ff7300",
26
+ "#00C49F",
27
+ "#0088FE",
28
+ ];
29
+
30
+ export interface MetricHistogramProps {
31
+ rows: OtelMetricsRow[];
32
+ isLoading?: boolean;
33
+ error?: Error;
34
+ height?: number;
35
+ yAxisLabel?: string;
36
+ showLegend?: boolean;
37
+ formatBucketLabel?: (
38
+ bound: number,
39
+ index: number,
40
+ bounds: number[]
41
+ ) => string;
42
+ formatValue?: (value: number) => string;
43
+ labelStyle?: "rotated" | "staggered" | "abbreviated";
44
+ }
45
+
46
+ interface BucketData {
47
+ bucket: string;
48
+ lowerBound: number;
49
+ upperBound: number;
50
+ [seriesKey: string]: number | string;
51
+ }
52
+
53
+ const defaultFormatBucketLabel = (
54
+ bound: number,
55
+ index: number,
56
+ bounds: number[]
57
+ ): string => {
58
+ if (index === 0) return `≤${bound}`;
59
+ if (index === bounds.length) return `>${bounds[bounds.length - 1]}`;
60
+ return `${bounds[index - 1]}-${bound}`;
61
+ };
62
+
63
+ const defaultFormatValue = (value: number): string => {
64
+ if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
65
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
66
+ return value.toFixed(0);
67
+ };
68
+
69
+ function buildHistogramData(
70
+ rows: OtelMetricsRow[],
71
+ formatLabel = defaultFormatBucketLabel
72
+ ): { buckets: BucketData[]; seriesKeys: string[] } {
73
+ const buckets: BucketData[] = [];
74
+ const seriesKeysSet = new Set<string>();
75
+
76
+ for (const row of rows) {
77
+ if (row.MetricType !== "Histogram") continue;
78
+ const name = row.MetricName ?? "count";
79
+ const key = row.Attributes ? JSON.stringify(row.Attributes) : "__default__";
80
+ const seriesName = key === "__default__" ? name : key;
81
+ seriesKeysSet.add(seriesName);
82
+
83
+ const bounds = row.ExplicitBounds ?? [];
84
+ const counts = row.BucketCounts ?? [];
85
+
86
+ for (let i = 0; i < counts.length; i++) {
87
+ const count = counts[i] ?? 0;
88
+ const upperBound = i < bounds.length ? bounds[i]! : Infinity;
89
+ const bucketLabel = formatLabel(upperBound, i, bounds);
90
+
91
+ let bucket = buckets.find((b) => b.bucket === bucketLabel);
92
+ if (!bucket) {
93
+ bucket = {
94
+ bucket: bucketLabel,
95
+ lowerBound: i === 0 ? 0 : (bounds[i - 1] ?? 0),
96
+ upperBound: bounds[i] ?? Infinity,
97
+ };
98
+ buckets.push(bucket);
99
+ }
100
+ bucket[seriesName] = ((bucket[seriesName] as number) ?? 0) + count;
101
+ }
102
+ }
103
+
104
+ buckets.sort((a, b) => a.lowerBound - b.lowerBound);
105
+ return { buckets, seriesKeys: Array.from(seriesKeysSet) };
106
+ }
107
+
108
+ export function MetricHistogram({
109
+ rows,
110
+ isLoading = false,
111
+ error,
112
+ height = 400,
113
+ yAxisLabel,
114
+ showLegend = true,
115
+ formatBucketLabel,
116
+ formatValue = defaultFormatValue,
117
+ labelStyle = "staggered",
118
+ }: MetricHistogramProps) {
119
+ const bucketLabelFormatter = formatBucketLabel ?? defaultFormatBucketLabel;
120
+ const unit = useMemo(() => {
121
+ for (const r of rows)
122
+ if (r.MetricType === "Histogram" && r.MetricUnit) return r.MetricUnit;
123
+ return "";
124
+ }, [rows]);
125
+
126
+ const { buckets, seriesKeys } = useMemo(() => {
127
+ if (rows.length === 0) return { buckets: [], seriesKeys: [] };
128
+ return buildHistogramData(rows, bucketLabelFormatter);
129
+ }, [rows, bucketLabelFormatter]);
130
+
131
+ if (isLoading) return <HistogramLoadingSkeleton height={height} />;
132
+
133
+ if (error) {
134
+ return (
135
+ <div
136
+ className="flex items-center justify-center bg-background rounded-lg border border-red-800"
137
+ style={{ height }}
138
+ >
139
+ <div className="text-center p-4">
140
+ <p className="text-red-400 font-medium">Error loading histogram</p>
141
+ <p className="text-gray-500 text-sm mt-1">{error.message}</p>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ if (rows.length === 0 || buckets.length === 0) {
148
+ return (
149
+ <div
150
+ className="flex items-center justify-center bg-background rounded-lg border border-gray-800"
151
+ style={{ height }}
152
+ >
153
+ <p className="text-gray-500">No histogram data available</p>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ return (
159
+ <div
160
+ className="bg-background rounded-lg p-4"
161
+ style={{ height }}
162
+ data-testid="metric-histogram"
163
+ >
164
+ <ResponsiveContainer width="100%" height="100%">
165
+ <BarChart
166
+ data={buckets}
167
+ margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
168
+ >
169
+ <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
170
+ <XAxis
171
+ dataKey="bucket"
172
+ stroke="#9CA3AF"
173
+ tick={
174
+ labelStyle === "staggered"
175
+ ? (props: {
176
+ x?: number;
177
+ y?: number;
178
+ payload?: { value: string; index: number };
179
+ }) => {
180
+ if (!props.payload) return <g />;
181
+ const yOffset = props.payload.index % 2 === 0 ? 0 : 12;
182
+ return (
183
+ <g transform={`translate(${props.x},${props.y})`}>
184
+ <text
185
+ x={0}
186
+ y={yOffset}
187
+ dy={12}
188
+ textAnchor="middle"
189
+ fill="#9CA3AF"
190
+ fontSize={11}
191
+ >
192
+ {props.payload.value}
193
+ </text>
194
+ </g>
195
+ );
196
+ }
197
+ : { fill: "#9CA3AF", fontSize: 11 }
198
+ }
199
+ angle={labelStyle === "rotated" ? -45 : 0}
200
+ textAnchor={labelStyle === "rotated" ? "end" : "middle"}
201
+ height={labelStyle === "staggered" ? 50 : 60}
202
+ interval={0}
203
+ />
204
+ <YAxis
205
+ tickFormatter={formatValue}
206
+ stroke="#9CA3AF"
207
+ tick={{ fill: "#9CA3AF", fontSize: 12 }}
208
+ label={
209
+ yAxisLabel
210
+ ? {
211
+ value: yAxisLabel,
212
+ angle: -90,
213
+ position: "insideLeft",
214
+ fill: "#9CA3AF",
215
+ }
216
+ : undefined
217
+ }
218
+ />
219
+ <Tooltip
220
+ content={<HistogramTooltip formatValue={formatValue} unit={unit} />}
221
+ />
222
+ {showLegend && seriesKeys.length > 1 && <Legend />}
223
+ {seriesKeys.map((key, i) => (
224
+ <Bar
225
+ key={key}
226
+ dataKey={key}
227
+ fill={COLORS[i % COLORS.length]}
228
+ radius={[4, 4, 0, 0]}
229
+ >
230
+ {buckets.map((_, bi) => (
231
+ <Cell key={`cell-${bi}`} fill={COLORS[i % COLORS.length]} />
232
+ ))}
233
+ </Bar>
234
+ ))}
235
+ </BarChart>
236
+ </ResponsiveContainer>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ function HistogramTooltip({
242
+ active,
243
+ payload,
244
+ formatValue,
245
+ unit,
246
+ }: {
247
+ active?: boolean;
248
+ payload?: Array<{
249
+ dataKey: string;
250
+ value: number;
251
+ color: string;
252
+ payload: BucketData;
253
+ }>;
254
+ formatValue: (val: number) => string;
255
+ unit?: string;
256
+ }) {
257
+ if (!active || !payload?.length) return null;
258
+ const bucket = payload[0]?.payload;
259
+ if (!bucket) return null;
260
+ return (
261
+ <div className="bg-background border border-gray-700 rounded-lg p-3 shadow-lg">
262
+ <p className="text-gray-300 text-sm font-medium mb-2">
263
+ Bucket: {bucket.bucket}
264
+ {unit ? ` ${unit}` : ""}
265
+ </p>
266
+ {payload.map((entry, i) => (
267
+ <p key={i} className="text-sm" style={{ color: entry.color }}>
268
+ <span className="font-medium">{entry.dataKey}:</span>{" "}
269
+ {formatValue(entry.value)} requests
270
+ </p>
271
+ ))}
272
+ </div>
273
+ );
274
+ }
275
+
276
+ function HistogramLoadingSkeleton({ height = 400 }: { height?: number }) {
277
+ return (
278
+ <div
279
+ className="bg-background rounded-lg p-4 animate-pulse"
280
+ style={{ height }}
281
+ data-testid="metric-histogram-loading"
282
+ >
283
+ <div className="h-full flex flex-col">
284
+ <div className="flex flex-1 gap-2">
285
+ <div className="flex flex-col justify-between w-12">
286
+ {[1, 2, 3, 4].map((i) => (
287
+ <div key={i} className="h-3 w-8 bg-gray-700 rounded" />
288
+ ))}
289
+ </div>
290
+ <div className="flex-1 flex items-end justify-around gap-2 pb-8">
291
+ {[30, 50, 80, 65, 45, 25, 15, 8].map((h, i) => (
292
+ <div
293
+ key={i}
294
+ className="w-8 bg-gray-700 rounded-t"
295
+ style={{ height: `${h}%` }}
296
+ />
297
+ ))}
298
+ </div>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ );
303
+ }
@@ -0,0 +1,30 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MetricStat } from "./index.js";
3
+ import { mockStatRows } from "../__fixtures__/metrics.js";
4
+
5
+ const meta: Meta<typeof MetricStat> = {
6
+ title: "Observability/MetricStat",
7
+ component: MetricStat,
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof MetricStat>;
11
+
12
+ export const Default: Story = { args: { rows: mockStatRows } };
13
+ export const WithSparkline: Story = {
14
+ args: { rows: mockStatRows, showSparkline: true },
15
+ };
16
+ export const WithThresholds: Story = {
17
+ args: {
18
+ rows: mockStatRows,
19
+ thresholds: [
20
+ { value: 0.5, color: "green" },
21
+ { value: 0.8, color: "yellow" },
22
+ { value: 1, color: "red" },
23
+ ],
24
+ },
25
+ };
26
+ export const Loading: Story = { args: { rows: [], isLoading: true } };
27
+ export const Error: Story = {
28
+ args: { rows: [], error: new globalThis.Error("Failed to fetch stat") },
29
+ };
30
+ export const Empty: Story = { args: { rows: [] } };
@@ -0,0 +1,281 @@
1
+ /**
2
+ * MetricStat - Accepts OtelMetricsRow[] and renders stat cards with optional sparklines.
3
+ */
4
+
5
+ import { useMemo } from "react";
6
+ import { AreaChart, Area, ResponsiveContainer, YAxis } from "recharts";
7
+ import type { denormalizedSignals } from "@kopai/core";
8
+ import type { MetricDataPoint } from "../types.js";
9
+
10
+ type OtelMetricsRow = denormalizedSignals.OtelMetricsRow;
11
+
12
+ export interface ThresholdConfig {
13
+ value: number;
14
+ color: "green" | "yellow" | "red" | string;
15
+ }
16
+
17
+ export interface MetricStatProps {
18
+ rows: OtelMetricsRow[];
19
+ isLoading?: boolean;
20
+ error?: Error;
21
+ label?: string;
22
+ formatValue?: (value: number, unit: string) => string;
23
+ showTimestamp?: boolean;
24
+ trend?: "up" | "down" | "neutral";
25
+ trendValue?: number;
26
+ className?: string;
27
+ showSparkline?: boolean;
28
+ sparklinePoints?: number;
29
+ sparklineHeight?: number;
30
+ thresholds?: ThresholdConfig[];
31
+ colorBackground?: boolean;
32
+ colorValue?: boolean;
33
+ }
34
+
35
+ const THRESHOLD_COLORS: Record<
36
+ string,
37
+ { bg: string; border: string; text: string; stroke: string; fill: string }
38
+ > = {
39
+ green: {
40
+ bg: "bg-green-900/20",
41
+ border: "border-green-700",
42
+ text: "text-green-400",
43
+ stroke: "#4ade80",
44
+ fill: "#22c55e",
45
+ },
46
+ yellow: {
47
+ bg: "bg-yellow-900/20",
48
+ border: "border-yellow-700",
49
+ text: "text-yellow-400",
50
+ stroke: "#facc15",
51
+ fill: "#eab308",
52
+ },
53
+ red: {
54
+ bg: "bg-red-900/20",
55
+ border: "border-red-700",
56
+ text: "text-red-400",
57
+ stroke: "#f87171",
58
+ fill: "#ef4444",
59
+ },
60
+ gray: {
61
+ bg: "bg-background",
62
+ border: "border-gray-800",
63
+ text: "text-gray-400",
64
+ stroke: "#9ca3af",
65
+ fill: "#6b7280",
66
+ },
67
+ };
68
+
69
+ function getColorConfig(color: string) {
70
+ return (
71
+ THRESHOLD_COLORS[color] ?? {
72
+ bg: "bg-background",
73
+ border: "border-gray-800",
74
+ text: "text-gray-400",
75
+ stroke: color,
76
+ fill: color,
77
+ }
78
+ );
79
+ }
80
+
81
+ function getThresholdColor(
82
+ value: number,
83
+ thresholds: ThresholdConfig[]
84
+ ): string {
85
+ const sorted = [...thresholds].sort((a, b) => a.value - b.value);
86
+ for (const t of sorted) if (value < t.value) return t.color;
87
+ return sorted[sorted.length - 1]?.color ?? "gray";
88
+ }
89
+
90
+ const defaultFormatValue = (value: number, unit: string): string => {
91
+ let formatted: string;
92
+ if (Math.abs(value) >= 1e6) formatted = `${(value / 1e6).toFixed(1)}M`;
93
+ else if (Math.abs(value) >= 1e3) formatted = `${(value / 1e3).toFixed(1)}K`;
94
+ else if (Number.isInteger(value)) formatted = value.toString();
95
+ else formatted = value.toFixed(2);
96
+ return unit ? `${formatted} ${unit}` : formatted;
97
+ };
98
+
99
+ function buildStatData(rows: OtelMetricsRow[]): {
100
+ latestValue: number | null;
101
+ unit: string;
102
+ timestamp: number;
103
+ dataPoints: MetricDataPoint[];
104
+ metricName: string;
105
+ } {
106
+ let latestTimestamp = 0;
107
+ let latestValue: number | null = null;
108
+ let unit = "";
109
+ let metricName = "Metric";
110
+ const dataPoints: MetricDataPoint[] = [];
111
+
112
+ for (const row of rows) {
113
+ if (
114
+ row.MetricType === "Histogram" ||
115
+ row.MetricType === "ExponentialHistogram" ||
116
+ row.MetricType === "Summary"
117
+ )
118
+ continue;
119
+ const timestamp = parseInt(row.TimeUnix, 10) / 1e6;
120
+ const value = "Value" in row ? row.Value : 0;
121
+ if (!unit && row.MetricUnit) unit = row.MetricUnit;
122
+ if (!metricName || metricName === "Metric")
123
+ metricName = row.MetricName ?? "Metric";
124
+ dataPoints.push({ timestamp, value });
125
+ if (timestamp > latestTimestamp) {
126
+ latestTimestamp = timestamp;
127
+ latestValue = value;
128
+ }
129
+ }
130
+
131
+ dataPoints.sort((a, b) => a.timestamp - b.timestamp);
132
+ return {
133
+ latestValue,
134
+ unit,
135
+ timestamp: latestTimestamp,
136
+ dataPoints,
137
+ metricName,
138
+ };
139
+ }
140
+
141
+ export function MetricStat({
142
+ rows,
143
+ isLoading = false,
144
+ error,
145
+ label,
146
+ formatValue = defaultFormatValue,
147
+ showTimestamp = false,
148
+ trend,
149
+ trendValue,
150
+ className = "",
151
+ showSparkline = false,
152
+ sparklinePoints = 20,
153
+ sparklineHeight = 40,
154
+ thresholds,
155
+ colorBackground,
156
+ colorValue = false,
157
+ }: MetricStatProps) {
158
+ const { latestValue, unit, timestamp, dataPoints, metricName } = useMemo(
159
+ () => buildStatData(rows),
160
+ [rows]
161
+ );
162
+
163
+ const sparklineData = useMemo(() => {
164
+ if (!showSparkline || dataPoints.length === 0) return [];
165
+ return dataPoints
166
+ .slice(-sparklinePoints)
167
+ .map((dp) => ({ value: dp.value }));
168
+ }, [dataPoints, showSparkline, sparklinePoints]);
169
+
170
+ const thresholdColor = useMemo(() => {
171
+ if (!thresholds || latestValue === null) return "gray";
172
+ return getThresholdColor(latestValue, thresholds);
173
+ }, [thresholds, latestValue]);
174
+
175
+ const colorConfig = getColorConfig(thresholdColor);
176
+ const shouldColorBackground = colorBackground ?? thresholds !== undefined;
177
+ const displayLabel = label ?? metricName;
178
+ const bgClass = shouldColorBackground ? colorConfig.bg : "bg-background";
179
+ const borderClass = shouldColorBackground
180
+ ? `border ${colorConfig.border}`
181
+ : "";
182
+ const valueClass = colorValue ? colorConfig.text : "text-white";
183
+
184
+ if (isLoading) {
185
+ return (
186
+ <div
187
+ className={`bg-background rounded-lg p-4 animate-pulse ${className}`}
188
+ data-testid="metric-stat-loading"
189
+ >
190
+ <div className="h-4 w-24 bg-gray-700 rounded mb-2" />
191
+ <div className="h-10 w-32 bg-gray-700 rounded" />
192
+ </div>
193
+ );
194
+ }
195
+
196
+ if (error) {
197
+ return (
198
+ <div
199
+ className={`bg-background rounded-lg p-4 border border-red-800 ${className}`}
200
+ data-testid="metric-stat-error"
201
+ >
202
+ <p className="text-red-400 text-sm">{error.message}</p>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ if (latestValue === null) {
208
+ return (
209
+ <div
210
+ className={`bg-background rounded-lg p-4 border border-gray-800 ${className}`}
211
+ data-testid="metric-stat-empty"
212
+ >
213
+ <p className="text-gray-500 text-sm">{displayLabel}</p>
214
+ <p className="text-gray-600 text-2xl font-semibold">--</p>
215
+ </div>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <div
221
+ className={`${bgClass} ${borderClass} rounded-lg p-4 ${className}`}
222
+ data-testid="metric-stat"
223
+ >
224
+ <div className="flex items-center justify-between mb-1">
225
+ <p className="text-gray-400 text-sm font-medium">{displayLabel}</p>
226
+ {trend && <TrendIndicator direction={trend} value={trendValue} />}
227
+ </div>
228
+ <p className={`${valueClass} text-3xl font-bold`}>
229
+ {formatValue(latestValue, unit)}
230
+ </p>
231
+ {showTimestamp && (
232
+ <p className="text-gray-500 text-xs mt-1">
233
+ {new Date(timestamp).toLocaleTimeString()}
234
+ </p>
235
+ )}
236
+ {showSparkline && sparklineData.length > 0 && (
237
+ <div className="mt-2" style={{ height: sparklineHeight }}>
238
+ <ResponsiveContainer width="100%" height="100%">
239
+ <AreaChart
240
+ data={sparklineData}
241
+ margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
242
+ >
243
+ <YAxis domain={["dataMin", "dataMax"]} hide />
244
+ <Area
245
+ type="monotone"
246
+ dataKey="value"
247
+ stroke={colorConfig.stroke}
248
+ fill={colorConfig.fill}
249
+ fillOpacity={0.3}
250
+ strokeWidth={1.5}
251
+ isAnimationActive={false}
252
+ />
253
+ </AreaChart>
254
+ </ResponsiveContainer>
255
+ </div>
256
+ )}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function TrendIndicator({
262
+ direction,
263
+ value,
264
+ }: {
265
+ direction: "up" | "down" | "neutral";
266
+ value?: number;
267
+ }) {
268
+ const colorClass =
269
+ direction === "up"
270
+ ? "text-green-400"
271
+ : direction === "down"
272
+ ? "text-red-400"
273
+ : "text-gray-400";
274
+ const arrow = direction === "up" ? "↑" : direction === "down" ? "↓" : "→";
275
+ return (
276
+ <span className={`text-sm font-medium ${colorClass}`}>
277
+ {arrow}
278
+ {value !== undefined && ` ${Math.abs(value).toFixed(1)}%`}
279
+ </span>
280
+ );
281
+ }
@@ -0,0 +1,20 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MetricTable } from "./index.js";
3
+ import { mockGaugeRows } from "../__fixtures__/metrics.js";
4
+
5
+ const meta: Meta<typeof MetricTable> = {
6
+ title: "Observability/MetricTable",
7
+ component: MetricTable,
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof MetricTable>;
11
+
12
+ export const Default: Story = { args: { rows: mockGaugeRows } };
13
+ export const CustomColumns: Story = {
14
+ args: { rows: mockGaugeRows, columns: ["metric", "value"] },
15
+ };
16
+ export const Loading: Story = { args: { rows: [], isLoading: true } };
17
+ export const Error: Story = {
18
+ args: { rows: [], error: new globalThis.Error("Failed to fetch metrics") },
19
+ };
20
+ export const Empty: Story = { args: { rows: [] } };