@kopai/ui 0.0.5 → 0.1.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 +828 -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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetricTable - Accepts OtelMetricsRow[] and renders tabular metric data.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import type { denormalizedSignals } from "@kopai/core";
|
|
7
|
+
|
|
8
|
+
type OtelMetricsRow = denormalizedSignals.OtelMetricsRow;
|
|
9
|
+
|
|
10
|
+
export interface MetricTableProps {
|
|
11
|
+
rows: OtelMetricsRow[];
|
|
12
|
+
isLoading?: boolean;
|
|
13
|
+
error?: Error;
|
|
14
|
+
maxRows?: number;
|
|
15
|
+
formatValue?: (value: number) => string;
|
|
16
|
+
formatTimestamp?: (timestamp: number) => string;
|
|
17
|
+
columns?: ("timestamp" | "metric" | "labels" | "value")[];
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TableRow {
|
|
22
|
+
id: string;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
metricName: string;
|
|
25
|
+
labels: Record<string, string>;
|
|
26
|
+
value: number;
|
|
27
|
+
unit: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const defaultFormatValue = (value: number): string => {
|
|
31
|
+
if (Number.isInteger(value)) return value.toLocaleString();
|
|
32
|
+
return value.toFixed(2);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const defaultFormatTimestamp = (timestamp: number): string => {
|
|
36
|
+
return new Date(timestamp).toLocaleString();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function formatLabels(labels: Record<string, string>): string {
|
|
40
|
+
const entries = Object.entries(labels);
|
|
41
|
+
if (entries.length === 0) return "-";
|
|
42
|
+
return entries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildTableRows(rows: OtelMetricsRow[], maxRows: number): TableRow[] {
|
|
46
|
+
const result: TableRow[] = [];
|
|
47
|
+
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
if (
|
|
50
|
+
row.MetricType === "Histogram" ||
|
|
51
|
+
row.MetricType === "ExponentialHistogram" ||
|
|
52
|
+
row.MetricType === "Summary"
|
|
53
|
+
)
|
|
54
|
+
continue;
|
|
55
|
+
|
|
56
|
+
const timestamp = Number(BigInt(row.TimeUnix) / 1_000_000n);
|
|
57
|
+
const value = "Value" in row ? row.Value : 0;
|
|
58
|
+
const labels: Record<string, string> = {};
|
|
59
|
+
if (row.Attributes) {
|
|
60
|
+
for (const [k, v] of Object.entries(row.Attributes))
|
|
61
|
+
labels[k] = String(v);
|
|
62
|
+
}
|
|
63
|
+
const key = row.Attributes ? JSON.stringify(row.Attributes) : "__default__";
|
|
64
|
+
|
|
65
|
+
result.push({
|
|
66
|
+
id: `${row.MetricName}-${key}-${timestamp}`,
|
|
67
|
+
timestamp,
|
|
68
|
+
metricName: row.MetricName ?? "unknown",
|
|
69
|
+
labels,
|
|
70
|
+
value,
|
|
71
|
+
unit: row.MetricUnit ?? "",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result.sort((a, b) => b.timestamp - a.timestamp).slice(0, maxRows);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function MetricTable({
|
|
79
|
+
rows,
|
|
80
|
+
isLoading = false,
|
|
81
|
+
error,
|
|
82
|
+
maxRows = 100,
|
|
83
|
+
formatValue = defaultFormatValue,
|
|
84
|
+
formatTimestamp = defaultFormatTimestamp,
|
|
85
|
+
columns = ["timestamp", "metric", "labels", "value"],
|
|
86
|
+
className = "",
|
|
87
|
+
}: MetricTableProps) {
|
|
88
|
+
const tableRows = useMemo(
|
|
89
|
+
() => buildTableRows(rows, maxRows),
|
|
90
|
+
[rows, maxRows]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (isLoading) {
|
|
94
|
+
return (
|
|
95
|
+
<div className={`bg-background rounded-lg p-4 ${className}`}>
|
|
96
|
+
<div className="animate-pulse" data-testid="metric-table-loading">
|
|
97
|
+
<div className="h-10 bg-gray-800 rounded mb-2" />
|
|
98
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
99
|
+
<div key={i} className="h-12 bg-gray-800/50 rounded mb-1" />
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (error) {
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
className={`bg-background rounded-lg p-4 border border-red-800 ${className}`}
|
|
110
|
+
data-testid="metric-table-error"
|
|
111
|
+
>
|
|
112
|
+
<p className="text-red-400">Error loading metrics: {error.message}</p>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (tableRows.length === 0) {
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
className={`bg-background rounded-lg p-4 border border-gray-800 ${className}`}
|
|
121
|
+
data-testid="metric-table-empty"
|
|
122
|
+
>
|
|
123
|
+
<p className="text-gray-500 text-center py-4">
|
|
124
|
+
No metric data available
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
className={`bg-background rounded-lg overflow-hidden ${className}`}
|
|
133
|
+
data-testid="metric-table"
|
|
134
|
+
>
|
|
135
|
+
<div className="overflow-x-auto">
|
|
136
|
+
<table className="w-full text-sm">
|
|
137
|
+
<thead>
|
|
138
|
+
<tr className="bg-gray-800 text-gray-300 text-left">
|
|
139
|
+
{columns.includes("timestamp") && (
|
|
140
|
+
<th className="px-4 py-3 font-medium">Timestamp</th>
|
|
141
|
+
)}
|
|
142
|
+
{columns.includes("metric") && (
|
|
143
|
+
<th className="px-4 py-3 font-medium">Metric</th>
|
|
144
|
+
)}
|
|
145
|
+
{columns.includes("labels") && (
|
|
146
|
+
<th className="px-4 py-3 font-medium">Labels</th>
|
|
147
|
+
)}
|
|
148
|
+
{columns.includes("value") && (
|
|
149
|
+
<th className="px-4 py-3 font-medium text-right">Value</th>
|
|
150
|
+
)}
|
|
151
|
+
</tr>
|
|
152
|
+
</thead>
|
|
153
|
+
<tbody className="divide-y divide-gray-800">
|
|
154
|
+
{tableRows.map((row) => (
|
|
155
|
+
<tr
|
|
156
|
+
key={row.id}
|
|
157
|
+
className="hover:bg-gray-800/50 transition-colors"
|
|
158
|
+
>
|
|
159
|
+
{columns.includes("timestamp") && (
|
|
160
|
+
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">
|
|
161
|
+
{formatTimestamp(row.timestamp)}
|
|
162
|
+
</td>
|
|
163
|
+
)}
|
|
164
|
+
{columns.includes("metric") && (
|
|
165
|
+
<td className="px-4 py-3 text-gray-200 font-mono">
|
|
166
|
+
{row.metricName}
|
|
167
|
+
</td>
|
|
168
|
+
)}
|
|
169
|
+
{columns.includes("labels") && (
|
|
170
|
+
<td className="px-4 py-3 text-gray-400 font-mono text-xs">
|
|
171
|
+
{formatLabels(row.labels)}
|
|
172
|
+
</td>
|
|
173
|
+
)}
|
|
174
|
+
{columns.includes("value") && (
|
|
175
|
+
<td className="px-4 py-3 text-white font-medium text-right whitespace-nowrap">
|
|
176
|
+
{formatValue(row.value)}
|
|
177
|
+
{row.unit && (
|
|
178
|
+
<span className="text-gray-500 ml-1">{row.unit}</span>
|
|
179
|
+
)}
|
|
180
|
+
</td>
|
|
181
|
+
)}
|
|
182
|
+
</tr>
|
|
183
|
+
))}
|
|
184
|
+
</tbody>
|
|
185
|
+
</table>
|
|
186
|
+
</div>
|
|
187
|
+
{tableRows.length === maxRows && (
|
|
188
|
+
<div className="px-4 py-2 bg-gray-800 text-gray-500 text-xs text-center">
|
|
189
|
+
Showing first {maxRows} rows
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MetricTimeSeries } from "./index.js";
|
|
3
|
+
import { mockGaugeRows, mockSumRows } from "../__fixtures__/metrics.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MetricTimeSeries> = {
|
|
6
|
+
title: "Observability/MetricTimeSeries",
|
|
7
|
+
component: MetricTimeSeries,
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof MetricTimeSeries>;
|
|
11
|
+
|
|
12
|
+
export const Default: Story = { args: { rows: mockGaugeRows } };
|
|
13
|
+
export const MultiSeries: Story = {
|
|
14
|
+
args: { rows: [...mockGaugeRows, ...mockSumRows] },
|
|
15
|
+
};
|
|
16
|
+
export const WithThresholds: Story = {
|
|
17
|
+
args: {
|
|
18
|
+
rows: mockGaugeRows,
|
|
19
|
+
thresholdLines: [
|
|
20
|
+
{ value: 80, color: "#ef4444", label: "Max", style: "dashed" },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export const Loading: Story = { args: { rows: [], isLoading: true } };
|
|
25
|
+
export const Error: Story = {
|
|
26
|
+
args: { rows: [], error: new globalThis.Error("Failed to fetch metrics") },
|
|
27
|
+
};
|
|
28
|
+
export const Empty: Story = { args: { rows: [] } };
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetricTimeSeries - Accepts OtelMetricsRow[] and renders line charts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useMemo, useState, useCallback } from "react";
|
|
6
|
+
import {
|
|
7
|
+
LineChart,
|
|
8
|
+
Line,
|
|
9
|
+
XAxis,
|
|
10
|
+
YAxis,
|
|
11
|
+
CartesianGrid,
|
|
12
|
+
Tooltip,
|
|
13
|
+
Legend,
|
|
14
|
+
ResponsiveContainer,
|
|
15
|
+
Brush,
|
|
16
|
+
ReferenceLine,
|
|
17
|
+
} from "recharts";
|
|
18
|
+
import type { denormalizedSignals } from "@kopai/core";
|
|
19
|
+
import type {
|
|
20
|
+
ParsedMetricGroup,
|
|
21
|
+
MetricSeries,
|
|
22
|
+
RechartsDataPoint,
|
|
23
|
+
} from "../types.js";
|
|
24
|
+
import { downsampleLTTB, type LTTBPoint } from "../utils/lttb.js";
|
|
25
|
+
|
|
26
|
+
type OtelMetricsRow = denormalizedSignals.OtelMetricsRow;
|
|
27
|
+
|
|
28
|
+
const COLORS = [
|
|
29
|
+
"#8884d8",
|
|
30
|
+
"#82ca9d",
|
|
31
|
+
"#ffc658",
|
|
32
|
+
"#ff7300",
|
|
33
|
+
"#00C49F",
|
|
34
|
+
"#0088FE",
|
|
35
|
+
"#FFBB28",
|
|
36
|
+
"#FF8042",
|
|
37
|
+
"#a4de6c",
|
|
38
|
+
"#d0ed57",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export interface ThresholdLine {
|
|
42
|
+
value: number;
|
|
43
|
+
color: string;
|
|
44
|
+
label?: string;
|
|
45
|
+
style?: "solid" | "dashed" | "dotted";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MetricTimeSeriesProps {
|
|
49
|
+
rows: OtelMetricsRow[];
|
|
50
|
+
isLoading?: boolean;
|
|
51
|
+
error?: Error;
|
|
52
|
+
maxDataPoints?: number;
|
|
53
|
+
showBrush?: boolean;
|
|
54
|
+
height?: number;
|
|
55
|
+
yAxisLabel?: string;
|
|
56
|
+
formatTime?: (timestamp: number) => string;
|
|
57
|
+
formatValue?: (value: number) => string;
|
|
58
|
+
onBrushChange?: (startTime: number, endTime: number) => void;
|
|
59
|
+
legendMaxLength?: number;
|
|
60
|
+
thresholdLines?: ThresholdLine[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultFormatTime = (timestamp: number): string => {
|
|
64
|
+
const date = new Date(timestamp);
|
|
65
|
+
return date.toLocaleTimeString("en-US", {
|
|
66
|
+
hour: "2-digit",
|
|
67
|
+
minute: "2-digit",
|
|
68
|
+
second: "2-digit",
|
|
69
|
+
hour12: false,
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const defaultFormatValue = (value: number): string => {
|
|
74
|
+
if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
75
|
+
if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
|
76
|
+
return value.toFixed(2);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function getStrokeDashArray(
|
|
80
|
+
style?: "solid" | "dashed" | "dotted"
|
|
81
|
+
): string | undefined {
|
|
82
|
+
if (style === "solid") return undefined;
|
|
83
|
+
if (style === "dotted") return "2 2";
|
|
84
|
+
return "5 5";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build metrics from denormalized rows */
|
|
88
|
+
function buildMetrics(rows: OtelMetricsRow[]): ParsedMetricGroup[] {
|
|
89
|
+
const metricMap = new Map<string, Map<string, MetricSeries>>();
|
|
90
|
+
const metricMeta = new Map<
|
|
91
|
+
string,
|
|
92
|
+
{ description: string; unit: string; type: string; serviceName: string }
|
|
93
|
+
>();
|
|
94
|
+
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const name = row.MetricName ?? "unknown";
|
|
97
|
+
const type = row.MetricType;
|
|
98
|
+
if (
|
|
99
|
+
type === "Histogram" ||
|
|
100
|
+
type === "ExponentialHistogram" ||
|
|
101
|
+
type === "Summary"
|
|
102
|
+
)
|
|
103
|
+
continue; // TimeSeries only handles Gauge/Sum
|
|
104
|
+
|
|
105
|
+
if (!metricMap.has(name)) metricMap.set(name, new Map());
|
|
106
|
+
if (!metricMeta.has(name))
|
|
107
|
+
metricMeta.set(name, {
|
|
108
|
+
description: row.MetricDescription ?? "",
|
|
109
|
+
unit: row.MetricUnit ?? "",
|
|
110
|
+
type,
|
|
111
|
+
serviceName: row.ServiceName ?? "unknown",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const seriesKey = row.Attributes
|
|
115
|
+
? JSON.stringify(
|
|
116
|
+
Object.fromEntries(
|
|
117
|
+
Object.entries(row.Attributes).sort(([a], [b]) =>
|
|
118
|
+
a.localeCompare(b)
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
: "__default__";
|
|
123
|
+
const seriesMap = metricMap.get(name)!;
|
|
124
|
+
|
|
125
|
+
if (!seriesMap.has(seriesKey)) {
|
|
126
|
+
const labels: Record<string, string> = {};
|
|
127
|
+
if (row.Attributes) {
|
|
128
|
+
for (const [k, v] of Object.entries(row.Attributes))
|
|
129
|
+
labels[k] = String(v);
|
|
130
|
+
}
|
|
131
|
+
seriesMap.set(seriesKey, {
|
|
132
|
+
key: seriesKey === "__default__" ? name : seriesKey,
|
|
133
|
+
labels,
|
|
134
|
+
dataPoints: [],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!("Value" in row)) continue;
|
|
139
|
+
const value = row.Value;
|
|
140
|
+
const timestamp = parseInt(row.TimeUnix, 10) / 1e6;
|
|
141
|
+
seriesMap.get(seriesKey)!.dataPoints.push({ timestamp, value });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const results: ParsedMetricGroup[] = [];
|
|
145
|
+
for (const [name, seriesMap] of metricMap) {
|
|
146
|
+
const meta = metricMeta.get(name)!;
|
|
147
|
+
const series = Array.from(seriesMap.values());
|
|
148
|
+
for (const s of series)
|
|
149
|
+
s.dataPoints.sort((a, b) => a.timestamp - b.timestamp);
|
|
150
|
+
results.push({
|
|
151
|
+
name,
|
|
152
|
+
description: meta.description,
|
|
153
|
+
unit: meta.unit,
|
|
154
|
+
type: meta.type as ParsedMetricGroup["type"],
|
|
155
|
+
series,
|
|
156
|
+
serviceName: meta.serviceName,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return results;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toRechartsData(metrics: ParsedMetricGroup[]): RechartsDataPoint[] {
|
|
163
|
+
const timestampMap = new Map<number, RechartsDataPoint>();
|
|
164
|
+
for (const metric of metrics) {
|
|
165
|
+
for (const series of metric.series) {
|
|
166
|
+
const seriesName =
|
|
167
|
+
series.key === "__default__" ? metric.name : series.key;
|
|
168
|
+
for (const dp of series.dataPoints) {
|
|
169
|
+
if (!timestampMap.has(dp.timestamp))
|
|
170
|
+
timestampMap.set(dp.timestamp, { timestamp: dp.timestamp });
|
|
171
|
+
timestampMap.get(dp.timestamp)![seriesName] = dp.value;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return Array.from(timestampMap.values()).sort(
|
|
176
|
+
(a, b) => a.timestamp - b.timestamp
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getSeriesKeys(metrics: ParsedMetricGroup[]): string[] {
|
|
181
|
+
const keys = new Set<string>();
|
|
182
|
+
for (const m of metrics)
|
|
183
|
+
for (const s of m.series)
|
|
184
|
+
keys.add(s.key === "__default__" ? m.name : s.key);
|
|
185
|
+
return Array.from(keys);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function downsampleRechartsData(
|
|
189
|
+
data: RechartsDataPoint[],
|
|
190
|
+
seriesKeys: string[],
|
|
191
|
+
maxPoints: number
|
|
192
|
+
): RechartsDataPoint[] {
|
|
193
|
+
if (data.length <= maxPoints) return data;
|
|
194
|
+
const timestamps = new Set<number>();
|
|
195
|
+
for (const key of seriesKeys) {
|
|
196
|
+
const pts: LTTBPoint[] = [];
|
|
197
|
+
for (const d of data) {
|
|
198
|
+
const v = d[key];
|
|
199
|
+
if (v !== undefined) pts.push({ x: d.timestamp, y: v });
|
|
200
|
+
}
|
|
201
|
+
if (pts.length === 0) continue;
|
|
202
|
+
const ds = downsampleLTTB(pts, Math.ceil(maxPoints / seriesKeys.length));
|
|
203
|
+
for (const p of ds) timestamps.add(p.x);
|
|
204
|
+
}
|
|
205
|
+
return data.filter((d) => timestamps.has(d.timestamp));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function MetricTimeSeries({
|
|
209
|
+
rows,
|
|
210
|
+
isLoading = false,
|
|
211
|
+
error,
|
|
212
|
+
maxDataPoints = 500,
|
|
213
|
+
showBrush = true,
|
|
214
|
+
height = 400,
|
|
215
|
+
yAxisLabel,
|
|
216
|
+
formatTime = defaultFormatTime,
|
|
217
|
+
formatValue = defaultFormatValue,
|
|
218
|
+
onBrushChange,
|
|
219
|
+
legendMaxLength = 30,
|
|
220
|
+
thresholdLines,
|
|
221
|
+
}: MetricTimeSeriesProps) {
|
|
222
|
+
const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set());
|
|
223
|
+
|
|
224
|
+
const parsedMetrics = useMemo(() => buildMetrics(rows), [rows]);
|
|
225
|
+
const unit = parsedMetrics[0]?.unit ?? "";
|
|
226
|
+
const chartData = useMemo(
|
|
227
|
+
() => toRechartsData(parsedMetrics),
|
|
228
|
+
[parsedMetrics]
|
|
229
|
+
);
|
|
230
|
+
const seriesKeys = useMemo(
|
|
231
|
+
() => getSeriesKeys(parsedMetrics),
|
|
232
|
+
[parsedMetrics]
|
|
233
|
+
);
|
|
234
|
+
const displayData = useMemo(
|
|
235
|
+
() => downsampleRechartsData(chartData, seriesKeys, maxDataPoints),
|
|
236
|
+
[chartData, seriesKeys, maxDataPoints]
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const handleLegendClick = useCallback((dataKey: string) => {
|
|
240
|
+
setHiddenSeries((prev) => {
|
|
241
|
+
const next = new Set(prev);
|
|
242
|
+
if (next.has(dataKey)) next.delete(dataKey);
|
|
243
|
+
else next.add(dataKey);
|
|
244
|
+
return next;
|
|
245
|
+
});
|
|
246
|
+
}, []);
|
|
247
|
+
|
|
248
|
+
const handleBrushChange = useCallback(
|
|
249
|
+
(brushData: { startIndex?: number; endIndex?: number }) => {
|
|
250
|
+
if (!onBrushChange || !displayData.length) return;
|
|
251
|
+
const { startIndex, endIndex } = brushData;
|
|
252
|
+
if (startIndex === undefined || endIndex === undefined) return;
|
|
253
|
+
const sp = displayData[startIndex],
|
|
254
|
+
ep = displayData[endIndex];
|
|
255
|
+
if (sp && ep) onBrushChange(sp.timestamp, ep.timestamp);
|
|
256
|
+
},
|
|
257
|
+
[displayData, onBrushChange]
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (isLoading) return <MetricLoadingSkeleton height={height} />;
|
|
261
|
+
|
|
262
|
+
if (error) {
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
className="flex items-center justify-center bg-background rounded-lg border border-red-800"
|
|
266
|
+
style={{ height }}
|
|
267
|
+
>
|
|
268
|
+
<div className="text-center p-4">
|
|
269
|
+
<p className="text-red-400 font-medium">Error loading metrics</p>
|
|
270
|
+
<p className="text-gray-500 text-sm mt-1">{error.message}</p>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (rows.length === 0 || displayData.length === 0) {
|
|
277
|
+
return (
|
|
278
|
+
<div
|
|
279
|
+
className="flex items-center justify-center bg-background rounded-lg border border-gray-800"
|
|
280
|
+
style={{ height }}
|
|
281
|
+
>
|
|
282
|
+
<p className="text-gray-500">No metric data available</p>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div
|
|
289
|
+
className="bg-background rounded-lg p-4"
|
|
290
|
+
style={{ height }}
|
|
291
|
+
data-testid="metric-time-series"
|
|
292
|
+
>
|
|
293
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
294
|
+
<LineChart
|
|
295
|
+
data={displayData}
|
|
296
|
+
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
297
|
+
>
|
|
298
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
|
299
|
+
<XAxis
|
|
300
|
+
dataKey="timestamp"
|
|
301
|
+
tickFormatter={formatTime}
|
|
302
|
+
stroke="#9CA3AF"
|
|
303
|
+
tick={{ fill: "#9CA3AF", fontSize: 12 }}
|
|
304
|
+
/>
|
|
305
|
+
<YAxis
|
|
306
|
+
tickFormatter={formatValue}
|
|
307
|
+
stroke="#9CA3AF"
|
|
308
|
+
tick={{ fill: "#9CA3AF", fontSize: 12 }}
|
|
309
|
+
label={
|
|
310
|
+
yAxisLabel
|
|
311
|
+
? {
|
|
312
|
+
value: yAxisLabel,
|
|
313
|
+
angle: -90,
|
|
314
|
+
position: "insideLeft",
|
|
315
|
+
fill: "#9CA3AF",
|
|
316
|
+
}
|
|
317
|
+
: undefined
|
|
318
|
+
}
|
|
319
|
+
/>
|
|
320
|
+
<Tooltip
|
|
321
|
+
content={
|
|
322
|
+
<CustomTooltip
|
|
323
|
+
formatTime={formatTime}
|
|
324
|
+
formatValue={formatValue}
|
|
325
|
+
unit={unit}
|
|
326
|
+
/>
|
|
327
|
+
}
|
|
328
|
+
/>
|
|
329
|
+
<Legend
|
|
330
|
+
onClick={(e) => {
|
|
331
|
+
const dk = e?.dataKey;
|
|
332
|
+
if (typeof dk === "string") handleLegendClick(dk);
|
|
333
|
+
}}
|
|
334
|
+
formatter={(value: string) => {
|
|
335
|
+
const truncated =
|
|
336
|
+
value.length > legendMaxLength
|
|
337
|
+
? value.slice(0, legendMaxLength - 3) + "..."
|
|
338
|
+
: value;
|
|
339
|
+
const isHidden = hiddenSeries.has(value);
|
|
340
|
+
return (
|
|
341
|
+
<span
|
|
342
|
+
style={{
|
|
343
|
+
color: isHidden ? "#6B7280" : "#E5E7EB",
|
|
344
|
+
textDecoration: isHidden ? "line-through" : "none",
|
|
345
|
+
cursor: "pointer",
|
|
346
|
+
}}
|
|
347
|
+
title={truncated !== value ? value : undefined}
|
|
348
|
+
>
|
|
349
|
+
{truncated}
|
|
350
|
+
</span>
|
|
351
|
+
);
|
|
352
|
+
}}
|
|
353
|
+
/>
|
|
354
|
+
{thresholdLines?.map((t, i) => (
|
|
355
|
+
<ReferenceLine
|
|
356
|
+
key={`t-${i}`}
|
|
357
|
+
y={t.value}
|
|
358
|
+
stroke={t.color}
|
|
359
|
+
strokeDasharray={getStrokeDashArray(t.style)}
|
|
360
|
+
strokeWidth={1.5}
|
|
361
|
+
label={
|
|
362
|
+
t.label
|
|
363
|
+
? {
|
|
364
|
+
value: t.label,
|
|
365
|
+
position: "right",
|
|
366
|
+
fill: t.color,
|
|
367
|
+
fontSize: 11,
|
|
368
|
+
}
|
|
369
|
+
: undefined
|
|
370
|
+
}
|
|
371
|
+
/>
|
|
372
|
+
))}
|
|
373
|
+
{seriesKeys.map((key, i) => (
|
|
374
|
+
<Line
|
|
375
|
+
key={key}
|
|
376
|
+
type="monotone"
|
|
377
|
+
dataKey={key}
|
|
378
|
+
stroke={COLORS[i % COLORS.length]}
|
|
379
|
+
strokeWidth={2}
|
|
380
|
+
dot={false}
|
|
381
|
+
activeDot={{ r: 4 }}
|
|
382
|
+
hide={hiddenSeries.has(key)}
|
|
383
|
+
connectNulls
|
|
384
|
+
/>
|
|
385
|
+
))}
|
|
386
|
+
{showBrush && displayData.length > 10 && (
|
|
387
|
+
<Brush
|
|
388
|
+
dataKey="timestamp"
|
|
389
|
+
height={30}
|
|
390
|
+
stroke="#6B7280"
|
|
391
|
+
fill="#1F2937"
|
|
392
|
+
tickFormatter={formatTime}
|
|
393
|
+
onChange={handleBrushChange}
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
</LineChart>
|
|
397
|
+
</ResponsiveContainer>
|
|
398
|
+
</div>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function CustomTooltip({
|
|
403
|
+
active,
|
|
404
|
+
payload,
|
|
405
|
+
label,
|
|
406
|
+
formatTime,
|
|
407
|
+
formatValue,
|
|
408
|
+
unit,
|
|
409
|
+
}: {
|
|
410
|
+
active?: boolean;
|
|
411
|
+
payload?: Array<{ dataKey: string; value: number; color: string }>;
|
|
412
|
+
label?: number;
|
|
413
|
+
formatTime: (ts: number) => string;
|
|
414
|
+
formatValue: (val: number) => string;
|
|
415
|
+
unit?: string;
|
|
416
|
+
}) {
|
|
417
|
+
if (!active || !payload || !label) return null;
|
|
418
|
+
return (
|
|
419
|
+
<div className="bg-background border border-gray-700 rounded-lg p-3 shadow-lg">
|
|
420
|
+
<p className="text-gray-400 text-xs mb-2">{formatTime(label)}</p>
|
|
421
|
+
{payload.map((entry, i) => (
|
|
422
|
+
<p key={i} className="text-sm" style={{ color: entry.color }}>
|
|
423
|
+
<span className="font-medium">{entry.dataKey}:</span>{" "}
|
|
424
|
+
{formatValue(entry.value)}
|
|
425
|
+
{unit ? ` ${unit}` : ""}
|
|
426
|
+
</p>
|
|
427
|
+
))}
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function MetricLoadingSkeleton({ height = 400 }: { height?: number }) {
|
|
433
|
+
return (
|
|
434
|
+
<div
|
|
435
|
+
className="bg-background rounded-lg p-4 animate-pulse"
|
|
436
|
+
style={{ height }}
|
|
437
|
+
data-testid="metric-time-series-loading"
|
|
438
|
+
>
|
|
439
|
+
<div className="h-full flex flex-col">
|
|
440
|
+
<div className="flex flex-1 gap-2">
|
|
441
|
+
<div className="flex flex-col justify-between w-12">
|
|
442
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
443
|
+
<div key={i} className="h-3 w-8 bg-gray-700 rounded" />
|
|
444
|
+
))}
|
|
445
|
+
</div>
|
|
446
|
+
<div className="flex-1 relative">
|
|
447
|
+
<div className="absolute inset-0 flex flex-col justify-between">
|
|
448
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
449
|
+
<div key={i} className="h-px bg-gray-800" />
|
|
450
|
+
))}
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
<div className="flex justify-between mt-2 px-14">
|
|
455
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
456
|
+
<div key={i} className="h-3 w-12 bg-gray-700 rounded" />
|
|
457
|
+
))}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { RawDataTable } from "./index.js";
|
|
3
|
+
import { mockRawTableData } from "../__fixtures__/raw-table.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof RawDataTable> = {
|
|
6
|
+
title: "Observability/RawDataTable",
|
|
7
|
+
component: RawDataTable,
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof RawDataTable>;
|
|
11
|
+
|
|
12
|
+
export const Default: Story = { args: { data: mockRawTableData } };
|
|
13
|
+
export const Truncated: Story = {
|
|
14
|
+
args: { data: mockRawTableData, maxRows: 5 },
|
|
15
|
+
};
|
|
16
|
+
export const Loading: Story = {
|
|
17
|
+
args: { data: { columns: [], types: [], rows: [] }, isLoading: true },
|
|
18
|
+
};
|
|
19
|
+
export const Error: Story = {
|
|
20
|
+
args: {
|
|
21
|
+
data: { columns: [], types: [], rows: [] },
|
|
22
|
+
error: new globalThis.Error("Failed to fetch data"),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const Empty: Story = {
|
|
26
|
+
args: { data: { columns: [], types: [], rows: [] } },
|
|
27
|
+
};
|