Skip to content

Commit 9a72aec

Browse files
kolbeyangclaude
andcommitted
fix: update imports for manage-signal-sheet folder restructure and add trace-picker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5075106 commit 9a72aec

File tree

7 files changed

+347
-5
lines changed

7 files changed

+347
-5
lines changed

CLAUDE.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ App Server │
9999
├──► PostgreSQL (5433) - main database [required]
100100
├──► ClickHouse (8123) - analytics/spans [required]
101101
├──► RabbitMQ (5672) - async processing [optional, has in-memory fallback]
102-
├──► Query Engine (8903) - SQL processing [required]
102+
# ├──► Query Engine (8903) - SQL processing [required]
103103
└──► Quickwit (7280/7281) - full-text search [optional]
104104
```
105105

@@ -127,3 +127,23 @@ The frontend uses Husky with lint-staged. Before commits:
127127
- Prettier formats staged files
128128
- ESLint fixes issues
129129
- TypeScript type-check runs
130+
131+
## Frontend Best Practices
132+
133+
### One component per file
134+
135+
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`).
136+
137+
Please do your best to keep components <150 lines.
138+
139+
### Bias towards complex logic and state in the Zustand store
140+
141+
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.
142+
143+
### Avoid syncing URL params with Zustand store antipattern
144+
145+
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.
146+
147+
### Use Zustand shallow to avoid unnecessary rerenders
148+
149+
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.

frontend/components/signal/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import SignalRunsTable from "@/components/signal/runs-table";
1414
import { useSignalStoreContext } from "@/components/signal/store.tsx";
1515
import TriggersTable from "@/components/signal/triggers-table";
1616
import { type EventNavigationItem, getEventsConfig } from "@/components/signal/utils";
17-
import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet.tsx";
17+
import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet";
1818
import TraceView from "@/components/traces/trace-view";
1919
import TraceViewNavigationProvider from "@/components/traces/trace-view/navigation-context";
2020
import { Button } from "@/components/ui/button";
@@ -25,7 +25,7 @@ import { setEventsTraceViewWidthCookie } from "@/lib/actions/traces/cookies";
2525
import { useResizableTraceViewWidth } from "@/lib/hooks/use-resizable-trace-view-width";
2626

2727
const ManageSignalSheet = dynamic(
28-
() => import("@/components/signals/manage-signal-sheet.tsx").then((mod) => mod.default),
28+
() => import("@/components/signals/manage-signal-sheet/index.tsx").then((mod) => mod.default),
2929
{ ssr: false }
3030
);
3131

frontend/components/signal/store.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createContext, type Dispatch, type PropsWithChildren, type SetStateActi
44
import { createStore, useStore } from "zustand";
55

66
import { calculateOptimalInterval, getTargetBarsForWidth } from "@/components/charts/time-series-chart/utils";
7-
import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet.tsx";
7+
import { type ManageSignalForm } from "@/components/signals/manage-signal-sheet";
88
import { jsonSchemaToSchemaFields } from "@/components/signals/utils";
99
import { type EventCluster, UNCLUSTERED_ID } from "@/lib/actions/clusters";
1010
import { type Filter } from "@/lib/actions/common/filters.ts";

frontend/components/signals/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type SignalTableMeta,
1111
type SparklineScale,
1212
} from "@/components/signals/columns.tsx";
13-
import ManageSignalSheet from "@/components/signals/manage-signal-sheet.tsx";
13+
import ManageSignalSheet from "@/components/signals/manage-signal-sheet";
1414
import { Button } from "@/components/ui/button";
1515
import DeleteSelectedRows from "@/components/ui/delete-selected-rows.tsx";
1616
import Header from "@/components/ui/header.tsx";
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { type ColumnDef } from "@tanstack/react-table";
2+
3+
import ClientTimestampFormatter from "@/components/client-timestamp-formatter";
4+
import SpanTypeIcon from "@/components/traces/span-type-icon";
5+
import { Skeleton } from "@/components/ui/skeleton.tsx";
6+
import { SpanType, type TraceRow } from "@/lib/traces/types";
7+
import { isStringDateOld } from "@/lib/traces/utils";
8+
import { cn } from "@/lib/utils";
9+
10+
const StatusCell = ({ value }: { value: unknown }) => (
11+
<div
12+
className={cn("min-h-6 w-1.5 rounded-[2.5px] bg-success-bright", {
13+
"bg-destructive-bright": value === "error",
14+
"": value === "info",
15+
"bg-yellow-400": value === "warning",
16+
})}
17+
/>
18+
);
19+
20+
const TopSpanCell = ({ row }: { row: TraceRow }) => {
21+
const topSpanId = row.topSpanId;
22+
const hasTopSpan = !!topSpanId && topSpanId !== "00000000-0000-0000-0000-000000000000";
23+
const isOld = isStringDateOld(row.endTime);
24+
const shouldAnimate = !hasTopSpan && !isOld;
25+
26+
return (
27+
<div className="cursor-pointer flex gap-2 items-center">
28+
<div className="flex items-center gap-2">
29+
{hasTopSpan ? (
30+
<SpanTypeIcon className="z-10" spanType={row.topSpanType ?? SpanType.DEFAULT} />
31+
) : (
32+
<SpanTypeIcon className={cn("z-10", shouldAnimate && "animate-pulse")} spanType={SpanType.DEFAULT} />
33+
)}
34+
</div>
35+
{hasTopSpan ? (
36+
<div title={row.topSpanName} className="text-sm truncate">
37+
{row.topSpanName}
38+
</div>
39+
) : row.topSpanName ? (
40+
<div
41+
title={row.topSpanName}
42+
className={cn("text-sm truncate text-muted-foreground", shouldAnimate && "animate-pulse")}
43+
>
44+
{row.topSpanName}
45+
</div>
46+
) : (
47+
<Skeleton className="w-14 h-4 text-secondary-foreground py-0.5 bg-secondary rounded-full text-sm" />
48+
)}
49+
</div>
50+
);
51+
};
52+
53+
const TokensCell = ({ row }: { row: TraceRow }) => (
54+
<div className="truncate">
55+
{`${row.inputTokens ?? "-"}`}
56+
{" → "}
57+
{`${row.outputTokens ?? "-"}`}
58+
{` (${row.totalTokens ?? "-"})`}
59+
</div>
60+
);
61+
62+
export const tracePickerColumns: ColumnDef<TraceRow, any>[] = [
63+
{
64+
cell: (row) => <StatusCell value={row.getValue()} />,
65+
accessorFn: (row) => (row.status === "error" ? "error" : row.analysis_status),
66+
header: () => <div />,
67+
id: "status",
68+
size: 40,
69+
},
70+
{
71+
accessorKey: "topSpanType",
72+
header: "Top level span",
73+
id: "top_span_type",
74+
cell: (row) => <TopSpanCell row={row.row.original} />,
75+
size: 150,
76+
},
77+
{
78+
accessorFn: (row) => row.startTime,
79+
header: "Time",
80+
cell: (row) => <ClientTimestampFormatter timestamp={String(row.getValue())} />,
81+
id: "start_time",
82+
size: 120,
83+
},
84+
{
85+
accessorFn: (row) => {
86+
const start = new Date(row.startTime);
87+
const end = new Date(row.endTime);
88+
if (isNaN(start.getTime()) || isNaN(end.getTime()) || end < start) return "-";
89+
return `${((end.getTime() - start.getTime()) / 1000).toFixed(2)}s`;
90+
},
91+
header: "Duration",
92+
id: "duration",
93+
size: 120,
94+
},
95+
{
96+
accessorFn: (row) => row.totalTokens ?? "-",
97+
header: "Tokens",
98+
id: "total_tokens",
99+
cell: (row) => <TokensCell row={row.row.original} />,
100+
size: 150,
101+
},
102+
];
103+
104+
export const tracePickerColumnOrder = ["status", "top_span_type", "start_time", "duration", "total_tokens"];
105+
106+
export const FETCH_SIZE = 30;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"use client";
2+
3+
import { DataTableStateProvider } from "@/components/ui/infinite-datatable/model/datatable-store";
4+
5+
import { FETCH_SIZE, tracePickerColumnOrder } from "./columns";
6+
import TracePickerContent, { type TracePickerProps } from "./trace-picker-content";
7+
8+
export type { TracePickerProps };
9+
10+
const TracePicker = (props: TracePickerProps) => (
11+
<DataTableStateProvider defaultColumnOrder={tracePickerColumnOrder} pageSize={FETCH_SIZE}>
12+
<TracePickerContent {...props} />
13+
</DataTableStateProvider>
14+
);
15+
16+
export default TracePicker;
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"use client";
2+
3+
import { type Row } from "@tanstack/react-table";
4+
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
5+
import { useCallback, useEffect, useMemo, useState } from "react";
6+
7+
import AdvancedSearch from "@/components/common/advanced-search";
8+
import { filters as traceFilters } from "@/components/traces/traces-table/columns";
9+
import DateRangeFilter from "@/components/ui/date-range-filter";
10+
import { InfiniteDataTable } from "@/components/ui/infinite-datatable";
11+
import { useInfiniteScroll } from "@/components/ui/infinite-datatable/hooks";
12+
import RefreshButton from "@/components/ui/infinite-datatable/ui/refresh-button";
13+
import type { Filter } from "@/lib/actions/common/filters";
14+
import type { TraceRow } from "@/lib/traces/types";
15+
16+
import { FETCH_SIZE, tracePickerColumns } from "./columns";
17+
18+
export interface TracePickerProps {
19+
onTraceSelect: (trace: TraceRow) => void;
20+
focusedTraceId?: string | null;
21+
excludeTraceId?: string;
22+
description?: string;
23+
fetchParams?: Record<string, string>;
24+
className?: string;
25+
mode?: "url" | "state";
26+
}
27+
28+
const TracePickerContent = ({
29+
onTraceSelect,
30+
focusedTraceId,
31+
excludeTraceId,
32+
description,
33+
fetchParams,
34+
className,
35+
mode = "state",
36+
}: TracePickerProps) => {
37+
const { projectId } = useParams<{ projectId: string }>();
38+
const searchParams = useSearchParams();
39+
const router = useRouter();
40+
const pathname = usePathname();
41+
42+
// State-mode local state
43+
const [stateFilters, setStateFilters] = useState<{ filters: Filter[]; search: string }>({ filters: [], search: "" });
44+
const [stateDateRange, setStateDateRange] = useState<{
45+
pastHours?: string;
46+
startDate?: string;
47+
endDate?: string;
48+
}>({ pastHours: "24" });
49+
50+
// URL-mode: set default pastHours=24 if no date params present
51+
useEffect(() => {
52+
if (mode !== "url") return;
53+
const hasPastHours = searchParams.has("pastHours");
54+
const hasStartDate = searchParams.has("startDate");
55+
const hasEndDate = searchParams.has("endDate");
56+
if (!hasPastHours && !hasStartDate && !hasEndDate) {
57+
const params = new URLSearchParams(searchParams.toString());
58+
params.set("pastHours", "24");
59+
router.replace(`${pathname}?${params.toString()}`);
60+
}
61+
}, [mode, searchParams, router, pathname]);
62+
63+
// Resolve effective values based on mode
64+
const effectiveFilter = useMemo(() => {
65+
if (mode === "url") return searchParams.getAll("filter");
66+
return stateFilters.filters.map((f) => JSON.stringify(f));
67+
}, [mode, searchParams, stateFilters.filters]);
68+
69+
const effectiveSearch = useMemo(() => {
70+
if (mode === "url") return searchParams.get("search") ?? "";
71+
return stateFilters.search;
72+
}, [mode, searchParams, stateFilters.search]);
73+
74+
const effectiveDateRange = useMemo(() => {
75+
if (mode === "url") {
76+
const startDate = searchParams.get("startDate") ?? undefined;
77+
const endDate = searchParams.get("endDate") ?? undefined;
78+
// If explicit start/end dates are set, ignore pastHours to avoid conflicts
79+
const pastHours = startDate && endDate ? undefined : (searchParams.get("pastHours") ?? undefined);
80+
return { pastHours, startDate, endDate };
81+
}
82+
return stateDateRange;
83+
}, [mode, searchParams, stateDateRange]);
84+
85+
const fetchTraces = useCallback(
86+
async (pageNumber: number) => {
87+
const urlParams = new URLSearchParams();
88+
89+
if (fetchParams) {
90+
for (const [key, value] of Object.entries(fetchParams)) {
91+
urlParams.set(key, value);
92+
}
93+
}
94+
95+
if (effectiveDateRange.pastHours) urlParams.set("pastHours", effectiveDateRange.pastHours);
96+
if (effectiveDateRange.startDate) urlParams.set("startDate", effectiveDateRange.startDate);
97+
if (effectiveDateRange.endDate) urlParams.set("endDate", effectiveDateRange.endDate);
98+
99+
effectiveFilter.forEach((filter) => {
100+
urlParams.append("filter", filter);
101+
});
102+
103+
if (excludeTraceId) {
104+
urlParams.append("filter", JSON.stringify({ column: "id", operator: "ne", value: excludeTraceId }));
105+
}
106+
107+
if (effectiveSearch.length > 0) {
108+
urlParams.set("search", effectiveSearch);
109+
}
110+
111+
urlParams.set("pageNumber", pageNumber.toString());
112+
urlParams.set("pageSize", FETCH_SIZE.toString());
113+
114+
const res = await fetch(`/api/projects/${projectId}/traces?${urlParams.toString()}`);
115+
if (!res.ok) {
116+
const text = (await res.json()) as { error: string };
117+
throw new Error(text.error);
118+
}
119+
120+
const data = (await res.json()) as { items: TraceRow[] };
121+
return { items: data.items ?? [], count: undefined };
122+
},
123+
[projectId, effectiveFilter, effectiveSearch, effectiveDateRange, fetchParams, excludeTraceId]
124+
);
125+
126+
const {
127+
data: traces,
128+
hasMore,
129+
isFetching,
130+
isLoading,
131+
fetchNextPage,
132+
refetch,
133+
} = useInfiniteScroll<TraceRow>({
134+
fetchFn: fetchTraces,
135+
enabled: !!(effectiveDateRange.pastHours || (effectiveDateRange.startDate && effectiveDateRange.endDate)),
136+
deps: [effectiveFilter, effectiveSearch, effectiveDateRange, projectId, fetchParams, excludeTraceId],
137+
});
138+
139+
const handleRowClick = useCallback(
140+
(row: Row<TraceRow>) => {
141+
onTraceSelect(row.original);
142+
},
143+
[onTraceSelect]
144+
);
145+
146+
return (
147+
<div className={className ?? "flex flex-col flex-1 gap-3 px-4 py-2 overflow-hidden"}>
148+
{description && <span className="text-secondary-foreground text-xs px-1">{description}</span>}
149+
150+
<InfiniteDataTable<TraceRow>
151+
className="w-full flex-1"
152+
columns={tracePickerColumns}
153+
data={traces}
154+
getRowId={(t) => t.id}
155+
onRowClick={handleRowClick}
156+
focusedRowId={focusedTraceId}
157+
hasMore={!effectiveSearch && hasMore}
158+
isFetching={isFetching}
159+
isLoading={isLoading}
160+
fetchNextPage={fetchNextPage}
161+
estimatedRowHeight={36}
162+
lockedColumns={["status"]}
163+
>
164+
<div className="flex gap-2 w-full items-center">
165+
{mode === "url" ? (
166+
<DateRangeFilter mode="url" />
167+
) : (
168+
<DateRangeFilter mode="state" value={stateDateRange} onChange={setStateDateRange} />
169+
)}
170+
<RefreshButton onClick={refetch} variant="outline" />
171+
</div>
172+
<div className="w-full px-px">
173+
{mode === "url" ? (
174+
<AdvancedSearch
175+
mode="url"
176+
filters={traceFilters}
177+
resource="traces"
178+
placeholder="Search traces..."
179+
className="w-full flex-1"
180+
options={{ disableHotKey: true }}
181+
/>
182+
) : (
183+
<AdvancedSearch
184+
mode="state"
185+
filters={traceFilters}
186+
resource="traces"
187+
value={stateFilters}
188+
onSubmit={(f, search) => setStateFilters({ filters: f, search })}
189+
placeholder="Search traces..."
190+
className="w-full flex-1"
191+
options={{ disableHotKey: true }}
192+
/>
193+
)}
194+
</div>
195+
</InfiniteDataTable>
196+
</div>
197+
);
198+
};
199+
200+
export default TracePickerContent;

0 commit comments

Comments
 (0)