@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.
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 +828 -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,828 @@
1
+ import {
2
+ useState,
3
+ useMemo,
4
+ useEffect,
5
+ useCallback,
6
+ useSyncExternalStore,
7
+ useRef,
8
+ } from "react";
9
+ import { KopaiSDKProvider, useKopaiSDK } from "../providers/kopai-provider.js";
10
+ import { KopaiClient } from "@kopai/sdk";
11
+ import { useKopaiData } from "../hooks/use-kopai-data.js";
12
+ import { useLiveLogs } from "../hooks/use-live-logs.js";
13
+ import type { denormalizedSignals, dataFilterSchemas } from "@kopai/core";
14
+ import type { DataSource } from "../lib/component-catalog.js";
15
+ import { observabilityCatalog } from "../lib/observability-catalog.js";
16
+ import { createRendererFromCatalog } from "../lib/renderer.js";
17
+
18
+ // Observability components
19
+ import {
20
+ LogTimeline,
21
+ LogFilter,
22
+ TabBar,
23
+ ServiceList,
24
+ TraceSearch,
25
+ TraceDetail,
26
+ KeyboardShortcutsProvider,
27
+ useRegisterShortcuts,
28
+ } from "../components/observability/index.js";
29
+ import type {
30
+ TraceSummary,
31
+ TraceSearchFilters,
32
+ } from "../components/observability/index.js";
33
+
34
+ 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
+
47
+ type OtelTracesRow = denormalizedSignals.OtelTracesRow;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Tab config
51
+ // ---------------------------------------------------------------------------
52
+
53
+ type Tab = "logs" | "services" | "metrics";
54
+
55
+ const TABS: { key: Tab; label: string; shortcutKey: string }[] = [
56
+ { key: "services", label: "Services", shortcutKey: "S" },
57
+ { key: "logs", label: "Logs", shortcutKey: "L" },
58
+ { key: "metrics", label: "Metrics", shortcutKey: "M" },
59
+ ];
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // URL state helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ interface URLState {
66
+ tab: Tab;
67
+ service: string | null;
68
+ trace: string | null;
69
+ span: string | null;
70
+ }
71
+
72
+ function readURLState(): URLState {
73
+ const params = new URLSearchParams(window.location.search);
74
+ const service = params.get("service");
75
+ const trace = params.get("trace");
76
+ const span = params.get("span");
77
+ const rawTab = params.get("tab");
78
+ const tab = service
79
+ ? "services"
80
+ : rawTab === "logs" || rawTab === "metrics"
81
+ ? rawTab
82
+ : "services";
83
+ return { tab, service, trace, span };
84
+ }
85
+
86
+ function pushURLState(
87
+ state: {
88
+ tab: Tab;
89
+ service?: string | null;
90
+ trace?: string | null;
91
+ span?: string | null;
92
+ },
93
+ { replace = false }: { replace?: boolean } = {}
94
+ ) {
95
+ const params = new URLSearchParams();
96
+ if (state.tab !== "services") params.set("tab", state.tab);
97
+ if (state.service) params.set("service", state.service);
98
+ if (state.trace) params.set("trace", state.trace);
99
+ if (state.span) params.set("span", state.span);
100
+ const qs = params.toString();
101
+ const url = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
102
+ if (replace) {
103
+ history.replaceState(null, "", url);
104
+ } else {
105
+ history.pushState(null, "", url);
106
+ }
107
+ dispatchEvent(new PopStateEvent("popstate"));
108
+ }
109
+
110
+ function subscribeURL(cb: () => void) {
111
+ window.addEventListener("popstate", cb);
112
+ return () => window.removeEventListener("popstate", cb);
113
+ }
114
+
115
+ let _cachedSearch = "";
116
+ let _cachedState: URLState = {
117
+ tab: "services",
118
+ service: null,
119
+ trace: null,
120
+ span: null,
121
+ };
122
+
123
+ function getURLSnapshot(): URLState {
124
+ const search = window.location.search;
125
+ if (search !== _cachedSearch) {
126
+ _cachedSearch = search;
127
+ _cachedState = readURLState();
128
+ }
129
+ return _cachedState;
130
+ }
131
+
132
+ function useURLState(): URLState {
133
+ return useSyncExternalStore(subscribeURL, getURLSnapshot);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Log filter URL helpers
138
+ // ---------------------------------------------------------------------------
139
+
140
+ function parseKeyValuesFromURL(
141
+ raw: string
142
+ ): Record<string, string> | undefined {
143
+ const result: Record<string, string> = {};
144
+ let hasAny = false;
145
+ for (const part of raw.split(",")) {
146
+ const trimmed = part.trim();
147
+ if (!trimmed) continue;
148
+ const eqIdx = trimmed.indexOf("=");
149
+ if (eqIdx < 1) continue;
150
+ result[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
151
+ hasAny = true;
152
+ }
153
+ return hasAny ? result : undefined;
154
+ }
155
+
156
+ function serializeKeyValues(rec: Record<string, string> | undefined): string {
157
+ if (!rec) return "";
158
+ return Object.entries(rec)
159
+ .map(([k, v]) => `${k}=${v}`)
160
+ .join(",");
161
+ }
162
+
163
+ interface LogURLState {
164
+ filters: Partial<dataFilterSchemas.LogsDataFilter>;
165
+ selectedServices: string[];
166
+ selectedLogId: string | null;
167
+ }
168
+
169
+ function readLogFilters(): LogURLState {
170
+ const p = new URLSearchParams(window.location.search);
171
+ const filters: Partial<dataFilterSchemas.LogsDataFilter> = {
172
+ limit: 200,
173
+ sortOrder: "DESC",
174
+ };
175
+
176
+ const severity = p.get("severity");
177
+ if (severity) filters.severityText = severity;
178
+
179
+ const body = p.get("body");
180
+ if (body) filters.bodyContains = body;
181
+
182
+ const sort = p.get("sort");
183
+ if (sort === "ASC" || sort === "DESC") filters.sortOrder = sort;
184
+
185
+ const limit = p.get("limit");
186
+ if (limit) {
187
+ const n = parseInt(limit, 10);
188
+ if (n >= 1 && n <= 1000) filters.limit = n;
189
+ }
190
+
191
+ const traceId = p.get("traceId");
192
+ if (traceId) filters.traceId = traceId;
193
+
194
+ const spanId = p.get("spanId");
195
+ if (spanId) filters.spanId = spanId;
196
+
197
+ const scope = p.get("scope");
198
+ if (scope) filters.scopeName = scope;
199
+
200
+ const tsMin = p.get("tsMin");
201
+ if (tsMin) filters.timestampMin = tsMin;
202
+
203
+ const tsMax = p.get("tsMax");
204
+ if (tsMax) filters.timestampMax = tsMax;
205
+
206
+ const logAttrs = p.get("logAttrs");
207
+ if (logAttrs) filters.logAttributes = parseKeyValuesFromURL(logAttrs);
208
+
209
+ const resAttrs = p.get("resAttrs");
210
+ if (resAttrs) filters.resourceAttributes = parseKeyValuesFromURL(resAttrs);
211
+
212
+ const scopeAttrs = p.get("scopeAttrs");
213
+ if (scopeAttrs) filters.scopeAttributes = parseKeyValuesFromURL(scopeAttrs);
214
+
215
+ const services = p.get("services");
216
+ const selectedServices = services ? services.split(",").filter(Boolean) : [];
217
+
218
+ if (selectedServices.length === 1) filters.serviceName = selectedServices[0];
219
+
220
+ const selectedLogId = p.get("log") || null;
221
+
222
+ return { filters, selectedServices, selectedLogId };
223
+ }
224
+
225
+ function writeLogFiltersToURL(
226
+ filters: Partial<dataFilterSchemas.LogsDataFilter>,
227
+ selectedServices: string[],
228
+ selectedLogId: string | null
229
+ ) {
230
+ const p = new URLSearchParams();
231
+ p.set("tab", "logs");
232
+
233
+ if (filters.severityText) p.set("severity", filters.severityText);
234
+ if (filters.bodyContains) p.set("body", filters.bodyContains);
235
+ if (selectedServices.length) p.set("services", selectedServices.join(","));
236
+ if (filters.sortOrder && filters.sortOrder !== "DESC")
237
+ p.set("sort", filters.sortOrder);
238
+ if (filters.limit != null && filters.limit !== 200)
239
+ p.set("limit", String(filters.limit));
240
+ if (filters.traceId) p.set("traceId", filters.traceId);
241
+ if (filters.spanId) p.set("spanId", filters.spanId);
242
+ if (filters.scopeName) p.set("scope", filters.scopeName);
243
+ if (filters.timestampMin) p.set("tsMin", filters.timestampMin);
244
+ if (filters.timestampMax) p.set("tsMax", filters.timestampMax);
245
+
246
+ const la = serializeKeyValues(filters.logAttributes);
247
+ if (la) p.set("logAttrs", la);
248
+ const ra = serializeKeyValues(filters.resourceAttributes);
249
+ if (ra) p.set("resAttrs", ra);
250
+ const sa = serializeKeyValues(filters.scopeAttributes);
251
+ if (sa) p.set("scopeAttrs", sa);
252
+
253
+ if (selectedLogId) p.set("log", selectedLogId);
254
+
255
+ const qs = p.toString();
256
+ const url = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
257
+ history.replaceState(null, "", url);
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Duration parser — "100ms" → nanosecond string
262
+ // ---------------------------------------------------------------------------
263
+
264
+ function parseDuration(input: string): string | undefined {
265
+ const trimmed = input.trim();
266
+ if (!trimmed) return undefined;
267
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(us|ms|s)$/i);
268
+ if (!match) return undefined;
269
+ const value = parseFloat(match[1]!);
270
+ const unit = match[2]!.toLowerCase();
271
+ const multipliers: Record<string, number> = {
272
+ us: 1_000,
273
+ ms: 1_000_000,
274
+ s: 1_000_000_000,
275
+ };
276
+ return String(Math.round(value * multipliers[unit]!));
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Logs tab (live-tailing)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ function LogsTab() {
284
+ const [initState] = useState(() => readLogFilters());
285
+ const [filters, setFilters] = useState<
286
+ Partial<dataFilterSchemas.LogsDataFilter>
287
+ >(initState.filters);
288
+ const [selectedServices, setSelectedServices] = useState<string[]>(
289
+ initState.selectedServices
290
+ );
291
+ const [selectedLogId, setSelectedLogId] = useState<string | null>(
292
+ initState.selectedLogId
293
+ );
294
+
295
+ // Sync filter state to URL
296
+ useEffect(() => {
297
+ writeLogFiltersToURL(filters, selectedServices, selectedLogId);
298
+ }, [filters, selectedServices, selectedLogId]);
299
+
300
+ const { logs, isLive, loading, error, setLive } = useLiveLogs({
301
+ params: filters,
302
+ pollIntervalMs: 3_000,
303
+ });
304
+
305
+ // Client-side multi-service filter (API only supports single serviceName)
306
+ const filteredLogs = useMemo(() => {
307
+ if (selectedServices.length <= 1) return logs;
308
+ const set = new Set(selectedServices);
309
+ return logs.filter((r) => set.has(r.ServiceName ?? ""));
310
+ }, [logs, selectedServices]);
311
+
312
+ const handleLogClick = useCallback((log: { logId: string }) => {
313
+ setSelectedLogId(log.logId);
314
+ }, []);
315
+
316
+ const handleTraceLinkClick = useCallback(
317
+ (traceId: string, spanId: string) => {
318
+ const log = filteredLogs.find((l) => l.TraceId === traceId);
319
+ pushURLState({
320
+ tab: "services",
321
+ service: log?.ServiceName ?? undefined,
322
+ trace: traceId,
323
+ span: spanId,
324
+ });
325
+ },
326
+ [filteredLogs]
327
+ );
328
+
329
+ return (
330
+ <div style={{ height: "calc(100vh - 160px)" }} className="flex flex-col">
331
+ <div className="shrink-0 mb-3">
332
+ <LogFilter
333
+ value={filters}
334
+ onChange={setFilters}
335
+ rows={logs}
336
+ selectedServices={selectedServices}
337
+ onSelectedServicesChange={setSelectedServices}
338
+ />
339
+ </div>
340
+ <div className="flex-1 min-h-0">
341
+ <LogTimeline
342
+ rows={filteredLogs}
343
+ isLoading={loading}
344
+ error={error ?? undefined}
345
+ streaming={isLive}
346
+ selectedLogId={selectedLogId ?? undefined}
347
+ onLogClick={handleLogClick}
348
+ onTraceLinkClick={handleTraceLinkClick}
349
+ onAtBottomChange={(atBottom) => setLive(atBottom)}
350
+ />
351
+ </div>
352
+ </div>
353
+ );
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Services tab — data-fetching wrappers around extracted UI components
358
+ // ---------------------------------------------------------------------------
359
+
360
+ const SERVICES_DS: DataSource = {
361
+ method: "searchTracesPage",
362
+ params: { limit: 1000, sortOrder: "DESC" },
363
+ };
364
+
365
+ function ServiceListView({
366
+ onSelect,
367
+ }: {
368
+ onSelect: (service: string) => void;
369
+ }) {
370
+ const { data, loading, error } = useKopaiData<{
371
+ data: OtelTracesRow[];
372
+ nextCursor: string | null;
373
+ }>(SERVICES_DS);
374
+
375
+ const services = useMemo(() => {
376
+ if (!data?.data) return [];
377
+ const names = new Set<string>();
378
+ for (const row of data.data) {
379
+ names.add(row.ServiceName ?? "unknown");
380
+ }
381
+ return Array.from(names)
382
+ .sort()
383
+ .map((name) => ({ name }));
384
+ }, [data]);
385
+
386
+ return (
387
+ <ServiceList
388
+ services={services}
389
+ isLoading={loading}
390
+ error={error ?? undefined}
391
+ onSelect={onSelect}
392
+ />
393
+ );
394
+ }
395
+
396
+ function TraceSearchView({
397
+ service,
398
+ onBack,
399
+ onSelectTrace,
400
+ }: {
401
+ service: string;
402
+ onBack: () => void;
403
+ onSelectTrace: (traceId: string) => void;
404
+ }) {
405
+ const [ds, setDs] = useState<DataSource>(() => ({
406
+ method: "searchTracesPage",
407
+ params: { serviceName: service, limit: 20, sortOrder: "DESC" as const },
408
+ }));
409
+
410
+ const handleSearch = useCallback(
411
+ (filters: TraceSearchFilters) => {
412
+ const params: Record<string, unknown> = {
413
+ serviceName: service,
414
+ limit: filters.limit,
415
+ sortOrder: "DESC",
416
+ };
417
+ if (filters.operation) params.spanName = filters.operation;
418
+ if (filters.lookbackMs) {
419
+ params.timestampMin = String((Date.now() - filters.lookbackMs) * 1e6);
420
+ }
421
+ if (filters.minDuration) {
422
+ const parsed = parseDuration(filters.minDuration);
423
+ if (parsed) params.durationMin = parsed;
424
+ }
425
+ if (filters.maxDuration) {
426
+ const parsed = parseDuration(filters.maxDuration);
427
+ if (parsed) params.durationMax = parsed;
428
+ }
429
+ setDs({
430
+ method: "searchTracesPage",
431
+ params,
432
+ } as DataSource);
433
+ },
434
+ [service]
435
+ );
436
+
437
+ const { data, loading, error } = useKopaiData<{
438
+ data: OtelTracesRow[];
439
+ nextCursor: string | null;
440
+ }>(ds);
441
+
442
+ // Fetch full traces for each unique traceId so service breakdown is complete
443
+ const client = useKopaiSDK();
444
+ const [fullTraces, setFullTraces] = useState<Map<string, OtelTracesRow[]>>(
445
+ () => new Map()
446
+ );
447
+
448
+ useEffect(() => {
449
+ if (!data?.data?.length) {
450
+ setFullTraces(new Map());
451
+ return;
452
+ }
453
+ const traceIds = [...new Set(data.data.map((r) => r.TraceId))];
454
+ const ac = new AbortController();
455
+
456
+ Promise.allSettled(
457
+ traceIds.map((tid) =>
458
+ client
459
+ .getTrace(tid, { signal: ac.signal })
460
+ .then((spans) => [tid, spans] as const)
461
+ )
462
+ )
463
+ .then((results) => {
464
+ if (!ac.signal.aborted) {
465
+ const entries = results
466
+ .filter(
467
+ (
468
+ r
469
+ ): r is PromiseFulfilledResult<
470
+ readonly [string, OtelTracesRow[]]
471
+ > => r.status === "fulfilled"
472
+ )
473
+ .map((r) => r.value);
474
+ setFullTraces(new Map(entries));
475
+ }
476
+ })
477
+ .catch((err) => {
478
+ if (!ac.signal.aborted)
479
+ console.error("Failed to fetch full traces", err);
480
+ });
481
+
482
+ return () => ac.abort();
483
+ }, [data, client]);
484
+
485
+ // Derive unique operations for filter dropdown
486
+ const operations = useMemo(() => {
487
+ if (!data?.data) return [];
488
+ const set = new Set<string>();
489
+ for (const row of data.data) {
490
+ if (row.SpanName) set.add(row.SpanName);
491
+ }
492
+ return Array.from(set).sort();
493
+ }, [data]);
494
+
495
+ const traces = useMemo<TraceSummary[]>(() => {
496
+ if (!data?.data) return [];
497
+ const grouped = new Map<string, OtelTracesRow[]>();
498
+ for (const row of data.data) {
499
+ const tid = row.TraceId;
500
+ if (!grouped.has(tid)) grouped.set(tid, []);
501
+ grouped.get(tid)!.push(row);
502
+ }
503
+
504
+ return Array.from(grouped.entries()).map(([traceId, searchSpans]) => {
505
+ const fullSpans = fullTraces.get(traceId);
506
+ const spans = fullSpans ?? searchSpans;
507
+
508
+ const root = spans.find((s) => !s.ParentSpanId) ?? spans[0]!;
509
+ const durationNs = root.Duration ? parseInt(root.Duration, 10) : 0;
510
+
511
+ const svcMap = new Map<string, { count: number; hasError: boolean }>();
512
+ let errorCount = 0;
513
+ for (const s of spans) {
514
+ const svcName = s.ServiceName ?? "unknown";
515
+ const entry = svcMap.get(svcName) ?? { count: 0, hasError: false };
516
+ entry.count++;
517
+ if (s.StatusCode === "ERROR") {
518
+ entry.hasError = true;
519
+ errorCount++;
520
+ }
521
+ svcMap.set(svcName, entry);
522
+ }
523
+ const services = Array.from(svcMap.entries())
524
+ .map(([name, v]) => ({ name, count: v.count, hasError: v.hasError }))
525
+ .sort((a, b) => b.count - a.count);
526
+
527
+ return {
528
+ traceId,
529
+ rootSpanName: root.SpanName ?? "unknown",
530
+ serviceName: root.ServiceName ?? "unknown",
531
+ durationMs: durationNs / 1e6,
532
+ statusCode: root.StatusCode ?? "UNSET",
533
+ timestampMs: parseInt(root.Timestamp, 10) / 1e6,
534
+ spanCount: spans.length,
535
+ services,
536
+ errorCount,
537
+ };
538
+ });
539
+ }, [data, fullTraces]);
540
+
541
+ return (
542
+ <TraceSearch
543
+ service={service}
544
+ traces={traces}
545
+ operations={operations}
546
+ isLoading={loading}
547
+ error={error ?? undefined}
548
+ onSelectTrace={onSelectTrace}
549
+ onBack={onBack}
550
+ onSearch={handleSearch}
551
+ />
552
+ );
553
+ }
554
+
555
+ function TraceDetailView({
556
+ service,
557
+ traceId,
558
+ selectedSpanId,
559
+ onSelectSpan,
560
+ onBack,
561
+ }: {
562
+ service: string;
563
+ traceId: string;
564
+ selectedSpanId: string | null;
565
+ onSelectSpan: (spanId: string) => void;
566
+ onBack: () => void;
567
+ }) {
568
+ const ds = useMemo<DataSource>(
569
+ () => ({
570
+ method: "getTrace",
571
+ params: { traceId },
572
+ }),
573
+ [traceId]
574
+ );
575
+
576
+ const { data, loading, error } = useKopaiData<OtelTracesRow[]>(ds);
577
+
578
+ return (
579
+ <TraceDetail
580
+ service={service}
581
+ traceId={traceId}
582
+ rows={data ?? []}
583
+ isLoading={loading}
584
+ error={error ?? undefined}
585
+ selectedSpanId={selectedSpanId ?? undefined}
586
+ onSpanClick={(span) => onSelectSpan(span.spanId)}
587
+ onBack={onBack}
588
+ />
589
+ );
590
+ }
591
+
592
+ function ServicesTab({
593
+ selectedService,
594
+ selectedTraceId,
595
+ selectedSpanId,
596
+ onSelectService,
597
+ onSelectTrace,
598
+ onSelectSpan,
599
+ onBackToServices,
600
+ onBackToTraceList,
601
+ }: {
602
+ selectedService: string | null;
603
+ selectedTraceId: string | null;
604
+ selectedSpanId: string | null;
605
+ onSelectService: (service: string) => void;
606
+ onSelectTrace: (traceId: string) => void;
607
+ onSelectSpan: (spanId: string) => void;
608
+ onBackToServices: () => void;
609
+ onBackToTraceList: () => void;
610
+ }) {
611
+ useRegisterShortcuts("services-tab", SERVICES_SHORTCUTS);
612
+
613
+ // Backspace → navigate back based on drill-down depth
614
+ const backToServicesRef = useRef(onBackToServices);
615
+ backToServicesRef.current = onBackToServices;
616
+ const backToTraceListRef = useRef(onBackToTraceList);
617
+ backToTraceListRef.current = onBackToTraceList;
618
+ useEffect(() => {
619
+ const handleKeyDown = (e: KeyboardEvent) => {
620
+ if (
621
+ e.target instanceof HTMLInputElement ||
622
+ e.target instanceof HTMLTextAreaElement ||
623
+ e.target instanceof HTMLSelectElement
624
+ )
625
+ return;
626
+ if (e.key === "Backspace") {
627
+ e.preventDefault();
628
+ if (selectedTraceId && selectedService) {
629
+ backToTraceListRef.current();
630
+ } else if (selectedService) {
631
+ backToServicesRef.current();
632
+ }
633
+ }
634
+ };
635
+ window.addEventListener("keydown", handleKeyDown);
636
+ return () => window.removeEventListener("keydown", handleKeyDown);
637
+ }, [selectedService, selectedTraceId]);
638
+
639
+ if (selectedTraceId && selectedService) {
640
+ return (
641
+ <TraceDetailView
642
+ service={selectedService}
643
+ traceId={selectedTraceId}
644
+ selectedSpanId={selectedSpanId}
645
+ onSelectSpan={onSelectSpan}
646
+ onBack={onBackToTraceList}
647
+ />
648
+ );
649
+ }
650
+ if (selectedService) {
651
+ return (
652
+ <TraceSearchView
653
+ service={selectedService}
654
+ onBack={onBackToServices}
655
+ onSelectTrace={onSelectTrace}
656
+ />
657
+ );
658
+ }
659
+ return <ServiceListView onSelect={onSelectService} />;
660
+ }
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // Metrics tab — renderer + catalog
664
+ // ---------------------------------------------------------------------------
665
+
666
+ const MetricsRenderer = createRendererFromCatalog(observabilityCatalog, {
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
+ });
683
+
684
+ const METRICS_TREE = {
685
+ root: "root",
686
+ elements: {
687
+ root: {
688
+ key: "root",
689
+ type: "Stack" as const,
690
+ children: ["heading", "description", "discovery-card"],
691
+ parentKey: "",
692
+ props: {
693
+ direction: "vertical" as const,
694
+ gap: "md" as const,
695
+ align: null,
696
+ },
697
+ },
698
+ heading: {
699
+ key: "heading",
700
+ type: "Heading" as const,
701
+ children: [],
702
+ parentKey: "root",
703
+ props: { text: "Metrics", level: "h2" as const },
704
+ },
705
+ description: {
706
+ key: "description",
707
+ type: "Text" as const,
708
+ children: [],
709
+ parentKey: "root",
710
+ props: {
711
+ content: "Discovered OpenTelemetry metrics",
712
+ variant: "body" as const,
713
+ color: "muted" as const,
714
+ },
715
+ },
716
+ "discovery-card": {
717
+ key: "discovery-card",
718
+ type: "Card" as const,
719
+ children: ["metric-discovery"],
720
+ parentKey: "root",
721
+ props: { title: null, description: null, padding: null },
722
+ },
723
+ "metric-discovery": {
724
+ key: "metric-discovery",
725
+ type: "MetricDiscovery" as const,
726
+ children: [],
727
+ parentKey: "discovery-card",
728
+ dataSource: { method: "discoverMetrics" as const },
729
+ props: {},
730
+ },
731
+ },
732
+ };
733
+
734
+ function MetricsTab() {
735
+ return <MetricsRenderer tree={METRICS_TREE} />;
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Page
740
+ // ---------------------------------------------------------------------------
741
+
742
+ const client = new KopaiClient({
743
+ baseUrl:
744
+ import.meta.env.VITE_KOPAI_API_URL ?? "http://localhost:8000/signals",
745
+ });
746
+
747
+ export default function ObservabilityPage() {
748
+ const {
749
+ tab: activeTab,
750
+ service: selectedService,
751
+ trace: selectedTraceId,
752
+ span: selectedSpanId,
753
+ } = useURLState();
754
+
755
+ const handleTabChange = useCallback((tab: Tab) => {
756
+ pushURLState({ tab });
757
+ }, []);
758
+
759
+ const handleSelectService = useCallback((service: string) => {
760
+ pushURLState({ tab: "services", service });
761
+ }, []);
762
+
763
+ const handleSelectTrace = useCallback(
764
+ (traceId: string) => {
765
+ pushURLState({
766
+ tab: "services",
767
+ service: selectedService,
768
+ trace: traceId,
769
+ });
770
+ },
771
+ [selectedService]
772
+ );
773
+
774
+ const handleSelectSpan = useCallback(
775
+ (spanId: string) => {
776
+ pushURLState(
777
+ {
778
+ tab: "services",
779
+ service: selectedService,
780
+ trace: selectedTraceId,
781
+ span: spanId,
782
+ },
783
+ { replace: true }
784
+ );
785
+ },
786
+ [selectedService, selectedTraceId]
787
+ );
788
+
789
+ const handleBackToServices = useCallback(() => {
790
+ pushURLState({ tab: "services" });
791
+ }, []);
792
+
793
+ const handleBackToTraceList = useCallback(() => {
794
+ pushURLState({ tab: "services", service: selectedService });
795
+ }, [selectedService]);
796
+
797
+ return (
798
+ <KopaiSDKProvider client={client}>
799
+ <KeyboardShortcutsProvider
800
+ onNavigateServices={() => pushURLState({ tab: "services" })}
801
+ onNavigateLogs={() => pushURLState({ tab: "logs" })}
802
+ onNavigateMetrics={() => pushURLState({ tab: "metrics" })}
803
+ >
804
+ <div>
805
+ <TabBar
806
+ tabs={TABS}
807
+ active={activeTab}
808
+ onChange={handleTabChange as (key: string) => void}
809
+ />
810
+ {activeTab === "logs" && <LogsTab />}
811
+ {activeTab === "services" && (
812
+ <ServicesTab
813
+ selectedService={selectedService}
814
+ selectedTraceId={selectedTraceId}
815
+ selectedSpanId={selectedSpanId}
816
+ onSelectService={handleSelectService}
817
+ onSelectTrace={handleSelectTrace}
818
+ onSelectSpan={handleSelectSpan}
819
+ onBackToServices={handleBackToServices}
820
+ onBackToTraceList={handleBackToTraceList}
821
+ />
822
+ )}
823
+ {activeTab === "metrics" && <MetricsTab />}
824
+ </div>
825
+ </KeyboardShortcutsProvider>
826
+ </KopaiSDKProvider>
827
+ );
828
+ }