diff --git a/.gitignore b/.gitignore index 31e08d1a2..209ff5373 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .idea target/ .claude/ +.agent-browser/ diff --git a/CLAUDE.md b/CLAUDE.md index 12137eceb..a6111495c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ App Server │ ├──► PostgreSQL (5433) - main database [required] ├──► ClickHouse (8123) - analytics/spans [required] ├──► RabbitMQ (5672) - async processing [optional, has in-memory fallback] - ├──► Query Engine (8903) - SQL processing [required] +# ├──► Query Engine (8903) - SQL processing [required] └──► Quickwit (7280/7281) - full-text search [optional] ``` @@ -127,3 +127,23 @@ The frontend uses Husky with lint-staged. Before commits: - Prettier formats staged files - ESLint fixes issues - TypeScript type-check runs + +## Frontend Best Practices + +### One component per file + +Related components should be in a folder named by the parent component (`my-list/`) and the parent component should follow the index.tsx pattern (`my-list/index.tsx`) and all related components should be in the folder (`my-list/my-list-item.tsx`). + +Please do your best to keep components <150 lines. + +### Bias towards complex logic and state in the Zustand store + +When you anticipate lots of complex state management with useState and useEffects, this would be a good time to rethink or refactor and move state into a shared store and expose derived state via selectors. + +### Avoid syncing URL params with Zustand store antipattern + +Use the nuqs library to handle url param state when possible. Avoid using a useEffect to sync URL param state with the Zustand store. Prefer keeping source of truth as the useQueryState and passing in necessary state as function params to the store when needed. + +### Use Zustand shallow to avoid unnecessary rerenders + +Pass shallow as the equality function to useStore when applicable. That way even with a new selector reference each render, Zustand compares the result shallowly and won't re-render if the contents are the same. diff --git a/frontend/app/api/projects/[projectId]/signals/[id]/events/clusters/route.ts b/frontend/app/api/projects/[projectId]/signals/[id]/events/clusters/route.ts index b0c7957b4..7687ccab4 100644 --- a/frontend/app/api/projects/[projectId]/signals/[id]/events/clusters/route.ts +++ b/frontend/app/api/projects/[projectId]/signals/[id]/events/clusters/route.ts @@ -15,7 +15,11 @@ export async function GET( signalId, }); - return NextResponse.json(result); + return NextResponse.json({ + items: result.items, + totalEventCount: result.totalEventCount, + clusteredEventCount: result.clusteredEventCount, + }); } catch (error) { if (error instanceof ZodError) { return NextResponse.json({ success: false, error: prettifyError(error) }, { status: 400 }); diff --git a/frontend/app/api/projects/[projectId]/signals/[id]/events/stats/route.ts b/frontend/app/api/projects/[projectId]/signals/[id]/events/clusters/stats/route.ts similarity index 69% rename from frontend/app/api/projects/[projectId]/signals/[id]/events/stats/route.ts rename to frontend/app/api/projects/[projectId]/signals/[id]/events/clusters/stats/route.ts index 7c9402483..d059d7c73 100644 --- a/frontend/app/api/projects/[projectId]/signals/[id]/events/stats/route.ts +++ b/frontend/app/api/projects/[projectId]/signals/[id]/events/clusters/stats/route.ts @@ -1,19 +1,18 @@ import { type NextRequest } from "next/server"; import { prettifyError, ZodError } from "zod/v4"; +import { getClusterEventCounts, GetClusterEventCountsSchema } from "@/lib/actions/clusters"; import { parseUrlParams } from "@/lib/actions/common/utils"; -import { getEventStats, GetEventStatsSchema } from "@/lib/actions/events/stats"; export async function GET( req: NextRequest, props: { params: Promise<{ projectId: string; id: string }> } ): Promise { - const params = await props.params; - const { projectId, id: signalId } = params; + const { projectId, id: signalId } = await props.params; const parseResult = parseUrlParams( req.nextUrl.searchParams, - GetEventStatsSchema.omit({ projectId: true, signalId: true }) + GetClusterEventCountsSchema.omit({ projectId: true, signalId: true }) ); if (!parseResult.success) { @@ -21,14 +20,14 @@ export async function GET( } try { - const result = await getEventStats({ ...parseResult.data, projectId, signalId }); + const result = await getClusterEventCounts({ ...parseResult.data, projectId, signalId }); return Response.json(result); } catch (error) { if (error instanceof ZodError) { return Response.json({ error: prettifyError(error) }, { status: 400 }); } return Response.json( - { error: error instanceof Error ? error.message : "Failed to fetch event stats." }, + { error: error instanceof Error ? error.message : "Failed to fetch cluster event counts." }, { status: 500 } ); } diff --git a/frontend/app/api/projects/[projectId]/signals/[id]/events/route.ts b/frontend/app/api/projects/[projectId]/signals/[id]/events/route.ts index b25d02b9f..7d505b391 100644 --- a/frontend/app/api/projects/[projectId]/signals/[id]/events/route.ts +++ b/frontend/app/api/projects/[projectId]/signals/[id]/events/route.ts @@ -12,7 +12,8 @@ export async function GET( const { projectId, id: signalId } = params; const parseResult = parseUrlParams( req.nextUrl.searchParams, - GetEventsPaginatedSchema.omit({ projectId: true, signalId: true }) + GetEventsPaginatedSchema.omit({ projectId: true, signalId: true }), + ["filter", "searchIn", "clusterId"] ); if (!parseResult.success) { diff --git a/frontend/app/api/projects/[projectId]/sql/export-job/route.ts b/frontend/app/api/projects/[projectId]/sql/export-job/route.ts index 31cbd3288..90a36195f 100644 --- a/frontend/app/api/projects/[projectId]/sql/export-job/route.ts +++ b/frontend/app/api/projects/[projectId]/sql/export-job/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { prettifyError, ZodError } from "zod/v4"; -import { createExportJob } from "@/lib/actions/sql"; +import { createExportJob } from "@/lib/actions/sql/export-job"; export async function POST(req: NextRequest, props: { params: Promise<{ projectId: string }> }): Promise { const params = await props.params; diff --git a/frontend/app/api/projects/[projectId]/sql/generate/route.ts b/frontend/app/api/projects/[projectId]/sql/generate/route.ts index cbd8ac7d9..6fad5d4f2 100644 --- a/frontend/app/api/projects/[projectId]/sql/generate/route.ts +++ b/frontend/app/api/projects/[projectId]/sql/generate/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { prettifyError, z, ZodError } from "zod/v4"; -import { generateSql } from "@/lib/actions/sql"; +import { generateSql } from "@/lib/actions/sql/generate"; const GenerateSchema = z.object({ prompt: z.string().min(1, "Prompt is required"), diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 782b074d6..41cb4dfd8 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,6 +2,7 @@ import "@/app/globals.css"; import "@/app/scroll.css"; import { type Metadata } from "next"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; import { type PropsWithChildren } from "react"; import { Toaster } from "@/components/ui/toaster"; @@ -78,12 +79,14 @@ export default async function RootLayout({ children }: PropsWithChildren) { -
-
-
{children}
- + +
+
+
{children}
+ +
-
+ diff --git a/frontend/components/chart-builder/charts/utils.ts b/frontend/components/chart-builder/charts/utils.ts index d8c3c5053..cc40cbbea 100644 --- a/frontend/components/chart-builder/charts/utils.ts +++ b/frontend/components/chart-builder/charts/utils.ts @@ -1,4 +1,4 @@ -import { scaleTime, scaleUtc } from "d3-scale"; +import { scaleUtc } from "d3-scale"; import { format, isValid, parseISO } from "date-fns"; import { isNil } from "lodash"; @@ -106,7 +106,7 @@ export const selectNiceTicksFromData = ( const scale = scaleUtc().domain([startDate, endDate]); const idealTicks = scale.ticks(targetTickCount); - const formatTick = scaleTime().domain([startDate, endDate]).tickFormat(); + const formatTick = scale.tickFormat(); const findClosestTimestamp = (targetTime: number) => dataTimestamps.reduce((closest, current) => { diff --git a/frontend/components/charts/time-series-chart/index.tsx b/frontend/components/charts/time-series-chart/index.tsx index 21a257faf..02e1c5bd6 100644 --- a/frontend/components/charts/time-series-chart/index.tsx +++ b/frontend/components/charts/time-series-chart/index.tsx @@ -7,6 +7,7 @@ import { type CategoricalChartFunc } from "recharts/types/chart/generateCategori import { numberFormatter, parseUtcTimestamp, selectNiceTicksFromData } from "@/components/chart-builder/charts/utils"; import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; +import { cn } from "@/lib/utils"; import RoundedBar from "./bar"; import { type TimeSeriesChartProps, type TimeSeriesDataPoint } from "./types"; @@ -32,7 +33,8 @@ export default function TimeSeriesChart({ onZoom, formatValue = numberFormatter.format, showTotal = true, -}: Omit, "isLoading" | "className">) { + className, +}: Omit, "isLoading">) { const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); @@ -104,8 +106,8 @@ export default function TimeSeriesChart({ ); return ( -
- +
+ { + const { filters } = get(); + const columnFilter = filters.find((f) => f.key === field); + const dataType = columnFilter ? toFilterDataType(columnFilter.dataType) : "string"; set((state) => ({ - tags: state.tags.map((t) => (t.id === tagId ? { ...t, field } : t)), + tags: state.tags.map((t) => (t.id === tagId ? { ...t, field, dataType } : t)), })); }, diff --git a/frontend/components/common/advanced-search/types.ts b/frontend/components/common/advanced-search/types.ts index 7649a3609..7eae4900c 100644 --- a/frontend/components/common/advanced-search/types.ts +++ b/frontend/components/common/advanced-search/types.ts @@ -1,7 +1,7 @@ import { uniqueId } from "lodash"; import { type ColumnFilter, dataTypeOperationsMap } from "@/components/ui/infinite-datatable/ui/datatable-filter/utils"; -import { type Filter } from "@/lib/actions/common/filters"; +import { type Filter, type FilterDataType } from "@/lib/actions/common/filters"; import { type Operator } from "@/lib/actions/common/operators"; export type { ColumnFilter } from "@/components/ui/infinite-datatable/ui/datatable-filter/utils"; @@ -21,6 +21,7 @@ export interface FilterTagRef { export interface FilterTag { id: string; field: string; + dataType?: FilterDataType; operator: Operator; value: string | string[]; } @@ -38,6 +39,7 @@ export type FilterTagFocusState = export function createFilterFromTag(tag: FilterTag): Filter { return { + dataType: tag.dataType, column: tag.field, operator: tag.operator, value: tag.value, @@ -48,6 +50,7 @@ export function createTagFromFilter(filter: Filter): FilterTag { return { id: `tag-${uniqueId()}`, field: filter.column, + dataType: filter.dataType, operator: filter.operator, // Preserve array values directly, stringify others value: Array.isArray(filter.value) ? filter.value : String(filter.value), diff --git a/frontend/components/settings/alerts/manage-alert-sheet.tsx b/frontend/components/settings/alerts/manage-alert-sheet.tsx index 3909afdc0..ab8d2efd1 100644 --- a/frontend/components/settings/alerts/manage-alert-sheet.tsx +++ b/frontend/components/settings/alerts/manage-alert-sheet.tsx @@ -7,8 +7,8 @@ import useSWR from "swr"; import TimeSeriesChart from "@/components/charts/time-series-chart"; import { ChartSkeleton } from "@/components/charts/time-series-chart/skeleton"; +import { type TimeSeriesDataPoint } from "@/components/charts/time-series-chart/types"; import { useTimeSeriesStatsUrl } from "@/components/charts/time-series-chart/use-time-series-stats-url"; -import { type EventsStatsDataPoint } from "@/components/signal/store"; import { Button } from "@/components/ui/button"; import DateRangeFilter from "@/components/ui/date-range-filter"; import { Input } from "@/components/ui/input"; @@ -143,7 +143,7 @@ export default function ManageAlertSheet({ endDate: dateRange.endDate ?? null, }); - const { data: eventsStats, isLoading: isLoadingStats } = useSWR<{ items: EventsStatsDataPoint[] }>( + const { data: eventsStats, isLoading: isLoadingStats } = useSWR<{ items: TimeSeriesDataPoint[] }>( selectedSignal && statsUrl ? statsUrl : null, swrFetcher, { diff --git a/frontend/components/signal/clusters-section/cluster-breadcrumb.tsx b/frontend/components/signal/clusters-section/cluster-breadcrumb.tsx new file mode 100644 index 000000000..384208fd2 --- /dev/null +++ b/frontend/components/signal/clusters-section/cluster-breadcrumb.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; + +import { type ClusterNode } from "./utils"; + +interface ClusterBreadcrumbProps { + breadcrumb: ClusterNode[]; + selectedClusterId: string | null; + onNavigateToBreadcrumb: (index: number) => void; +} + +const slideIn = { + initial: { opacity: 0.3, x: -45 }, + animate: { opacity: 1, x: 0, transition: { type: "spring", stiffness: 300, damping: 30, mass: 0.3 } }, + exit: { + opacity: 0.3, + x: -20, + transition: { duration: 0.1, ease: "easeOut" }, + }, +}; + +const slashSlideIn = { + initial: { opacity: 0.3, x: -12 }, + animate: { opacity: 1, x: 0, transition: { type: "spring", stiffness: 300, damping: 30, mass: 0.8 } }, + exit: { + opacity: 0.3, + x: -8, + transition: { duration: 0.1, ease: "easeOut" }, + }, +}; + +const levelTransition = { + initial: { opacity: 0, width: 0 }, + animate: { opacity: 1, width: "auto" }, + exit: { opacity: 0, width: 0 }, + transition: { type: "spring", stiffness: 300, damping: 30, mass: 0.3 }, +}; + +// Slash width (~6px at text-sm) + gap to match parent's gap-2 (8px) on each side +const SLASH_CONTAINER_PL = "pl-[22px]"; + +export default function ClusterBreadcrumb({ + breadcrumb, + selectedClusterId, + onNavigateToBreadcrumb, +}: ClusterBreadcrumbProps) { + return ( +
+ + + {/* Outer: handles levels appearing/disappearing */} + + {breadcrumb.map((node, index) => { + const isLast = index === breadcrumb.length - 1; + return ( + + {/* Inner: handles swaps within this level (e.g. sibling leaf selection) */} + + + + / + + onNavigateToBreadcrumb(index)} + {...slideIn} + > + {node.name} + + + + + ); + })} + +
+ ); +} diff --git a/frontend/components/signal/clusters-section/cluster-breadcrumbs.tsx b/frontend/components/signal/clusters-section/cluster-breadcrumbs.tsx new file mode 100644 index 000000000..21d433d83 --- /dev/null +++ b/frontend/components/signal/clusters-section/cluster-breadcrumbs.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback } from "react"; +import { shallow } from "zustand/shallow"; + +import { useClusterId } from "@/components/signal/hooks/use-cluster-id"; +import { getBreadcrumb, useSignalStoreContext } from "@/components/signal/store.tsx"; + +import ClusterBreadcrumb from "./cluster-breadcrumb"; + +export default function ClusterBreadcrumbs() { + const [clusterId, setClusterId] = useClusterId(); + + const breadcrumb = useSignalStoreContext((state) => getBreadcrumb(state, clusterId), shallow); + + const isClustersLoading = useSignalStoreContext((state) => state.isClustersLoading); + + const navigateToBreadcrumb = useCallback( + (index: number) => { + if (index < 0) { + setClusterId(null); + } else { + setClusterId(breadcrumb[index].id); + } + }, + [setClusterId, breadcrumb] + ); + + if (isClustersLoading) { + return ( +
+ All Events +
+ ); + } + + return ( + + ); +} diff --git a/frontend/components/signal/clusters-section/cluster-list/cluster-item.tsx b/frontend/components/signal/clusters-section/cluster-list/cluster-item.tsx new file mode 100644 index 000000000..5e3a3da8a --- /dev/null +++ b/frontend/components/signal/clusters-section/cluster-list/cluster-item.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Circle, CircleDashed, Folder } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { formatShortRelativeTime } from "@/components/client-timestamp-formatter"; +import { cn } from "@/lib/utils"; + +import { withOpacity } from "../colors"; +import { type ClusterNode } from "../utils"; + +export type IconVariant = "folder" | "circle" | "circle-dashed"; + +interface HoverRect { + top: number; + left: number; + width: number; + height: number; +} + +export default function ClusterItem({ + cluster, + iconVariant, + color, + isSelected, + filteredCount, + onClick, +}: { + cluster: ClusterNode; + iconVariant: IconVariant; + color: string; + isSelected: boolean; + filteredCount: number | undefined; + onClick: () => void; +}) { + const hasChildren = iconVariant === "folder"; + const displayCount = filteredCount ?? 0; + const showFilteredRange = filteredCount !== undefined; + const createdAgo = useMemo(() => { + const d = new Date(cluster.createdAt); + return isNaN(d.getTime()) ? null : formatShortRelativeTime(d); + }, [cluster.createdAt]); + const updatedAgo = useMemo(() => { + const d = new Date(cluster.updatedAt); + return isNaN(d.getTime()) ? null : formatShortRelativeTime(d); + }, [cluster.updatedAt]); + + const [hovered, setHovered] = useState(false); + const [rect, setRect] = useState(null); + const buttonRef = useRef(null); + const leaveTimeoutRef = useRef | null>(null); + + const clearLeaveTimeout = useCallback(() => { + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + leaveTimeoutRef.current = null; + } + }, []); + + useEffect( + () => () => { + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + } + }, + [] + ); + + const handleMouseEnter = useCallback(() => { + clearLeaveTimeout(); + if (buttonRef.current) { + const r = buttonRef.current.getBoundingClientRect(); + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); + setHovered(true); + } + }, [clearLeaveTimeout]); + + const scheduleClose = useCallback(() => { + clearLeaveTimeout(); + leaveTimeoutRef.current = setTimeout(() => { + setHovered(false); + setRect(null); + }, 80); + }, [clearLeaveTimeout]); + + const icon = + iconVariant === "folder" ? ( + + ) : iconVariant === "circle-dashed" ? ( + + ) : ( + + ); + + return ( + <> + + + {typeof document !== "undefined" && + createPortal( + + {hovered && rect && ( + { + setHovered(false); + setRect(null); + }} + > + { + setHovered(false); + setRect(null); + onClick(); + }} + className={cn( + "flex flex-col pl-2 pr-3 pt-1.5 pb-1 rounded text-sm text-left cursor-pointer overflow-hidden", + "bg-muted outline -outline-offset-1 outline-border shadow-md shadow-background/80 w-full", + isSelected && "font-medium" + )} + initial={{ width: rect.width, height: rect.height }} + animate={{ + width: "auto", + height: "auto", + transition: { duration: 0.15, ease: "easeOut", delay: 0.5 }, + }} + exit={{ width: rect.width, height: rect.height, transition: { duration: 0.15, ease: "easeOut" } }} + style={{ minWidth: rect.width, minHeight: rect.height }} + > +
+ {icon} + {cluster.name} +
+ + {hasChildren && ( + + {cluster.children.length} sub-clusters + + )} + + {displayCount} + {showFilteredRange ? ` / ${cluster.numEvents} events in selected range` : ` events`} + + {createdAgo && ( + + Created {createdAgo} + + )} + {updatedAgo && ( + + Updated {updatedAgo} + + )} + +
+
+ )} +
, + document.body + )} + + ); +} diff --git a/frontend/components/signal/clusters-section/cluster-list/index.tsx b/frontend/components/signal/clusters-section/cluster-list/index.tsx new file mode 100644 index 000000000..ea1be12d2 --- /dev/null +++ b/frontend/components/signal/clusters-section/cluster-list/index.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { shallow } from "zustand/shallow"; + +import { useClusterId } from "@/components/signal/hooks/use-cluster-id"; +import { + getUnclusteredVirtualCluster, + getVisibleClusters, + selectUnclusteredCount, + useSignalStoreContext, +} from "@/components/signal/store"; +import { UNCLUSTERED_ID } from "@/lib/actions/clusters"; +import { cn } from "@/lib/utils"; + +import { getClusterColor, UNCLUSTERED_COLOR } from "../colors"; +import ClusterItem, { type IconVariant } from "./cluster-item"; + +interface ClusterListProps { + displayId: string | null; + drillDownDepth: number; + filteredCountByCluster: Map; + onNavigateToCluster: (clusterId: string) => void; + className?: string; +} + +export default function ClusterList({ + displayId, + drillDownDepth, + filteredCountByCluster, + onNavigateToCluster, + className, +}: ClusterListProps) { + const [clusterId] = useClusterId(); + + const visibleClusters = useSignalStoreContext((state) => getVisibleClusters(state, displayId), shallow); + const unclusteredCount = useSignalStoreContext(selectUnclusteredCount); + const unclusteredVirtualCluster = useSignalStoreContext(getUnclusteredVirtualCluster); + + const showUnclustered = drillDownDepth === 0; + + return ( +
+
+ {visibleClusters.length === 0 && !showUnclustered ? ( +
No sub-clusters
+ ) : ( + <> + {visibleClusters.map((cluster, index) => { + const hasChildren = cluster.children.length > 0; + const filteredCount = filteredCountByCluster.get(cluster.id); + const iconVariant: IconVariant = hasChildren ? "folder" : "circle"; + return ( + onNavigateToCluster(cluster.id)} + /> + ); + })} + + {showUnclustered && unclusteredCount > 0 && ( + <> + {visibleClusters.length > 0 &&
} + onNavigateToCluster(UNCLUSTERED_ID)} + /> + + )} + + )} +
+
+ ); +} diff --git a/frontend/components/signal/clusters-section/cluster-stacked-chart.tsx b/frontend/components/signal/clusters-section/cluster-stacked-chart.tsx new file mode 100644 index 000000000..e48d36f10 --- /dev/null +++ b/frontend/components/signal/clusters-section/cluster-stacked-chart.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useMemo } from "react"; + +import TimeSeriesChart from "@/components/charts/time-series-chart"; +import { type TimeSeriesChartConfig, type TimeSeriesDataPoint } from "@/components/charts/time-series-chart/types"; +import { type ClusterStatsDataPoint, type EventCluster } from "@/lib/actions/clusters"; + +import { UNCLUSTERED_COLOR, withOpacity } from "./colors"; + +interface ClusterStackedChartProps { + clusters: EventCluster[]; + statsData: ClusterStatsDataPoint[]; + containerWidth: number | null; + colorMap: Map; +} + +export default function ClusterStackedChart({ + clusters, + statsData, + containerWidth, + colorMap, +}: ClusterStackedChartProps) { + const { data, chartConfig, fields } = useMemo(() => { + const config: TimeSeriesChartConfig = {}; + const fieldKeys: string[] = []; + + clusters.forEach((cluster) => { + const key = cluster.id; + const baseColor = colorMap.get(key) ?? UNCLUSTERED_COLOR; + const color = withOpacity(baseColor, 0.75); + config[key] = { + label: cluster.name, + color, + stackId: "stack", + }; + fieldKeys.push(key); + }); + + // Group stats by timestamp + const timestampMap = new Map>(); + for (const row of statsData) { + if (!timestampMap.has(row.timestamp)) { + timestampMap.set(row.timestamp, {}); + } + const entry = timestampMap.get(row.timestamp)!; + entry[row.cluster_id] = typeof row.count === "number" ? row.count : parseInt(String(row.count), 10); + } + + // Build chart data points + const chartData: TimeSeriesDataPoint[] = Array.from(timestampMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([timestamp, counts]) => { + const point: TimeSeriesDataPoint = { timestamp } as TimeSeriesDataPoint; + for (const key of fieldKeys) { + (point as Record)[key] = counts[key] || 0; + } + return point; + }); + + return { data: chartData, chartConfig: config, fields: fieldKeys }; + }, [clusters, statsData, colorMap]); + + if (data.length === 0) { + return ( +
+ No data for selected time range +
+ ); + } + + return ( + + ); +} diff --git a/frontend/components/signal/clusters-section/colors.ts b/frontend/components/signal/clusters-section/colors.ts new file mode 100644 index 000000000..1aa1b2f8d --- /dev/null +++ b/frontend/components/signal/clusters-section/colors.ts @@ -0,0 +1,58 @@ +export const UNCLUSTERED_COLOR = "var(--color-primary)"; + +// Tailwind v4 color variables, alternating warm/cool per level +const COLOR_PALETTES = [ + // Level 0 + [ + "var(--color-blue-500)", + "var(--color-fuchsia-500)", + "var(--color-indigo-500)", + "var(--color-emerald-500)", + "var(--color-amber-500)", + "var(--color-cyan-500)", + "var(--color-rose-500)", + "var(--color-lime-500)", + "var(--color-orange-500)", + "var(--color-teal-500)", + "var(--color-sky-500)", + "var(--color-violet-500)", + "var(--color-slate-500)", + ], + // Level 1 + [ + "var(--color-indigo-500)", + "var(--color-blue-400)", + "var(--color-orange-400)", + "var(--color-yellow-500)", + "var(--color-green-500)", + "var(--color-red-500)", + "var(--color-sky-500)", + "var(--color-fuchsia-500)", + "var(--color-slate-500)", + "var(--color-emerald-400)", + "var(--color-purple-500)", + ], + // Level 2+ + [ + "var(--color-purple-500)", + "var(--color-lime-500)", + "var(--color-cyan-500)", + "var(--color-rose-400)", + "var(--color-amber-500)", + "var(--color-teal-400)", + "var(--color-pink-400)", + "var(--color-yellow-400)", + "var(--color-emerald-300)", + "var(--color-emerald-400)", + "var(--color-red-500)", + ], +]; + +export function getClusterColor(index: number, depthLevel: number = 0): string { + const palette = COLOR_PALETTES[Math.min(depthLevel, COLOR_PALETTES.length - 1)]; + return palette[index % palette.length]; +} + +export function withOpacity(color: string, opacity: number): string { + return `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, transparent)`; +} diff --git a/frontend/components/signal/clusters-section/index.tsx b/frontend/components/signal/clusters-section/index.tsx new file mode 100644 index 000000000..d696026d5 --- /dev/null +++ b/frontend/components/signal/clusters-section/index.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { Circle } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { shallow } from "zustand/shallow"; + +import { useClusterId } from "@/components/signal/hooks/use-cluster-id"; +import { + getChartClusters, + getCurrentNode, + getDrillDownDepth, + getFilteredCountByCluster, + getIsLeaf, + getVisibleClusters, + useSignalStoreContext, +} from "@/components/signal/store.tsx"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { UNCLUSTERED_ID } from "@/lib/actions/clusters"; + +import ClusterList from "./cluster-list"; +import ClusterStackedChart from "./cluster-stacked-chart"; +import { getClusterColor, UNCLUSTERED_COLOR } from "./colors"; + +export default function ClustersSection() { + const searchParams = useSearchParams(); + const [clusterId, setClusterId] = useClusterId(); + + // For leaf nodes, stay at the parent's navigation level + const isLeaf = useSignalStoreContext((state) => getIsLeaf(state, clusterId)); + const currentNode = useSignalStoreContext((state) => getCurrentNode(state, clusterId)); + const displayId = isLeaf ? (currentNode?.parentId ?? null) : clusterId; + + const isClustersLoading = useSignalStoreContext((state) => state.isClustersLoading); + const clusterStatsData = useSignalStoreContext((state) => state.clusterStatsData); + const isClusterStatsLoading = useSignalStoreContext((state) => state.isClusterStatsLoading); + const rawClusters = useSignalStoreContext((state) => state.rawClusters); + const fetchClusters = useSignalStoreContext((state) => state.fetchClusters); + const fetchClusterStats = useSignalStoreContext((state) => state.fetchClusterStats); + + const pastHours = searchParams.get("pastHours"); + const startDate = searchParams.get("startDate"); + const endDate = searchParams.get("endDate"); + const hasTimeRange = !!(pastHours || startDate); + + // Depth uses displayId (parent level for leaves), chart uses clusterId (shows selected node's data) + const visibleClusters = useSignalStoreContext((state) => getVisibleClusters(state, displayId), shallow); + const drillDownDepth = useSignalStoreContext((state) => getDrillDownDepth(state, displayId)); + const chartClusters = useSignalStoreContext((state) => getChartClusters(state, clusterId), shallow); + const filteredCountByCluster = useSignalStoreContext( + (state) => getFilteredCountByCluster(state, displayId, hasTimeRange), + shallow + ); + + // Build stable color map from sibling list so colors match between list and chart + const colorMap = useMemo(() => { + const map = new Map(); + visibleClusters.forEach((c, i) => map.set(c.id, getClusterColor(i, drillDownDepth))); + map.set(UNCLUSTERED_ID, UNCLUSTERED_COLOR); + return map; + }, [visibleClusters, drillDownDepth]); + + // Fetch clusters on mount + useEffect(() => { + fetchClusters(); + }, [fetchClusters]); + + // Local UI state for resize observer + const chartContainerRef = useRef(null); + const [localChartWidth, setLocalChartWidth] = useState(null); + + useEffect(() => { + if (!chartContainerRef.current) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setLocalChartWidth(entry.contentRect.width); + } + }); + observer.observe(chartContainerRef.current); + return () => observer.disconnect(); + }, []); + + // Fetch stats when time range or chart width change + useEffect(() => { + const controller = new AbortController(); + + fetchClusterStats({ + pastHours, + startDate, + endDate, + chartWidth: localChartWidth, + abortSignal: controller.signal, + }); + + return () => { + controller.abort(); + }; + }, [pastHours, startDate, endDate, localChartWidth, fetchClusterStats, rawClusters]); + + // Navigation callbacks + const navigateToCluster = useCallback( + (id: string) => { + // Toggle off if clicking the already-selected leaf/unclustered — go back to parent + if (id === clusterId && isLeaf) { + setClusterId(displayId); + } else { + setClusterId(id); + } + }, + [setClusterId, clusterId, isLeaf, displayId] + ); + + if (isClustersLoading) { + return ( +
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+
+
+ ))} +
+
+
+ Loading clusters +
+
+ ); + } + + return ( + + + + + + + + + +
+ {isClusterStatsLoading ? ( +
+ Loading chart... +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/components/signal/clusters-section/utils.ts b/frontend/components/signal/clusters-section/utils.ts new file mode 100644 index 000000000..4e281cb02 --- /dev/null +++ b/frontend/components/signal/clusters-section/utils.ts @@ -0,0 +1,63 @@ +import { type EventCluster } from "@/lib/actions/clusters"; + +export interface ClusterNode extends EventCluster { + children: ClusterNode[]; +} + +export function buildTree(flatClusters: EventCluster[]): ClusterNode[] { + const nodeMap = new Map(); + const roots: ClusterNode[] = []; + + flatClusters.forEach((cluster) => { + nodeMap.set(cluster.id, { ...cluster, children: [] }); + }); + + flatClusters.forEach((cluster) => { + const node = nodeMap.get(cluster.id)!; + if (cluster.parentId === null || !nodeMap.has(cluster.parentId)) { + roots.push(node); + } else { + nodeMap.get(cluster.parentId)!.children.push(node); + } + }); + + return roots; +} + +export function findNodeById(nodes: ClusterNode[], id: string): ClusterNode | null { + for (const node of nodes) { + if (node.id === id) return node; + const found = findNodeById(node.children, id); + if (found) return found; + } + return null; +} + +export function collectDescendantIds(node: ClusterNode): string[] { + const ids = [node.id]; + for (const child of node.children) { + ids.push(...collectDescendantIds(child)); + } + return ids; +} + +export function buildPath(allNodes: ClusterNode[], targetId: string): ClusterNode[] { + const path: ClusterNode[] = []; + + function dfs(nodes: ClusterNode[], target: string): boolean { + for (const node of nodes) { + if (node.id === target) { + path.push(node); + return true; + } + if (dfs(node.children, target)) { + path.unshift(node); + return true; + } + } + return false; + } + + dfs(allNodes, targetId); + return path; +} diff --git a/frontend/components/signal/clusters-table/columns.tsx b/frontend/components/signal/clusters-table/columns.tsx deleted file mode 100644 index 77cdded65..000000000 --- a/frontend/components/signal/clusters-table/columns.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { type ColumnDef } from "@tanstack/react-table"; -import Link from "next/link"; -import postgres from "postgres"; - -import ClientTimestampFormatter from "@/components/client-timestamp-formatter.tsx"; -import { Button } from "@/components/ui/button"; -import { type EventCluster } from "@/lib/actions/clusters"; -import { cn } from "@/lib/utils.ts"; -import column = postgres.toPascal.column; - -export interface ClusterRow extends EventCluster { - subRows?: ClusterRow[]; -} - -interface ClusterTableMeta { - totalCount: number; -} - -export const getClusterColumns = (projectId: string, eventDefinitionId: string): ColumnDef[] => [ - { - header: "", - cell: ({ row }) => - row.original.numChildrenClusters > 0 && row.original.level > 1 ? ( - +
+ ); + }, + size: 180, id: "traceId", }, - { - id: "payload", - accessorKey: "payload", - header: "Payload", - accessorFn: (row) => row.payload, - cell: ({ getValue, column }) => ( - - ), - size: 840, - }, - { - accessorKey: "timestamp", - header: "Timestamp", - cell: (row) => , - size: 140, - id: "timestamp", - }, ]; -export const defaultEventsColumnOrder = ["id", "traceId", "payload", "timestamp"]; - -export const eventsTableFilters: ColumnFilter[] = [ +const staticFilters: ColumnFilter[] = [ { name: "ID", key: "id", @@ -63,9 +194,22 @@ export const eventsTableFilters: ColumnFilter[] = [ key: "run_id", dataType: "string", }, - { - name: "Payload", - key: "payload", - dataType: "json", - }, ]; + +export function buildEventsColumns(schemaFields: SchemaField[]): { + columns: ColumnDef[]; + columnOrder: string[]; + filters: ColumnFilter[]; +} { + const validFields = schemaFields.filter((f) => f.name.trim()); + const payloadColumns = validFields.map(createPayloadColumnDef); + const payloadFilters = validFields.map(createPayloadFilter); + + const columns = [...staticColumnsBeforePayload, ...payloadColumns, ...staticColumnsAfterPayload]; + + const columnOrder = ["timestamp", "traceId", ...validFields.map((f) => `payload:${f.name}`), "id"]; + + const filters = [...staticFilters, ...payloadFilters]; + + return { columns, columnOrder, filters }; +} diff --git a/frontend/components/signal/events-table/event-detail-panel.tsx b/frontend/components/signal/events-table/event-detail-panel.tsx new file mode 100644 index 000000000..cac9f233f --- /dev/null +++ b/frontend/components/signal/events-table/event-detail-panel.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Check, List, X } from "lucide-react"; +import { useMemo } from "react"; + +import { type SchemaField } from "@/components/signals/utils"; +import { Button } from "@/components/ui/button"; +import { type EventRow } from "@/lib/events/types"; + +interface EventDetailPanelProps { + event: EventRow; + schemaFields: SchemaField[]; + onClose: () => void; + onOpenTrace: (traceId: string) => void; +} + +function parsePayload(payload: string): Record { + try { + return JSON.parse(payload); + } catch { + return {}; + } +} + +function PayloadValue({ value, field }: { value: unknown; field: SchemaField }) { + if (value === null || value === undefined) { + return ; + } + + switch (field.type) { + case "boolean": + return ( + + {value ? : } + {value ? "true" : "false"} + + ); + case "enum": + return ( + + {String(value)} + + ); + case "number": + return {String(value)}; + case "string": + return {String(value)}; + } +} + +export default function EventDetailPanel({ event, schemaFields, onClose, onOpenTrace }: EventDetailPanelProps) { + const parsed = useMemo(() => parsePayload(event.payload), [event.payload]); + const validFields = schemaFields.filter((f) => f.name.trim()); + + return ( +
+
+
+ Event + +
+
+ + {new Date(event.timestamp).toLocaleString()} + + +
+
+ +
+ {validFields.map((field) => ( +
+
{field.name}
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/frontend/components/signal/events-table/index.tsx b/frontend/components/signal/events-table/index.tsx index 23636c5cf..0b20a4c16 100644 --- a/frontend/components/signal/events-table/index.tsx +++ b/frontend/components/signal/events-table/index.tsx @@ -2,12 +2,13 @@ import { type Row } from "@tanstack/react-table"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { shallow } from "zustand/shallow"; -import { useTimeSeriesStatsUrl } from "@/components/charts/time-series-chart/use-time-series-stats-url.ts"; -import ClientTimestampFormatter from "@/components/client-timestamp-formatter"; -import EventsChart from "@/components/signal/events-chart"; -import { useSignalStoreContext } from "@/components/signal/store.tsx"; +import ClustersSection from "@/components/signal/clusters-section"; +import ClusterBreadcrumbs from "@/components/signal/clusters-section/cluster-breadcrumbs"; +import { useClusterId } from "@/components/signal/hooks/use-cluster-id"; +import { getFilterClusterIds, useSignalStoreContext } from "@/components/signal/store.tsx"; import { type EventNavigationItem } from "@/components/signal/utils.ts"; import { useTraceViewNavigation } from "@/components/traces/trace-view/navigation-context.tsx"; import DateRangeFilter from "@/components/ui/date-range-filter"; @@ -18,10 +19,11 @@ import { DataTableStateProvider } from "@/components/ui/infinite-datatable/model import ColumnsMenu from "@/components/ui/infinite-datatable/ui/columns-menu"; import DataTableFilter, { DataTableFilterList } from "@/components/ui/infinite-datatable/ui/datatable-filter"; import { TableCell, TableRow } from "@/components/ui/table.tsx"; +import { UNCLUSTERED_ID } from "@/lib/actions/clusters"; import { type EventRow } from "@/lib/events/types"; import { useToast } from "@/lib/hooks/use-toast"; -import { defaultEventsColumnOrder, eventsTableColumns, eventsTableFilters } from "./columns"; +import { buildEventsColumns } from "./columns"; const FETCH_SIZE = 50; @@ -55,19 +57,39 @@ function PureEventsTable() { const { toast } = useToast(); const params = useParams<{ projectId: string }>(); - const { signal, lastEvent } = useSignalStoreContext((state) => ({ - signal: state.signal, - lastEvent: state.lastEvent, - })); + const [clusterId] = useClusterId(); + const signal = useSignalStoreContext((state) => state.signal); + const selectedEvent = useSignalStoreContext((state) => state.selectedEvent); + const selectedClusterIds = useSignalStoreContext((state) => getFilterClusterIds(state, clusterId), shallow); + const isUnclusteredFilter = clusterId === UNCLUSTERED_ID; const searchParams = useSearchParams(); const pathName = usePathname(); const router = useRouter(); - const chartContainerRef = useRef(null); const pastHours = searchParams.get("pastHours"); const startDate = searchParams.get("startDate"); const endDate = searchParams.get("endDate"); - const filter = searchParams.getAll("filter"); + const filterRaw = searchParams.getAll("filter"); + const filter = useMemo(() => filterRaw, [JSON.stringify(filterRaw)]); + + const { columns, filters } = useMemo(() => buildEventsColumns(signal.schemaFields), [signal.schemaFields]); + + const setTraceId = useSignalStoreContext((state) => state.setTraceId); + const setSelectedEvent = useSignalStoreContext((state) => state.setSelectedEvent); + + // Listen for open-trace events from the traceId column button + useEffect(() => { + const handler = (e: Event) => { + const traceId = (e as CustomEvent).detail; + setTraceId(traceId); + + const newParams = new URLSearchParams(searchParams.toString()); + newParams.set("traceId", traceId); + router.push(`${pathName}?${newParams.toString()}`); + }; + window.addEventListener("open-trace", handler); + return () => window.removeEventListener("open-trace", handler); + }, [setTraceId, searchParams, pathName, router]); const fetchEvents = useCallback( async (pageNumber: number) => { @@ -90,6 +112,12 @@ function PureEventsTable() { filter.forEach((f) => urlParams.append("filter", f)); + if (isUnclusteredFilter) { + urlParams.set("unclustered", "true"); + } else { + selectedClusterIds.forEach((id) => urlParams.append("clusterId", id)); + } + urlParams.set("eventDefinitionId", signal.id); urlParams.set("eventSource", "SEMANTIC"); @@ -112,47 +140,25 @@ function PureEventsTable() { } return { items: [], count: 0 }; }, - [pastHours, startDate, endDate, filter, signal.id, params.projectId, toast] + [pastHours, startDate, endDate, filter, selectedClusterIds, isUnclusteredFilter, signal.id, params.projectId, toast] ); const getRowHref = useCallback( (row: Row) => { const params = new URLSearchParams(searchParams.toString()); - params.set("traceId", row.original.traceId); + params.set("eventId", row.original.id); return `${pathName}?${params.toString()}`; }, [pathName, searchParams] ); - const { traceId, setTraceId, fetchStats, setChartContainerWidth, chartContainerWidth } = useSignalStoreContext( - (state) => ({ - traceId: state.traceId, - setTraceId: state.setTraceId, - fetchStats: state.fetchStats, - setChartContainerWidth: state.setChartContainerWidth, - chartContainerWidth: state.chartContainerWidth, - }) - ); - const handleRowClick = useCallback( (row: Row) => { - setTraceId(row.original.traceId); + setSelectedEvent(row.original); }, - [setTraceId] + [setSelectedEvent] ); - const statsUrl = useTimeSeriesStatsUrl({ - baseUrl: `/api/projects/${params.projectId}/signals/${signal.id}/events/stats`, - chartContainerWidth, - pastHours, - startDate, - endDate, - filters: filter, - additionalParams: { - eventSource: "SEMANTIC", - }, - }); - const { setNavigationRefList } = useTraceViewNavigation(); const { @@ -164,13 +170,13 @@ function PureEventsTable() { } = useInfiniteScroll({ fetchFn: fetchEvents, enabled: !!(pastHours || (startDate && endDate)), - deps: [params.projectId, signal.id, pastHours, startDate, endDate, filter], + deps: [params.projectId, signal.id, pastHours, startDate, endDate, filter, selectedClusterIds, isUnclusteredFilter], }); const focusedRowId = useMemo(() => { - if (!traceId) return undefined; - return events?.find((event) => event.traceId === traceId)?.id; - }, [events, traceId]); + if (!selectedEvent) return undefined; + return selectedEvent.id; + }, [selectedEvent]); useEffect(() => { if (events) { @@ -182,23 +188,6 @@ function PureEventsTable() { } }, [events, setNavigationRefList]); - useEffect(() => { - if (!chartContainerRef.current) return; - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const width = entry.contentRect.width; - setChartContainerWidth(width); - } - }); - - resizeObserver.observe(chartContainerRef.current); - - return () => { - resizeObserver.disconnect(); - }; - }, [setChartContainerWidth]); - useEffect(() => { if (!pastHours && !startDate && !endDate) { const params = new URLSearchParams(searchParams.toString()); @@ -207,26 +196,11 @@ function PureEventsTable() { } }, [pastHours, startDate, endDate, searchParams, pathName, router]); - useEffect(() => { - if (statsUrl) { - fetchStats(statsUrl); - } - }, [statsUrl, fetchStats]); - return ( -
- - Last event:{" "} - {lastEvent ? ( - - ) : ( - - - )} - - +
className="w-full" - columns={eventsTableColumns} + columns={columns} data={events} onRowClick={handleRowClick} getRowId={(row: EventRow) => row.id} @@ -240,26 +214,31 @@ function PureEventsTable() { estimatedRowHeight={80} emptyRow={filter.length === 0 ? getEmptyRow({ pastHours, startDate, endDate }) : undefined} > -
- +
+ ({ + columnLabels={columns.map((column) => ({ id: column.id!, label: typeof column.header === "string" ? column.header : column.id!, }))} />
+ - +
); } export default function EventsTable() { + const signal = useSignalStoreContext((state) => state.signal); + + const { columnOrder } = useMemo(() => buildEventsColumns(signal.schemaFields), [signal.schemaFields]); + return ( - + ); diff --git a/frontend/components/signal/hooks/use-cluster-id.ts b/frontend/components/signal/hooks/use-cluster-id.ts new file mode 100644 index 000000000..91f8a0e93 --- /dev/null +++ b/frontend/components/signal/hooks/use-cluster-id.ts @@ -0,0 +1,5 @@ +import { parseAsString, useQueryState } from "nuqs"; + +export function useClusterId() { + return useQueryState("clusterId", parseAsString.withOptions({ history: "push" })); +} diff --git a/frontend/components/signal/index.tsx b/frontend/components/signal/index.tsx index 994c462cc..f8d44a534 100644 --- a/frontend/components/signal/index.tsx +++ b/frontend/components/signal/index.tsx @@ -1,18 +1,20 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; import dynamic from "next/dynamic"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; import { Resizable } from "re-resizable"; -import React, { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; -import ClustersTable from "@/components/signal/clusters-table"; import EventsTable from "@/components/signal/events-table"; +import EventDetailPanel from "@/components/signal/events-table/event-detail-panel"; import SignalJobsTable from "@/components/signal/jobs-table"; import SignalRunsTable from "@/components/signal/runs-table"; +import SignalOverviewTooltip from "@/components/signal/signal-overview-tooltip"; import { useSignalStoreContext } from "@/components/signal/store.tsx"; import TriggersTable from "@/components/signal/triggers-table"; import { type EventNavigationItem, getEventsConfig } from "@/components/signal/utils"; -import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet.tsx"; +import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet"; import TraceView from "@/components/traces/trace-view"; import TraceViewNavigationProvider from "@/components/traces/trace-view/navigation-context"; import { Button } from "@/components/ui/button"; @@ -32,7 +34,7 @@ function SignalContent() { const params = useParams<{ projectId: string }>(); const { push } = useRouter(); const searchParams = useSearchParams(); - const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isSheetOpen, setIsSheetOpen] = useState(false); const { workspace } = useProjectContext(); const activeTab = searchParams.get("tab") || "events"; @@ -42,13 +44,17 @@ function SignalContent() { initialTraceViewWidth: state.initialTraceViewWidth, })); - const { setSignal, traceId, spanId, setTraceId, setSpanId } = useSignalStoreContext((state) => ({ - setSignal: state.setSignal, - traceId: state.traceId, - spanId: state.spanId, - setTraceId: state.setTraceId, - setSpanId: state.setSpanId, - })); + const { setSignal, traceId, spanId, setTraceId, setSpanId, selectedEvent, setSelectedEvent } = useSignalStoreContext( + (state) => ({ + setSignal: state.setSignal, + traceId: state.traceId, + spanId: state.spanId, + setTraceId: state.setTraceId, + setSpanId: state.setSpanId, + selectedEvent: state.selectedEvent, + setSelectedEvent: state.setSelectedEvent, + }) + ); const { width, handleResizeStop } = useResizableTraceViewWidth({ initialWidth: initialTraceViewWidth, @@ -82,37 +88,35 @@ function SignalContent() {
- - - Events - - - Triggers - - - Jobs - - - Runs - - + setIsSheetOpen(true)} + > + + + Events + + + Triggers + + + Jobs + + + Runs + + + {!isFreeTier && ( - - - + )}
- - + @@ -125,6 +129,48 @@ function SignalContent() {
+ + {!isFreeTier && ( + + )} + + + {selectedEvent && !traceId && ( + + { + setSelectedEvent(null); + const params = new URLSearchParams(searchParams); + params.delete("eventId"); + push(`${pathName}?${params.toString()}`); + }} + onOpenTrace={(traceId) => { + setSelectedEvent(null); + setTraceId(traceId); + const params = new URLSearchParams(searchParams); + params.delete("eventId"); + params.set("traceId", traceId); + push(`${pathName}?${params.toString()}`); + }} + /> + + )} + {traceId && (
& { id: string }; + activeTab: string; + onTabChange: (tab: string) => void; + onEditClick: () => void; + children: React.ReactNode; +} + +export default function SignalOverviewTooltip({ + signal, + activeTab, + onTabChange, + onEditClick, + children, +}: SignalOverviewTooltipProps) { + const handleTabChange = useCallback( + (tab: string) => { + onTabChange(tab); + }, + [onTabChange] + ); + + return ( + + + {children} + + + + + + ); +} diff --git a/frontend/components/signal/signal-overview-tooltip/signal-overview-content.tsx b/frontend/components/signal/signal-overview-tooltip/signal-overview-content.tsx new file mode 100644 index 000000000..7b8f226f8 --- /dev/null +++ b/frontend/components/signal/signal-overview-tooltip/signal-overview-content.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { ArrowRight, Pencil } from "lucide-react"; + +import TabButton from "@/components/signal/signal-overview-tooltip/tab-button"; +import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet"; + +interface SignalOverviewContentProps { + signal: Omit & { id: string }; + activeTab: string; + onTabChange: (tab: string) => void; + onEditClick: () => void; +} + +export default function SignalOverviewContent({ + signal, + activeTab, + onTabChange, + onEditClick, +}: SignalOverviewContentProps) { + return ( +
+ {/* Input column */} +
+ Input +
+ onTabChange("triggers")} + title="Triggers" + description="Automatically run this signal" + /> + onTabChange("jobs")} + title="Jobs" + description="Run this signal on past traces" + /> +
+
+ + {/* Arrow */} +
+ +
+ + {/* Definition column */} +
+ Definition +
+
+

+ {signal.prompt || "No prompt defined"} +

+ {/* Bottom gradient */} +
+ {/* Edit pencil */} +
+ +
+
+
+
+ + {/* Arrow */} +
+ +
+ + {/* Output column */} +
+ Output +
+ onTabChange("events")} + title="Events" + description="Traces that match your definition" + /> + onTabChange("runs")} + title="Runs" + description="All signal runs" + /> +
+
+
+ ); +} diff --git a/frontend/components/signal/signal-overview-tooltip/tab-button.tsx b/frontend/components/signal/signal-overview-tooltip/tab-button.tsx new file mode 100644 index 000000000..eebf81f45 --- /dev/null +++ b/frontend/components/signal/signal-overview-tooltip/tab-button.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface TabButtonProps { + tab: string; + activeTab: string; + onClick: () => void; + title: string; + description: string; +} + +export default function TabButton({ tab, activeTab, onClick, title, description }: TabButtonProps) { + const isActive = activeTab === tab; + return ( + + ); +} diff --git a/frontend/components/signal/store.tsx b/frontend/components/signal/store.tsx index cdd3abad7..64159f9e9 100644 --- a/frontend/components/signal/store.tsx +++ b/frontend/components/signal/store.tsx @@ -1,17 +1,19 @@ "use client"; + import { createContext, type Dispatch, type PropsWithChildren, type SetStateAction, useContext, useState } from "react"; import { createStore, useStore } from "zustand"; -import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet.tsx"; +import { calculateOptimalInterval, getTargetBarsForWidth } from "@/components/charts/time-series-chart/utils"; +import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet"; import { jsonSchemaToSchemaFields } from "@/components/signals/utils"; +import { type ClusterStatsDataPoint, type EventCluster, UNCLUSTERED_ID } from "@/lib/actions/clusters"; import { type Filter } from "@/lib/actions/common/filters.ts"; import { type Signal } from "@/lib/actions/signals"; import { type EventRow } from "@/lib/events/types"; -export type EventsStatsDataPoint = { - timestamp: string; - count: number; -} & Record; +import { buildPath, buildTree, type ClusterNode, collectDescendantIds, findNodeById } from "./clusters-section/utils"; + +export type { ClusterStatsDataPoint }; export type SignalState = { events?: EventRow[]; @@ -19,9 +21,7 @@ export type SignalState = { signal: Omit & { id: string }; traceId: string | null; spanId: string | null; - stats?: EventsStatsDataPoint[]; - isLoadingStats: boolean; - chartContainerWidth: number | null; + selectedEvent: EventRow | null; runsFilters: Filter[]; jobsFilters: Filter[]; triggersFilters: Filter[]; @@ -30,18 +30,36 @@ export type SignalState = { id: string; timestamp: string; }; + // Cluster state + rawClusters: EventCluster[]; + clusterTree: ClusterNode[]; + totalEventCount: number; + clusteredEventCount: number; + isClustersLoading: boolean; + clusterStatsData: ClusterStatsDataPoint[]; + isClusterStatsLoading: boolean; +}; + +export type FetchClusterStatsParams = { + pastHours: string | null; + startDate: string | null; + endDate: string | null; + chartWidth: number | null; + abortSignal?: AbortSignal; }; export type SignalActions = { setTraceId: (traceId: string | null) => void; setSpanId: (spanId: string | null) => void; + setSelectedEvent: (event: EventRow | null) => void; fetchEvents: (params: URLSearchParams) => Promise; setSignal: (eventDefinition?: SignalState["signal"]) => void; - fetchStats: (url: string) => Promise; - setChartContainerWidth: (width: number) => void; setRunsFilters: Dispatch>; setJobsFilters: Dispatch>; setTriggersFilters: Dispatch>; + // Cluster actions + fetchClusters: () => Promise; + fetchClusterStats: (params: FetchClusterStatsParams) => Promise; }; export interface EventsProps { @@ -57,6 +75,100 @@ export interface EventsProps { export type Store = SignalState & SignalActions; +// --- Selectors --- + +export const selectTree = (state: Store): ClusterNode[] => state.clusterTree; + +export const getCurrentNode = (state: Store, clusterId: string | null): ClusterNode | null => { + if (!clusterId) return null; + return findNodeById(state.clusterTree, clusterId); +}; + +export const getBreadcrumb = (state: Store, clusterId: string | null): ClusterNode[] => { + if (!clusterId) return []; + if (clusterId === UNCLUSTERED_ID) return [getUnclusteredVirtualCluster(state)]; + return buildPath(state.clusterTree, clusterId); +}; + +export const getVisibleClusters = (state: Store, clusterId: string | null): ClusterNode[] => { + const node = getCurrentNode(state, clusterId); + if (!node) return state.clusterTree; + return node.children; +}; + +export const getIsLeaf = (state: Store, clusterId: string | null): boolean => { + if (clusterId === UNCLUSTERED_ID) return true; + const node = getCurrentNode(state, clusterId); + return node !== null && node.children.length === 0; +}; + +export const getDrillDownDepth = (state: Store, clusterId: string | null): number => + getBreadcrumb(state, clusterId).length; + +export const selectUnclusteredCount = (state: Store): number => + Math.max(0, state.totalEventCount - state.clusteredEventCount); + +export const getFilterClusterIds = (state: Store, clusterId: string | null): string[] => { + const node = getCurrentNode(state, clusterId); + if (!node) return []; + return collectDescendantIds(node); +}; + +export const getUnclusteredVirtualCluster = (state: Store): ClusterNode => ({ + id: UNCLUSTERED_ID, + name: "Unclustered Events", + parentId: null, + level: 0, + numChildrenClusters: 0, + numEvents: selectUnclusteredCount(state), + createdAt: "", + updatedAt: "", + children: [], +}); + +export const getChartClusters = (state: Store, clusterId: string | null): ClusterNode[] => { + // Unclustered selected — show only unclustered + if (clusterId === UNCLUSTERED_ID) { + return [getUnclusteredVirtualCluster(state)]; + } + // Leaf selected — show only that leaf + const node = getCurrentNode(state, clusterId); + if (node && node.children.length === 0) { + return [node]; + } + // Parent or root — show children + unclustered at root + const visible = getVisibleClusters(state, clusterId); + const depth = getDrillDownDepth(state, clusterId); + const unclustered = selectUnclusteredCount(state); + const clusters: ClusterNode[] = [...visible]; + if (depth === 0 && unclustered > 0) { + clusters.push(getUnclusteredVirtualCluster(state)); + } + return clusters; +}; + +export const getFilteredCountByCluster = ( + state: Store, + clusterId: string | null, + hasTimeRange: boolean +): Map => { + const counts = new Map(); + if (hasTimeRange) { + for (const cluster of getVisibleClusters(state, clusterId)) { + counts.set(cluster.id, 0); + } + if (getDrillDownDepth(state, clusterId) === 0) { + counts.set(UNCLUSTERED_ID, 0); + } + } + for (const row of state.clusterStatsData) { + counts.set(row.cluster_id, (counts.get(row.cluster_id) ?? 0) + row.count); + } + return counts; +}; + +// --- Store --- + export type SignalStoreApi = ReturnType; export const createSignalStore = (initProps: EventsProps) => @@ -64,14 +176,20 @@ export const createSignalStore = (initProps: EventsProps) => totalCount: 0, traceId: initProps.traceId || null, spanId: initProps.spanId || null, + selectedEvent: null, runsFilters: [], jobsFilters: [], triggersFilters: [], lastEvent: initProps.lastEvent, initialTraceViewWidth: initProps.initialTraceViewWidth, - stats: undefined, - isLoadingStats: false, - chartContainerWidth: null, + // Cluster state + rawClusters: [], + clusterTree: [], + totalEventCount: 0, + clusteredEventCount: 0, + isClustersLoading: true, + clusterStatsData: [], + isClusterStatsLoading: false, signal: { ...initProps.signal, prompt: initProps.signal.prompt, @@ -80,7 +198,7 @@ export const createSignalStore = (initProps: EventsProps) => setSignal: (signal) => set({ signal }), setTraceId: (traceId) => set({ traceId }), setSpanId: (spanId) => set({ spanId }), - setChartContainerWidth: (width: number) => set({ chartContainerWidth: width }), + setSelectedEvent: (event) => set({ selectedEvent: event }), setRunsFilters: (filters) => set((state) => ({ runsFilters: typeof filters === "function" ? filters(state.runsFilters) : filters, @@ -113,28 +231,102 @@ export const createSignalStore = (initProps: EventsProps) => console.error("Error fetching events:", error); } }, - fetchStats: async (url: string) => { - set({ isLoadingStats: true }); + // Cluster actions + fetchClusters: async () => { + const { signal } = get(); + set({ isClustersLoading: true }); try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch stats: ${response.status} ${response.statusText}`); + const res = await fetch(`/api/projects/${signal.projectId}/signals/${signal.id}/events/clusters`); + if (!res.ok) { + const text = (await res.json()) as { error: string }; + throw new Error(text.error); } - const data = (await response.json()) as { items: EventsStatsDataPoint[] }; - set({ stats: data.items, isLoadingStats: false }); - } catch (error) { - console.error("Failed to fetch event stats:", error); - set({ isLoadingStats: false }); + const data = (await res.json()) as { + items: EventCluster[]; + totalEventCount: number; + clusteredEventCount: number; + }; + set({ + rawClusters: data.items, + clusterTree: buildTree(data.items), + totalEventCount: data.totalEventCount, + clusteredEventCount: data.clusteredEventCount, + }); + } catch (err) { + console.error("Failed to load clusters:", err); + } finally { + set({ isClustersLoading: false }); + } + }, + fetchClusterStats: async ({ pastHours, startDate, endDate, chartWidth, abortSignal }: FetchClusterStatsParams) => { + if (!pastHours && !startDate) { + set({ clusterStatsData: [], isClusterStatsLoading: false }); + return; + } + + const { signal } = get(); + + const width = chartWidth ?? 800; + const targetBars = getTargetBarsForWidth(width); + let range: { start: Date; end: Date } | null = null; + if (pastHours && pastHours !== "all") { + const hours = parseInt(pastHours); + if (!isNaN(hours)) { + range = { start: new Date(Date.now() - hours * 60 * 60 * 1000), end: new Date() }; + } + } else if (startDate && endDate) { + range = { start: new Date(startDate), end: new Date(endDate) }; + } + const interval = range + ? calculateOptimalInterval(range.start, range.end, targetBars) + : { value: 1, unit: "hour" as const }; + + set({ isClusterStatsLoading: true }); + + const urlParams = new URLSearchParams(); + if (pastHours) urlParams.set("pastHours", pastHours); + if (startDate) urlParams.set("startDate", startDate); + if (endDate) urlParams.set("endDate", endDate); + urlParams.set("intervalValue", interval.value.toString()); + urlParams.set("intervalUnit", interval.unit); + + try { + const res = await fetch( + `/api/projects/${signal.projectId}/signals/${signal.id}/events/clusters/stats?${urlParams.toString()}`, + { signal: abortSignal } + ); + if (!res.ok) throw new Error("Failed to fetch cluster event counts"); + const data = (await res.json()) as { + items: ClusterStatsDataPoint[]; + unclusteredCounts: Array<{ timestamp: string; count: number }>; + }; + + const unclusteredData: ClusterStatsDataPoint[] = data.unclusteredCounts.map((item) => ({ + cluster_id: UNCLUSTERED_ID, + timestamp: item.timestamp, + count: item.count, + })); + + set({ + clusterStatsData: [...data.items, ...unclusteredData], + isClusterStatsLoading: false, + }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + set({ isClusterStatsLoading: false }); + return; + } + set({ clusterStatsData: [], isClusterStatsLoading: false }); } }, })); export const SignalContext = createContext(null); -export const useSignalStoreContext = (selector: (state: Store) => T): T => { +export const useSignalStoreContext = (selector: (state: Store) => T, equalityFn?: (a: T, b: T) => boolean): T => { const store = useContext(SignalContext); if (!store) throw new Error("Missing SignalContext.Provider in the tree"); - return useStore(store, selector); + return useStore(store, selector, equalityFn); }; export const SignalStoreProvider = ({ children, ...props }: PropsWithChildren) => { diff --git a/frontend/components/signals/manage-signal-sheet.tsx b/frontend/components/signals/manage-signal-sheet.tsx index f9ddacc73..6ca547887 100644 --- a/frontend/components/signals/manage-signal-sheet.tsx +++ b/frontend/components/signals/manage-signal-sheet.tsx @@ -232,7 +232,12 @@ function SchemaFieldRow({ index, onRemove, canRemove }: { index: number; onRemov name={`schemaFields.${index}.description`} control={control} render={({ field }) => ( -