@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.
- package/README.md +137 -0
- package/dist/index.cjs +5069 -3
- package/dist/index.d.cts +301 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +302 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5010 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +25 -7
- package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +113 -0
- package/src/components/KeyboardShortcuts/ShortcutsHelpDialog.tsx +82 -0
- package/src/components/KeyboardShortcuts/context.ts +23 -0
- package/src/components/KeyboardShortcuts/index.ts +8 -0
- package/src/components/KeyboardShortcuts/types.ts +11 -0
- package/src/components/dashboard/Badge/Badge.stories.tsx +29 -0
- package/src/components/dashboard/Badge/index.tsx +32 -0
- package/src/components/dashboard/Button/Button.stories.tsx +107 -0
- package/src/components/dashboard/Button/index.tsx +63 -0
- package/src/components/dashboard/Card/Card.stories.tsx +81 -0
- package/src/components/dashboard/Card/index.tsx +58 -0
- package/src/components/dashboard/Chart/Chart.stories.tsx +48 -0
- package/src/components/dashboard/Chart/index.tsx +74 -0
- package/src/components/dashboard/DatePicker/DatePicker.stories.tsx +33 -0
- package/src/components/dashboard/DatePicker/index.tsx +41 -0
- package/src/components/dashboard/Divider/Divider.stories.tsx +17 -0
- package/src/components/dashboard/Divider/index.tsx +49 -0
- package/src/components/dashboard/Empty/Empty.stories.tsx +48 -0
- package/src/components/dashboard/Empty/index.tsx +46 -0
- package/src/components/dashboard/Grid/Grid.stories.tsx +52 -0
- package/src/components/dashboard/Grid/index.tsx +26 -0
- package/src/components/dashboard/Heading/Heading.stories.tsx +25 -0
- package/src/components/dashboard/Heading/index.tsx +27 -0
- package/src/components/dashboard/List/List.stories.tsx +37 -0
- package/src/components/dashboard/List/index.tsx +24 -0
- package/src/components/dashboard/Metric/Metric.stories.tsx +65 -0
- package/src/components/dashboard/Metric/index.tsx +36 -0
- package/src/components/dashboard/Stack/Stack.stories.tsx +61 -0
- package/src/components/dashboard/Stack/index.tsx +33 -0
- package/src/components/dashboard/Table/Table.stories.tsx +38 -0
- package/src/components/dashboard/Table/index.tsx +104 -0
- package/src/components/dashboard/Text/Text.stories.tsx +53 -0
- package/src/components/dashboard/Text/index.tsx +18 -0
- package/src/components/dashboard/index.ts +46 -0
- package/src/components/index.ts +17 -0
- package/src/components/observability/LogTimeline/LogDetailPane/AttributesTab.tsx +56 -0
- package/src/components/observability/LogTimeline/LogDetailPane/JsonTreeView.tsx +139 -0
- package/src/components/observability/LogTimeline/LogDetailPane/index.tsx +271 -0
- package/src/components/observability/LogTimeline/LogFilter.stories.tsx +66 -0
- package/src/components/observability/LogTimeline/LogFilter.test.tsx +696 -0
- package/src/components/observability/LogTimeline/LogFilter.tsx +674 -0
- package/src/components/observability/LogTimeline/LogRow.tsx +174 -0
- package/src/components/observability/LogTimeline/LogTimeline.stories.tsx +154 -0
- package/src/components/observability/LogTimeline/index.tsx +542 -0
- package/src/components/observability/LogTimeline/shortcuts.ts +18 -0
- package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +20 -0
- package/src/components/observability/MetricHistogram/index.tsx +303 -0
- package/src/components/observability/MetricStat/MetricStat.stories.tsx +30 -0
- package/src/components/observability/MetricStat/index.tsx +281 -0
- package/src/components/observability/MetricTable/MetricTable.stories.tsx +20 -0
- package/src/components/observability/MetricTable/index.tsx +194 -0
- package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +28 -0
- package/src/components/observability/MetricTimeSeries/index.tsx +462 -0
- package/src/components/observability/RawDataTable/RawDataTable.stories.tsx +27 -0
- package/src/components/observability/RawDataTable/index.tsx +131 -0
- package/src/components/observability/ServiceList/ServiceList.stories.tsx +20 -0
- package/src/components/observability/ServiceList/index.tsx +60 -0
- package/src/components/observability/ServiceList/shortcuts.ts +6 -0
- package/src/components/observability/TabBar/TabBar.stories.tsx +34 -0
- package/src/components/observability/TabBar/index.tsx +46 -0
- package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +51 -0
- package/src/components/observability/TraceDetail/index.tsx +53 -0
- package/src/components/observability/TraceSearch/TraceSearch.stories.tsx +49 -0
- package/src/components/observability/TraceSearch/index.tsx +292 -0
- package/src/components/observability/TraceTimeline/DetailPane/AttributesTab.tsx +152 -0
- package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +128 -0
- package/src/components/observability/TraceTimeline/DetailPane/LinksTab.tsx +210 -0
- package/src/components/observability/TraceTimeline/DetailPane/index.tsx +174 -0
- package/src/components/observability/TraceTimeline/SpanRow.tsx +173 -0
- package/src/components/observability/TraceTimeline/TimelineBar.tsx +41 -0
- package/src/components/observability/TraceTimeline/Tooltip.tsx +42 -0
- package/src/components/observability/TraceTimeline/TraceHeader.tsx +88 -0
- package/src/components/observability/TraceTimeline/TraceTimeline.stories.tsx +25 -0
- package/src/components/observability/TraceTimeline/index.tsx +478 -0
- package/src/components/observability/TraceTimeline/shortcuts.ts +16 -0
- package/src/components/observability/__fixtures__/logs.ts +476 -0
- package/src/components/observability/__fixtures__/metrics.ts +216 -0
- package/src/components/observability/__fixtures__/raw-table.ts +204 -0
- package/src/components/observability/__fixtures__/services.ts +8 -0
- package/src/components/observability/__fixtures__/trace-summaries.ts +81 -0
- package/src/components/observability/__fixtures__/traces.ts +396 -0
- package/src/components/observability/index.ts +66 -0
- package/src/components/observability/renderers/OtelMetricDiscovery.tsx +77 -0
- package/src/components/observability/renderers/OtelMetricHistogram.tsx +29 -0
- package/src/components/observability/renderers/OtelMetricStat.tsx +44 -0
- package/src/components/observability/renderers/OtelMetricTable.tsx +29 -0
- package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +30 -0
- package/src/components/observability/renderers/index.ts +5 -0
- package/src/components/observability/shared/LoadingSkeleton.tsx +43 -0
- package/src/components/observability/types.ts +113 -0
- package/src/components/observability/utils/attributes.ts +17 -0
- package/src/components/observability/utils/colors.ts +29 -0
- package/src/components/observability/utils/flatten-tree.ts +53 -0
- package/src/components/observability/utils/lttb.ts +121 -0
- package/src/components/observability/utils/time.ts +46 -0
- package/src/hooks/use-kopai-data.test.ts +296 -0
- package/src/hooks/use-kopai-data.ts +64 -0
- package/src/hooks/use-live-logs.test.ts +193 -0
- package/src/hooks/use-live-logs.ts +113 -0
- package/src/index.ts +15 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +33 -0
- package/src/lib/catalog.ts +165 -0
- package/src/lib/component-catalog.test.ts +357 -0
- package/src/lib/component-catalog.ts +171 -0
- package/src/lib/dashboard-datasource.ts +76 -0
- package/src/lib/generate-prompt-instructions.test.ts +27 -0
- package/src/lib/generate-prompt-instructions.ts +185 -0
- package/src/lib/log-buffer.test.ts +88 -0
- package/src/lib/log-buffer.ts +62 -0
- package/src/lib/observability-catalog.ts +143 -0
- package/src/lib/renderer.test.tsx +693 -0
- package/src/lib/renderer.tsx +276 -0
- package/src/pages/observability.tsx +825 -0
- package/src/providers/kopai-provider.tsx +51 -0
- package/src/styles/globals.css +46 -0
- 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: [] } };
|