diff --git a/.changeset/ui-core-dedup.md b/.changeset/ui-core-dedup.md new file mode 100644 index 0000000..fab8575 --- /dev/null +++ b/.changeset/ui-core-dedup.md @@ -0,0 +1,6 @@ +--- +"@kopai/ui": minor +"@kopai/ui-core": minor +--- + +Internal restructure — `@kopai/ui` now re-exports DOM-free symbols from `@kopai/ui-core` instead of shipping its own copies. Public API unchanged (additive only — new symbols exposed, no removals). New code should prefer importing non-DOM symbols from `@kopai/ui-core` directly. `@kopai/ui-core` patch: add `CatalogueComponentProps` to the public barrel so `@kopai/ui`'s dashboard primitives can use it. diff --git a/packages/ui-core/src/index.ts b/packages/ui-core/src/index.ts index 9e8ba42..b3c6180 100644 --- a/packages/ui-core/src/index.ts +++ b/packages/ui-core/src/index.ts @@ -12,6 +12,7 @@ export { } from "./lib/renderer.js"; export { createCatalog, + type CatalogueComponentProps, type ComponentDefinition, type DataSource, type InferProps, diff --git a/packages/ui/package.json b/packages/ui/package.json index f9cac25..29d0a7b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,6 +39,7 @@ "dependencies": { "@kopai/core": "workspace:*", "@kopai/sdk": "workspace:*", + "@kopai/ui-core": "workspace:*", "@tanstack/react-query": "^5", "@tanstack/react-virtual": "^3.13.24", "recharts": "^3.8.1" diff --git a/packages/ui/src/components/dashboard/Badge/index.tsx b/packages/ui/src/components/dashboard/Badge/index.tsx index 537d84f..374b2d5 100644 --- a/packages/ui/src/components/dashboard/Badge/index.tsx +++ b/packages/ui/src/components/dashboard/Badge/index.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { CatalogueComponentProps } from "@kopai/ui-core"; export function Badge({ element, diff --git a/packages/ui/src/components/dashboard/Card/index.tsx b/packages/ui/src/components/dashboard/Card/index.tsx index 6077e31..c4867e0 100644 --- a/packages/ui/src/components/dashboard/Card/index.tsx +++ b/packages/ui/src/components/dashboard/Card/index.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { RendererComponentProps } from "@kopai/ui-core"; export function Card({ element, diff --git a/packages/ui/src/components/dashboard/Divider/index.tsx b/packages/ui/src/components/dashboard/Divider/index.tsx index 4ef9fda..b9d8932 100644 --- a/packages/ui/src/components/dashboard/Divider/index.tsx +++ b/packages/ui/src/components/dashboard/Divider/index.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { CatalogueComponentProps } from "@kopai/ui-core"; export function Divider({ element, diff --git a/packages/ui/src/components/dashboard/Empty/index.tsx b/packages/ui/src/components/dashboard/Empty/index.tsx index 9f34721..c784ca7 100644 --- a/packages/ui/src/components/dashboard/Empty/index.tsx +++ b/packages/ui/src/components/dashboard/Empty/index.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { CatalogueComponentProps } from "@kopai/ui-core"; export function Empty({ element, diff --git a/packages/ui/src/components/dashboard/Grid/index.tsx b/packages/ui/src/components/dashboard/Grid/index.tsx index 7c6d81c..f8659d7 100644 --- a/packages/ui/src/components/dashboard/Grid/index.tsx +++ b/packages/ui/src/components/dashboard/Grid/index.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { CatalogueComponentProps } from "@kopai/ui-core"; export function Grid({ element, diff --git a/packages/ui/src/components/dashboard/Heading/index.tsx b/packages/ui/src/components/dashboard/Heading/index.tsx index 20f5fc8..9df8205 100644 --- a/packages/ui/src/components/dashboard/Heading/index.tsx +++ b/packages/ui/src/components/dashboard/Heading/index.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { CatalogueComponentProps } from "@kopai/ui-core"; export function Heading({ element, diff --git a/packages/ui/src/components/dashboard/Stack/index.tsx b/packages/ui/src/components/dashboard/Stack/index.tsx index f435091..588aacd 100644 --- a/packages/ui/src/components/dashboard/Stack/index.tsx +++ b/packages/ui/src/components/dashboard/Stack/index.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { CatalogueComponentProps } from "@kopai/ui-core"; export function Stack({ element, diff --git a/packages/ui/src/components/dashboard/Text/index.tsx b/packages/ui/src/components/dashboard/Text/index.tsx index 8c545c8..4aedc4f 100644 --- a/packages/ui/src/components/dashboard/Text/index.tsx +++ b/packages/ui/src/components/dashboard/Text/index.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { CatalogueComponentProps } from "@kopai/ui-core"; export function Text({ element, diff --git a/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx b/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx index f182682..8740bbc 100644 --- a/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +++ b/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createElement } from "react"; import { render, waitFor, fireEvent } from "@testing-library/react"; import { DynamicDashboard, type UITree } from "./index.js"; -import { queryClient } from "../../../providers/kopai-provider.js"; +import { queryClient } from "@kopai/ui-core"; import type { KopaiClient } from "@kopai/sdk"; type MockClient = { diff --git a/packages/ui/src/components/observability/DynamicDashboard/index.tsx b/packages/ui/src/components/observability/DynamicDashboard/index.tsx index fbbe0ca..aba2ed5 100644 --- a/packages/ui/src/components/observability/DynamicDashboard/index.tsx +++ b/packages/ui/src/components/observability/DynamicDashboard/index.tsx @@ -1,12 +1,10 @@ import { createRendererFromCatalog, type UITree, -} from "../../../lib/renderer.js"; -import { KopaiSDKProvider, type KopaiClient, -} from "../../../providers/kopai-provider.js"; -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; + observabilityCatalog, +} from "@kopai/ui-core"; import { Heading, Text, diff --git a/packages/ui/src/components/observability/TraceComparison/index.tsx b/packages/ui/src/components/observability/TraceComparison/index.tsx index 99bc926..a5c4748 100644 --- a/packages/ui/src/components/observability/TraceComparison/index.tsx +++ b/packages/ui/src/components/observability/TraceComparison/index.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import type { denormalizedSignals } from "@kopai/core"; -import type { DataSource } from "../../../lib/component-catalog.js"; -import { useKopaiData } from "../../../hooks/use-kopai-data.js"; +import type { DataSource } from "@kopai/ui-core"; +import { useKopaiData } from "@kopai/ui-core"; import { TraceTimeline } from "../TraceTimeline/index.js"; import { formatDuration } from "../utils/time.js"; diff --git a/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx b/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx index d207573..0da0d36 100644 --- a/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx +++ b/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { RendererComponentProps } from "@kopai/ui-core"; import { LogTimeline } from "../index.js"; import { NoDataSource } from "./NoDataSource.js"; diff --git a/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx b/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx index 78c2b7f..ab25173 100644 --- a/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx +++ b/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { RendererComponentProps } from "@kopai/ui-core"; type Props = RendererComponentProps< typeof observabilityCatalog.components.MetricDiscovery diff --git a/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx b/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx index b12e9d1..5227128 100644 --- a/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx +++ b/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { RendererComponentProps } from "@kopai/ui-core"; import { MetricHistogram } from "../index.js"; import { NoDataSource } from "./NoDataSource.js"; diff --git a/packages/ui/src/components/observability/renderers/OtelMetricStat.tsx b/packages/ui/src/components/observability/renderers/OtelMetricStat.tsx index c22e48f..2e9466d 100644 --- a/packages/ui/src/components/observability/renderers/OtelMetricStat.tsx +++ b/packages/ui/src/components/observability/renderers/OtelMetricStat.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { RendererComponentProps } from "@kopai/ui-core"; import { MetricStat } from "../index.js"; import { formatOtelValue } from "../utils/units.js"; import { NoDataSource } from "./NoDataSource.js"; diff --git a/packages/ui/src/components/observability/renderers/OtelMetricTable.tsx b/packages/ui/src/components/observability/renderers/OtelMetricTable.tsx index b1e6b9d..74fdad4 100644 --- a/packages/ui/src/components/observability/renderers/OtelMetricTable.tsx +++ b/packages/ui/src/components/observability/renderers/OtelMetricTable.tsx @@ -1,5 +1,5 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { observabilityCatalog } from "@kopai/ui-core"; +import type { RendererComponentProps } from "@kopai/ui-core"; import { MetricTable } from "../index.js"; import { NoDataSource } from "./NoDataSource.js"; diff --git a/packages/ui/src/components/observability/renderers/OtelMetricTimeSeries.tsx b/packages/ui/src/components/observability/renderers/OtelMetricTimeSeries.tsx index 4559de5..c9a3b81 100644 --- a/packages/ui/src/components/observability/renderers/OtelMetricTimeSeries.tsx +++ b/packages/ui/src/components/observability/renderers/OtelMetricTimeSeries.tsx @@ -1,5 +1,7 @@ -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { + observabilityCatalog, + type RendererComponentProps, +} from "@kopai/ui-core"; import { MetricTimeSeries } from "../index.js"; import { NoDataSource } from "./NoDataSource.js"; diff --git a/packages/ui/src/components/observability/renderers/OtelTraceDetail.tsx b/packages/ui/src/components/observability/renderers/OtelTraceDetail.tsx index 3cf29df..f839440 100644 --- a/packages/ui/src/components/observability/renderers/OtelTraceDetail.tsx +++ b/packages/ui/src/components/observability/renderers/OtelTraceDetail.tsx @@ -1,11 +1,13 @@ import { useState, useMemo, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; -import { observabilityCatalog } from "../../../lib/observability-catalog.js"; -import type { RendererComponentProps } from "../../../lib/renderer.js"; +import { + observabilityCatalog, + type RendererComponentProps, + useKopaiSDK, +} from "@kopai/ui-core"; import { TraceDetail } from "../index.js"; import { TraceSearch } from "../TraceSearch/index.js"; import type { TraceSummary } from "../TraceSearch/index.js"; -import { useKopaiSDK } from "../../../providers/kopai-provider.js"; import { NoDataSource } from "./NoDataSource.js"; import type { dataFilterSchemas } from "@kopai/core"; import type { OtelTracesRow, SearchResult } from "@kopai/sdk"; diff --git a/packages/ui/src/hooks/use-kopai-data.test.ts b/packages/ui/src/hooks/use-kopai-data.test.ts deleted file mode 100644 index 95e5500..0000000 --- a/packages/ui/src/hooks/use-kopai-data.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { createElement, type ReactNode } from "react"; -import { useKopaiData } from "./use-kopai-data.js"; -import { - KopaiSDKProvider, - queryClient, - type KopaiClient, -} from "../providers/kopai-provider.js"; -import type { DataSource } from "../lib/component-catalog.js"; - -const createMockClient = () => ({ - searchTracesPage: vi.fn(), - searchLogsPage: vi.fn(), - searchMetricsPage: vi.fn(), - searchAggregatedMetrics: vi.fn(), - getTrace: vi.fn(), - discoverMetrics: vi.fn(), - getDashboard: vi.fn(), - getServices: vi.fn(), - getOperations: vi.fn(), - searchTraceSummariesPage: vi.fn(), -}); - -type MockClient = ReturnType; - -function wrapper(client: KopaiClient) { - return function Wrapper({ children }: { children: ReactNode }) { - return createElement(KopaiSDKProvider, { client, children }); - }; -} - -describe("useKopaiData", () => { - let mockClient: MockClient; - - beforeEach(() => { - mockClient = createMockClient(); - queryClient.clear(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("initial state", () => { - it("returns null data when no dataSource", () => { - const { result } = renderHook(() => useKopaiData(undefined), { - wrapper: wrapper(mockClient), - }); - - expect(result.current.data).toBeNull(); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBeNull(); - }); - }); - - describe("searchTracesPage", () => { - it("fetches traces and updates state", async () => { - const mockData = { data: [{ traceId: "123" }], nextCursor: null }; - mockClient.searchTracesPage.mockResolvedValue(mockData); - - const dataSource: DataSource = { - method: "searchTracesPage", - params: { serviceName: "test-service" }, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - expect(result.current.loading).toBe(true); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.data).toEqual(mockData); - expect(result.current.error).toBeNull(); - expect(mockClient.searchTracesPage).toHaveBeenCalledWith( - { serviceName: "test-service" }, - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ); - }); - - it("handles errors", async () => { - const error = new Error("Network error"); - mockClient.searchTracesPage.mockRejectedValue(error); - - const dataSource: DataSource = { - method: "searchTracesPage", - params: {}, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.error).toEqual(error); - expect(result.current.data).toBeNull(); - }); - }); - - describe("searchLogsPage", () => { - it("fetches logs", async () => { - const mockData = { data: [{ body: "log entry" }], nextCursor: null }; - mockClient.searchLogsPage.mockResolvedValue(mockData); - - const dataSource: DataSource = { - method: "searchLogsPage", - params: { serviceName: "test-service" }, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.data).toEqual(mockData); - expect(mockClient.searchLogsPage).toHaveBeenCalled(); - }); - }); - - describe("searchMetricsPage", () => { - it("fetches metrics", async () => { - const mockData = { data: [{ metricName: "cpu" }], nextCursor: null }; - mockClient.searchMetricsPage.mockResolvedValue(mockData); - - const dataSource: DataSource = { - method: "searchMetricsPage", - params: { metricType: "Gauge" }, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.data).toEqual(mockData); - expect(mockClient.searchMetricsPage).toHaveBeenCalled(); - }); - - it("calls searchAggregatedMetrics for searchAggregatedMetrics method", async () => { - const mockData = { - data: [{ groups: { signal: "/v1/traces" }, value: 1024 }], - nextCursor: null, - }; - mockClient.searchAggregatedMetrics.mockResolvedValue(mockData); - - const dataSource: DataSource = { - method: "searchAggregatedMetrics", - params: { - metricType: "Sum", - metricName: "kopai.ingestion.bytes", - aggregate: "sum", - groupBy: ["signal"], - }, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.data).toEqual(mockData); - expect(mockClient.searchAggregatedMetrics).toHaveBeenCalledWith( - { - metricType: "Sum", - metricName: "kopai.ingestion.bytes", - aggregate: "sum", - groupBy: ["signal"], - }, - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ); - expect(mockClient.searchMetricsPage).not.toHaveBeenCalled(); - }); - }); - - describe("getTrace", () => { - it("fetches single trace", async () => { - const mockData = [{ traceId: "abc", spanId: "123" }]; - mockClient.getTrace.mockResolvedValue(mockData); - - const dataSource: DataSource = { - method: "getTrace", - params: { traceId: "abc" }, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.data).toEqual(mockData); - expect(mockClient.getTrace).toHaveBeenCalledWith( - "abc", - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ); - }); - }); - - describe("discoverMetrics", () => { - it("discovers metrics", async () => { - const mockData = { metrics: [{ name: "cpu_usage", type: "Gauge" }] }; - mockClient.discoverMetrics.mockResolvedValue(mockData); - - const dataSource: DataSource = { - method: "discoverMetrics", - params: {}, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.data).toEqual(mockData); - expect(mockClient.discoverMetrics).toHaveBeenCalled(); - }); - }); - - describe("refetch", () => { - it("refetches same query on refetch()", async () => { - const mockData1 = { data: [{ id: "1" }], nextCursor: "cursor1" }; - const mockData2 = { data: [{ id: "2" }], nextCursor: null }; - mockClient.searchTracesPage - .mockResolvedValueOnce(mockData1) - .mockResolvedValueOnce(mockData2); - - const dataSource: DataSource = { - method: "searchTracesPage", - params: { limit: 10 }, - }; - - const { result } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(result.current.data).toEqual(mockData1); - }); - - act(() => { - result.current.refetch(); - }); - - await waitFor(() => { - expect(result.current.data).toEqual(mockData2); - }); - - expect(mockClient.searchTracesPage).toHaveBeenCalledTimes(2); - }); - }); - - describe("dataSource change", () => { - it("triggers new fetch when dataSource changes", async () => { - const tracesData = { data: [{ traceId: "t1" }] }; - const logsData = { data: [{ body: "log1" }] }; - mockClient.searchTracesPage.mockResolvedValue(tracesData); - mockClient.searchLogsPage.mockResolvedValue(logsData); - - const { result, rerender } = renderHook( - ({ ds }: { ds: DataSource }) => useKopaiData(ds), - { - wrapper: wrapper(mockClient), - initialProps: { - ds: { method: "searchTracesPage", params: {} } as DataSource, - }, - } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(tracesData); - }); - - rerender({ - ds: { method: "searchLogsPage", params: {} } as DataSource, - }); - - await waitFor(() => { - expect(result.current.data).toEqual(logsData); - }); - - expect(mockClient.searchTracesPage).toHaveBeenCalledTimes(1); - expect(mockClient.searchLogsPage).toHaveBeenCalledTimes(1); - }); - }); - - describe("cleanup", () => { - it("cancels in-flight request on unmount", async () => { - let abortSignal: AbortSignal | undefined; - mockClient.searchTracesPage.mockImplementation( - async (_: unknown, opts?: { signal?: AbortSignal }) => { - abortSignal = opts?.signal; - return new Promise(() => {}); - } - ); - - const dataSource: DataSource = { - method: "searchTracesPage", - params: {}, - }; - - const { unmount } = renderHook(() => useKopaiData(dataSource), { - wrapper: wrapper(mockClient), - }); - - await waitFor(() => { - expect(abortSignal).toBeDefined(); - }); - - unmount(); - - expect(abortSignal?.aborted).toBe(true); - }); - }); -}); diff --git a/packages/ui/src/hooks/use-kopai-data.ts b/packages/ui/src/hooks/use-kopai-data.ts deleted file mode 100644 index f1e367f..0000000 --- a/packages/ui/src/hooks/use-kopai-data.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import type { DataSource } from "../lib/component-catalog.js"; -import { useKopaiSDK } from "../providers/kopai-provider.js"; - -export interface UseKopaiDataResult { - data: T | null; - loading: boolean; - error: Error | null; - refetch: () => void; -} - -function fetchForDataSource( - client: ReturnType, - dataSource: DataSource, - signal: AbortSignal -): Promise { - switch (dataSource.method) { - case "searchTracesPage": - return client.searchTracesPage( - dataSource.params as Parameters[0], - { signal } - ); - case "searchLogsPage": - return client.searchLogsPage( - dataSource.params as Parameters[0], - { signal } - ); - case "searchMetricsPage": - return client.searchMetricsPage( - dataSource.params as Parameters[0], - { signal } - ); - case "searchAggregatedMetrics": - return client.searchAggregatedMetrics( - dataSource.params as Parameters< - typeof client.searchAggregatedMetrics - >[0], - { signal } - ); - case "getTrace": - return client.getTrace(dataSource.params.traceId, { signal }); - case "discoverMetrics": - return client.discoverMetrics({ signal }); - case "getServices": - return client.getServices({ signal }); - case "getOperations": - return client.getOperations(dataSource.params.serviceName, { signal }); - case "searchTraceSummariesPage": - return client.searchTraceSummariesPage( - dataSource.params as Parameters< - typeof client.searchTraceSummariesPage - >[0], - { signal } - ); - default: { - const exhaustiveCheck: never = dataSource; - throw new Error( - `Unknown method: ${(exhaustiveCheck as DataSource).method}` - ); - } - } -} - -export function useKopaiData( - dataSource: DataSource | undefined -): UseKopaiDataResult { - const client = useKopaiSDK(); - - const { data, isFetching, error, refetch } = useQuery({ - queryKey: ["kopai", dataSource?.method, dataSource?.params], - queryFn: ({ signal }) => fetchForDataSource(client, dataSource!, signal), - enabled: !!dataSource, - refetchInterval: dataSource?.refetchIntervalMs, - }); - - return { - data: (data as T) ?? null, - loading: isFetching, - error: error ?? null, - refetch, - }; -} diff --git a/packages/ui/src/hooks/use-live-logs.test.ts b/packages/ui/src/hooks/use-live-logs.test.ts deleted file mode 100644 index 161dd7a..0000000 --- a/packages/ui/src/hooks/use-live-logs.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { createElement, type ReactNode } from "react"; -import { useLiveLogs } from "./use-live-logs.js"; -import { - KopaiSDKProvider, - queryClient, - type KopaiClient, -} from "../providers/kopai-provider.js"; - -const BASE_NS = 1700000000000000000n; -const ts = (offsetMs: number) => - (BASE_NS + BigInt(offsetMs) * 1000000n).toString(); - -const createMockClient = () => ({ - searchTracesPage: vi.fn(), - searchLogsPage: vi.fn(), - searchMetricsPage: vi.fn(), - searchAggregatedMetrics: vi.fn(), - getTrace: vi.fn(), - discoverMetrics: vi.fn(), - getDashboard: vi.fn(), - getServices: vi.fn(), - getOperations: vi.fn(), - searchTraceSummariesPage: vi.fn(), -}); - -function wrapper(client: KopaiClient) { - return function Wrapper({ children }: { children: ReactNode }) { - return createElement(KopaiSDKProvider, { client, children }); - }; -} - -describe("useLiveLogs", () => { - let mockClient: ReturnType; - - beforeEach(() => { - mockClient = createMockClient(); - queryClient.clear(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("fetches and returns logs on initial load", async () => { - const batch = [ - { - Timestamp: ts(100), - Body: "log1", - ServiceName: "svc", - SeverityNumber: 9, - }, - { - Timestamp: ts(200), - Body: "log2", - ServiceName: "svc", - SeverityNumber: 9, - }, - ]; - - mockClient.searchLogsPage.mockResolvedValue({ - data: batch, - nextCursor: null, - }); - - const { result } = renderHook( - () => - useLiveLogs({ - params: { limit: 200 }, - pollIntervalMs: 60_000, // long interval so no refetch during test - }), - { wrapper: wrapper(mockClient) } - ); - - await waitFor(() => { - expect(result.current.logs).toHaveLength(2); - }); - - expect(result.current.totalReceived).toBe(2); - expect(result.current.isLive).toBe(true); - expect(result.current.error).toBeNull(); - - // First call should not have timestampMin - const firstCall = mockClient.searchLogsPage.mock.calls[0]![0]; - expect(firstCall.timestampMin).toBeUndefined(); - }); - - it("uses timestampMin on manual refetch after first load", async () => { - mockClient.searchLogsPage - .mockResolvedValueOnce({ - data: [ - { - Timestamp: ts(100), - Body: "log1", - ServiceName: "svc", - SeverityNumber: 9, - }, - ], - nextCursor: null, - }) - .mockResolvedValueOnce({ - data: [ - { - Timestamp: ts(300), - Body: "log3", - ServiceName: "svc", - SeverityNumber: 9, - }, - ], - nextCursor: null, - }); - - const { result } = renderHook( - () => - useLiveLogs({ - params: { limit: 200 }, - pollIntervalMs: 600_000, - }), - { wrapper: wrapper(mockClient) } - ); - - // Wait for first fetch - await waitFor(() => { - expect(result.current.logs).toHaveLength(1); - }); - - // Pause then resume to trigger refetch - act(() => { - result.current.setLive(false); - }); - act(() => { - result.current.setLive(true); - }); - - await waitFor( - () => { - expect( - mockClient.searchLogsPage.mock.calls.length - ).toBeGreaterThanOrEqual(2); - }, - { timeout: 3000 } - ); - - // The refetch call(s) after first should have timestampMin - const calls = mockClient.searchLogsPage.mock.calls; - const expectedMin = String(BigInt(ts(100)) + 1n); - const callsWithTimestampMin = calls.filter( - (c: unknown[]) => - (c[0] as Record).timestampMin !== undefined - ); - expect(callsWithTimestampMin.length).toBeGreaterThan(0); - expect(callsWithTimestampMin[0]![0].timestampMin).toBe(expectedMin); - }); - - it("setLive(false) sets isLive to false", async () => { - mockClient.searchLogsPage.mockResolvedValue({ data: [], nextCursor: null }); - - const { result } = renderHook( - () => - useLiveLogs({ - params: { limit: 200 }, - pollIntervalMs: 600_000, - }), - { wrapper: wrapper(mockClient) } - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.isLive).toBe(true); - - act(() => { - result.current.setLive(false); - }); - - expect(result.current.isLive).toBe(false); - }); - - it("starts as live by default", async () => { - mockClient.searchLogsPage.mockResolvedValue({ data: [], nextCursor: null }); - - const { result } = renderHook( - () => - useLiveLogs({ - params: { limit: 200 }, - }), - { wrapper: wrapper(mockClient) } - ); - - expect(result.current.isLive).toBe(true); - }); -}); diff --git a/packages/ui/src/hooks/use-live-logs.ts b/packages/ui/src/hooks/use-live-logs.ts deleted file mode 100644 index d7149da..0000000 --- a/packages/ui/src/hooks/use-live-logs.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { useState, useRef, useCallback, useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { denormalizedSignals, dataFilterSchemas } from "@kopai/core"; -import { useKopaiSDK } from "../providers/kopai-provider.js"; -import { LogBuffer } from "../lib/log-buffer.js"; - -type OtelLogsRow = denormalizedSignals.OtelLogsRow; -type LogsDataFilter = dataFilterSchemas.LogsDataFilter; - -export interface UseLiveLogsOptions { - params: LogsDataFilter; - pollIntervalMs?: number; - maxLogs?: number; - enabled?: boolean; -} - -export interface UseLiveLogsResult { - logs: OtelLogsRow[]; - isLive: boolean; - totalReceived: number; - loading: boolean; - error: Error | null; - setLive: (live: boolean) => void; -} - -export function useLiveLogs({ - params, - pollIntervalMs = 3_000, - maxLogs = 1_000, - enabled = true, -}: UseLiveLogsOptions): UseLiveLogsResult { - const client = useKopaiSDK(); - const bufferRef = useRef(new LogBuffer(maxLogs)); - const [version, setVersion] = useState(0); - const [isLive, setIsLiveState] = useState(true); - const totalReceivedRef = useRef(0); - const hasFetchedOnce = useRef(false); - - // Reset buffer when params change so stale data from a previous filter - // doesn't persist while the new query is in flight. - const paramsKey = JSON.stringify(params); - const prevParamsKey = useRef(paramsKey); - useEffect(() => { - if (prevParamsKey.current !== paramsKey) { - prevParamsKey.current = paramsKey; - bufferRef.current.clear(); - hasFetchedOnce.current = false; - totalReceivedRef.current = 0; - setVersion((v) => v + 1); - } - }, [paramsKey]); - - const { isFetching, error, refetch } = useQuery< - { data: OtelLogsRow[]; nextCursor: string | null }, - Error - >({ - queryKey: ["live-logs", params], - queryFn: async ({ signal }) => { - const fetchParams: LogsDataFilter = { ...params }; - - // If params changed since queryFn was scheduled, treat as first fetch - if (prevParamsKey.current !== JSON.stringify(params)) { - hasFetchedOnce.current = false; - } - - // After first fetch, only get newer logs - if (hasFetchedOnce.current) { - const newest = bufferRef.current.getNewestTimestamp(); - if (newest) { - // Add 1ns to avoid re-fetching the same row - fetchParams.timestampMin = String(BigInt(newest) + 1n); - } - } - - const result = await client.searchLogsPage(fetchParams, { signal }); - hasFetchedOnce.current = true; - - if (result.data.length > 0) { - totalReceivedRef.current += result.data.length; - bufferRef.current.merge(result.data); - setVersion((v) => v + 1); - } - - return result; - }, - enabled: enabled, - refetchInterval: isLive ? pollIntervalMs : false, - }); - - const setLive = useCallback( - (live: boolean) => { - setIsLiveState(live); - if (live) { - // Immediate refetch on resume - refetch(); - } - }, - [refetch] - ); - - // Read buffer (version forces re-render) - void version; - const logs = bufferRef.current.getAll(); - - return { - logs, - isLive, - totalReceived: totalReceivedRef.current, - loading: isFetching, - error: error ?? null, - setLive, - }; -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 252cf94..946fd9c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,14 +1,32 @@ +// DOM-only page composition — stays here. export { default as ObservabilityPage } from "./pages/observability.js"; -export { observabilityCatalog } from "./lib/observability-catalog.js"; -export { generatePromptInstructions } from "./lib/generate-prompt-instructions.js"; +// Back-compat re-exports — original DOM-free symbols now live in @kopai/ui-core. +// Additive: earlier versions of @kopai/ui exposed only a subset of these. export { + observabilityCatalog, + generatePromptInstructions, createRendererFromCatalog, + Renderer, + createCatalog, + KopaiSDKProvider, + useKopaiSDK, + queryClient, + useKopaiData, + useLiveLogs, + LogBuffer, type RendererComponentProps, type UITree, -} from "./lib/renderer.js"; -export { createCatalog } from "./lib/component-catalog.js"; -export { - KopaiSDKProvider, + type ComponentRenderer, + type ComponentRenderProps, + type ComponentRenderPropsBase, + type ComponentRenderPropsWithData, + type CatalogueComponentProps, + type ComponentDefinition, + type DataSource, + type InferProps, type KopaiClient, -} from "./providers/kopai-provider.js"; + type UseKopaiDataResult, + type UseLiveLogsOptions, + type UseLiveLogsResult, +} from "@kopai/ui-core"; diff --git a/packages/ui/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap b/packages/ui/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap deleted file mode 100644 index 4ce00f8..0000000 --- a/packages/ui/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`generatePromptInstructions > generates full prompt instructions 1`] = ` -"## UI Tree Version - -Use uiTreeVersion: "0.5.0" when creating dashboards. - ---- - -## Available Components - -### Card -A card container - -Props: -- title: string (required) -Accepts children: yes - ---- - -### Button -Clickable button - -Props: -- label: string (required) -Accepts dataSource: no - ---- - -## Output Schema - -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"root":{"type":"string","description":"root uiElement key in the elements array"},"elements":{"type":"object","propertyNames":{"type":"string","description":"equal to the element key"},"additionalProperties":{"oneOf":[{"type":"object","properties":{"key":{"type":"string"},"type":{"type":"string","const":"Card"},"children":{"type":"array","items":{"type":"string"}},"parentKey":{"type":"string"},"dataSource":{"$ref":"#/$defs/DataSource"},"props":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}},"required":["key","type","children","parentKey","props"],"additionalProperties":false},{"type":"object","properties":{"key":{"type":"string"},"type":{"type":"string","const":"Button"},"children":{"type":"array","items":{"type":"string"}},"parentKey":{"type":"string"},"dataSource":{"$ref":"#/$defs/DataSource"},"props":{"type":"object","properties":{"label":{"type":"string"}},"required":["label"],"additionalProperties":false}},"required":["key","type","children","parentKey","props"],"additionalProperties":false}]}}},"required":["root","elements"],"additionalProperties":false,"$defs":{"DataSource":{"$schema":"https://json-schema.org/draft/2020-12/schema","oneOf":[{"type":"object","properties":{"method":{"type":"string","const":"searchTracesPage"},"params":{"type":"object","properties":{"traceId":{"description":"Unique identifier for a trace. All spans from the same trace share the same trace_id. The ID is a 16-byte array.","type":"string"},"spanId":{"description":"Unique identifier for a span within a trace. The ID is an 8-byte array.","type":"string"},"parentSpanId":{"description":"The span_id of this span's parent span. Empty if this is a root span.","type":"string"},"serviceName":{"description":"Service name from resource attributes (service.name).","type":"string"},"spanName":{"description":"Description of the span's operation. E.g., qualified method name or file name with line number.","type":"string"},"spanKind":{"description":"Type of span (INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER). Used to identify relationships between spans.","type":"string"},"statusCode":{"description":"Status code (UNSET, OK, ERROR).","type":"string"},"scopeName":{"description":"Name denoting the instrumentation scope.","type":"string"},"timestampMin":{"description":"Minimum start time of the span. UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"timestampMax":{"description":"Maximum start time of the span. UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"durationMin":{"description":"Minimum duration of the span in nanoseconds (end_time - start_time). Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"durationMax":{"description":"Maximum duration of the span in nanoseconds (end_time - start_time). Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"spanAttributes":{"description":"Key/value pairs describing the span.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"resourceAttributes":{"description":"Attributes that describe the resource.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"eventsAttributes":{"description":"Attribute key/value pairs on the event.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"linksAttributes":{"description":"Attribute key/value pairs on the link.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"limit":{"description":"Max items to return. Default determined by datasource.","type":"integer","exclusiveMinimum":0,"maximum":1000},"cursor":{"description":"Opaque cursor from previous response for next page.","type":"string"},"sortOrder":{"description":"Sort by timestamp. ASC = oldest first, DESC = newest first.","type":"string","enum":["ASC","DESC"]}},"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method","params"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"searchLogsPage"},"params":{"type":"object","properties":{"traceId":{"description":"Unique identifier for a trace. All logs from the same trace share the same trace_id. The ID is a 16-byte array.","type":"string"},"spanId":{"description":"Unique identifier for a span within a trace. The ID is an 8-byte array.","type":"string"},"serviceName":{"description":"Service name from resource attributes (service.name).","type":"string"},"scopeName":{"description":"Name denoting the instrumentation scope.","type":"string"},"eventName":{"description":"A unique identifier of event category/type. Filters logs that are events with this name.","type":"string"},"severityText":{"description":"Severity text (also known as log level). Original string representation as known at the source.","type":"string"},"severityNumberMin":{"description":"Minimum severity number (inclusive). Normalized to values described in Log Data Model.","type":"number"},"severityNumberMax":{"description":"Maximum severity number (inclusive). Normalized to values described in Log Data Model.","type":"number"},"bodyContains":{"description":"Filter logs where body contains this substring.","type":"string"},"timestampMin":{"description":"Minimum time when the event occurred. UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"timestampMax":{"description":"Maximum time when the event occurred. UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"logAttributes":{"description":"Additional attributes that describe the specific event occurrence.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"resourceAttributes":{"description":"Attributes that describe the resource.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"scopeAttributes":{"description":"Attributes of the instrumentation scope.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"limit":{"description":"Max items to return. Default determined by datasource.","type":"integer","exclusiveMinimum":0,"maximum":1000},"cursor":{"description":"Opaque cursor from previous response for next page.","type":"string"},"sortOrder":{"description":"Sort by timestamp. ASC = oldest first, DESC = newest first.","type":"string","enum":["ASC","DESC"]}},"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method","params"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"searchMetricsPage"},"params":{"type":"object","properties":{"metricType":{"type":"string","enum":["Gauge","Sum","Histogram","ExponentialHistogram","Summary"],"description":"Metric type to query."},"metricName":{"description":"The name of the metric.","type":"string"},"serviceName":{"description":"Service name from resource attributes (service.name).","type":"string"},"scopeName":{"description":"Name denoting the instrumentation scope.","type":"string"},"timeUnixMin":{"description":"Minimum time when the data point was recorded. UNIX Epoch time in nanoseconds. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"timeUnixMax":{"description":"Maximum time when the data point was recorded. UNIX Epoch time in nanoseconds. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"attributes":{"description":"Key/value pairs that uniquely identify the timeseries.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"resourceAttributes":{"description":"Attributes that describe the resource.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"scopeAttributes":{"description":"Attributes of the instrumentation scope.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"aggregate":{"description":"Aggregation function to apply to metric values. When set, returns aggregated results instead of raw data points.","type":"string","enum":["sum","avg","min","max","count"]},"groupBy":{"description":"Attribute keys to group by when aggregating (e.g. ['tenant.id', 'signal']).","type":"array","items":{"type":"string"}},"limit":{"description":"Max items to return. Default determined by datasource.","type":"integer","exclusiveMinimum":0,"maximum":1000},"cursor":{"description":"Opaque cursor from previous response for next page.","type":"string"},"sortOrder":{"description":"Sort by timestamp. ASC = oldest first, DESC = newest first.","type":"string","enum":["ASC","DESC"]}},"required":["metricType"],"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method","params"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"getTrace"},"params":{"type":"object","properties":{"traceId":{"type":"string"}},"required":["traceId"],"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method","params"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"discoverMetrics"},"params":{"type":"object","properties":{},"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"getServices"},"params":{"type":"object","properties":{},"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"getOperations"},"params":{"type":"object","properties":{"serviceName":{"type":"string"}},"required":["serviceName"],"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method","params"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"searchTraceSummariesPage"},"params":{"type":"object","properties":{"serviceName":{"type":"string"},"spanName":{"type":"string"},"timestampMin":{"type":"string","pattern":"^\\\\d+$"},"timestampMax":{"type":"string","pattern":"^\\\\d+$"},"durationMin":{"type":"string","pattern":"^\\\\d+$"},"durationMax":{"type":"string","pattern":"^\\\\d+$"},"spanAttributes":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"resourceAttributes":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"limit":{"default":20,"type":"integer","minimum":1,"maximum":1000},"cursor":{"type":"string"},"sortOrder":{"default":"DESC","type":"string","enum":["ASC","DESC"]}},"required":["limit","sortOrder"],"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method","params"],"additionalProperties":false},{"type":"object","properties":{"method":{"type":"string","const":"searchAggregatedMetrics"},"params":{"type":"object","properties":{"metricType":{"type":"string","enum":["Gauge","Sum","Histogram","ExponentialHistogram","Summary"],"description":"Metric type to query."},"metricName":{"description":"The name of the metric.","type":"string"},"serviceName":{"description":"Service name from resource attributes (service.name).","type":"string"},"scopeName":{"description":"Name denoting the instrumentation scope.","type":"string"},"timeUnixMin":{"description":"Minimum time when the data point was recorded. UNIX Epoch time in nanoseconds. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"timeUnixMax":{"description":"Maximum time when the data point was recorded. UNIX Epoch time in nanoseconds. Expressed as string in JSON.","type":"string","pattern":"^\\\\d+$"},"attributes":{"description":"Key/value pairs that uniquely identify the timeseries.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"resourceAttributes":{"description":"Attributes that describe the resource.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"scopeAttributes":{"description":"Attributes of the instrumentation scope.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"aggregate":{"description":"Aggregation function to apply to metric values. When set, returns aggregated results instead of raw data points.","type":"string","enum":["sum","avg","min","max","count"]},"groupBy":{"description":"Attribute keys to group by when aggregating (e.g. ['tenant.id', 'signal']).","type":"array","items":{"type":"string"}},"limit":{"description":"Max items to return. Default determined by datasource.","type":"integer","exclusiveMinimum":0,"maximum":1000},"cursor":{"description":"Opaque cursor from previous response for next page.","type":"string"},"sortOrder":{"description":"Sort by timestamp. ASC = oldest first, DESC = newest first.","type":"string","enum":["ASC","DESC"]}},"required":["metricType"],"additionalProperties":false},"refetchIntervalMs":{"type":"number"}},"required":["method","params"],"additionalProperties":false}]}}} - ---- - -## Example - -{"root":"card-1","elements":{"card-1":{"key":"card-1","type":"Card","props":{},"children":["button-1"]},"button-1":{"key":"button-1","type":"Button","props":{},"parentKey":"card-1"}}}" -`; diff --git a/packages/ui/src/lib/component-catalog.test.ts b/packages/ui/src/lib/component-catalog.test.ts deleted file mode 100644 index 81b2f74..0000000 --- a/packages/ui/src/lib/component-catalog.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { z } from "zod"; -import { dataSourceSchema, createCatalog } from "./component-catalog.js"; -import type { CatalogueComponentProps } from "./component-catalog.js"; - -describe("schemas", () => { - it("datasource", () => { - expect.assertions(0); - type DataSource = z.infer; - - const _testDataSource1 = { - method: "searchTracesPage", - params: { - // @ts-expect-error - invalid param - nonExisting: "", - }, - } satisfies DataSource; - - const _testDataSource2 = { - method: "searchTracesPage", - params: { - cursor: "test", - eventsAttributes: { - foo: "bar", - }, - limit: 3, - }, - } satisfies DataSource; - }); - - describe("createCatalog", () => { - it("creates uiTreeSchema that validates component props", () => { - const catalog = createCatalog({ - name: "test catalog", - components: { - TestComponent: { - description: "test", - hasChildren: false, - props: z.object({ isImportant: z.boolean() }), - }, - }, - }); - - // Valid data passes - const validResult = catalog.uiTreeSchema.safeParse({ - root: "test-1", - elements: { - TestComponent: { - key: "test-1", - type: "TestComponent", - children: [], - parentKey: "", - props: { isImportant: true }, - }, - }, - }); - expect(validResult.success).toBe(true); - - // Invalid props fail - const invalidResult = catalog.uiTreeSchema.safeParse({ - root: "test-1", - elements: { - TestComponent: { - key: "test-1", - type: "TestComponent", - children: [], - parentKey: "", - props: { isImportant: "not-a-boolean" }, - }, - }, - }); - expect(invalidResult.success).toBe(false); - }); - - it("returns components with exact types from config", () => { - expect.assertions(0); - - const catalog = createCatalog({ - name: "test catalog", - components: { - TestComponent: { - description: "test", - hasChildren: false, - props: z.object({ isImportant: z.boolean() }), - }, - }, - }); - - // Valid: accessing defined component - const _validAccess = catalog.components.TestComponent; - - // @ts-expect-error - non-existent component - const _invalidAccess = catalog.components.NonExistentComponent; - - // Props type preserved - can infer schema type - type Props = z.infer; - const _validProps: Props = { isImportant: true }; - // @ts-expect-error - wrong prop type - const _invalidProps: Props = { isImportant: "not-boolean" }; - // @ts-expect-error - non-existent prop - const _missingProps: Props = { nonExistent: true }; - }); - - it("validates multiple components with dataSource", () => { - const catalog = createCatalog({ - name: "multi catalog", - components: { - Button: { - description: "button", - hasChildren: false, - props: z.object({ label: z.string() }), - }, - Card: { - description: "card", - hasChildren: true, - props: z.object({ title: z.string(), bordered: z.boolean() }), - }, - }, - }); - - expect(catalog.name).toBe("multi catalog"); - - // Valid with dataSource - const validResult = catalog.uiTreeSchema.safeParse({ - root: "card-1", - elements: { - Card: { - key: "card-1", - type: "Card", - children: ["btn-1"], - parentKey: "", - props: { title: "Hello", bordered: true }, - dataSource: { - method: "searchTracesPage", - params: { limit: 10 }, - }, - }, - Button: { - key: "btn-1", - type: "Button", - children: [], - parentKey: "card-1", - props: { label: "Click" }, - }, - }, - }); - expect(validResult.success).toBe(true); - - // Invalid dataSource params - const invalidDataSource = catalog.uiTreeSchema.safeParse({ - root: "btn-1", - elements: { - Button: { - key: "btn-1", - type: "Button", - children: [], - parentKey: "", - props: { label: "Click" }, - dataSource: { - method: "searchTracesPage", - params: { limit: null }, - }, - }, - }, - }); - expect(invalidDataSource.success).toBe(false); - }); - - it("types multiple components correctly", () => { - expect.assertions(0); - - const catalog = createCatalog({ - name: "multi catalog", - components: { - Button: { - description: "button", - hasChildren: false, - props: z.object({ label: z.string() }), - }, - Card: { - description: "card", - hasChildren: true, - props: z.object({ title: z.string() }), - }, - }, - }); - - // Both components accessible - const _button = catalog.components.Button; - const _card = catalog.components.Card; - - // @ts-expect-error - non-existent - const _missing = catalog.components.Missing; - - // Props types preserved per component - type ButtonProps = z.infer; - type CardProps = z.infer; - - const _validButton: ButtonProps = { label: "hi" }; - const _validCard: CardProps = { title: "hi" }; - - // @ts-expect-error - wrong component props - const _wrongButton: ButtonProps = { title: "hi" }; - // @ts-expect-error - wrong component props - const _wrongCard: CardProps = { label: "hi" }; - }); - - it("types uiTreeSchema elements with dataSource", () => { - expect.assertions(0); - - const _catalog = createCatalog({ - name: "test catalog", - components: { - Button: { - description: "button", - hasChildren: false, - props: z.object({ label: z.string() }), - }, - Card: { - description: "card", - hasChildren: true, - props: z.object({ title: z.string() }), - }, - }, - }); - - type UiTree = z.infer; - - // Valid: elements with dataSource - const _validTree: UiTree = { - root: "card-1", - elements: { - "card-1": { - key: "card-1", - type: "Card", - children: ["btn-1"], - parentKey: "", - props: { title: "Hello" }, - dataSource: { - method: "searchTracesPage", - params: { limit: 10 }, - }, - }, - "btn-1": { - key: "btn-1", - type: "Button", - children: [], - parentKey: "card-1", - props: { label: "Click" }, - }, - }, - }; - - const _invalidType: UiTree = { - root: "x", - elements: { - "random-1": { - key: "random-1", - // @ts-expect-error - non-existent type - type: "NonExistant", - children: [], - parentKey: "", - // @ts-expect-error - props don't match any valid type - props: {}, - }, - }, - }; - - const _invalidProps: UiTree = { - root: "btn-1", - elements: { - Button: { - key: "btn-1", - type: "Button", - children: [], - parentKey: "", - // @ts-expect-error - wrong props type for element - props: { title: "wrong prop" }, - }, - }, - }; - - const _invalidDataSource: UiTree = { - root: "btn-1", - elements: { - Button: { - key: "btn-1", - type: "Button", - children: [], - parentKey: "", - props: { label: "Click" }, - dataSource: { - // @ts-expect-error - invalid dataSource method - method: "invalidMethod", - params: {}, - }, - }, - }, - }; - - const _invalidDataSourceParams: UiTree = { - root: "btn-1", - elements: { - Button: { - key: "btn-1", - type: "Button", - children: [], - parentKey: "", - props: { label: "Click" }, - dataSource: { - method: "searchTracesPage", - // @ts-expect-error - invalid params for searchTracesPage - params: { foo: 1 }, - }, - }, - }, - }; - }); - - it("types CatalogueComponentProps based on hasChildren", () => { - expect.assertions(0); - - type WithChildren = { - hasChildren: true; - description: string; - props: { title: string }; - }; - type NoChildren = { - hasChildren: false; - description: string; - props: { label: string }; - }; - - // hasChildren: true -> includes children prop - type PropsWithChildren = CatalogueComponentProps; - const _validWithChildren: PropsWithChildren = { - element: { props: { title: "hi" } }, - children: null, - }; - // @ts-expect-error - missing children - const _missingChildren: PropsWithChildren = { - element: { props: { title: "hi" } }, - }; - - // hasChildren: false -> no children prop - type PropsNoChildren = CatalogueComponentProps; - const _validNoChildren: PropsNoChildren = { - element: { props: { label: "hi" } }, - }; - const _extraChildren: PropsNoChildren = { - element: { props: { label: "hi" } }, - // @ts-expect-error - children not allowed - children: null, - }; - }); - }); -}); diff --git a/packages/ui/src/lib/component-catalog.ts b/packages/ui/src/lib/component-catalog.ts deleted file mode 100644 index 591baec..0000000 --- a/packages/ui/src/lib/component-catalog.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { z } from "zod"; -import { dataFilterSchemas } from "@kopai/core"; -import type { ReactNode } from "react"; - -// DataSource schema - discriminated union with type-safe params per method -export const dataSourceSchema = z.discriminatedUnion("method", [ - z.object({ - method: z.literal("searchTracesPage"), - params: dataFilterSchemas.tracesDataFilterSchema, - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("searchLogsPage"), - params: dataFilterSchemas.logsDataFilterSchema, - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("searchMetricsPage"), - params: dataFilterSchemas.metricsDataFilterSchema, - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("getTrace"), - params: z.object({ traceId: z.string() }), - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("discoverMetrics"), - params: z.object({}).optional(), - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("getServices"), - params: z.object({}).optional(), - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("getOperations"), - params: z.object({ serviceName: z.string() }), - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("searchTraceSummariesPage"), - params: dataFilterSchemas.traceSummariesFilterSchema, - refetchIntervalMs: z.number().optional(), - }), - z.object({ - method: z.literal("searchAggregatedMetrics"), - params: dataFilterSchemas.metricsDataFilterSchema, - refetchIntervalMs: z.number().optional(), - }), -]); - -export type DataSource = z.infer; - -type DataSourceMethodLiteral = - (typeof dataSourceSchema.options)[number]["shape"]["method"]["value"]; - -export const dataSourceMethodSchema = z.enum( - dataSourceSchema.options.map((o) => o.shape.method.value) as [ - DataSourceMethodLiteral, - ...DataSourceMethodLiteral[], - ] -); - -export type DataSourceMethod = z.infer; - -export const componentDefinitionSchema = z - .object({ - hasChildren: z.boolean(), - description: z - .string() - .describe( - "Component description to be displayed by the prompt generator" - ), - props: z.unknown(), - acceptsDataFrom: z.array(dataSourceMethodSchema).readonly().optional(), - }) - .describe( - "All options and properties necessary to render the React component with renderer" - ); - -export const catalogConfigSchema = z.object({ - name: z.string().describe("catalog name"), - components: z.record( - z.string().describe("React component name"), - componentDefinitionSchema - ), -}); - -// Zod schema type for a single element variant (preserves K-to-props mapping) -type ElementVariantSchema< - K extends string, - Props extends z.ZodTypeAny, -> = z.ZodObject<{ - key: z.ZodString; - type: z.ZodLiteral; - children: z.ZodArray; - parentKey: z.ZodString; - dataSource: z.ZodOptional; - props: Props; -}>; - -// Union of all element variant schemas -type ElementVariantSchemas> = { - [K in keyof C & string]: ElementVariantSchema< - K, - C[K]["props"] extends z.ZodTypeAny ? C[K]["props"] : z.ZodUnknown - >; -}[keyof C & string]; - -/** - * Creates a component catalog with typed UI tree schema for validation. - * - * @param catalogConfig - Catalog configuration with name and component definitions - * @returns Catalog object with name, components, and generated uiTreeSchema - * - * @example - * ```ts - * const catalog = createCatalog({ - * name: "my-catalog", - * components: { - * Card: { - * hasChildren: true, - * description: "Container card", - * props: z.object({ title: z.string() }), - * }, - * }, - * }); - * ``` - */ -export function createCatalog< - C extends Record>, ->(catalogConfig: { name: string; components: C }) { - const elementVariants = ( - Object.keys(catalogConfig.components) as (keyof C & string)[] - ) - .map((catalogItemName) => ({ - catalogItemName, - component: catalogConfig.components[catalogItemName], - })) - .filter( - ( - itemConfig - ): itemConfig is typeof itemConfig & { component: C[keyof C] } => - !!itemConfig.component - ) - .map(({ catalogItemName, component }) => - z.object({ - key: z.string(), - type: z.literal(catalogItemName), - children: z.array(z.string()), - parentKey: z.string(), - dataSource: dataSourceSchema.optional(), - props: component.props, - }) - ); - - type Schemas = ElementVariantSchemas; - const elementsUnion = z.discriminatedUnion( - "type", - elementVariants as unknown as [Schemas, ...Schemas[]] - ); - - // TODO: implement a mechanism for validating there are no circular references - const uiTreeSchema = z.object({ - root: z.string().describe("root uiElement key in the elements array"), - elements: z.record( - z.string().describe("equal to the element key"), - elementsUnion - ), - }); - - return { - name: catalogConfig.name, - components: catalogConfig.components, - uiTreeSchema, - }; -} - -export type ComponentDefinition = z.infer; - -export type InferProps

= P extends z.ZodTypeAny ? z.infer

: P; - -export type CatalogueComponentProps = - CD extends { hasChildren: true; props: infer P } - ? { element: { props: InferProps

}; children: ReactNode } - : CD extends { props: infer P } - ? { element: { props: InferProps

} } - : never; diff --git a/packages/ui/src/lib/generate-prompt-instructions.test.ts b/packages/ui/src/lib/generate-prompt-instructions.test.ts deleted file mode 100644 index 62c9e6e..0000000 --- a/packages/ui/src/lib/generate-prompt-instructions.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { z } from "zod"; -import { createCatalog } from "./component-catalog.js"; -import { generatePromptInstructions } from "./generate-prompt-instructions.js"; - -describe("generatePromptInstructions", () => { - it("generates full prompt instructions", () => { - const catalog = createCatalog({ - name: "test", - components: { - Card: { - props: z.object({ title: z.string() }), - description: "A card container", - hasChildren: true, - }, - Button: { - props: z.object({ label: z.string() }), - description: "Clickable button", - hasChildren: false, - }, - }, - }); - - const prompt = generatePromptInstructions(catalog, "0.5.0"); - expect(prompt).toMatchSnapshot(); - }); -}); diff --git a/packages/ui/src/lib/generate-prompt-instructions.ts b/packages/ui/src/lib/generate-prompt-instructions.ts deleted file mode 100644 index ed851c8..0000000 --- a/packages/ui/src/lib/generate-prompt-instructions.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { z } from "zod"; -import { dataSourceSchema } from "./component-catalog.js"; - -type Catalog = { - name: string; - components: Record< - string, - { - hasChildren: boolean; - description: string; - props: unknown; - acceptsDataFrom?: readonly string[]; - } - >; - uiTreeSchema: z.ZodTypeAny; -}; - -// Helper to format prop type from JSON schema property -function formatPropType(prop: { - type?: string | string[]; - enum?: string[]; - items?: object; - anyOf?: { type?: string; enum?: string[] }[]; -}): string { - // Handle nullable types: anyOf: [{type/enum}, {type: "null"}] - if (prop.anyOf) { - const nonNull = prop.anyOf.filter((v) => v.type !== "null"); - const isNullable = prop.anyOf.some((v) => v.type === "null"); - if (nonNull.length === 1 && nonNull[0]) { - const inner = formatPropType(nonNull[0]); - return isNullable ? `${inner} | null` : inner; - } - } - if (prop.enum) return prop.enum.map((v) => `"${v}"`).join(" | "); - if (Array.isArray(prop.type)) - return prop.type.filter((t) => t !== "null").join(" | "); - if (prop.type === "array" && prop.items) - return `array of ${formatPropType(prop.items as Parameters[0])}`; - return prop.type ?? "unknown"; -} - -// Helper to format props from JSON schema -function formatPropsFromJsonSchema(jsonSchema: object): string { - const schema = jsonSchema as { - properties?: Record; - required?: string[]; - }; - if (!schema.properties) return "(no props)"; - - const required = new Set(schema.required ?? []); - const lines: string[] = []; - - for (const [key, value] of Object.entries(schema.properties)) { - const prop = value as { - type?: string | string[]; - enum?: string[]; - description?: string; - items?: object; - anyOf?: { type?: string; enum?: string[] }[]; - }; - const typeStr = formatPropType(prop); - const isNullable = typeStr.endsWith("| null"); - const isRequired = required.has(key); - const reqStr = isRequired - ? isNullable - ? " (required, may be null)" - : " (required)" - : isNullable - ? " (optional, may be null)" - : " (optional)"; - const descStr = prop.description ? ` - ${prop.description}` : ""; - lines.push(`- ${key}: ${typeStr}${reqStr}${descStr}`); - } - return lines.join("\n"); -} - -// Helper to build example UI tree -function buildExampleElements( - names: string[], - components: Record< - string, - { hasChildren: boolean; acceptsDataFrom?: readonly string[] } - > -): { root: string; elements: Record } { - const containerName = - names.find((n) => components[n]?.hasChildren) ?? names[0]; - const containerKey = `${String(containerName).toLowerCase()}-1`; - - const childKeys: string[] = []; - const elements: Record = {}; - - elements[containerKey] = { - key: containerKey, - type: String(containerName), - props: {}, - children: childKeys, - }; - - const otherNames = names.filter((n) => n !== containerName).slice(0, 2); - for (const name of otherNames) { - const key = `${String(name).toLowerCase()}-1`; - childKeys.push(key); - const element: Record = { - key, - type: String(name), - props: {}, - parentKey: containerKey, - }; - const acceptedMethod = components[name]?.acceptsDataFrom?.[0]; - if (!components[name]?.hasChildren && acceptedMethod) { - element.dataSource = { - method: acceptedMethod, - params: { limit: 10 }, - }; - } - elements[key] = element; - } - - return { root: containerKey, elements }; -} - -// Helper to check if a value looks like the dataSource schema -function isDataSourceSchema(value: unknown): boolean { - if (!value || typeof value !== "object") return false; - const obj = value as Record; - if (!Array.isArray(obj.oneOf)) return false; - const first = obj.oneOf[0] as Record | undefined; - if (!first?.properties) return false; - const props = first.properties as Record; - const method = props.method as Record | undefined; - return method?.const === "searchTracesPage"; -} - -// Helper to recursively replace dataSource schema with $ref -function replaceDataSourceWithRef(obj: unknown): void { - if (!obj || typeof obj !== "object") return; - - const record = obj as Record; - for (const key of Object.keys(record)) { - const value = record[key]; - if (key === "dataSource" && isDataSourceSchema(value)) { - record[key] = { $ref: "#/$defs/DataSource" }; - } else if (value && typeof value === "object") { - replaceDataSourceWithRef(value); - } - } -} - -// Helper to build unified schema with $defs -function buildUnifiedSchema(treeSchema: z.ZodTypeAny): object { - const dataSourceJsonSchema = z.toJSONSchema(dataSourceSchema); - const treeJsonSchema = z.toJSONSchema(treeSchema) as Record; - - replaceDataSourceWithRef(treeJsonSchema); - treeJsonSchema.$defs = { DataSource: dataSourceJsonSchema }; - - return treeJsonSchema; -} - -/** - * Generates LLM prompt instructions from a catalog. - * Includes component docs, JSON schema, and usage examples. - * - * @param catalog - The catalog created via createCatalog - * @returns Markdown string with component docs, schema, and examples - * - * @example - * ```ts - * const instructions = generatePromptInstructions(catalog); - * const prompt = `Build a dashboard UI.\n\n${instructions}`; - * ``` - */ -export function generatePromptInstructions( - catalog: Catalog, - uiTreeVersion: string -): string { - const componentNames = Object.keys(catalog.components); - - const componentSections = componentNames - .map((name) => { - const def = catalog.components[name]!; - const propsSchema = z.toJSONSchema(def.props as z.ZodTypeAny); - const propsFormatted = formatPropsFromJsonSchema(propsSchema); - const roleLine = def.hasChildren - ? "Accepts children: yes" - : def.acceptsDataFrom?.length - ? `Accepts dataSource methods: ${def.acceptsDataFrom.join(", ")}` - : "Accepts dataSource: no"; - - return `### ${name} -${def.description ?? "No description"} - -Props: -${propsFormatted} -${roleLine}`; - }) - .join("\n\n---\n\n"); - - const unifiedSchema = buildUnifiedSchema(catalog.uiTreeSchema); - const exampleElements = buildExampleElements( - componentNames, - catalog.components - ); - - return `## UI Tree Version - -Use uiTreeVersion: "${uiTreeVersion}" when creating dashboards. - ---- - -## Available Components - -${componentSections} - ---- - -## Output Schema - -${JSON.stringify(unifiedSchema)} - ---- - -## Example - -${JSON.stringify(exampleElements)}`; -} diff --git a/packages/ui/src/lib/log-buffer.test.ts b/packages/ui/src/lib/log-buffer.test.ts deleted file mode 100644 index df2f429..0000000 --- a/packages/ui/src/lib/log-buffer.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { LogBuffer } from "./log-buffer.js"; -import type { denormalizedSignals } from "@kopai/core"; - -type OtelLogsRow = denormalizedSignals.OtelLogsRow; - -const BASE_NS = 1700000000000000000n; -const ts = (offsetMs: number) => - (BASE_NS + BigInt(offsetMs) * 1000000n).toString(); - -function makeRow(offsetMs: number, body: string, service = "svc"): OtelLogsRow { - return { - Timestamp: ts(offsetMs), - Body: body, - ServiceName: service, - SeverityText: "INFO", - SeverityNumber: 9, - }; -} - -describe("LogBuffer", () => { - it("stores and returns rows sorted by timestamp", () => { - const buf = new LogBuffer(); - buf.merge([makeRow(200, "second"), makeRow(100, "first")]); - const all = buf.getAll(); - expect(all).toHaveLength(2); - expect(all[0]!.Body).toBe("first"); - expect(all[1]!.Body).toBe("second"); - }); - - it("deduplicates rows with same key", () => { - const buf = new LogBuffer(); - const row = makeRow(100, "hello"); - buf.merge([row]); - buf.merge([row]); - expect(buf.size).toBe(1); - }); - - it("trims oldest rows when exceeding maxSize", () => { - const buf = new LogBuffer(3); - buf.merge([makeRow(10, "a"), makeRow(20, "b"), makeRow(30, "c")]); - expect(buf.size).toBe(3); - - buf.merge([makeRow(40, "d")]); - expect(buf.size).toBe(3); - // oldest ("a" at ts=10) should be gone - const bodies = buf.getAll().map((r) => r.Body); - expect(bodies).toEqual(["b", "c", "d"]); - }); - - it("getNewestTimestamp returns latest timestamp", () => { - const buf = new LogBuffer(); - buf.merge([makeRow(100, "a"), makeRow(300, "c"), makeRow(200, "b")]); - expect(buf.getNewestTimestamp()).toBe(ts(300)); - }); - - it("getNewestTimestamp returns undefined for empty buffer", () => { - const buf = new LogBuffer(); - expect(buf.getNewestTimestamp()).toBeUndefined(); - }); - - it("clear resets the buffer", () => { - const buf = new LogBuffer(); - buf.merge([makeRow(100, "a")]); - expect(buf.size).toBe(1); - buf.clear(); - expect(buf.size).toBe(0); - expect(buf.getAll()).toEqual([]); - }); - - it("handles empty merge", () => { - const buf = new LogBuffer(); - buf.merge([]); - expect(buf.size).toBe(0); - }); - - it("distinguishes rows with same timestamp but different body", () => { - const buf = new LogBuffer(); - buf.merge([makeRow(100, "hello"), makeRow(100, "world")]); - expect(buf.size).toBe(2); - }); - - it("distinguishes rows with same timestamp+body but different service", () => { - const buf = new LogBuffer(); - buf.merge([makeRow(100, "hello", "svc-a"), makeRow(100, "hello", "svc-b")]); - expect(buf.size).toBe(2); - }); -}); diff --git a/packages/ui/src/lib/log-buffer.ts b/packages/ui/src/lib/log-buffer.ts deleted file mode 100644 index c040ce9..0000000 --- a/packages/ui/src/lib/log-buffer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { denormalizedSignals } from "@kopai/core"; - -type OtelLogsRow = denormalizedSignals.OtelLogsRow; - -function logKey(row: OtelLogsRow): string { - const body = row.Body ?? ""; - let hash = 0; - for (let i = 0; i < body.length; i++) { - hash = (hash << 5) - hash + body.charCodeAt(i); - hash = hash & hash; - } - return `${row.Timestamp}-${row.ServiceName ?? ""}-${Math.abs(hash).toString(36)}`; -} - -export class LogBuffer { - private readonly maxSize: number; - private rows: OtelLogsRow[] = []; - private keys = new Set(); - - constructor(maxSize = 1000) { - this.maxSize = maxSize; - } - - /** Merge new rows, dedup, sort by timestamp, trim oldest when over max. */ - merge(incoming: OtelLogsRow[]): void { - for (const row of incoming) { - const k = logKey(row); - if (this.keys.has(k)) continue; - this.keys.add(k); - this.rows.push(row); - } - // Sort ascending by timestamp - this.rows.sort((a, b) => { - if (a.Timestamp < b.Timestamp) return -1; - if (a.Timestamp > b.Timestamp) return 1; - return 0; - }); - // Trim oldest - if (this.rows.length > this.maxSize) { - const removed = this.rows.splice(0, this.rows.length - this.maxSize); - for (const r of removed) this.keys.delete(logKey(r)); - } - } - - getAll(): OtelLogsRow[] { - return this.rows.slice(); - } - - getNewestTimestamp(): string | undefined { - if (this.rows.length === 0) return undefined; - return this.rows[this.rows.length - 1]!.Timestamp; - } - - get size(): number { - return this.rows.length; - } - - clear(): void { - this.rows = []; - this.keys.clear(); - } -} diff --git a/packages/ui/src/lib/observability-catalog.ts b/packages/ui/src/lib/observability-catalog.ts deleted file mode 100644 index 6cd3adb..0000000 --- a/packages/ui/src/lib/observability-catalog.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { createCatalog } from "./component-catalog.js"; -import { z } from "zod"; - -export const observabilityCatalog = createCatalog({ - name: "observability", - components: { - // Layout Components - Card: { - props: z.object({ - title: z.string().nullable(), - description: z.string().nullable(), - padding: z.enum(["sm", "md", "lg"]).nullable(), - }), - hasChildren: true, - description: "A card container with optional title", - }, - - Grid: { - props: z.object({ - columns: z.number().min(1).max(4).nullable(), - gap: z.enum(["sm", "md", "lg"]).nullable(), - }), - hasChildren: true, - description: "Grid layout with configurable columns", - }, - - Stack: { - props: z.object({ - direction: z.enum(["horizontal", "vertical"]).nullable(), - gap: z.enum(["sm", "md", "lg"]).nullable(), - align: z.enum(["start", "center", "end", "stretch"]).nullable(), - }), - hasChildren: true, - description: "Flex stack for horizontal or vertical layouts", - }, - - // Typography - Heading: { - props: z.object({ - text: z.string(), - level: z.enum(["h1", "h2", "h3", "h4"]).nullable(), - }), - hasChildren: false, - description: "Section heading", - }, - - Text: { - props: z.object({ - content: z.string(), - variant: z.enum(["body", "caption", "label"]).nullable(), - color: z - .enum(["default", "muted", "success", "warning", "danger"]) - .nullable(), - }), - hasChildren: false, - description: "Text paragraph", - }, - - // Status Components - Badge: { - props: z.object({ - text: z.string(), - variant: z - .enum(["default", "success", "warning", "danger", "info"]) - .nullable(), - }), - hasChildren: false, - description: "Small status badge", - }, - - Divider: { - props: z.object({ - label: z.string().nullable(), - }), - hasChildren: false, - description: "Visual divider", - }, - - Empty: { - props: z.object({ - title: z.string(), - description: z.string().nullable(), - action: z.string().nullable(), - actionLabel: z.string().nullable(), - }), - hasChildren: false, - description: "Empty state placeholder", - }, - - // Observability Components - LogTimeline: { - props: z.object({ height: z.number().nullable() }), - hasChildren: false, - description: - "Log timeline with virtual scroll, severity filtering, detail pane", - acceptsDataFrom: ["searchLogsPage"] as const, - }, - - TraceDetail: { - props: z.object({ height: z.number().nullable() }), - hasChildren: false, - description: - "Trace detail with traceId input field and waterfall timeline", - acceptsDataFrom: [ - "searchTracesPage", - "searchTraceSummariesPage", - ] as const, - }, - - MetricTimeSeries: { - props: z.object({ - height: z.number().nullable(), - showBrush: z.boolean().nullable(), - yAxisLabel: z.string().nullable(), - unit: z.string().nullable(), - }), - hasChildren: false, - description: "Time series line chart for Gauge/Sum metrics", - acceptsDataFrom: ["searchMetricsPage"] as const, - }, - - MetricHistogram: { - props: z.object({ - height: z.number().nullable(), - yAxisLabel: z.string().nullable(), - unit: z.string().nullable(), - }), - hasChildren: false, - description: "Histogram bar chart for distribution metrics", - acceptsDataFrom: ["searchMetricsPage"] as const, - }, - - MetricStat: { - props: z.object({ - label: z.string().nullable(), - showSparkline: z.boolean().nullable(), - }), - hasChildren: false, - description: - "Single metric KPI card with sparkline and threshold coloring", - acceptsDataFrom: [ - "searchMetricsPage", - "searchAggregatedMetrics", - ] as const, - }, - - MetricTable: { - props: z.object({ maxRows: z.number().nullable() }), - hasChildren: false, - description: "Tabular display of metric data points", - acceptsDataFrom: ["searchMetricsPage"] as const, - }, - - MetricDiscovery: { - props: z.object({}), - hasChildren: false, - description: - "Table of discovered metric names, types, units and descriptions", - acceptsDataFrom: ["discoverMetrics"] as const, - }, - }, -}); diff --git a/packages/ui/src/lib/renderer.test.tsx b/packages/ui/src/lib/renderer.test.tsx deleted file mode 100644 index dddc827..0000000 --- a/packages/ui/src/lib/renderer.test.tsx +++ /dev/null @@ -1,701 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createElement, type ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { render, screen, waitFor, act } from "@testing-library/react"; -import { - createRendererFromCatalog, - type RendererComponentProps, -} from "./renderer.js"; -import { KopaiSDKProvider, queryClient } from "../providers/kopai-provider.js"; -import { createCatalog } from "./component-catalog.js"; -import z from "zod"; -import type { KopaiClient } from "@kopai/sdk"; - -// Create a simple catalog and derive UITree type -const _testCatalog = createCatalog({ - name: "test", - components: { - Box: { - hasChildren: true, - description: "A box", - props: z.object({}), - }, - Text: { - hasChildren: false, - description: "Text", - props: z.object({ content: z.string() }), - }, - Capture: { - hasChildren: false, - description: "Captures props", - props: z.object({ content: z.string() }), - }, - DataComponent: { - hasChildren: false, - description: "Data test component", - props: z.object({}), - acceptsDataFrom: ["searchTracesPage"] as const, - }, - RefetchComponent: { - hasChildren: false, - description: "Refetch test component", - props: z.object({}), - acceptsDataFrom: ["searchTracesPage"] as const, - }, - }, -}); - -type UITree = z.infer; - -type MockClient = { - searchTracesPage: ReturnType; - searchLogsPage: ReturnType; - searchMetricsPage: ReturnType; - searchAggregatedMetrics: ReturnType; - getTrace: ReturnType; - discoverMetrics: ReturnType; - searchTraces: ReturnType; - searchLogs: ReturnType; - searchMetrics: ReturnType; -}; - -function createWrapper(client: MockClient) { - return function Wrapper({ children }: { children: ReactNode }) { - return createElement(KopaiSDKProvider, { - client: client as unknown as KopaiClient, - children, - }); - }; -} - -// Simple test components -function Box({ - element, - children, -}: RendererComponentProps) { - return createElement( - "div", - { "data-type": element.type, "data-key": element.key }, - children - ); -} - -function Text({ - element, -}: RendererComponentProps) { - const { content } = element.props; - return createElement("span", null, content); -} - -function Capture( - _props: RendererComponentProps -) { - return createElement("div", null, "captured"); -} - -function DataComponent( - props: RendererComponentProps -) { - if (!props.hasData) { - return createElement("div", { "data-testid": "no-data" }, "No data source"); - } - const { response, loading, error } = props; - if (loading) - return createElement("div", { "data-testid": "loading" }, "Loading..."); - if (error) - return createElement("div", { "data-testid": "error" }, error.message); - return createElement( - "div", - { "data-testid": "data" }, - JSON.stringify(response) - ); -} - -function RefetchComponent( - props: RendererComponentProps -) { - if (!props.hasData) return null; - return createElement( - "div", - { "data-testid": "data" }, - JSON.stringify(props.response) - ); -} - -const TestRenderer = createRendererFromCatalog(_testCatalog, { - Box, - Text, - Capture, - DataComponent, - RefetchComponent, -}); - -describe("Renderer", () => { - it("renders null for null tree", () => { - const result = renderToStaticMarkup( - createElement(TestRenderer, { tree: null }) - ); - expect(result).toBe(""); - }); - - it("renders null for tree without root", () => { - const tree = { root: "", elements: {} } as unknown as UITree; - const result = renderToStaticMarkup(createElement(TestRenderer, { tree })); - expect(result).toBe(""); - }); - - it("renders single element", () => { - const tree = { - root: "text-1", - elements: { - "text-1": { - key: "text-1", - type: "Text", - children: [], - parentKey: "", - props: { content: "Hello" }, - }, - }, - } satisfies UITree; // should be like this, not casts - const result = renderToStaticMarkup(createElement(TestRenderer, { tree })); - expect(result).toBe("Hello"); - }); - - it("renders nested elements", () => { - const tree = { - root: "box-1", - elements: { - "box-1": { - key: "box-1", - type: "Box", - props: {}, - children: ["text-1"], - parentKey: "", - }, - "text-1": { - key: "text-1", - type: "Text", - props: { content: "Nested" }, - children: [], - parentKey: "box-1", - }, - }, - } satisfies UITree; - const result = renderToStaticMarkup(createElement(TestRenderer, { tree })); - expect(result).toBe( - '

Nested
' - ); - }); - - it("renders deeply nested tree", () => { - const tree = { - root: "box-1", - elements: { - "box-1": { - key: "box-1", - type: "Box", - props: {}, - children: ["box-2"], - parentKey: "", - }, - "box-2": { - key: "box-2", - type: "Box", - props: {}, - children: ["text-1"], - parentKey: "box-1", - }, - "text-1": { - key: "text-1", - type: "Text", - props: { content: "Deep" }, - children: [], - parentKey: "box-2", - }, - }, - } satisfies UITree; - const result = renderToStaticMarkup(createElement(TestRenderer, { tree })); - expect(result).toContain("Deep"); - expect(result).toContain('data-key="box-2"'); - }); - - it("skips children with missing elements", () => { - const tree = { - root: "box-1", - elements: { - "box-1": { - key: "box-1", - type: "Box", - props: {}, - children: ["missing-1", "text-1"], - parentKey: "", - }, - "text-1": { - key: "text-1", - type: "Text", - props: { content: "Present" }, - children: [], - parentKey: "box-1", - }, - }, - } satisfies UITree; - const result = renderToStaticMarkup(createElement(TestRenderer, { tree })); - expect(result).toContain("Present"); - expect(result).not.toContain("missing"); - }); - - it("passes hasData=false for elements without dataSource", () => { - let receivedProps: RendererComponentProps< - typeof _testCatalog.components.Capture - > | null = null; - function CaptureLocal( - props: RendererComponentProps - ) { - receivedProps = props; - return createElement("div", null, "captured"); - } - const LocalRenderer = createRendererFromCatalog(_testCatalog, { - Box, - Text, - Capture: CaptureLocal, - DataComponent, - RefetchComponent, - }); - const tree = { - root: "capture-1", - elements: { - "capture-1": { - key: "capture-1", - type: "Capture", - props: { content: "hello" }, - children: [], - parentKey: "", - }, - }, - } satisfies UITree; - renderToStaticMarkup(createElement(LocalRenderer, { tree })); - expect(receivedProps).not.toBeNull(); - expect(receivedProps!.hasData).toBe(false); - expect(receivedProps!.element.props).toEqual({ content: "hello" }); - }); -}); - -describe("Renderer with dataSource", () => { - const createMockClient = (): MockClient => ({ - searchTracesPage: vi.fn(), - searchLogsPage: vi.fn(), - searchMetricsPage: vi.fn(), - searchAggregatedMetrics: vi.fn(), - getTrace: vi.fn(), - discoverMetrics: vi.fn(), - searchTraces: vi.fn(), - searchLogs: vi.fn(), - searchMetrics: vi.fn(), - }); - - let mockClient: MockClient; - - beforeEach(() => { - mockClient = createMockClient(); - queryClient.clear(); - vi.clearAllMocks(); - }); - - it("passes data props to component with dataSource", async () => { - mockClient.searchTracesPage.mockResolvedValueOnce({ - data: [{ traceId: "abc" }], - }); - - const tree = { - root: "data-1", - elements: { - "data-1": { - key: "data-1", - type: "DataComponent", - props: {}, - children: [], - parentKey: "", - dataSource: { method: "searchTracesPage", params: { limit: 10 } }, - }, - }, - } satisfies UITree; - - const Wrapper = createWrapper(mockClient); - render(createElement(TestRenderer, { tree }), { - wrapper: Wrapper, - }); - - // Initially loading - expect(screen.getByTestId("loading")).toBeDefined(); - - // After data loads - await waitFor(() => { - expect(screen.queryByTestId("data")).not.toBeNull(); - }); - expect(screen.getByTestId("data").textContent).toBe( - '{"data":[{"traceId":"abc"}]}' - ); - }); - - it("passes loading state correctly", async () => { - let resolvePromise: (value: unknown) => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockClient.searchTracesPage.mockReturnValueOnce(promise); - - const tree = { - root: "data-1", - elements: { - "data-1": { - key: "data-1", - type: "DataComponent", - props: {}, - children: [], - parentKey: "", - dataSource: { method: "searchTracesPage", params: {} }, - }, - }, - } satisfies UITree; - - const Wrapper = createWrapper(mockClient); - render(createElement(TestRenderer, { tree }), { - wrapper: Wrapper, - }); - - expect(screen.getByTestId("loading")).toBeDefined(); - - resolvePromise!({ data: [] }); - await waitFor(() => { - expect(screen.queryByTestId("data")).not.toBeNull(); - }); - }); - - it("passes error state correctly", async () => { - mockClient.searchTracesPage.mockRejectedValueOnce( - new Error("Network error") - ); - - const tree = { - root: "data-1", - elements: { - "data-1": { - key: "data-1", - type: "DataComponent", - props: {}, - children: [], - parentKey: "", - dataSource: { method: "searchTracesPage", params: {} }, - }, - }, - } satisfies UITree; - - const Wrapper = createWrapper(mockClient); - render(createElement(TestRenderer, { tree }), { - wrapper: Wrapper, - }); - - await waitFor(() => { - expect(screen.queryByTestId("error")).not.toBeNull(); - }); - expect(screen.getByTestId("error").textContent).toBe("Network error"); - }); - - it("provides updateParams that triggers refetch with new params", async () => { - mockClient.searchTracesPage - .mockResolvedValueOnce({ data: [{ traceId: "first" }] }) - .mockResolvedValueOnce({ data: [{ traceId: "second" }] }); - - let capturedUpdateParams: - | ((params: Record) => void) - | null = null; - function RefetchComponentLocal( - props: RendererComponentProps< - typeof _testCatalog.components.RefetchComponent - > - ) { - if (!props.hasData) return null; - capturedUpdateParams = props.updateParams; - return createElement( - "div", - { "data-testid": "data" }, - JSON.stringify(props.response) - ); - } - - const LocalRenderer = createRendererFromCatalog(_testCatalog, { - Box, - Text, - Capture, - DataComponent, - RefetchComponent: RefetchComponentLocal, - }); - - const tree = { - root: "data-1", - elements: { - "data-1": { - key: "data-1", - type: "RefetchComponent", - props: {}, - children: [], - parentKey: "", - dataSource: { method: "searchTracesPage", params: {} }, - }, - }, - } satisfies UITree; - - const Wrapper = createWrapper(mockClient); - render(createElement(LocalRenderer, { tree }), { - wrapper: Wrapper, - }); - - await waitFor(() => { - expect(capturedUpdateParams).not.toBeNull(); - }); - - act(() => { - capturedUpdateParams!({ limit: 5 }); - }); - - await waitFor(() => { - expect(mockClient.searchTracesPage).toHaveBeenCalledTimes(2); - }); - }); - - it("renders element without dataSource normally", () => { - const tree = { - root: "data-1", - elements: { - "data-1": { - key: "data-1", - type: "DataComponent", - props: {}, - children: [], - parentKey: "", - // No dataSource - }, - }, - } satisfies UITree; - - const Wrapper = createWrapper(mockClient); - render(createElement(TestRenderer, { tree }), { - wrapper: Wrapper, - }); - - expect(screen.getByTestId("no-data")).toBeDefined(); - expect(screen.getByTestId("no-data").textContent).toBe("No data source"); - }); -}); - -describe("createRendererFromCatalog integration", () => { - const integrationCatalog = createCatalog({ - name: "integration-test", - components: { - Wrapper: { - hasChildren: true, - description: "A wrapper", - props: z.object({}), - }, - Label: { - hasChildren: false, - description: "A label", - props: z.object({ text: z.string() }), - }, - }, - }); - - type IntegrationUITree = z.infer; - - function Wrapper({ - children, - }: RendererComponentProps) { - return createElement("div", { "data-testid": "wrapper" }, children); - } - - function Label({ - element, - }: RendererComponentProps) { - return createElement( - "span", - { "data-testid": "label" }, - element.props.text - ); - } - - const IntegrationRenderer = createRendererFromCatalog(integrationCatalog, { - Wrapper, - Label, - }); - - it("renders tree using createRendererFromCatalog", () => { - const tree: IntegrationUITree = { - root: "wrapper-1", - elements: { - "wrapper-1": { - key: "wrapper-1", - type: "Wrapper", - props: {}, - children: ["label-1"], - parentKey: "", - }, - "label-1": { - key: "label-1", - type: "Label", - props: { text: "Hello World" }, - children: [], - parentKey: "wrapper-1", - }, - }, - }; - - const result = renderToStaticMarkup( - createElement(IntegrationRenderer, { tree }) - ); - - expect(result).toContain("Hello World"); - expect(result).toContain('data-testid="wrapper"'); - expect(result).toContain('data-testid="label"'); - }); - - it("renders single element tree", () => { - const tree: IntegrationUITree = { - root: "label-1", - elements: { - "label-1": { - key: "label-1", - type: "Label", - props: { text: "Test" }, - children: [], - parentKey: "", - }, - }, - }; - - const result = renderToStaticMarkup( - createElement(IntegrationRenderer, { tree }) - ); - - expect(result).toContain("Test"); - }); -}); - -describe("createRendererFromCatalog type safety", () => { - const typeCatalog = createCatalog({ - name: "type-test", - components: { - Button: { - hasChildren: false, - description: "A button", - props: z.object({ label: z.string() }), - }, - Container: { - hasChildren: true, - description: "A container", - props: z.object({ padding: z.number() }), - }, - }, - }); - - it("creates renderer with correct component types", () => { - expect.assertions(0); - - function Button({ - element, - }: RendererComponentProps) { - return createElement("button", null, element.props.label); - } - - function Container({ - element, - children, - }: RendererComponentProps) { - return createElement( - "div", - { style: { padding: element.props.padding } }, - children - ); - } - - const _Renderer = createRendererFromCatalog(typeCatalog, { - Button, - Container, - }); - }); - - it("errors when catalog component is missing", () => { - expect.assertions(0); - - function Button({ - element, - }: RendererComponentProps) { - return createElement("button", null, element.props.label); - } - - // @ts-expect-error - Container is missing from registry - const _Renderer = createRendererFromCatalog(typeCatalog, { - Button, - }); - }); - - it("errors when component has wrong props type", () => { - expect.assertions(0); - - // Wrong props - expects { label: string } but gets { title: string } - function Button({ element }: { element: { props: { title: string } } }) { - return createElement("button", null, element.props.title); - } - - function Container({ - element, - children, - }: RendererComponentProps) { - return createElement( - "div", - { style: { padding: element.props.padding } }, - children - ); - } - - const _Renderer = createRendererFromCatalog(typeCatalog, { - // @ts-expect-error - Button has wrong props type - Button, - Container, - }); - }); - - it("errors when extra component is provided", () => { - expect.assertions(0); - - function Button({ - element, - }: RendererComponentProps) { - return createElement("button", null, element.props.label); - } - - function Container({ - element, - children, - }: RendererComponentProps) { - return createElement( - "div", - { style: { padding: element.props.padding } }, - children - ); - } - - function Extra() { - return createElement("div", null, "extra"); - } - - const _Renderer = createRendererFromCatalog(typeCatalog, { - Button, - Container, - // @ts-expect-error - Extra is not in catalog - Extra, - }); - }); -}); diff --git a/packages/ui/src/lib/renderer.tsx b/packages/ui/src/lib/renderer.tsx deleted file mode 100644 index 510a57e..0000000 --- a/packages/ui/src/lib/renderer.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { - useState, - useMemo, - useCallback, - type ReactNode, - type ComponentType, -} from "react"; -import { - createCatalog, - type InferProps, - type ComponentDefinition, -} from "./component-catalog.js"; -import z from "zod"; -import { useKopaiData } from "../hooks/use-kopai-data.js"; -import type { DataSource } from "./component-catalog.js"; -import type { KopaiClient } from "../providers/kopai-provider.js"; - -type RegistryFromCatalog< - C extends { components: Record }, -> = { - [K in keyof C["components"]]: ComponentType< - RendererComponentProps - >; -}; - -type Catalog = ReturnType; - -export type UITree = z.infer; - -type UIElement = UITree["elements"][string]; - -// Simplified - renderer just passes through to useKopaiData -type RendererDataSource = { - method: string; - params?: Record; -}; - -type BaseElement = { - key: string; - type: string; - children: string[]; - parentKey: string; - dataSource?: RendererDataSource; - props: Props; -}; - -/** Derives the SDK response type for a given client method. */ -type SDKResponseFor = Awaited< - ReturnType ->; - -/** Infers the data type from a component definition's `acceptsDataFrom`. */ -type InferData = CD extends { acceptsDataFrom: readonly (infer M)[] } - ? M extends keyof KopaiClient - ? SDKResponseFor - : unknown - : unknown; - -type WithData = { - hasData: true; - response: D | null; - loading: boolean; - error: Error | null; - refetch: () => void; - updateParams: (params: Record) => void; -}; - -type WithoutData = { - hasData: false; -}; - -/** Distributes WithData over a union: WithData → WithData | WithData */ -type DistributeWithData = D extends unknown ? WithData : never; - -export type RendererComponentProps = - CD extends { - hasChildren: true; - props: infer P; - } - ? - | ({ - element: BaseElement>; - children: ReactNode; - } & WithoutData) - | ({ - element: BaseElement>; - children: ReactNode; - } & DistributeWithData>) - : CD extends { props: infer P } - ? - | ({ element: BaseElement> } & WithoutData) - | ({ element: BaseElement> } & DistributeWithData< - InferData - >) - : never; - -/** - * Base props (no dataSource) - */ -export interface ComponentRenderPropsBase { - element: UIElement; - children?: ReactNode; - hasData: false; -} - -/** - * Props with dataSource - */ -export interface ComponentRenderPropsWithData { - element: UIElement; - children?: ReactNode; - hasData: true; - response: unknown; - loading: boolean; - error: Error | null; - refetch: () => void; - updateParams: (params: Record) => void; -} - -/** - * Discriminated union for component render props - */ -export type ComponentRenderProps = - | ComponentRenderPropsBase - | ComponentRenderPropsWithData; - -/** - * Component renderer type - */ -export type ComponentRenderer = ComponentType; - -/** - * Registry mapping component type names to React components - */ -type ComponentRegistry = Record; - -/** Map from component type name to the dataSource methods it accepts. */ -type AcceptsDataFromByType = Record; - -/** - * Creates a typed Renderer component bound to a catalog and component implementations. - * - * @param catalog - The catalog created via createCatalog (used for type inference and runtime acceptsDataFrom check) - * @param components - React component implementations matching catalog definitions - * @returns A Renderer component that only needs `tree` and optional `fallback` - * - * @example - * ```tsx - * const DashboardRenderer = createRendererFromCatalog(catalog, { - * Card: ({ element, children }) =>
{children}
, - * Table: ({ element, data }) => ...
, - * }); - * - * - * ``` - */ -export function createRendererFromCatalog< - C extends { components: Record }, ->(catalog: C, components: RegistryFromCatalog) { - const acceptsDataFromByType: AcceptsDataFromByType = Object.fromEntries( - Object.entries(catalog.components).map(([name, def]) => [ - name, - def.acceptsDataFrom, - ]) - ); - return function CatalogRenderer({ - tree, - fallback, - }: { - tree: UITree | null; - fallback?: ComponentRenderer; - }) { - return ( - - ); - }; -} - -/** - * Wrapper component for elements with dataSource. - * - * When `acceptsDataFromByType` is provided (i.e. the renderer was created via - * createRendererFromCatalog), validates that the element's dataSource.method - * is one of the methods the component declared via `acceptsDataFrom`. This - * guards against tree persistence / authoring bugs where a component ends up - * bound to a dataSource method it wasn't designed to consume. When the map is - * undefined (e.g. Renderer is called directly without a catalog), validation - * is skipped — the caller has opted out of strict checking. - */ -function DataSourceElement({ - element, - Component, - acceptsDataFromByType, - children, -}: { - element: UIElement; - Component: ComponentRenderer; - acceptsDataFromByType: AcceptsDataFromByType | undefined; - children?: ReactNode; -}) { - const [paramsOverride, setParamsOverride] = useState>( - {} - ); - - const acceptsDataFrom = acceptsDataFromByType?.[element.type]; - const methodIsAccepted = - !element.dataSource || - acceptsDataFromByType === undefined || - (acceptsDataFrom?.includes(element.dataSource.method) ?? false); - - const effectiveDataSource = useMemo(() => { - if (!element.dataSource || !methodIsAccepted) return undefined; - const merged = { - ...element.dataSource, - params: { ...element.dataSource.params, ...paramsOverride }, - }; - return merged as DataSource; - }, [element.dataSource, paramsOverride, methodIsAccepted]); - - const { data, loading, error, refetch } = useKopaiData(effectiveDataSource); - - if (!methodIsAccepted && element.dataSource) { - const accepted = acceptsDataFrom?.length - ? acceptsDataFrom.join(", ") - : "none"; - return ( -
- Component {element.type} does not accept dataSource method{" "} - {element.dataSource.method}. Accepted methods: {accepted}. -
- ); - } - - const updateParams = useCallback((params: Record) => { - setParamsOverride((prev) => ({ ...prev, ...params })); - }, []); - - return ( - - {children} - - ); -} - -/** - * Internal element renderer - recursively renders elements and children - */ -function ElementRenderer({ - element, - tree, - registry, - fallback, - acceptsDataFromByType, -}: { - element: UIElement; - tree: UITree; - registry: ComponentRegistry; - fallback?: ComponentRenderer; - acceptsDataFromByType: AcceptsDataFromByType | undefined; -}) { - const Component = registry[element.type] ?? fallback; - - if (!Component) { - console.warn(`No renderer for component type: ${element.type}`); - return null; - } - - const children = element.children?.map((childKey) => { - const childElement = tree.elements[childKey]; - if (!childElement) return null; - return ( - - ); - }); - - // If element has dataSource, wrap with data fetching - if (element.dataSource) { - return ( - - {children} - - ); - } - - // Otherwise render directly (no data) - return ( - - {children} - - ); -} - -/** - * Renders a UITree using a component registry. - * - * Prefer using {@link createRendererFromCatalog}, which auto-derives - * `acceptsDataFromByType` from the catalog and enforces strict dataSource - * compatibility checks at runtime. Calling Renderer directly without - * `acceptsDataFromByType` skips those checks — the caller is responsible - * for ensuring tree authors don't pair components with incompatible - * dataSource methods. - */ -export function Renderer< - C extends { components: Record }, ->({ - tree, - registry, - fallback, - acceptsDataFromByType, -}: { - tree: z.infer["uiTreeSchema"]> | null; - registry: RegistryFromCatalog; - fallback?: ComponentRenderer; - acceptsDataFromByType?: AcceptsDataFromByType; -}) { - if (!tree || !tree.root) return null; - - const rootElement = tree.elements[tree.root]; - if (!rootElement) return null; - - return ( - - ); -} diff --git a/packages/ui/src/pages/observability.test.tsx b/packages/ui/src/pages/observability.test.tsx index d5f293c..a3a9e8c 100644 --- a/packages/ui/src/pages/observability.test.tsx +++ b/packages/ui/src/pages/observability.test.tsx @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createElement } from "react"; import { render, screen, waitFor } from "@testing-library/react"; import ObservabilityPage from "./observability.js"; -import { queryClient } from "../providers/kopai-provider.js"; +import { queryClient } from "@kopai/ui-core"; import type { KopaiClient } from "@kopai/sdk"; type MockClient = { diff --git a/packages/ui/src/pages/observability.tsx b/packages/ui/src/pages/observability.tsx index 19dbff2..412a3ea 100644 --- a/packages/ui/src/pages/observability.tsx +++ b/packages/ui/src/pages/observability.tsx @@ -6,14 +6,17 @@ import { useSyncExternalStore, useRef, } from "react"; -import { KopaiSDKProvider, useKopaiSDK } from "../providers/kopai-provider.js"; +import { + KopaiSDKProvider, + useKopaiSDK, + useKopaiData, + useLiveLogs, + type DataSource, + observabilityCatalog, +} from "@kopai/ui-core"; import { useQuery } from "@tanstack/react-query"; import { KopaiClient } from "@kopai/sdk"; -import { useKopaiData } from "../hooks/use-kopai-data.js"; -import { useLiveLogs } from "../hooks/use-live-logs.js"; import type { denormalizedSignals, dataFilterSchemas } from "@kopai/core"; -import type { DataSource } from "../lib/component-catalog.js"; -import { observabilityCatalog } from "../lib/observability-catalog.js"; // Observability components import { LogTimeline, diff --git a/packages/ui/src/providers/kopai-provider.tsx b/packages/ui/src/providers/kopai-provider.tsx deleted file mode 100644 index 52d306d..0000000 --- a/packages/ui/src/providers/kopai-provider.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { createContext, useContext, type ReactNode } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { KopaiClient as SDKClient } from "@kopai/sdk"; - -export type KopaiClient = Pick< - SDKClient, - | "searchTracesPage" - | "searchLogsPage" - | "searchMetricsPage" - | "searchAggregatedMetrics" - | "getTrace" - | "discoverMetrics" - | "getDashboard" - | "getServices" - | "getOperations" - | "searchTraceSummariesPage" ->; - -interface KopaiSDKContextValue { - client: KopaiClient; -} - -const KopaiSDKContext = createContext(null); - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: false, - retry: false, - }, - }, -}); - -interface KopaiSDKProviderProps { - client: KopaiClient; - children: ReactNode; -} - -export function KopaiSDKProvider({ client, children }: KopaiSDKProviderProps) { - return ( - - - {children} - - - ); -} - -export function useKopaiSDK(): KopaiClient { - const ctx = useContext(KopaiSDKContext); - if (!ctx) { - throw new Error("useKopaiSDK must be used within KopaiSDKProvider"); - } - return ctx.client; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5aa0f67..002f192 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -474,6 +474,9 @@ importers: "@kopai/sdk": specifier: workspace:* version: link:../sdk + "@kopai/ui-core": + specifier: workspace:* + version: link:../ui-core "@tanstack/react-query": specifier: ^5 version: 5.100.7(react@19.2.5)