diff --git a/.changeset/witty-ties-cross.md b/.changeset/witty-ties-cross.md new file mode 100644 index 0000000..5d5891d --- /dev/null +++ b/.changeset/witty-ties-cross.md @@ -0,0 +1,5 @@ +--- +"@kopai/ui": minor +--- + +Improve uiTree schema and remove unncessary components and exports diff --git a/packages/ui/src/components/dashboard/Badge/index.tsx b/packages/ui/src/components/dashboard/Badge/index.tsx index 6b31e26..537d84f 100644 --- a/packages/ui/src/components/dashboard/Badge/index.tsx +++ b/packages/ui/src/components/dashboard/Badge/index.tsx @@ -1,9 +1,9 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; export function Badge({ element, -}: CatalogueComponentProps) { +}: CatalogueComponentProps) { const { text, variant } = element.props; const colors: Record = { diff --git a/packages/ui/src/components/dashboard/Button/Button.stories.tsx b/packages/ui/src/components/dashboard/Button/Button.stories.tsx deleted file mode 100644 index c4fa660..0000000 --- a/packages/ui/src/components/dashboard/Button/Button.stories.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Button } from "./index.js"; - -const meta: Meta = { - title: "Dashboard/Button", - component: Button, -}; -export default meta; -type Story = StoryObj; - -export const Primary: Story = { - args: { - element: { - props: { - label: "Primary", - variant: "primary", - size: "md", - action: "click", - disabled: null, - }, - }, - }, -}; - -export const Secondary: Story = { - args: { - element: { - props: { - label: "Secondary", - variant: "secondary", - size: "md", - action: "click", - disabled: null, - }, - }, - }, -}; - -export const Danger: Story = { - args: { - element: { - props: { - label: "Danger", - variant: "danger", - size: "md", - action: "click", - disabled: null, - }, - }, - }, -}; - -export const Ghost: Story = { - args: { - element: { - props: { - label: "Ghost", - variant: "ghost", - size: "md", - action: "click", - disabled: null, - }, - }, - }, -}; - -export const Disabled: Story = { - args: { - element: { - props: { - label: "Disabled", - variant: "primary", - size: "md", - action: "click", - disabled: true, - }, - }, - }, -}; - -export const Small: Story = { - args: { - element: { - props: { - label: "Small", - variant: "primary", - size: "sm", - action: "click", - disabled: null, - }, - }, - }, -}; - -export const Large: Story = { - args: { - element: { - props: { - label: "Large", - variant: "primary", - size: "lg", - action: "click", - disabled: null, - }, - }, - }, -}; diff --git a/packages/ui/src/components/dashboard/Button/index.tsx b/packages/ui/src/components/dashboard/Button/index.tsx deleted file mode 100644 index d45e157..0000000 --- a/packages/ui/src/components/dashboard/Button/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; - -export function Button({ - element, -}: CatalogueComponentProps) { - const { label, variant, size, action, disabled } = element.props; - - const variants: Record< - string, - { bg: string; color: string; border: string } - > = { - primary: { - bg: "hsl(var(--foreground))", - color: "hsl(var(--background))", - border: "hsl(var(--foreground))", - }, - secondary: { - bg: "hsl(var(--card))", - color: "hsl(var(--foreground))", - border: "hsl(var(--border))", - }, - danger: { - bg: "#ef4444", - color: "white", - border: "#ef4444", - }, - ghost: { - bg: "transparent", - color: "hsl(var(--foreground))", - border: "transparent", - }, - }; - - const sizes: Record = { - sm: { padding: "6px 12px", fontSize: 12 }, - md: { padding: "8px 16px", fontSize: 14 }, - lg: { padding: "12px 24px", fontSize: 16 }, - }; - - const v = variants[variant || "primary"] ?? variants["primary"]!; - const s = sizes[size || "md"] ?? sizes["md"]!; - - return ( - - ); -} diff --git a/packages/ui/src/components/dashboard/Card/index.tsx b/packages/ui/src/components/dashboard/Card/index.tsx index 2c77f5c..6077e31 100644 --- a/packages/ui/src/components/dashboard/Card/index.tsx +++ b/packages/ui/src/components/dashboard/Card/index.tsx @@ -1,10 +1,10 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { RendererComponentProps } from "../../../lib/renderer.js"; export function Card({ element, children, -}: RendererComponentProps) { +}: RendererComponentProps) { const { title, description, padding } = element.props as { title?: string | null; description?: string | null; diff --git a/packages/ui/src/components/dashboard/Chart/Chart.stories.tsx b/packages/ui/src/components/dashboard/Chart/Chart.stories.tsx deleted file mode 100644 index 0d5d049..0000000 --- a/packages/ui/src/components/dashboard/Chart/Chart.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Chart } from "./index.js"; - -const meta: Meta = { - title: "Dashboard/Chart", - component: Chart, -}; -export default meta; -type Story = StoryObj; - -export const Bar: Story = { - args: { - element: { - props: { - type: "bar", - dataPath: "analytics.weekly", - title: "Weekly Activity", - height: 150, - }, - }, - }, -}; - -export const Line: Story = { - args: { - element: { - props: { - type: "line", - dataPath: "analytics.daily", - title: "Daily Trend", - height: 200, - }, - }, - }, -}; - -export const NoTitle: Story = { - args: { - element: { - props: { - type: "area", - dataPath: "data", - title: null, - height: 120, - }, - }, - }, -}; diff --git a/packages/ui/src/components/dashboard/Chart/index.tsx b/packages/ui/src/components/dashboard/Chart/index.tsx deleted file mode 100644 index 1a9f5e4..0000000 --- a/packages/ui/src/components/dashboard/Chart/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; - -export function Chart({ - element, -}: CatalogueComponentProps) { - const { type, dataPath, title, height } = element.props; - - // Static mock data for example page - const mockData = [ - { label: "Mon", value: 40 }, - { label: "Tue", value: 65 }, - { label: "Wed", value: 45 }, - { label: "Thu", value: 80 }, - { label: "Fri", value: 55 }, - ]; - - const maxValue = Math.max(...mockData.map((d) => d.value)); - - return ( -
- {title && ( -

- {title} -

- )} -
- {mockData.map((d, i) => ( -
-
- - {d.label} - -
- ))} -
-

- Type: {type} | Data: {dataPath} -

-
- ); -} diff --git a/packages/ui/src/components/dashboard/DatePicker/DatePicker.stories.tsx b/packages/ui/src/components/dashboard/DatePicker/DatePicker.stories.tsx deleted file mode 100644 index a374386..0000000 --- a/packages/ui/src/components/dashboard/DatePicker/DatePicker.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { DatePicker } from "./index.js"; - -const meta: Meta = { - title: "Dashboard/DatePicker", - component: DatePicker, -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - element: { - props: { - label: "Select Date", - bindPath: "filters.date", - placeholder: "Choose a date", - }, - }, - }, -}; - -export const NoLabel: Story = { - args: { - element: { - props: { - label: null, - bindPath: "filters.startDate", - placeholder: "Start date", - }, - }, - }, -}; diff --git a/packages/ui/src/components/dashboard/DatePicker/index.tsx b/packages/ui/src/components/dashboard/DatePicker/index.tsx deleted file mode 100644 index 865e931..0000000 --- a/packages/ui/src/components/dashboard/DatePicker/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; - -export function DatePicker({ - element, -}: CatalogueComponentProps) { - const { label, bindPath } = element.props; - const inputId = `datepicker-${bindPath}`; - - return ( -
- {label && ( - - )} - -

- Bound to: {bindPath} -

-
- ); -} diff --git a/packages/ui/src/components/dashboard/Divider/index.tsx b/packages/ui/src/components/dashboard/Divider/index.tsx index 7d564b4..4ef9fda 100644 --- a/packages/ui/src/components/dashboard/Divider/index.tsx +++ b/packages/ui/src/components/dashboard/Divider/index.tsx @@ -1,9 +1,9 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; export function Divider({ element, -}: CatalogueComponentProps) { +}: CatalogueComponentProps) { const { label } = element.props; if (label) { diff --git a/packages/ui/src/components/dashboard/Empty/index.tsx b/packages/ui/src/components/dashboard/Empty/index.tsx index aeae085..9f34721 100644 --- a/packages/ui/src/components/dashboard/Empty/index.tsx +++ b/packages/ui/src/components/dashboard/Empty/index.tsx @@ -1,10 +1,10 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; export function Empty({ element, onAction, -}: CatalogueComponentProps & { +}: CatalogueComponentProps & { onAction?: (action: string) => void; }) { const { title, description, action, actionLabel } = element.props; diff --git a/packages/ui/src/components/dashboard/Grid/index.tsx b/packages/ui/src/components/dashboard/Grid/index.tsx index f6b9213..7c6d81c 100644 --- a/packages/ui/src/components/dashboard/Grid/index.tsx +++ b/packages/ui/src/components/dashboard/Grid/index.tsx @@ -1,10 +1,10 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; export function Grid({ element, children, -}: CatalogueComponentProps) { +}: CatalogueComponentProps) { const { columns, gap } = element.props; const gaps: Record = { sm: "8px", diff --git a/packages/ui/src/components/dashboard/Heading/index.tsx b/packages/ui/src/components/dashboard/Heading/index.tsx index 32d06cf..20f5fc8 100644 --- a/packages/ui/src/components/dashboard/Heading/index.tsx +++ b/packages/ui/src/components/dashboard/Heading/index.tsx @@ -1,10 +1,10 @@ import React from "react"; -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; export function Heading({ element, -}: CatalogueComponentProps) { +}: CatalogueComponentProps) { const { text, level } = element.props; const Tag = (level || "h2") as keyof React.JSX.IntrinsicElements; const sizes: Record = { diff --git a/packages/ui/src/components/dashboard/List/List.stories.tsx b/packages/ui/src/components/dashboard/List/List.stories.tsx deleted file mode 100644 index 0f2a437..0000000 --- a/packages/ui/src/components/dashboard/List/List.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { List } from "./index.js"; - -const meta: Meta = { - title: "Dashboard/List", - component: List, -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => ( - -
- Item 1 -
-
- Item 2 -
-
Item 3
-
- ), -}; - -export const Empty: Story = { - render: () => ( - - {null} - - ), -}; diff --git a/packages/ui/src/components/dashboard/List/index.tsx b/packages/ui/src/components/dashboard/List/index.tsx deleted file mode 100644 index 9792b91..0000000 --- a/packages/ui/src/components/dashboard/List/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; - -export function List({ - element, - children, -}: CatalogueComponentProps) { - const { dataPath, emptyMessage } = element.props; - - return ( -
- {children} -

- Data: {dataPath} {emptyMessage && `| Empty: "${emptyMessage}"`} -

-
- ); -} diff --git a/packages/ui/src/components/dashboard/Metric/Metric.stories.tsx b/packages/ui/src/components/dashboard/Metric/Metric.stories.tsx deleted file mode 100644 index 82d1425..0000000 --- a/packages/ui/src/components/dashboard/Metric/Metric.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Metric } from "./index.js"; - -const meta: Meta = { - title: "Dashboard/Metric", - component: Metric, -}; -export default meta; -type Story = StoryObj; - -export const TrendUp: Story = { - args: { - element: { - props: { - label: "Total Users", - valuePath: "1,234", - format: "number", - trend: "up", - trendValue: "12%", - }, - }, - }, -}; - -export const TrendDown: Story = { - args: { - element: { - props: { - label: "Bounce Rate", - valuePath: "23%", - format: "percent", - trend: "down", - trendValue: "5%", - }, - }, - }, -}; - -export const Neutral: Story = { - args: { - element: { - props: { - label: "Sessions", - valuePath: "890", - format: "number", - trend: "neutral", - trendValue: "0%", - }, - }, - }, -}; - -export const NoTrend: Story = { - args: { - element: { - props: { - label: "Revenue", - valuePath: "$45,678", - format: "currency", - trend: null, - trendValue: null, - }, - }, - }, -}; diff --git a/packages/ui/src/components/dashboard/Metric/index.tsx b/packages/ui/src/components/dashboard/Metric/index.tsx deleted file mode 100644 index b12f2ca..0000000 --- a/packages/ui/src/components/dashboard/Metric/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; - -export function Metric({ - element, -}: CatalogueComponentProps) { - const { label, valuePath, trend, trendValue } = element.props; - - // For example page, we just display the valuePath as placeholder - const displayValue = valuePath; - - return ( -
- - {label} - - {displayValue} - {trendValue && ( - - {trend === "up" ? "+" : trend === "down" ? "-" : ""} - {trendValue} - - )} -
- ); -} diff --git a/packages/ui/src/components/dashboard/Stack/index.tsx b/packages/ui/src/components/dashboard/Stack/index.tsx index 44dd50a..f435091 100644 --- a/packages/ui/src/components/dashboard/Stack/index.tsx +++ b/packages/ui/src/components/dashboard/Stack/index.tsx @@ -1,10 +1,10 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; export function Stack({ element, children, -}: CatalogueComponentProps) { +}: CatalogueComponentProps) { const { direction, gap, align } = element.props; const gaps: Record = { sm: "8px", diff --git a/packages/ui/src/components/dashboard/Table/Table.stories.tsx b/packages/ui/src/components/dashboard/Table/Table.stories.tsx deleted file mode 100644 index b495b70..0000000 --- a/packages/ui/src/components/dashboard/Table/Table.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Table } from "./index.js"; - -const meta: Meta = { - title: "Dashboard/Table", - component: Table, -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - element: { - props: { - dataPath: "users", - columns: [ - { key: "name", label: "Name", format: "text" }, - { key: "status", label: "Status", format: "badge" }, - { key: "amount", label: "Amount", format: "currency" }, - ], - }, - }, - }, -}; - -export const TwoColumns: Story = { - args: { - element: { - props: { - dataPath: "items", - columns: [ - { key: "name", label: "Name", format: "text" }, - { key: "amount", label: "Amount", format: "currency" }, - ], - }, - }, - }, -}; diff --git a/packages/ui/src/components/dashboard/Table/index.tsx b/packages/ui/src/components/dashboard/Table/index.tsx deleted file mode 100644 index c01c38e..0000000 --- a/packages/ui/src/components/dashboard/Table/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; -import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; - -export function Table({ - element, -}: CatalogueComponentProps) { - const { dataPath, columns } = element.props; - - // Static mock data for example page - const mockData = [ - { id: 1, name: "Item A", status: "Active", amount: 1250 }, - { id: 2, name: "Item B", status: "Pending", amount: 830 }, - { id: 3, name: "Item C", status: "Completed", amount: 2100 }, - ]; - - const formatCell = ( - value: unknown, - format?: "text" | "currency" | "date" | "badge" | null - ) => { - if (value === null || value === undefined) return "-"; - if (format === "currency" && typeof value === "number") { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(value); - } - if (format === "date" && typeof value === "string") { - const d = new Date(value); - return isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(); - } - if (format === "badge") { - return ( - - {String(value)} - - ); - } - return String(value); - }; - - return ( -
- - - - {columns.map((col) => ( - - ))} - - - - {mockData.map((row, i) => ( - - {columns.map((col) => ( - - ))} - - ))} - -
- {col.label} -
- {formatCell(row[col.key as keyof typeof row], col.format)} -
-

- Data: {dataPath} -

-
- ); -} diff --git a/packages/ui/src/components/dashboard/Text/index.tsx b/packages/ui/src/components/dashboard/Text/index.tsx index f8490d1..8c545c8 100644 --- a/packages/ui/src/components/dashboard/Text/index.tsx +++ b/packages/ui/src/components/dashboard/Text/index.tsx @@ -1,9 +1,9 @@ -import { dashboardCatalog } from "../../../lib/catalog.js"; +import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { CatalogueComponentProps } from "../../../lib/component-catalog.js"; export function Text({ element, -}: CatalogueComponentProps) { +}: CatalogueComponentProps) { const { content, color } = element.props; const colors: Record = { default: "hsl(var(--foreground))", diff --git a/packages/ui/src/components/dashboard/index.ts b/packages/ui/src/components/dashboard/index.ts index 88f15cc..8647ed5 100644 --- a/packages/ui/src/components/dashboard/index.ts +++ b/packages/ui/src/components/dashboard/index.ts @@ -1,46 +1,8 @@ export { Badge } from "./Badge/index.js"; -export { Button } from "./Button/index.js"; export { Card } from "./Card/index.js"; -export { Chart } from "./Chart/index.js"; -export { DatePicker } from "./DatePicker/index.js"; export { Divider } from "./Divider/index.js"; export { Empty } from "./Empty/index.js"; export { Grid } from "./Grid/index.js"; export { Heading } from "./Heading/index.js"; -export { List } from "./List/index.js"; -export { Metric } from "./Metric/index.js"; export { Stack } from "./Stack/index.js"; -export { Table } from "./Table/index.js"; export { Text } from "./Text/index.js"; - -import { Badge } from "./Badge/index.js"; -import { Button } from "./Button/index.js"; -import { Card } from "./Card/index.js"; -import { Chart } from "./Chart/index.js"; -import { DatePicker } from "./DatePicker/index.js"; -import { Divider } from "./Divider/index.js"; -import { Empty } from "./Empty/index.js"; -import { Grid } from "./Grid/index.js"; -import { Heading } from "./Heading/index.js"; -import { List } from "./List/index.js"; -import { Metric } from "./Metric/index.js"; -import { Stack } from "./Stack/index.js"; -import { Table } from "./Table/index.js"; -import { Text } from "./Text/index.js"; - -export const componentRegistry = { - Badge, - Button, - Card, - Chart, - DatePicker, - Divider, - Empty, - Grid, - Heading, - List, - Metric, - Stack, - Table, - Text, -}; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 0b27937..828446d 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,17 +1,10 @@ export { Badge, - Button, Card, - Chart, - DatePicker, Divider, Empty, Grid, Heading, - List, - Metric, Stack, - Table, Text, - componentRegistry, } from "./dashboard/index.js"; diff --git a/packages/ui/src/components/observability/LogTimeline/LogRow.tsx b/packages/ui/src/components/observability/LogTimeline/LogRow.tsx index 6f02333..e6ee951 100644 --- a/packages/ui/src/components/observability/LogTimeline/LogRow.tsx +++ b/packages/ui/src/components/observability/LogTimeline/LogRow.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from "react"; +import { memo } from "react"; import type { LogEntry } from "../types.js"; import { getServiceColor } from "../utils/colors.js"; @@ -109,7 +109,7 @@ export const LogRow = memo(function LogRow({ referenceTimeMs, }: LogRowProps) { const severityColor = getSeverityColor(log.severityText); - const message = useMemo(() => log.body || "", [log.body]); + const message = log.body || ""; const timestamp = relativeTime && referenceTimeMs != null ? formatRelativeTime(log.timeUnixMs, referenceTimeMs) diff --git a/packages/ui/src/components/observability/TraceTimeline/FlamegraphView.tsx b/packages/ui/src/components/observability/TraceTimeline/FlamegraphView.tsx index cf954d6..dcf55fe 100644 --- a/packages/ui/src/components/observability/TraceTimeline/FlamegraphView.tsx +++ b/packages/ui/src/components/observability/TraceTimeline/FlamegraphView.tsx @@ -63,7 +63,10 @@ export function FlamegraphView({ return getAncestorPath(trace.rootSpans, zoomSpanId); }, [trace.rootSpans, zoomSpanId]); - const viewRoots = zoomRoot ? [zoomRoot] : trace.rootSpans; + const viewRoots = useMemo( + () => (zoomRoot ? [zoomRoot] : trace.rootSpans), + [zoomRoot, trace.rootSpans] + ); const viewMinTime = zoomRoot ? zoomRoot.startTimeUnixMs : trace.minTimeMs; const viewMaxTime = zoomRoot ? zoomRoot.endTimeUnixMs : trace.maxTimeMs; const viewDuration = viewMaxTime - viewMinTime; diff --git a/packages/ui/src/components/observability/TraceTimeline/SpanRow.tsx b/packages/ui/src/components/observability/TraceTimeline/SpanRow.tsx index 12da64b..bde4a3d 100644 --- a/packages/ui/src/components/observability/TraceTimeline/SpanRow.tsx +++ b/packages/ui/src/components/observability/TraceTimeline/SpanRow.tsx @@ -10,7 +10,6 @@ export interface SpanRowProps { level: number; isCollapsed: boolean; isSelected: boolean; - isHovered?: boolean; isParentOfHovered?: boolean; relativeStart: number; relativeDuration: number; diff --git a/packages/ui/src/components/observability/TraceTimeline/index.tsx b/packages/ui/src/components/observability/TraceTimeline/index.tsx index 7b3a38b..42f97fd 100644 --- a/packages/ui/src/components/observability/TraceTimeline/index.tsx +++ b/packages/ui/src/components/observability/TraceTimeline/index.tsx @@ -569,7 +569,6 @@ export function TraceTimeline({ const { span, level } = item; const isCollapsed = collapsedIds.has(span.spanId); const isSelected = span.spanId === selectedSpanId; - const isHovered = span.spanId === hoveredSpanId; const isParentOfHovered = hoveredSpanId ? isSpanAncestorOf(span, hoveredSpanId, flattenedSpans) : false; @@ -594,7 +593,6 @@ export function TraceTimeline({ level={level} isCollapsed={isCollapsed} isSelected={isSelected} - isHovered={isHovered} isParentOfHovered={isParentOfHovered} relativeStart={relativeStart} relativeDuration={relativeDuration} diff --git a/packages/ui/src/components/observability/renderers/NoDataSource.tsx b/packages/ui/src/components/observability/renderers/NoDataSource.tsx new file mode 100644 index 0000000..48ae8ef --- /dev/null +++ b/packages/ui/src/components/observability/renderers/NoDataSource.tsx @@ -0,0 +1,5 @@ +export function NoDataSource() { + return ( +
No data source
+ ); +} diff --git a/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx b/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx index 6820e70..d207573 100644 --- a/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx +++ b/packages/ui/src/components/observability/renderers/OtelLogTimeline.tsx @@ -1,29 +1,21 @@ import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { RendererComponentProps } from "../../../lib/renderer.js"; import { LogTimeline } from "../index.js"; -import type { denormalizedSignals } from "@kopai/core"; - -type OtelLogsRow = denormalizedSignals.OtelLogsRow; +import { NoDataSource } from "./NoDataSource.js"; type Props = RendererComponentProps< typeof observabilityCatalog.components.LogTimeline >; export function OtelLogTimeline(props: Props) { - if (!props.hasData) { - return ( -
No data source
- ); - } - - const response = props.data as { data?: OtelLogsRow[] } | null; + if (!props.hasData) return ; const height = props.element.props.height ?? 600; return (
diff --git a/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx b/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx index 345ed18..78c2b7f 100644 --- a/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx +++ b/packages/ui/src/components/observability/renderers/OtelMetricDiscovery.tsx @@ -1,7 +1,6 @@ import { useMemo } from "react"; import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { RendererComponentProps } from "../../../lib/renderer.js"; -import type { MetricsDiscoveryResult } from "@kopai/sdk"; type Props = RendererComponentProps< typeof observabilityCatalog.components.MetricDiscovery @@ -16,9 +15,7 @@ const TYPE_ORDER: Record = { }; export function OtelMetricDiscovery(props: Props) { - const data = props.hasData - ? (props.data as MetricsDiscoveryResult | null) - : null; + const data = props.hasData ? props.response : null; const loading = props.hasData ? props.loading : false; const error = props.hasData ? props.error : null; diff --git a/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx b/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx index 94c2837..b12e9d1 100644 --- a/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx +++ b/packages/ui/src/components/observability/renderers/OtelMetricHistogram.tsx @@ -1,26 +1,18 @@ import { observabilityCatalog } from "../../../lib/observability-catalog.js"; import type { RendererComponentProps } from "../../../lib/renderer.js"; import { MetricHistogram } from "../index.js"; -import type { denormalizedSignals } from "@kopai/core"; - -type OtelMetricsRow = denormalizedSignals.OtelMetricsRow; +import { NoDataSource } from "./NoDataSource.js"; type Props = RendererComponentProps< typeof observabilityCatalog.components.MetricHistogram >; export function OtelMetricHistogram(props: Props) { - if (!props.hasData) { - return ( -
No data source
- ); - } - - const response = props.data as { data?: OtelMetricsRow[] } | null; + if (!props.hasData) return ; return ( ; +type AggregatedDataProps = Props & { + hasData: true; + response: { data: AggregatedMetricRow[]; nextCursor: null } | null; +}; + const EMPTY_ROWS: never[] = []; const GROUPED_AGGREGATE_ERROR = new Error( "MetricStat cannot display grouped aggregates. Remove groupBy or use MetricTable." ); -function isAggregatedRequest(props: Props & { hasData: true }): boolean { - const ds = props.element.dataSource; - if (!ds || ds.method !== "searchMetricsPage" || !ds.params) return false; - return !!ds.params.aggregate; +function isAggregatedRequest( + props: Props & { hasData: true } +): props is AggregatedDataProps { + return props.element.dataSource?.method === "searchAggregatedMetrics"; } export function OtelMetricStat(props: Props) { - if (!props.hasData) { - return ( -
No data source
- ); - } + if (!props.hasData) return ; if (isAggregatedRequest(props)) { - const response = props.data as { data: AggregatedMetricRow[] } | null; - const rows = response?.data ?? []; + const rows = props.response?.data ?? []; if (rows.length > 1) { return ( @@ -57,11 +57,11 @@ export function OtelMetricStat(props: Props) { ); } - const response = props.data as { data?: OtelMetricsRow[] } | null; + const rows = props.response?.data ?? []; return ( ; export function OtelMetricTable(props: Props) { - if (!props.hasData) { - return ( -
No data source
- ); - } - - const response = props.data as { data?: OtelMetricsRow[] } | null; + if (!props.hasData) return ; return ( ; export function OtelMetricTimeSeries(props: Props) { - if (!props.hasData) { - return ( -
No data source
- ); - } - - const response = props.data as { data?: OtelMetricsRow[] } | null; + if (!props.hasData) return ; return ( ; -function isTraceSummariesSource(props: Props & { hasData: true }): boolean { +type SummariesDataProps = Props & { + hasData: true; + response: SearchResult | null; +}; + +function isTraceSummariesSource( + props: Props & { hasData: true } +): props is SummariesDataProps { return props.element.dataSource?.method === "searchTraceSummariesPage"; } @@ -24,17 +32,15 @@ function TraceSummariesView({ loading, error, }: { - data: unknown; + data: SearchResult | null; loading: boolean; error: Error | null; }) { const [selectedTraceId, setSelectedTraceId] = useState(null); const client = useKopaiSDK(); - const response = data as { data?: TraceSummaryRow[] } | null; - const traces = useMemo(() => { - const rows = response?.data; + const rows = data?.data; if (!Array.isArray(rows)) return []; return rows.map((row) => ({ traceId: row.traceId, @@ -47,7 +53,7 @@ function TraceSummariesView({ services: row.services, errorCount: row.errorCount, })); - }, [response]); + }, [data]); const { data: traceRows, @@ -86,24 +92,19 @@ function TraceSummariesView({ } export function OtelTraceDetail(props: Props) { - if (!props.hasData) { - return ( -
No data source
- ); - } + if (!props.hasData) return ; if (isTraceSummariesSource(props)) { return ( ); } - const response = props.data as { data?: OtelTracesRow[] } | null; - const rows = Array.isArray(response?.data) ? response.data : []; + const rows = props.response?.data ?? []; const traceId = rows[0]?.TraceId ?? ""; return ( diff --git a/packages/ui/src/components/observability/utils/flatten-tree.ts b/packages/ui/src/components/observability/utils/flatten-tree.ts index 1fc0154..ef76498 100644 --- a/packages/ui/src/components/observability/utils/flatten-tree.ts +++ b/packages/ui/src/components/observability/utils/flatten-tree.ts @@ -26,20 +26,6 @@ export function flattenTree( return result; } -export function getAllDescendantIds(span: SpanNode): string[] { - const ids: string[] = [span.spanId]; - - function traverse(s: SpanNode) { - s.children.forEach((child) => { - ids.push(child.spanId); - traverse(child); - }); - } - - traverse(span); - return ids; -} - /** Flatten all spans (ignoring collapse state) with depth. */ export function flattenAllSpans(rootSpans: SpanNode[]): FlattenedSpan[] { return flattenTree(rootSpans, new Set()); diff --git a/packages/ui/src/components/observability/utils/lttb.ts b/packages/ui/src/components/observability/utils/lttb.ts index 4229426..81ce53d 100644 --- a/packages/ui/src/components/observability/utils/lttb.ts +++ b/packages/ui/src/components/observability/utils/lttb.ts @@ -102,20 +102,3 @@ export function downsampleLTTB( return sampled; } - -export function downsampleTimeSeries< - T extends { timestamp: number; value: number }, ->(data: T[], targetPoints: number): T[] { - if (targetPoints >= data.length) { - return data; - } - - const points: LTTBPoint[] = data.map((d) => ({ - x: d.timestamp, - y: d.value, - })); - - const sampled = downsampleLTTB(points, targetPoints); - const sampledTimestamps = new Set(sampled.map((p) => p.x)); - return data.filter((d) => sampledTimestamps.has(d.timestamp)); -} diff --git a/packages/ui/src/hooks/use-kopai-data.test.ts b/packages/ui/src/hooks/use-kopai-data.test.ts index 377769c..95e5500 100644 --- a/packages/ui/src/hooks/use-kopai-data.test.ts +++ b/packages/ui/src/hooks/use-kopai-data.test.ts @@ -153,7 +153,7 @@ describe("useKopaiData", () => { expect(mockClient.searchMetricsPage).toHaveBeenCalled(); }); - it("routes to searchAggregatedMetrics when aggregate is set", async () => { + it("calls searchAggregatedMetrics for searchAggregatedMetrics method", async () => { const mockData = { data: [{ groups: { signal: "/v1/traces" }, value: 1024 }], nextCursor: null, @@ -161,7 +161,7 @@ describe("useKopaiData", () => { mockClient.searchAggregatedMetrics.mockResolvedValue(mockData); const dataSource: DataSource = { - method: "searchMetricsPage", + method: "searchAggregatedMetrics", params: { metricType: "Sum", metricName: "kopai.ingestion.bytes", @@ -179,7 +179,15 @@ describe("useKopaiData", () => { }); expect(result.current.data).toEqual(mockData); - expect(mockClient.searchAggregatedMetrics).toHaveBeenCalled(); + 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(); }); }); diff --git a/packages/ui/src/hooks/use-kopai-data.ts b/packages/ui/src/hooks/use-kopai-data.ts index 1ee298a..f1e367f 100644 --- a/packages/ui/src/hooks/use-kopai-data.ts +++ b/packages/ui/src/hooks/use-kopai-data.ts @@ -25,18 +25,18 @@ function fetchForDataSource( dataSource.params as Parameters[0], { signal } ); - case "searchMetricsPage": { - const params = dataSource.params as Parameters< - typeof client.searchMetricsPage - >[0]; - if (params.aggregate) { - return client.searchAggregatedMetrics( - { ...params, aggregate: params.aggregate }, - { signal } - ); - } - return client.searchMetricsPage(params, { 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": diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 068b098..3607680 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,16 +1,3 @@ export { default as ObservabilityPage } from "./pages/observability.js"; -export { createCatalog } from "./lib/component-catalog.js"; export { observabilityCatalog } from "./lib/observability-catalog.js"; export { generatePromptInstructions } from "./lib/generate-prompt-instructions.js"; -export { - Renderer, - createRendererFromCatalog, - type RendererComponentProps, - type ComponentRenderProps, - type ComponentRenderer, -} from "./lib/renderer.js"; -export { - KopaiSDKProvider, - useKopaiSDK, - type KopaiClient, -} from "./providers/kopai-provider.js"; 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 index 99ca8e9..b2a677f 100644 --- a/packages/ui/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +++ b/packages/ui/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap @@ -23,17 +23,17 @@ Clickable button Props: - label: string (required) -Accepts dataSource: yes +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"},"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}]}}} +{"$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"},"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","dataSource":{"method":"searchTracesPage","params":{"limit":10}}}}}" +{"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/catalog.ts b/packages/ui/src/lib/catalog.ts deleted file mode 100644 index 917d80f..0000000 --- a/packages/ui/src/lib/catalog.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { createCatalog } from "./component-catalog.js"; -import { z } from "zod"; - -export const dashboardCatalog = createCatalog({ - name: "dashboard", - 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", - }, - - // Data Display Components - Metric: { - props: z.object({ - label: z.string(), - valuePath: z.string(), - format: z.enum(["number", "currency", "percent"]).nullable(), - trend: z.enum(["up", "down", "neutral"]).nullable(), - trendValue: z.string().nullable(), - }), - hasChildren: false, - description: "Display a single metric with optional trend indicator", - }, - - Chart: { - props: z.object({ - type: z.enum(["bar", "line", "pie", "area"]), - dataPath: z.string(), - title: z.string().nullable(), - height: z.number().nullable(), - }), - hasChildren: false, - description: "Display a chart from array data", - }, - - Table: { - props: z.object({ - dataPath: z.string(), - columns: z.array( - z.object({ - key: z.string(), - label: z.string(), - format: z.enum(["text", "currency", "date", "badge"]).nullable(), - }) - ), - }), - hasChildren: false, - description: "Display tabular data", - }, - - List: { - props: z.object({ - dataPath: z.string(), - emptyMessage: z.string().nullable(), - }), - hasChildren: true, - description: "Render a list from array data", - }, - - // Interactive Components - Button: { - props: z.object({ - label: z.string(), - variant: z.enum(["primary", "secondary", "danger", "ghost"]).nullable(), - size: z.enum(["sm", "md", "lg"]).nullable(), - action: z.string(), - disabled: z.boolean().nullable(), - }), - hasChildren: false, - description: "Clickable button with action", - }, - - DatePicker: { - props: z.object({ - label: z.string().nullable(), - bindPath: z.string(), - placeholder: z.string().nullable(), - }), - hasChildren: false, - description: "Date picker input", - }, - - // 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", - }, - - // Special Components - 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", - }, - }, -}); - -// Export the component list for the AI prompt -export const componentList = Object.keys(dashboardCatalog.components); diff --git a/packages/ui/src/lib/component-catalog.ts b/packages/ui/src/lib/component-catalog.ts index 61692d7..591baec 100644 --- a/packages/ui/src/lib/component-catalog.ts +++ b/packages/ui/src/lib/component-catalog.ts @@ -44,10 +44,27 @@ export const dataSourceSchema = z.discriminatedUnion("method", [ 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(), @@ -57,6 +74,7 @@ export const componentDefinitionSchema = z "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" @@ -70,20 +88,6 @@ export const catalogConfigSchema = z.object({ ), }); -// Union of all element types with literal type discriminator -export type InferredElement> = { - [K in keyof C & string]: { - key: string; - type: K; - children: string[]; - parentKey: string; - dataSource?: z.infer; - props: C[K]["props"] extends z.ZodTypeAny - ? z.infer - : unknown; - }; -}[keyof C & string]; - // Zod schema type for a single element variant (preserves K-to-props mapping) type ElementVariantSchema< K extends string, diff --git a/packages/ui/src/lib/generate-prompt-instructions.ts b/packages/ui/src/lib/generate-prompt-instructions.ts index fc5b621..ed851c8 100644 --- a/packages/ui/src/lib/generate-prompt-instructions.ts +++ b/packages/ui/src/lib/generate-prompt-instructions.ts @@ -5,7 +5,12 @@ type Catalog = { name: string; components: Record< string, - { hasChildren: boolean; description: string; props: unknown } + { + hasChildren: boolean; + description: string; + props: unknown; + acceptsDataFrom?: readonly string[]; + } >; uiTreeSchema: z.ZodTypeAny; }; @@ -15,7 +20,17 @@ 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(" | "); @@ -41,10 +56,18 @@ function formatPropsFromJsonSchema(jsonSchema: object): string { enum?: string[]; description?: string; items?: object; + anyOf?: { type?: string; enum?: string[] }[]; }; - const isRequired = required.has(key); const typeStr = formatPropType(prop); - const reqStr = isRequired ? " (required)" : " (optional)"; + 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}`); } @@ -54,7 +77,10 @@ function formatPropsFromJsonSchema(jsonSchema: object): string { // Helper to build example UI tree function buildExampleElements( names: string[], - components: Record + components: Record< + string, + { hasChildren: boolean; acceptsDataFrom?: readonly string[] } + > ): { root: string; elements: Record } { const containerName = names.find((n) => components[n]?.hasChildren) ?? names[0]; @@ -80,9 +106,10 @@ function buildExampleElements( props: {}, parentKey: containerKey, }; - if (!components[name]?.hasChildren) { + const acceptedMethod = components[name]?.acceptsDataFrom?.[0]; + if (!components[name]?.hasChildren && acceptedMethod) { element.dataSource = { - method: "searchTracesPage", + method: acceptedMethod, params: { limit: 10 }, }; } @@ -156,7 +183,9 @@ export function generatePromptInstructions( const propsFormatted = formatPropsFromJsonSchema(propsSchema); const roleLine = def.hasChildren ? "Accepts children: yes" - : "Accepts dataSource: yes"; + : def.acceptsDataFrom?.length + ? `Accepts dataSource methods: ${def.acceptsDataFrom.join(", ")}` + : "Accepts dataSource: no"; return `### ${name} ${def.description ?? "No description"} diff --git a/packages/ui/src/lib/observability-catalog.ts b/packages/ui/src/lib/observability-catalog.ts index 9fbd55a..6cd3adb 100644 --- a/packages/ui/src/lib/observability-catalog.ts +++ b/packages/ui/src/lib/observability-catalog.ts @@ -93,6 +93,7 @@ export const observabilityCatalog = createCatalog({ hasChildren: false, description: "Log timeline with virtual scroll, severity filtering, detail pane", + acceptsDataFrom: ["searchLogsPage"] as const, }, TraceDetail: { @@ -100,6 +101,10 @@ export const observabilityCatalog = createCatalog({ hasChildren: false, description: "Trace detail with traceId input field and waterfall timeline", + acceptsDataFrom: [ + "searchTracesPage", + "searchTraceSummariesPage", + ] as const, }, MetricTimeSeries: { @@ -111,6 +116,7 @@ export const observabilityCatalog = createCatalog({ }), hasChildren: false, description: "Time series line chart for Gauge/Sum metrics", + acceptsDataFrom: ["searchMetricsPage"] as const, }, MetricHistogram: { @@ -121,6 +127,7 @@ export const observabilityCatalog = createCatalog({ }), hasChildren: false, description: "Histogram bar chart for distribution metrics", + acceptsDataFrom: ["searchMetricsPage"] as const, }, MetricStat: { @@ -131,12 +138,17 @@ export const observabilityCatalog = createCatalog({ 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: { @@ -144,6 +156,7 @@ export const observabilityCatalog = createCatalog({ 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 index 5646b5d..dddc827 100644 --- a/packages/ui/src/lib/renderer.test.tsx +++ b/packages/ui/src/lib/renderer.test.tsx @@ -37,11 +37,13 @@ const _testCatalog = createCatalog({ 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, }, }, }); @@ -100,12 +102,16 @@ function DataComponent( if (!props.hasData) { return createElement("div", { "data-testid": "no-data" }, "No data source"); } - const { data, loading, error } = props; + 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(data)); + return createElement( + "div", + { "data-testid": "data" }, + JSON.stringify(response) + ); } function RefetchComponent( @@ -115,7 +121,7 @@ function RefetchComponent( return createElement( "div", { "data-testid": "data" }, - JSON.stringify(props.data) + JSON.stringify(props.response) ); } @@ -416,7 +422,7 @@ describe("Renderer with dataSource", () => { return createElement( "div", { "data-testid": "data" }, - JSON.stringify(props.data) + JSON.stringify(props.response) ); } diff --git a/packages/ui/src/lib/renderer.tsx b/packages/ui/src/lib/renderer.tsx index 811ff57..510a57e 100644 --- a/packages/ui/src/lib/renderer.tsx +++ b/packages/ui/src/lib/renderer.tsx @@ -13,6 +13,7 @@ import { 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 }, @@ -43,9 +44,21 @@ type BaseElement = { props: Props; }; -type WithData = { +/** 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; - data: unknown; + response: D | null; loading: boolean; error: Error | null; refetch: () => void; @@ -56,6 +69,9 @@ 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; @@ -69,11 +85,13 @@ export type RendererComponentProps = | ({ element: BaseElement>; children: ReactNode; - } & WithData) + } & DistributeWithData>) : CD extends { props: infer P } ? | ({ element: BaseElement> } & WithoutData) - | ({ element: BaseElement> } & WithData) + | ({ element: BaseElement> } & DistributeWithData< + InferData + >) : never; /** @@ -92,7 +110,7 @@ export interface ComponentRenderPropsWithData { element: UIElement; children?: ReactNode; hasData: true; - data: unknown; + response: unknown; loading: boolean; error: Error | null; refetch: () => void; @@ -116,10 +134,13 @@ export type ComponentRenderer = ComponentType; */ 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) + * @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` * @@ -135,7 +156,13 @@ type ComponentRegistry = Record; */ export function createRendererFromCatalog< C extends { components: Record }, ->(_catalog: C, components: RegistryFromCatalog) { +>(catalog: C, components: RegistryFromCatalog) { + const acceptsDataFromByType: AcceptsDataFromByType = Object.fromEntries( + Object.entries(catalog.components).map(([name, def]) => [ + name, + def.acceptsDataFrom, + ]) + ); return function CatalogRenderer({ tree, fallback, @@ -143,37 +170,72 @@ export function createRendererFromCatalog< tree: UITree | null; fallback?: ComponentRenderer; }) { - return ; + return ( + + ); }; } /** - * Wrapper component for elements with dataSource + * 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) return undefined; + if (!element.dataSource || !methodIsAccepted) return undefined; const merged = { ...element.dataSource, params: { ...element.dataSource.params, ...paramsOverride }, }; return merged as DataSource; - }, [element.dataSource, paramsOverride]); + }, [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 })); }, []); @@ -182,7 +244,7 @@ function DataSourceElement({ ); }); @@ -231,7 +296,11 @@ function ElementRenderer({ // If element has dataSource, wrap with data fetching if (element.dataSource) { return ( - + {children} ); @@ -247,7 +316,13 @@ function ElementRenderer({ /** * Renders a UITree using a component registry. - * Prefer using {@link createRendererFromCatalog} for type-safe rendering. + * + * 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 }, @@ -255,10 +330,12 @@ export function Renderer< tree, registry, fallback, + acceptsDataFromByType, }: { tree: z.infer["uiTreeSchema"]> | null; registry: RegistryFromCatalog; fallback?: ComponentRenderer; + acceptsDataFromByType?: AcceptsDataFromByType; }) { if (!tree || !tree.root) return null; @@ -271,6 +348,7 @@ export function Renderer< tree={tree} registry={registry as ComponentRegistry} fallback={fallback} + acceptsDataFromByType={acceptsDataFromByType} /> ); } diff --git a/packages/ui/src/pages/observability.tsx b/packages/ui/src/pages/observability.tsx index 8c6b8fe..19dbff2 100644 --- a/packages/ui/src/pages/observability.tsx +++ b/packages/ui/src/pages/observability.tsx @@ -379,12 +379,6 @@ function parseLogfmt(str: string): Record { return result; } -export function serializeLogfmt(rec: Record): string { - return Object.entries(rec) - .map(([k, v]) => (v.includes(" ") ? `${k}="${v}"` : `${k}=${v}`)) - .join(" "); -} - // --------------------------------------------------------------------------- // Lookback presets (ms values) // --------------------------------------------------------------------------- @@ -796,7 +790,7 @@ const METRICS_TREE = { children: [], parentKey: "card-bytes", dataSource: { - method: "searchMetricsPage" as const, + method: "searchAggregatedMetrics" as const, params: { metricType: "Sum" as const, metricName: "kopai.ingestion.bytes", @@ -823,7 +817,7 @@ const METRICS_TREE = { children: [], parentKey: "card-requests", dataSource: { - method: "searchMetricsPage" as const, + method: "searchAggregatedMetrics" as const, params: { metricType: "Sum" as const, metricName: "kopai.ingestion.requests", diff --git a/skills/create-dashboard/rules/workflow.md b/skills/create-dashboard/rules/workflow.md index c6d91b8..4385ca7 100644 --- a/skills/create-dashboard/rules/workflow.md +++ b/skills/create-dashboard/rules/workflow.md @@ -57,7 +57,7 @@ priority: critical ## Component Compatibility - **MetricStat** — works with **Sum** and **Gauge** only. Does NOT work with Histogram (shows "--") -- **MetricStat with aggregation** — add `aggregate: "sum"` to params for a single aggregated value (e.g. total bytes). Do NOT use `groupBy` with MetricStat (use MetricTable for grouped results) +- **MetricStat with aggregation** — use `method: "searchAggregatedMetrics"` with `aggregate: "sum"` (or `"avg"`, `"min"`, `"max"`, `"count"`) in params for a single aggregated value (e.g. total bytes). Do NOT use `groupBy` with MetricStat (use MetricTable for grouped results) - **MetricTimeSeries** — works with **Sum**, **Gauge**, and **Histogram** (renders mean duration over time) - **MetricHistogram** — works with **Histogram** and **ExponentialHistogram** only