@kopai/ui 0.4.0 → 0.6.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 +23 -0
- package/dist/index.cjs +1598 -233
- package/dist/index.d.cts +566 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +565 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1597 -209
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -12
- package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +234 -0
- package/src/components/observability/DynamicDashboard/index.tsx +64 -0
- package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +10 -1
- package/src/components/observability/MetricHistogram/index.tsx +85 -19
- package/src/components/observability/MetricStat/MetricStat.stories.tsx +2 -1
- package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +23 -1
- package/src/components/observability/MetricTimeSeries/index.tsx +70 -27
- package/src/components/observability/__fixtures__/metrics.ts +97 -0
- package/src/components/observability/index.ts +3 -0
- package/src/components/observability/renderers/OtelLogTimeline.tsx +28 -0
- package/src/components/observability/renderers/OtelMetricHistogram.tsx +2 -0
- package/src/components/observability/renderers/OtelMetricStat.tsx +1 -13
- package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +2 -0
- package/src/components/observability/renderers/OtelTraceDetail.tsx +35 -0
- package/src/components/observability/renderers/index.ts +2 -0
- package/src/components/observability/utils/attributes.ts +7 -0
- package/src/components/observability/utils/units.test.ts +116 -0
- package/src/components/observability/utils/units.ts +132 -0
- package/src/index.ts +1 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +7 -1
- package/src/lib/generate-prompt-instructions.test.ts +1 -1
- package/src/lib/generate-prompt-instructions.ts +18 -6
- package/src/lib/observability-catalog.ts +7 -1
- package/src/lib/renderer.tsx +1 -1
- package/src/pages/observability.test.tsx +124 -0
- package/src/pages/observability.tsx +71 -36
- package/src/lib/dashboard-datasource.ts +0 -76
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kopai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Vladimir Adamic",
|
|
6
6
|
"repository": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"import": "./dist/index.mjs",
|
|
20
20
|
"require": "./dist/index.cjs"
|
|
21
21
|
},
|
|
22
|
-
"./globals.css": "./src/styles/globals.css"
|
|
22
|
+
"./globals.css": "./src/styles/globals.css",
|
|
23
|
+
"./package.json": "./package.json"
|
|
23
24
|
},
|
|
24
25
|
"main": "./dist/index.cjs",
|
|
25
26
|
"module": "./dist/index.mjs",
|
|
@@ -30,30 +31,30 @@
|
|
|
30
31
|
],
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@tanstack/react-query": "^5",
|
|
33
|
-
"@tanstack/react-virtual": "^3.
|
|
34
|
+
"@tanstack/react-virtual": "^3.13.19",
|
|
34
35
|
"recharts": "^3.7.0",
|
|
35
|
-
"@kopai/core": "0.
|
|
36
|
-
"@kopai/sdk": "0.
|
|
36
|
+
"@kopai/core": "0.7.0",
|
|
37
|
+
"@kopai/sdk": "0.4.0"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
40
|
"react": "^19.2.4",
|
|
40
41
|
"react-dom": "^19.2.4"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
|
-
"@storybook/addon-docs": "^10.2.
|
|
44
|
-
"@storybook/react": "^10.2.
|
|
45
|
-
"@storybook/react-vite": "^10.2.
|
|
44
|
+
"@storybook/addon-docs": "^10.2.13",
|
|
45
|
+
"@storybook/react": "^10.2.13",
|
|
46
|
+
"@storybook/react-vite": "^10.2.13",
|
|
46
47
|
"@testing-library/react": "^16.3.0",
|
|
47
48
|
"@types/react": "^19.2.14",
|
|
48
49
|
"@types/react-dom": "^19.1.0",
|
|
49
50
|
"@vitejs/plugin-react": "^5.1.4",
|
|
50
|
-
"@tailwindcss/postcss": "^4.1
|
|
51
|
-
"jsdom": "^28.
|
|
51
|
+
"@tailwindcss/postcss": "^4.2.1",
|
|
52
|
+
"jsdom": "^28.1.0",
|
|
52
53
|
"postcss": "^8.5.3",
|
|
53
54
|
"react": "^19.2.4",
|
|
54
55
|
"react-dom": "^19.2.4",
|
|
55
|
-
"storybook": "^10.2.
|
|
56
|
-
"tailwindcss": "^4.1
|
|
56
|
+
"storybook": "^10.2.13",
|
|
57
|
+
"tailwindcss": "^4.2.1",
|
|
57
58
|
"tsdown": "^0.20.3",
|
|
58
59
|
"vite": "^7.3.1",
|
|
59
60
|
"@kopai/tsconfig": "0.2.0"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
|
+
import { createElement } from "react";
|
|
6
|
+
import { render, waitFor } from "@testing-library/react";
|
|
7
|
+
import { DynamicDashboard, type UITree } from "./index.js";
|
|
8
|
+
import { queryClient } from "../../../providers/kopai-provider.js";
|
|
9
|
+
import type { KopaiClient } from "@kopai/sdk";
|
|
10
|
+
|
|
11
|
+
type MockClient = {
|
|
12
|
+
[K in keyof KopaiClient]: ReturnType<typeof vi.fn>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function createMockClient(): MockClient {
|
|
16
|
+
return {
|
|
17
|
+
searchTracesPage: vi.fn().mockResolvedValue({ data: [] }),
|
|
18
|
+
searchLogsPage: vi.fn().mockResolvedValue({ data: [] }),
|
|
19
|
+
searchMetricsPage: vi.fn().mockResolvedValue({ data: [] }),
|
|
20
|
+
getTrace: vi.fn().mockResolvedValue({ data: [] }),
|
|
21
|
+
discoverMetrics: vi.fn().mockResolvedValue({ data: [] }),
|
|
22
|
+
searchTraces: vi.fn().mockResolvedValue({ data: [] }),
|
|
23
|
+
searchLogs: vi.fn().mockResolvedValue({ data: [] }),
|
|
24
|
+
searchMetrics: vi.fn().mockResolvedValue({ data: [] }),
|
|
25
|
+
createDashboard: vi.fn().mockResolvedValue({}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* UITree containing every component from the observability catalog.
|
|
31
|
+
*
|
|
32
|
+
* Structure:
|
|
33
|
+
* Stack (root)
|
|
34
|
+
* ├── Heading
|
|
35
|
+
* ├── Text
|
|
36
|
+
* ├── Badge
|
|
37
|
+
* ├── Divider
|
|
38
|
+
* ├── Empty
|
|
39
|
+
* ├── Card
|
|
40
|
+
* │ └── Grid
|
|
41
|
+
* │ ├── MetricTimeSeries (dataSource: searchMetricsPage)
|
|
42
|
+
* │ ├── MetricHistogram (dataSource: searchMetricsPage)
|
|
43
|
+
* │ ├── MetricStat (dataSource: searchMetricsPage)
|
|
44
|
+
* │ └── MetricTable (dataSource: searchMetricsPage)
|
|
45
|
+
* ├── LogTimeline (dataSource: searchLogsPage)
|
|
46
|
+
* ├── TraceDetail (dataSource: searchTracesPage)
|
|
47
|
+
* └── MetricDiscovery (dataSource: discoverMetrics)
|
|
48
|
+
*/
|
|
49
|
+
const ALL_ELEMENTS_TREE = {
|
|
50
|
+
root: "root",
|
|
51
|
+
elements: {
|
|
52
|
+
root: {
|
|
53
|
+
key: "root",
|
|
54
|
+
type: "Stack" as const,
|
|
55
|
+
children: [
|
|
56
|
+
"heading",
|
|
57
|
+
"text",
|
|
58
|
+
"badge",
|
|
59
|
+
"divider",
|
|
60
|
+
"empty",
|
|
61
|
+
"card",
|
|
62
|
+
"log-timeline",
|
|
63
|
+
"trace-detail",
|
|
64
|
+
"metric-discovery",
|
|
65
|
+
],
|
|
66
|
+
parentKey: "",
|
|
67
|
+
props: {
|
|
68
|
+
direction: "vertical" as const,
|
|
69
|
+
gap: "md" as const,
|
|
70
|
+
align: null,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
heading: {
|
|
74
|
+
key: "heading",
|
|
75
|
+
type: "Heading" as const,
|
|
76
|
+
children: [],
|
|
77
|
+
parentKey: "root",
|
|
78
|
+
props: { text: "Dashboard", level: "h1" as const },
|
|
79
|
+
},
|
|
80
|
+
text: {
|
|
81
|
+
key: "text",
|
|
82
|
+
type: "Text" as const,
|
|
83
|
+
children: [],
|
|
84
|
+
parentKey: "root",
|
|
85
|
+
props: { content: "Overview", variant: null, color: null },
|
|
86
|
+
},
|
|
87
|
+
badge: {
|
|
88
|
+
key: "badge",
|
|
89
|
+
type: "Badge" as const,
|
|
90
|
+
children: [],
|
|
91
|
+
parentKey: "root",
|
|
92
|
+
props: { text: "Active", variant: "success" as const },
|
|
93
|
+
},
|
|
94
|
+
divider: {
|
|
95
|
+
key: "divider",
|
|
96
|
+
type: "Divider" as const,
|
|
97
|
+
children: [],
|
|
98
|
+
parentKey: "root",
|
|
99
|
+
props: { label: null },
|
|
100
|
+
},
|
|
101
|
+
empty: {
|
|
102
|
+
key: "empty",
|
|
103
|
+
type: "Empty" as const,
|
|
104
|
+
children: [],
|
|
105
|
+
parentKey: "root",
|
|
106
|
+
props: {
|
|
107
|
+
title: "No data",
|
|
108
|
+
description: null,
|
|
109
|
+
action: null,
|
|
110
|
+
actionLabel: null,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
card: {
|
|
114
|
+
key: "card",
|
|
115
|
+
type: "Card" as const,
|
|
116
|
+
children: ["grid"],
|
|
117
|
+
parentKey: "root",
|
|
118
|
+
props: { title: "Metrics", description: null, padding: "md" as const },
|
|
119
|
+
},
|
|
120
|
+
grid: {
|
|
121
|
+
key: "grid",
|
|
122
|
+
type: "Grid" as const,
|
|
123
|
+
children: [
|
|
124
|
+
"metric-time-series",
|
|
125
|
+
"metric-histogram",
|
|
126
|
+
"metric-stat",
|
|
127
|
+
"metric-table",
|
|
128
|
+
],
|
|
129
|
+
parentKey: "card",
|
|
130
|
+
props: { columns: 2, gap: "md" as const },
|
|
131
|
+
},
|
|
132
|
+
"metric-time-series": {
|
|
133
|
+
key: "metric-time-series",
|
|
134
|
+
type: "MetricTimeSeries" as const,
|
|
135
|
+
children: [],
|
|
136
|
+
parentKey: "grid",
|
|
137
|
+
props: { height: 300, showBrush: null },
|
|
138
|
+
dataSource: {
|
|
139
|
+
method: "searchMetricsPage" as const,
|
|
140
|
+
params: { metricType: "Gauge" as const },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
"metric-histogram": {
|
|
144
|
+
key: "metric-histogram",
|
|
145
|
+
type: "MetricHistogram" as const,
|
|
146
|
+
children: [],
|
|
147
|
+
parentKey: "grid",
|
|
148
|
+
props: { height: 300 },
|
|
149
|
+
dataSource: {
|
|
150
|
+
method: "searchMetricsPage" as const,
|
|
151
|
+
params: { metricType: "Gauge" as const },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
"metric-stat": {
|
|
155
|
+
key: "metric-stat",
|
|
156
|
+
type: "MetricStat" as const,
|
|
157
|
+
children: [],
|
|
158
|
+
parentKey: "grid",
|
|
159
|
+
props: { label: "Requests", showSparkline: true },
|
|
160
|
+
dataSource: {
|
|
161
|
+
method: "searchMetricsPage" as const,
|
|
162
|
+
params: { metricType: "Gauge" as const },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
"metric-table": {
|
|
166
|
+
key: "metric-table",
|
|
167
|
+
type: "MetricTable" as const,
|
|
168
|
+
children: [],
|
|
169
|
+
parentKey: "grid",
|
|
170
|
+
props: { maxRows: 50 },
|
|
171
|
+
dataSource: {
|
|
172
|
+
method: "searchMetricsPage" as const,
|
|
173
|
+
params: { metricType: "Gauge" as const },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
"log-timeline": {
|
|
177
|
+
key: "log-timeline",
|
|
178
|
+
type: "LogTimeline" as const,
|
|
179
|
+
children: [],
|
|
180
|
+
parentKey: "root",
|
|
181
|
+
props: { height: 400 },
|
|
182
|
+
dataSource: { method: "searchLogsPage" as const, params: {} },
|
|
183
|
+
},
|
|
184
|
+
"trace-detail": {
|
|
185
|
+
key: "trace-detail",
|
|
186
|
+
type: "TraceDetail" as const,
|
|
187
|
+
children: [],
|
|
188
|
+
parentKey: "root",
|
|
189
|
+
props: { height: 400 },
|
|
190
|
+
dataSource: { method: "searchTracesPage" as const, params: {} },
|
|
191
|
+
},
|
|
192
|
+
"metric-discovery": {
|
|
193
|
+
key: "metric-discovery",
|
|
194
|
+
type: "MetricDiscovery" as const,
|
|
195
|
+
children: [],
|
|
196
|
+
parentKey: "root",
|
|
197
|
+
props: {},
|
|
198
|
+
dataSource: { method: "discoverMetrics" as const, params: {} },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
} satisfies UITree;
|
|
202
|
+
|
|
203
|
+
describe("DynamicDashboard", () => {
|
|
204
|
+
let mockClient: MockClient;
|
|
205
|
+
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
mockClient = createMockClient();
|
|
208
|
+
queryClient.clear();
|
|
209
|
+
vi.clearAllMocks();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("renders a UITree containing all catalog components", async () => {
|
|
213
|
+
const { container } = render(
|
|
214
|
+
createElement(DynamicDashboard, {
|
|
215
|
+
kopaiClient: mockClient as unknown as KopaiClient,
|
|
216
|
+
uiTree: ALL_ELEMENTS_TREE,
|
|
217
|
+
})
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Wait for async data fetches to settle
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(mockClient.searchMetricsPage).toHaveBeenCalled();
|
|
223
|
+
expect(mockClient.searchLogsPage).toHaveBeenCalled();
|
|
224
|
+
expect(mockClient.searchTracesPage).toHaveBeenCalled();
|
|
225
|
+
expect(mockClient.discoverMetrics).toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Verify static content rendered
|
|
229
|
+
expect(container.textContent).toContain("Dashboard");
|
|
230
|
+
expect(container.textContent).toContain("Overview");
|
|
231
|
+
expect(container.textContent).toContain("Active");
|
|
232
|
+
expect(container.textContent).toContain("No data");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRendererFromCatalog,
|
|
3
|
+
type UITree,
|
|
4
|
+
} from "../../../lib/renderer.js";
|
|
5
|
+
import {
|
|
6
|
+
KopaiSDKProvider,
|
|
7
|
+
type KopaiClient,
|
|
8
|
+
} from "../../../providers/kopai-provider.js";
|
|
9
|
+
import { observabilityCatalog } from "../../../lib/observability-catalog.js";
|
|
10
|
+
import {
|
|
11
|
+
Heading,
|
|
12
|
+
Text,
|
|
13
|
+
Card,
|
|
14
|
+
Stack,
|
|
15
|
+
Grid,
|
|
16
|
+
Badge,
|
|
17
|
+
Divider,
|
|
18
|
+
Empty,
|
|
19
|
+
} from "../../dashboard/index.js";
|
|
20
|
+
import {
|
|
21
|
+
OtelLogTimeline,
|
|
22
|
+
OtelMetricDiscovery,
|
|
23
|
+
OtelMetricHistogram,
|
|
24
|
+
OtelMetricStat,
|
|
25
|
+
OtelMetricTable,
|
|
26
|
+
OtelMetricTimeSeries,
|
|
27
|
+
OtelTraceDetail,
|
|
28
|
+
} from "../renderers/index.js";
|
|
29
|
+
|
|
30
|
+
const MetricsRenderer = createRendererFromCatalog(observabilityCatalog, {
|
|
31
|
+
Card,
|
|
32
|
+
Grid,
|
|
33
|
+
Stack,
|
|
34
|
+
Heading,
|
|
35
|
+
Text,
|
|
36
|
+
Badge,
|
|
37
|
+
Divider,
|
|
38
|
+
Empty,
|
|
39
|
+
LogTimeline: OtelLogTimeline,
|
|
40
|
+
TraceDetail: OtelTraceDetail,
|
|
41
|
+
MetricTimeSeries: OtelMetricTimeSeries,
|
|
42
|
+
MetricHistogram: OtelMetricHistogram,
|
|
43
|
+
MetricStat: OtelMetricStat,
|
|
44
|
+
MetricTable: OtelMetricTable,
|
|
45
|
+
MetricDiscovery: OtelMetricDiscovery,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export { type UITree };
|
|
49
|
+
|
|
50
|
+
export interface DynamicDashboardProps {
|
|
51
|
+
kopaiClient: KopaiClient;
|
|
52
|
+
uiTree: UITree;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function DynamicDashboard({
|
|
56
|
+
kopaiClient,
|
|
57
|
+
uiTree,
|
|
58
|
+
}: DynamicDashboardProps) {
|
|
59
|
+
return (
|
|
60
|
+
<KopaiSDKProvider client={kopaiClient}>
|
|
61
|
+
<MetricsRenderer tree={uiTree} />
|
|
62
|
+
</KopaiSDKProvider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { MetricHistogram } from "./index.js";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
mockHistogramRows,
|
|
5
|
+
mockNoAttributeHistogramRows,
|
|
6
|
+
} from "../__fixtures__/metrics.js";
|
|
4
7
|
|
|
5
8
|
const meta: Meta<typeof MetricHistogram> = {
|
|
6
9
|
title: "Observability/MetricHistogram",
|
|
@@ -18,3 +21,9 @@ export const Error: Story = {
|
|
|
18
21
|
},
|
|
19
22
|
};
|
|
20
23
|
export const Empty: Story = { args: { rows: [] } };
|
|
24
|
+
export const NoAttributes: Story = {
|
|
25
|
+
args: { rows: mockNoAttributeHistogramRows },
|
|
26
|
+
};
|
|
27
|
+
export const WithUnit: Story = {
|
|
28
|
+
args: { rows: mockHistogramRows, unit: "ms" },
|
|
29
|
+
};
|
|
@@ -15,6 +15,12 @@ import {
|
|
|
15
15
|
Cell,
|
|
16
16
|
} from "recharts";
|
|
17
17
|
import type { denormalizedSignals } from "@kopai/core";
|
|
18
|
+
import { formatSeriesLabel } from "../utils/attributes.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveUnitScale,
|
|
21
|
+
formatDisplayValue,
|
|
22
|
+
type ResolvedScale,
|
|
23
|
+
} from "../utils/units.js";
|
|
18
24
|
|
|
19
25
|
type OtelMetricsRow = denormalizedSignals.OtelMetricsRow;
|
|
20
26
|
|
|
@@ -32,6 +38,7 @@ export interface MetricHistogramProps {
|
|
|
32
38
|
isLoading?: boolean;
|
|
33
39
|
error?: Error;
|
|
34
40
|
height?: number;
|
|
41
|
+
unit?: string;
|
|
35
42
|
yAxisLabel?: string;
|
|
36
43
|
showLegend?: boolean;
|
|
37
44
|
formatBucketLabel?: (
|
|
@@ -69,17 +76,38 @@ const defaultFormatValue = (value: number): string => {
|
|
|
69
76
|
function buildHistogramData(
|
|
70
77
|
rows: OtelMetricsRow[],
|
|
71
78
|
formatLabel = defaultFormatBucketLabel
|
|
72
|
-
): {
|
|
79
|
+
): {
|
|
80
|
+
buckets: BucketData[];
|
|
81
|
+
seriesKeys: string[];
|
|
82
|
+
displayLabelMap: Map<string, string>;
|
|
83
|
+
unit: string;
|
|
84
|
+
} {
|
|
73
85
|
const buckets: BucketData[] = [];
|
|
74
86
|
const seriesKeysSet = new Set<string>();
|
|
87
|
+
const displayLabelMap = new Map<string, string>();
|
|
88
|
+
let unit = "";
|
|
75
89
|
|
|
76
90
|
for (const row of rows) {
|
|
77
91
|
if (row.MetricType !== "Histogram") continue;
|
|
92
|
+
if (!unit && row.MetricUnit) unit = row.MetricUnit;
|
|
78
93
|
const name = row.MetricName ?? "count";
|
|
79
94
|
const key = row.Attributes ? JSON.stringify(row.Attributes) : "__default__";
|
|
80
95
|
const seriesName = key === "__default__" ? name : key;
|
|
81
96
|
seriesKeysSet.add(seriesName);
|
|
82
97
|
|
|
98
|
+
if (!displayLabelMap.has(seriesName)) {
|
|
99
|
+
if (key === "__default__") {
|
|
100
|
+
displayLabelMap.set(seriesName, name);
|
|
101
|
+
} else {
|
|
102
|
+
const labels: Record<string, string> = {};
|
|
103
|
+
if (row.Attributes) {
|
|
104
|
+
for (const [k, v] of Object.entries(row.Attributes))
|
|
105
|
+
labels[k] = String(v);
|
|
106
|
+
}
|
|
107
|
+
displayLabelMap.set(seriesName, formatSeriesLabel(labels) || name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
const bounds = row.ExplicitBounds ?? [];
|
|
84
112
|
const counts = row.BucketCounts ?? [];
|
|
85
113
|
|
|
@@ -102,7 +130,12 @@ function buildHistogramData(
|
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
buckets.sort((a, b) => a.lowerBound - b.lowerBound);
|
|
105
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
buckets,
|
|
135
|
+
seriesKeys: Array.from(seriesKeysSet),
|
|
136
|
+
displayLabelMap,
|
|
137
|
+
unit,
|
|
138
|
+
};
|
|
106
139
|
}
|
|
107
140
|
|
|
108
141
|
export function MetricHistogram({
|
|
@@ -110,6 +143,7 @@ export function MetricHistogram({
|
|
|
110
143
|
isLoading = false,
|
|
111
144
|
error,
|
|
112
145
|
height = 400,
|
|
146
|
+
unit: unitProp,
|
|
113
147
|
yAxisLabel,
|
|
114
148
|
showLegend = true,
|
|
115
149
|
formatBucketLabel,
|
|
@@ -117,17 +151,30 @@ export function MetricHistogram({
|
|
|
117
151
|
labelStyle = "staggered",
|
|
118
152
|
}: MetricHistogramProps) {
|
|
119
153
|
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
154
|
|
|
126
|
-
const { buckets, seriesKeys } = useMemo(() => {
|
|
127
|
-
if (rows.length === 0)
|
|
155
|
+
const { buckets, seriesKeys, displayLabelMap, unit } = useMemo(() => {
|
|
156
|
+
if (rows.length === 0)
|
|
157
|
+
return {
|
|
158
|
+
buckets: [],
|
|
159
|
+
seriesKeys: [],
|
|
160
|
+
displayLabelMap: new Map(),
|
|
161
|
+
unit: "",
|
|
162
|
+
};
|
|
128
163
|
return buildHistogramData(rows, bucketLabelFormatter);
|
|
129
164
|
}, [rows, bucketLabelFormatter]);
|
|
130
165
|
|
|
166
|
+
const effectiveUnit = unitProp ?? unit;
|
|
167
|
+
|
|
168
|
+
const boundsScale = useMemo(() => {
|
|
169
|
+
if (!effectiveUnit || buckets.length === 0) return null;
|
|
170
|
+
// Buckets are sorted by lowerBound — walk backwards to find last finite upperBound
|
|
171
|
+
for (let i = buckets.length - 1; i >= 0; i--) {
|
|
172
|
+
if (buckets[i]!.upperBound !== Infinity)
|
|
173
|
+
return resolveUnitScale(effectiveUnit, buckets[i]!.upperBound);
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}, [effectiveUnit, buckets]);
|
|
177
|
+
|
|
131
178
|
if (isLoading) return <HistogramLoadingSkeleton height={height} />;
|
|
132
179
|
|
|
133
180
|
if (error) {
|
|
@@ -217,9 +264,20 @@ export function MetricHistogram({
|
|
|
217
264
|
}
|
|
218
265
|
/>
|
|
219
266
|
<Tooltip
|
|
220
|
-
content={
|
|
267
|
+
content={(props) => (
|
|
268
|
+
<HistogramTooltip
|
|
269
|
+
{...props}
|
|
270
|
+
formatValue={formatValue}
|
|
271
|
+
boundsScale={boundsScale}
|
|
272
|
+
displayLabelMap={displayLabelMap}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
221
275
|
/>
|
|
222
|
-
{showLegend && seriesKeys.length > 1 &&
|
|
276
|
+
{showLegend && seriesKeys.length > 1 && (
|
|
277
|
+
<Legend
|
|
278
|
+
formatter={(value: string) => displayLabelMap.get(value) ?? value}
|
|
279
|
+
/>
|
|
280
|
+
)}
|
|
223
281
|
{seriesKeys.map((key, i) => (
|
|
224
282
|
<Bar
|
|
225
283
|
key={key}
|
|
@@ -242,31 +300,39 @@ function HistogramTooltip({
|
|
|
242
300
|
active,
|
|
243
301
|
payload,
|
|
244
302
|
formatValue,
|
|
245
|
-
|
|
303
|
+
boundsScale,
|
|
304
|
+
displayLabelMap,
|
|
246
305
|
}: {
|
|
247
306
|
active?: boolean;
|
|
248
|
-
payload?:
|
|
307
|
+
payload?: readonly {
|
|
249
308
|
dataKey: string;
|
|
250
309
|
value: number;
|
|
251
310
|
color: string;
|
|
252
311
|
payload: BucketData;
|
|
253
|
-
}
|
|
312
|
+
}[];
|
|
254
313
|
formatValue: (val: number) => string;
|
|
255
|
-
|
|
314
|
+
boundsScale: ResolvedScale | null;
|
|
315
|
+
displayLabelMap: Map<string, string>;
|
|
256
316
|
}) {
|
|
257
317
|
if (!active || !payload?.length) return null;
|
|
258
318
|
const bucket = payload[0]?.payload;
|
|
259
319
|
if (!bucket) return null;
|
|
320
|
+
|
|
321
|
+
const boundsLabel = boundsScale
|
|
322
|
+
? `${formatDisplayValue(bucket.lowerBound, boundsScale)} – ${bucket.upperBound === Infinity ? "∞" : formatDisplayValue(bucket.upperBound, boundsScale)}`
|
|
323
|
+
: bucket.bucket;
|
|
324
|
+
|
|
260
325
|
return (
|
|
261
326
|
<div className="bg-background border border-gray-700 rounded-lg p-3 shadow-lg">
|
|
262
327
|
<p className="text-gray-300 text-sm font-medium mb-2">
|
|
263
|
-
Bucket: {
|
|
264
|
-
{unit ? ` ${unit}` : ""}
|
|
328
|
+
Bucket: {boundsLabel}
|
|
265
329
|
</p>
|
|
266
330
|
{payload.map((entry, i) => (
|
|
267
331
|
<p key={i} className="text-sm" style={{ color: entry.color }}>
|
|
268
|
-
<span className="font-medium">
|
|
269
|
-
|
|
332
|
+
<span className="font-medium">
|
|
333
|
+
{displayLabelMap.get(entry.dataKey) ?? entry.dataKey}:
|
|
334
|
+
</span>{" "}
|
|
335
|
+
{formatValue(entry.value)}
|
|
270
336
|
</p>
|
|
271
337
|
))}
|
|
272
338
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { MetricStat } from "./index.js";
|
|
3
|
-
import { mockStatRows } from "../__fixtures__/metrics.js";
|
|
3
|
+
import { mockStatRows, mockNoAttributeRows } from "../__fixtures__/metrics.js";
|
|
4
4
|
|
|
5
5
|
const meta: Meta<typeof MetricStat> = {
|
|
6
6
|
title: "Observability/MetricStat",
|
|
@@ -28,3 +28,4 @@ export const Error: Story = {
|
|
|
28
28
|
args: { rows: [], error: new globalThis.Error("Failed to fetch stat") },
|
|
29
29
|
};
|
|
30
30
|
export const Empty: Story = { args: { rows: [] } };
|
|
31
|
+
export const NoAttributes: Story = { args: { rows: mockNoAttributeRows } };
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { MetricTimeSeries } from "./index.js";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
mockGaugeRows,
|
|
5
|
+
mockSumRows,
|
|
6
|
+
mockNoAttributeRows,
|
|
7
|
+
mockMultiAttributeRows,
|
|
8
|
+
mockStatRows,
|
|
9
|
+
mockLargeByteRows,
|
|
10
|
+
} from "../__fixtures__/metrics.js";
|
|
4
11
|
|
|
5
12
|
const meta: Meta<typeof MetricTimeSeries> = {
|
|
6
13
|
title: "Observability/MetricTimeSeries",
|
|
@@ -26,3 +33,18 @@ export const Error: Story = {
|
|
|
26
33
|
args: { rows: [], error: new globalThis.Error("Failed to fetch metrics") },
|
|
27
34
|
};
|
|
28
35
|
export const Empty: Story = { args: { rows: [] } };
|
|
36
|
+
export const NoAttributes: Story = {
|
|
37
|
+
args: { rows: mockNoAttributeRows },
|
|
38
|
+
};
|
|
39
|
+
export const MultiAttributes: Story = {
|
|
40
|
+
args: { rows: mockMultiAttributeRows },
|
|
41
|
+
};
|
|
42
|
+
export const BytesUnit: Story = {
|
|
43
|
+
args: { rows: mockNoAttributeRows, unit: "By" },
|
|
44
|
+
};
|
|
45
|
+
export const PercentUnit: Story = {
|
|
46
|
+
args: { rows: mockStatRows, unit: "1" },
|
|
47
|
+
};
|
|
48
|
+
export const GigabyteScale: Story = {
|
|
49
|
+
args: { rows: mockLargeByteRows, unit: "By" },
|
|
50
|
+
};
|