@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export interface ResolvedScale {
|
|
2
|
+
divisor: number;
|
|
3
|
+
suffix: string;
|
|
4
|
+
label: string;
|
|
5
|
+
isPercent: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ScaleEntry {
|
|
9
|
+
threshold: number;
|
|
10
|
+
divisor: number;
|
|
11
|
+
suffix: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const BYTE_SCALES: ScaleEntry[] = [
|
|
15
|
+
{ threshold: 1e12, divisor: 1e12, suffix: "TB" },
|
|
16
|
+
{ threshold: 1e9, divisor: 1e9, suffix: "GB" },
|
|
17
|
+
{ threshold: 1e6, divisor: 1e6, suffix: "MB" },
|
|
18
|
+
{ threshold: 1e3, divisor: 1e3, suffix: "KB" },
|
|
19
|
+
{ threshold: 0, divisor: 1, suffix: "B" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const SECOND_SCALES: ScaleEntry[] = [
|
|
23
|
+
{ threshold: 3600, divisor: 3600, suffix: "h" },
|
|
24
|
+
{ threshold: 60, divisor: 60, suffix: "min" },
|
|
25
|
+
{ threshold: 1, divisor: 1, suffix: "s" },
|
|
26
|
+
{ threshold: 0, divisor: 0.001, suffix: "ms" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const MS_SCALES: ScaleEntry[] = [
|
|
30
|
+
{ threshold: 1000, divisor: 1000, suffix: "s" },
|
|
31
|
+
{ threshold: 0, divisor: 1, suffix: "ms" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const US_SCALES: ScaleEntry[] = [
|
|
35
|
+
{ threshold: 1e6, divisor: 1e6, suffix: "s" },
|
|
36
|
+
{ threshold: 1000, divisor: 1000, suffix: "ms" },
|
|
37
|
+
{ threshold: 0, divisor: 1, suffix: "\u03BCs" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const GENERIC_SCALES: ScaleEntry[] = [
|
|
41
|
+
{ threshold: 1e9, divisor: 1e9, suffix: "B" },
|
|
42
|
+
{ threshold: 1e6, divisor: 1e6, suffix: "M" },
|
|
43
|
+
{ threshold: 1e3, divisor: 1e3, suffix: "K" },
|
|
44
|
+
{ threshold: 0, divisor: 1, suffix: "" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const BRACE_UNIT_PATTERN = /^\{(.+)\}$/;
|
|
48
|
+
|
|
49
|
+
const UNIT_SCALE_MAP: Record<string, ScaleEntry[]> = {
|
|
50
|
+
By: BYTE_SCALES,
|
|
51
|
+
s: SECOND_SCALES,
|
|
52
|
+
ms: MS_SCALES,
|
|
53
|
+
us: US_SCALES,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function pickScale(scales: ScaleEntry[], maxValue: number): ScaleEntry {
|
|
57
|
+
const abs = Math.abs(maxValue);
|
|
58
|
+
for (const s of scales) {
|
|
59
|
+
if (abs >= s.threshold && s.threshold > 0) return s;
|
|
60
|
+
}
|
|
61
|
+
return scales[scales.length - 1]!;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveUnitScale(
|
|
65
|
+
unit: string | null | undefined,
|
|
66
|
+
maxValue: number
|
|
67
|
+
): ResolvedScale {
|
|
68
|
+
if (!unit) {
|
|
69
|
+
const s = pickScale(GENERIC_SCALES, maxValue);
|
|
70
|
+
return {
|
|
71
|
+
divisor: s.divisor,
|
|
72
|
+
suffix: s.suffix,
|
|
73
|
+
label: "",
|
|
74
|
+
isPercent: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Dimensionless ratio → percent
|
|
79
|
+
if (unit === "1") {
|
|
80
|
+
return { divisor: 0.01, suffix: "%", label: "Percent", isPercent: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Known unit families
|
|
84
|
+
const scales = UNIT_SCALE_MAP[unit];
|
|
85
|
+
if (scales) {
|
|
86
|
+
const s = pickScale(scales, maxValue);
|
|
87
|
+
return {
|
|
88
|
+
divisor: s.divisor,
|
|
89
|
+
suffix: s.suffix,
|
|
90
|
+
label: s.suffix,
|
|
91
|
+
isPercent: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Curly-brace units like {requests}
|
|
96
|
+
const braceMatch = BRACE_UNIT_PATTERN.exec(unit);
|
|
97
|
+
if (braceMatch) {
|
|
98
|
+
const cleaned = braceMatch[1]!;
|
|
99
|
+
const s = pickScale(GENERIC_SCALES, maxValue);
|
|
100
|
+
const suffix = s.suffix ? `${s.suffix} ${cleaned}` : cleaned;
|
|
101
|
+
return { divisor: s.divisor, suffix, label: cleaned, isPercent: false };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Unknown unit — generic scaling + append unit
|
|
105
|
+
const s = pickScale(GENERIC_SCALES, maxValue);
|
|
106
|
+
const suffix = s.suffix ? `${s.suffix} ${unit}` : unit;
|
|
107
|
+
return { divisor: s.divisor, suffix, label: unit, isPercent: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatTickValue(value: number, scale: ResolvedScale): string {
|
|
111
|
+
const scaled = value / scale.divisor;
|
|
112
|
+
if (scale.isPercent) return `${scaled.toFixed(1)}`;
|
|
113
|
+
if (Number.isInteger(scaled) && Math.abs(scaled) < 1e4)
|
|
114
|
+
return scaled.toString();
|
|
115
|
+
return scaled.toFixed(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatDisplayValue(
|
|
119
|
+
value: number,
|
|
120
|
+
scale: ResolvedScale
|
|
121
|
+
): string {
|
|
122
|
+
const tick = formatTickValue(value, scale);
|
|
123
|
+
if (!scale.suffix) return tick;
|
|
124
|
+
if (scale.isPercent) return `${tick}${scale.suffix}`;
|
|
125
|
+
return `${tick} ${scale.suffix}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Convenience: resolve + format in one call (for MetricStat) */
|
|
129
|
+
export function formatOtelValue(value: number, unit: string): string {
|
|
130
|
+
const scale = resolveUnitScale(unit, Math.abs(value));
|
|
131
|
+
return formatDisplayValue(value, scale);
|
|
132
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { default as ObservabilityPage } from "./pages/observability.js";
|
|
2
2
|
export { createCatalog } from "./lib/component-catalog.js";
|
|
3
|
+
export { observabilityCatalog } from "./lib/observability-catalog.js";
|
|
3
4
|
export { generatePromptInstructions } from "./lib/generate-prompt-instructions.js";
|
|
4
5
|
export {
|
|
5
6
|
Renderer,
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
3
|
exports[`generatePromptInstructions > generates full prompt instructions 1`] = `
|
|
4
|
-
"##
|
|
4
|
+
"## UI Tree Version
|
|
5
|
+
|
|
6
|
+
Use uiTreeVersion: "0.5.0" when creating dashboards.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Available Components
|
|
5
11
|
|
|
6
12
|
### Card
|
|
7
13
|
A card container
|
|
@@ -74,16 +74,19 @@ function buildExampleElements(
|
|
|
74
74
|
for (const name of otherNames) {
|
|
75
75
|
const key = `${String(name).toLowerCase()}-1`;
|
|
76
76
|
childKeys.push(key);
|
|
77
|
-
|
|
77
|
+
const element: Record<string, unknown> = {
|
|
78
78
|
key,
|
|
79
79
|
type: String(name),
|
|
80
80
|
props: {},
|
|
81
81
|
parentKey: containerKey,
|
|
82
|
-
|
|
82
|
+
};
|
|
83
|
+
if (!components[name]?.hasChildren) {
|
|
84
|
+
element.dataSource = {
|
|
83
85
|
method: "searchTracesPage",
|
|
84
86
|
params: { limit: 10 },
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
elements[key] = element;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
return { root: containerKey, elements };
|
|
@@ -140,7 +143,10 @@ function buildUnifiedSchema(treeSchema: z.ZodTypeAny): object {
|
|
|
140
143
|
* const prompt = `Build a dashboard UI.\n\n${instructions}`;
|
|
141
144
|
* ```
|
|
142
145
|
*/
|
|
143
|
-
export function generatePromptInstructions(
|
|
146
|
+
export function generatePromptInstructions(
|
|
147
|
+
catalog: Catalog,
|
|
148
|
+
uiTreeVersion: string
|
|
149
|
+
): string {
|
|
144
150
|
const componentNames = Object.keys(catalog.components);
|
|
145
151
|
|
|
146
152
|
const componentSections = componentNames
|
|
@@ -167,7 +173,13 @@ ${roleLine}`;
|
|
|
167
173
|
catalog.components
|
|
168
174
|
);
|
|
169
175
|
|
|
170
|
-
return `##
|
|
176
|
+
return `## UI Tree Version
|
|
177
|
+
|
|
178
|
+
Use uiTreeVersion: "${uiTreeVersion}" when creating dashboards.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Available Components
|
|
171
183
|
|
|
172
184
|
${componentSections}
|
|
173
185
|
|
|
@@ -106,13 +106,19 @@ export const observabilityCatalog = createCatalog({
|
|
|
106
106
|
props: z.object({
|
|
107
107
|
height: z.number().nullable(),
|
|
108
108
|
showBrush: z.boolean().nullable(),
|
|
109
|
+
yAxisLabel: z.string().nullable(),
|
|
110
|
+
unit: z.string().nullable(),
|
|
109
111
|
}),
|
|
110
112
|
hasChildren: false,
|
|
111
113
|
description: "Time series line chart for Gauge/Sum metrics",
|
|
112
114
|
},
|
|
113
115
|
|
|
114
116
|
MetricHistogram: {
|
|
115
|
-
props: z.object({
|
|
117
|
+
props: z.object({
|
|
118
|
+
height: z.number().nullable(),
|
|
119
|
+
yAxisLabel: z.string().nullable(),
|
|
120
|
+
unit: z.string().nullable(),
|
|
121
|
+
}),
|
|
116
122
|
hasChildren: false,
|
|
117
123
|
description: "Histogram bar chart for distribution metrics",
|
|
118
124
|
},
|
package/src/lib/renderer.tsx
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import { createElement } from "react";
|
|
6
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
7
|
+
import ObservabilityPage from "./observability.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
|
+
const VALID_TREE = {
|
|
30
|
+
root: "root",
|
|
31
|
+
elements: {
|
|
32
|
+
root: {
|
|
33
|
+
key: "root",
|
|
34
|
+
type: "Stack",
|
|
35
|
+
children: ["heading"],
|
|
36
|
+
parentKey: "",
|
|
37
|
+
props: { direction: "vertical", gap: "md", align: null },
|
|
38
|
+
},
|
|
39
|
+
heading: {
|
|
40
|
+
key: "heading",
|
|
41
|
+
type: "Heading",
|
|
42
|
+
children: [],
|
|
43
|
+
parentKey: "root",
|
|
44
|
+
props: { text: "Test Dashboard", level: "h2" },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe("useDashboardTree validation", () => {
|
|
50
|
+
let mockClient: MockClient;
|
|
51
|
+
let originalLocation: string;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
mockClient = createMockClient();
|
|
55
|
+
queryClient.clear();
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
originalLocation = window.location.search;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
window.history.replaceState(
|
|
63
|
+
null,
|
|
64
|
+
"",
|
|
65
|
+
window.location.pathname + originalLocation
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function setURL(params: string) {
|
|
70
|
+
window.history.replaceState(null, "", window.location.pathname + params);
|
|
71
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("renders DynamicDashboard when API returns a valid uiTree", async () => {
|
|
75
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
76
|
+
new Response(JSON.stringify({ uiTree: VALID_TREE }), {
|
|
77
|
+
status: 200,
|
|
78
|
+
headers: { "Content-Type": "application/json" },
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
setURL("?tab=metrics&dashboardId=abc");
|
|
83
|
+
|
|
84
|
+
render(
|
|
85
|
+
createElement(ObservabilityPage, {
|
|
86
|
+
client: mockClient as unknown as KopaiClient,
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(screen.getByText("Test Dashboard")).toBeTruthy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(screen.queryByText(/invalid layout/i)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("shows error when API returns an invalid uiTree", async () => {
|
|
98
|
+
const invalidTree = {
|
|
99
|
+
root: "x",
|
|
100
|
+
elements: {
|
|
101
|
+
x: { type: "Bogus", key: "x", children: [], parentKey: "" },
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
106
|
+
new Response(JSON.stringify({ uiTree: invalidTree }), {
|
|
107
|
+
status: 200,
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
setURL("?tab=metrics&dashboardId=def");
|
|
113
|
+
|
|
114
|
+
render(
|
|
115
|
+
createElement(ObservabilityPage, {
|
|
116
|
+
client: mockClient as unknown as KopaiClient,
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(screen.getByText(/invalid layout/i)).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -7,14 +7,13 @@ import {
|
|
|
7
7
|
useRef,
|
|
8
8
|
} from "react";
|
|
9
9
|
import { KopaiSDKProvider, useKopaiSDK } from "../providers/kopai-provider.js";
|
|
10
|
+
import { useQuery } from "@tanstack/react-query";
|
|
10
11
|
import { KopaiClient } from "@kopai/sdk";
|
|
11
12
|
import { useKopaiData } from "../hooks/use-kopai-data.js";
|
|
12
13
|
import { useLiveLogs } from "../hooks/use-live-logs.js";
|
|
13
14
|
import type { denormalizedSignals, dataFilterSchemas } from "@kopai/core";
|
|
14
15
|
import type { DataSource } from "../lib/component-catalog.js";
|
|
15
16
|
import { observabilityCatalog } from "../lib/observability-catalog.js";
|
|
16
|
-
import { createRendererFromCatalog } from "../lib/renderer.js";
|
|
17
|
-
|
|
18
17
|
// Observability components
|
|
19
18
|
import {
|
|
20
19
|
LogTimeline,
|
|
@@ -25,24 +24,15 @@ import {
|
|
|
25
24
|
TraceDetail,
|
|
26
25
|
KeyboardShortcutsProvider,
|
|
27
26
|
useRegisterShortcuts,
|
|
27
|
+
DynamicDashboard,
|
|
28
28
|
} from "../components/observability/index.js";
|
|
29
|
+
import type { UITree } from "../components/observability/DynamicDashboard/index.js";
|
|
29
30
|
import type {
|
|
30
31
|
TraceSummary,
|
|
31
32
|
TraceSearchFilters,
|
|
32
33
|
} from "../components/observability/index.js";
|
|
33
34
|
|
|
34
35
|
import { SERVICES_SHORTCUTS } from "../components/observability/ServiceList/shortcuts.js";
|
|
35
|
-
import { OtelMetricDiscovery } from "../components/observability/renderers/index.js";
|
|
36
|
-
import {
|
|
37
|
-
Heading,
|
|
38
|
-
Text,
|
|
39
|
-
Card,
|
|
40
|
-
Stack,
|
|
41
|
-
Grid,
|
|
42
|
-
Badge,
|
|
43
|
-
Divider,
|
|
44
|
-
Empty,
|
|
45
|
-
} from "../components/dashboard/index.js";
|
|
46
36
|
|
|
47
37
|
type OtelTracesRow = denormalizedSignals.OtelTracesRow;
|
|
48
38
|
|
|
@@ -67,6 +57,7 @@ interface URLState {
|
|
|
67
57
|
service: string | null;
|
|
68
58
|
trace: string | null;
|
|
69
59
|
span: string | null;
|
|
60
|
+
dashboardId: string | null;
|
|
70
61
|
}
|
|
71
62
|
|
|
72
63
|
function readURLState(): URLState {
|
|
@@ -74,13 +65,14 @@ function readURLState(): URLState {
|
|
|
74
65
|
const service = params.get("service");
|
|
75
66
|
const trace = params.get("trace");
|
|
76
67
|
const span = params.get("span");
|
|
68
|
+
const dashboardId = params.get("dashboardId");
|
|
77
69
|
const rawTab = params.get("tab");
|
|
78
70
|
const tab = service
|
|
79
71
|
? "services"
|
|
80
72
|
: rawTab === "logs" || rawTab === "metrics"
|
|
81
73
|
? rawTab
|
|
82
74
|
: "services";
|
|
83
|
-
return { tab, service, trace, span };
|
|
75
|
+
return { tab, service, trace, span, dashboardId };
|
|
84
76
|
}
|
|
85
77
|
|
|
86
78
|
function pushURLState(
|
|
@@ -89,6 +81,7 @@ function pushURLState(
|
|
|
89
81
|
service?: string | null;
|
|
90
82
|
trace?: string | null;
|
|
91
83
|
span?: string | null;
|
|
84
|
+
dashboardId?: string | null;
|
|
92
85
|
},
|
|
93
86
|
{ replace = false }: { replace?: boolean } = {}
|
|
94
87
|
) {
|
|
@@ -97,6 +90,12 @@ function pushURLState(
|
|
|
97
90
|
if (state.service) params.set("service", state.service);
|
|
98
91
|
if (state.trace) params.set("trace", state.trace);
|
|
99
92
|
if (state.span) params.set("span", state.span);
|
|
93
|
+
// Preserve dashboardId from current URL if not explicitly provided
|
|
94
|
+
const dashboardId =
|
|
95
|
+
state.dashboardId !== undefined
|
|
96
|
+
? state.dashboardId
|
|
97
|
+
: new URLSearchParams(window.location.search).get("dashboardId");
|
|
98
|
+
if (dashboardId) params.set("dashboardId", dashboardId);
|
|
100
99
|
const qs = params.toString();
|
|
101
100
|
const url = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
|
102
101
|
if (replace) {
|
|
@@ -118,6 +117,7 @@ let _cachedState: URLState = {
|
|
|
118
117
|
service: null,
|
|
119
118
|
trace: null,
|
|
120
119
|
span: null,
|
|
120
|
+
dashboardId: null,
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
function getURLSnapshot(): URLState {
|
|
@@ -660,26 +660,10 @@ function ServicesTab({
|
|
|
660
660
|
}
|
|
661
661
|
|
|
662
662
|
// ---------------------------------------------------------------------------
|
|
663
|
-
// Metrics tab —
|
|
663
|
+
// Metrics tab — DynamicDashboard
|
|
664
664
|
// ---------------------------------------------------------------------------
|
|
665
665
|
|
|
666
|
-
const
|
|
667
|
-
Card,
|
|
668
|
-
Grid,
|
|
669
|
-
Stack,
|
|
670
|
-
Heading,
|
|
671
|
-
Text,
|
|
672
|
-
Badge,
|
|
673
|
-
Divider,
|
|
674
|
-
Empty,
|
|
675
|
-
LogTimeline: () => null,
|
|
676
|
-
TraceDetail: () => null,
|
|
677
|
-
MetricTimeSeries: () => null,
|
|
678
|
-
MetricHistogram: () => null,
|
|
679
|
-
MetricStat: () => null,
|
|
680
|
-
MetricTable: () => null,
|
|
681
|
-
MetricDiscovery: OtelMetricDiscovery,
|
|
682
|
-
});
|
|
666
|
+
const DASHBOARDS_API_BASE = "/dashboards";
|
|
683
667
|
|
|
684
668
|
const METRICS_TREE = {
|
|
685
669
|
root: "root",
|
|
@@ -731,17 +715,68 @@ const METRICS_TREE = {
|
|
|
731
715
|
},
|
|
732
716
|
};
|
|
733
717
|
|
|
718
|
+
function useDashboardTree(dashboardId: string | null) {
|
|
719
|
+
const { data, isFetching, error } = useQuery<UITree, Error>({
|
|
720
|
+
queryKey: ["dashboard-tree", dashboardId],
|
|
721
|
+
queryFn: async ({ signal }) => {
|
|
722
|
+
const res = await fetch(`${DASHBOARDS_API_BASE}/${dashboardId}`, {
|
|
723
|
+
signal,
|
|
724
|
+
});
|
|
725
|
+
if (!res.ok) throw new Error(`Failed to load dashboard: ${res.status}`);
|
|
726
|
+
const json = await res.json();
|
|
727
|
+
const parsed = observabilityCatalog.uiTreeSchema.safeParse(json.uiTree);
|
|
728
|
+
if (!parsed.success) {
|
|
729
|
+
const issue = parsed.error.issues[0];
|
|
730
|
+
const path = issue?.path.length ? issue.path.join(".") + ": " : "";
|
|
731
|
+
throw new Error(
|
|
732
|
+
`Dashboard has an invalid layout: ${path}${issue?.message}`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
return parsed.data;
|
|
736
|
+
},
|
|
737
|
+
enabled: !!dashboardId,
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
loading: isFetching,
|
|
742
|
+
error: error?.message ?? null,
|
|
743
|
+
tree: data ?? null,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
734
747
|
function MetricsTab() {
|
|
735
|
-
|
|
748
|
+
const kopaiClient = useKopaiSDK();
|
|
749
|
+
const { dashboardId } = useURLState();
|
|
750
|
+
const { loading, error, tree } = useDashboardTree(dashboardId);
|
|
751
|
+
|
|
752
|
+
if (loading)
|
|
753
|
+
return (
|
|
754
|
+
<p className="text-muted-foreground text-sm">Loading dashboard...</p>
|
|
755
|
+
);
|
|
756
|
+
if (error)
|
|
757
|
+
return <p className="text-muted-foreground text-sm">Error: {error}</p>;
|
|
758
|
+
|
|
759
|
+
return (
|
|
760
|
+
<DynamicDashboard kopaiClient={kopaiClient} uiTree={tree ?? METRICS_TREE} />
|
|
761
|
+
);
|
|
736
762
|
}
|
|
737
763
|
|
|
738
764
|
// ---------------------------------------------------------------------------
|
|
739
765
|
// Page
|
|
740
766
|
// ---------------------------------------------------------------------------
|
|
741
767
|
|
|
742
|
-
|
|
768
|
+
let _defaultClient: KopaiClient | undefined;
|
|
769
|
+
function getDefaultClient() {
|
|
770
|
+
_defaultClient ??= new KopaiClient({ baseUrl: "" });
|
|
771
|
+
return _defaultClient;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
interface ObservabilityPageProps {
|
|
775
|
+
client?: KopaiClient;
|
|
776
|
+
}
|
|
743
777
|
|
|
744
|
-
export default function ObservabilityPage() {
|
|
778
|
+
export default function ObservabilityPage({ client }: ObservabilityPageProps) {
|
|
779
|
+
const activeClient = client ?? getDefaultClient();
|
|
745
780
|
const {
|
|
746
781
|
tab: activeTab,
|
|
747
782
|
service: selectedService,
|
|
@@ -792,7 +827,7 @@ export default function ObservabilityPage() {
|
|
|
792
827
|
}, [selectedService]);
|
|
793
828
|
|
|
794
829
|
return (
|
|
795
|
-
<KopaiSDKProvider client={
|
|
830
|
+
<KopaiSDKProvider client={activeClient}>
|
|
796
831
|
<KeyboardShortcutsProvider
|
|
797
832
|
onNavigateServices={() => pushURLState({ tab: "services" })}
|
|
798
833
|
onNavigateLogs={() => pushURLState({ tab: "logs" })}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { observabilityCatalog } from "./observability-catalog.js";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
|
|
4
|
-
type UiTree = z.infer<typeof observabilityCatalog.uiTreeSchema>;
|
|
5
|
-
|
|
6
|
-
export type DashboardId = string;
|
|
7
|
-
export type UiTreeId = string;
|
|
8
|
-
export type OwnerId = string;
|
|
9
|
-
|
|
10
|
-
export interface DashboardMeta {
|
|
11
|
-
dashboardId: DashboardId;
|
|
12
|
-
uiTreeId: UiTreeId;
|
|
13
|
-
name: string;
|
|
14
|
-
ownerId: OwnerId;
|
|
15
|
-
pinned: boolean;
|
|
16
|
-
createdAt: string;
|
|
17
|
-
updatedAt: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface VersionMeta {
|
|
21
|
-
uiTreeId: UiTreeId;
|
|
22
|
-
createdAt: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface DashboardWriteDatasource {
|
|
26
|
-
createDashboard(
|
|
27
|
-
uiTree: UiTree,
|
|
28
|
-
name: string,
|
|
29
|
-
ownerId: OwnerId
|
|
30
|
-
): Promise<DashboardMeta>;
|
|
31
|
-
|
|
32
|
-
// uiTrees are immutable, creates new one with ref to previous
|
|
33
|
-
updateDashboard(
|
|
34
|
-
dashboardId: DashboardId,
|
|
35
|
-
uiTree: UiTree,
|
|
36
|
-
pinned?: boolean
|
|
37
|
-
): Promise<DashboardMeta>;
|
|
38
|
-
|
|
39
|
-
deleteDashboard(dashboardId: DashboardId): Promise<void>;
|
|
40
|
-
|
|
41
|
-
// Creates new uiTree copying content from specified version
|
|
42
|
-
restoreVersion(
|
|
43
|
-
dashboardId: DashboardId,
|
|
44
|
-
uiTreeId: UiTreeId
|
|
45
|
-
): Promise<DashboardMeta>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface DashboardReadDatasource {
|
|
49
|
-
searchDashboards(params: {
|
|
50
|
-
limit: number;
|
|
51
|
-
cursor?: string;
|
|
52
|
-
pinned?: boolean;
|
|
53
|
-
ownerId?: OwnerId;
|
|
54
|
-
}): Promise<{
|
|
55
|
-
dashboards: (DashboardMeta & { uiTree: UiTree })[];
|
|
56
|
-
nextCursor?: string;
|
|
57
|
-
}>;
|
|
58
|
-
|
|
59
|
-
getDashboard(
|
|
60
|
-
dashboardId: DashboardId
|
|
61
|
-
): Promise<DashboardMeta & { uiTree: UiTree }>;
|
|
62
|
-
|
|
63
|
-
listVersions(params: {
|
|
64
|
-
dashboardId: DashboardId;
|
|
65
|
-
limit: number;
|
|
66
|
-
cursor?: string;
|
|
67
|
-
}): Promise<{
|
|
68
|
-
versions: VersionMeta[];
|
|
69
|
-
nextCursor?: string;
|
|
70
|
-
}>;
|
|
71
|
-
|
|
72
|
-
getDashboardAtVersion(uiTreeId: UiTreeId): Promise<{
|
|
73
|
-
uiTree: UiTree;
|
|
74
|
-
createdAt: string;
|
|
75
|
-
}>;
|
|
76
|
-
}
|