@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,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
+ };