Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a185903
initial commit
kolbeyang Mar 7, 2026
efb07a2
feat: cluster chart fills height, scrollable list, WITH FILL for zero…
kolbeyang Mar 9, 2026
8ef021a
wip
kolbeyang Mar 9, 2026
3f8252e
feat: clusters table styling and animations
kolbeyang Mar 10, 2026
cfa0055
feat: two-layer breadcrumb animation and fix unclustered count
kolbeyang Mar 10, 2026
4bb6bba
wip
kolbeyang Mar 10, 2026
a3f5759
feat: split ClusterItem into own file and add delayed hover tooltip
kolbeyang Mar 10, 2026
42085a9
feat: event detail drawer, fix cluster queries to use query engine, r…
kolbeyang Mar 10, 2026
fddecfc
fix: remove debug logs, fix type safety, race conditions, and cleanup
kolbeyang Mar 10, 2026
c86ce46
refactor: claude refactor state into store
kolbeyang Mar 11, 2026
bdf6eb2
feat: replace selectedClusterId with nuqs URL state, fix chart/list b…
kolbeyang Mar 11, 2026
5075106
refactor: restructure manage-signal-sheet, add size variants to Input…
kolbeyang Mar 11, 2026
9a72aec
fix: update imports for manage-signal-sheet folder restructure and ad…
kolbeyang Mar 11, 2026
ca80b5b
fix: cache cluster tree in store, use AbortController for stale fetch…
kolbeyang Mar 11, 2026
c6c9a6c
feat: redesign signal detail header with tab dropdown popover
kolbeyang Mar 11, 2026
3a2948e
fix: reset isClusterStatsLoading on abort and early return paths
kolbeyang Mar 12, 2026
2cf4b1e
fix: parse ClickHouse count values and stabilize useEffect deps
kolbeyang Mar 12, 2026
fb49384
feat: replace signal popover with tooltip, simplify trace picker, reu…
kolbeyang Mar 12, 2026
0b7c8a6
refactor: extract signals list redesign, trace-picker, and manage-sig…
kolbeyang Mar 12, 2026
4395a6d
refactor: consolidate cluster stats into single getClusterEventCounts…
kolbeyang Mar 12, 2026
62fdf79
Add .agent-browser to .gitignore
kolbeyang Mar 13, 2026
3234a93
feat: add optional dataType field to filter schema for type-aware Cli…
kolbeyang Mar 13, 2026
eaceee0
refactor: remove redundant parseInt loops for ClickHouse count results
kolbeyang Mar 13, 2026
f58f68c
refactor: fix store selectors, type cluster integer fields as number
kolbeyang Mar 13, 2026
b51af8a
refactor: add shallow equality to array-returning store selectors
kolbeyang Mar 13, 2026
48338c1
Merge remote-tracking branch 'origin/dev' into feat/signals-visualiza…
kolbeyang Mar 13, 2026
19524e5
fix: resolve merge conflicts from dev (duplicate prompt field, remove…
kolbeyang Mar 13, 2026
badd838
fix: add shallow equality to array/map-returning store selectors
kolbeyang Mar 13, 2026
a64250d
feat: show created and updated timestamps on cluster item hover
kolbeyang Mar 13, 2026
1ec2f99
fix: guard against invalid cluster timestamps in hover tooltip
kolbeyang Mar 13, 2026
3dfe965
fix: apply WITH FILL per-column to prevent cross-cluster gap filling
kolbeyang Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
.idea
target/
.claude/
.agent-browser/
22 changes: 21 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
```

Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
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<Response> {
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) {
return Response.json({ error: prettifyError(parseResult.error) }, { status: 400 });
}

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 }
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this is the correct import because it's not exported from actions/sql/index.ts


export async function POST(req: NextRequest, props: { params: Promise<{ projectId: string }> }): Promise<NextResponse> {
const params = await props.params;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm generateSql actually isn't exported from @/lib/actions/sql so this seems correct


const GenerateSchema = z.object({
prompt: z.string().min(1, "Prompt is required"),
Expand Down
13 changes: 8 additions & 5 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -78,12 +79,14 @@ export default async function RootLayout({ children }: PropsWithChildren) {
<FeatureFlagsProvider flags={featureFlags}>
<PostHogProvider telemetryEnabled={featureFlags[Feature.POSTHOG]}>
<body className="flex flex-col h-full">
<div className="flex">
<div className="flex flex-col grow max-w-full min-h-screen">
<main className="z-10 flex flex-col grow">{children}</main>
<Toaster />
<NuqsAdapter>
<div className="flex">
<div className="flex flex-col grow max-w-full min-h-screen">
<main className="z-10 flex flex-col grow">{children}</main>
<Toaster />
</div>
</div>
</div>
</NuqsAdapter>
</body>
</PostHogProvider>
</FeatureFlagsProvider>
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/chart-builder/charts/utils.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) => {
Expand Down
8 changes: 5 additions & 3 deletions frontend/components/charts/time-series-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,7 +33,8 @@ export default function TimeSeriesChart<T extends TimeSeriesDataPoint>({
onZoom,
formatValue = numberFormatter.format,
showTotal = true,
}: Omit<TimeSeriesChartProps<T>, "isLoading" | "className">) {
className,
}: Omit<TimeSeriesChartProps<T>, "isLoading">) {
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
Expand Down Expand Up @@ -104,8 +106,8 @@ export default function TimeSeriesChart<T extends TimeSeriesDataPoint>({
);

return (
<div className="flex flex-col items-start">
<ChartContainer config={chartConfig} className="h-48 w-full">
<div className="flex flex-col items-start h-full">
<ChartContainer config={chartConfig} className={cn("h-48 w-full", className)}>
<BarChart
data={data}
margin={{ left: -8, top: 8 }}
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/client-timestamp-formatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { differenceInDays, differenceInHours, differenceInMinutes, differenceInS
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, formatTimestamp } from "@/lib/utils.ts";

function formatShortRelativeTime(date: Date): string {
export function formatShortRelativeTime(date: Date): string {
const now = new Date();
const seconds = differenceInSeconds(now, date);
const minutes = differenceInMinutes(now, date);
Expand Down
14 changes: 12 additions & 2 deletions frontend/components/common/advanced-search/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createContext, type PropsWithChildren, type RefObject, useContext, useM
import { createStore, type StoreApi, useStore } from "zustand";

import { dataTypeOperationsMap } from "@/components/ui/infinite-datatable/ui/datatable-filter/utils";
import { type Filter, FilterSchema } from "@/lib/actions/common/filters";
import { type Filter, type FilterDataType, FilterSchema } from "@/lib/actions/common/filters";
import { Operator } from "@/lib/actions/common/operators";

import {
Expand All @@ -23,6 +23,11 @@ import {
} from "./types";
import { getNextField, getPreviousField } from "./utils";

/** Map UI column dataType (which includes "enum") to the Filter schema's FilterDataType */
function toFilterDataType(uiDataType: ColumnFilter["dataType"]): FilterDataType {
return uiDataType === "enum" ? "string" : uiDataType;
}

interface AdvancedSearchStore {
// State
autocompleteData: AutocompleteCache;
Expand Down Expand Up @@ -154,6 +159,7 @@ const createAdvancedSearchStore = (
const newTag: FilterTag = {
id: `tag-${uniqueId()}`,
field,
dataType: toFilterDataType(columnFilter.dataType),
operator: defaultOperator,
value: defaultValue,
};
Expand Down Expand Up @@ -183,6 +189,7 @@ const createAdvancedSearchStore = (
const newTag: FilterTag = {
id: `tag-${uniqueId()}`,
field,
dataType: toFilterDataType(columnFilter.dataType),
operator,
value: tagValue,
};
Expand Down Expand Up @@ -242,8 +249,11 @@ const createAdvancedSearchStore = (
},

updateTagField: (tagId, field) => {
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)),
}));
},

Expand Down
5 changes: 4 additions & 1 deletion frontend/components/common/advanced-search/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +21,7 @@ export interface FilterTagRef {
export interface FilterTag {
id: string;
field: string;
dataType?: FilterDataType;
operator: Operator;
value: string | string[];
}
Expand All @@ -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,
Expand All @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/settings/alerts/manage-alert-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
{
Expand Down
91 changes: 91 additions & 0 deletions frontend/components/signal/clusters-section/cluster-breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center text-sm min-w-0 pl-1">
<button
className={`hover:underline shrink-0 ${!selectedClusterId ? "text-secondary-foreground" : "text-muted-foreground"}`}
onClick={() => onNavigateToBreadcrumb(-1)}
>
Event clusters
</button>

{/* Outer: handles levels appearing/disappearing */}
<AnimatePresence initial={false}>
{breadcrumb.map((node, index) => {
const isLast = index === breadcrumb.length - 1;
return (
<motion.div
key={index}
className={`relative min-w-0 flex-shrink overflow-hidden ${SLASH_CONTAINER_PL}`}
style={{ maskImage: "linear-gradient(to right, transparent, black 12px, black)" }}
{...levelTransition}
>
{/* Inner: handles swaps within this level (e.g. sibling leaf selection) */}
<AnimatePresence initial={false} mode="wait">
<motion.div key={node.id} className="flex">
<motion.span className="absolute left-[8px] top-0 text-muted-foreground" {...slashSlideIn}>
/
</motion.span>
<motion.button
className={`hover:underline truncate block max-w-full text-left ${
isLast ? "text-secondary-foreground" : "text-muted-foreground"
}`}
onClick={() => onNavigateToBreadcrumb(index)}
{...slideIn}
>
{node.name}
</motion.button>
</motion.div>
</AnimatePresence>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}
Loading