Collapse dual-path filtering into single React Query

All 10 filter keys now go through the useQuery — the imperative
performSearch / debouncedSearch / backendFilteredLogs path is deleted.
Filter values are debounced via useDebouncedValue(300ms) before hitting
the query key so text inputs don't fire per-keystroke.

Removed: performSearch, debouncedSearch, backendFilteredLogs,
lastSearchTimestamp, hasBackendFilters, clientDerivedFilteredLogs,
the sort/page/time refetch useEffect, and the filteredLogs chooser memo.
This commit is contained in:
Ryan Crabbe 2026-04-16 09:22:37 -07:00
parent b5455d12c0
commit f307ad90bf
No known key found for this signature in database
3 changed files with 35 additions and 219 deletions

View file

@ -26,7 +26,6 @@ interface LogsTableToolbarProps {
isButtonLoading: boolean;
onRefetch: () => void;
filteredLogs: PaginatedResponse;
hasBackendFilters: boolean;
}
export function LogsTableToolbar({
@ -49,7 +48,6 @@ export function LogsTableToolbar({
isButtonLoading,
onRefetch,
filteredLogs,
hasBackendFilters,
}: LogsTableToolbarProps) {
const [quickSelectOpen, setQuickSelectOpen] = useState(false);
const quickSelectRef = useRef<HTMLDivElement>(null);
@ -227,7 +225,7 @@ export function LogsTableToolbar({
</div>
</div>
</div>
{isLiveTail && currentPage === 1 && !hasBackendFilters && (
{isLiveTail && currentPage === 1 && (
<div className="mb-4 px-4 py-2 bg-green-50 border border-greem-200 rounded-md flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>

View file

@ -108,7 +108,6 @@ export default function SpendLogsTable({
const {
logsQuery,
filteredLogs,
hasBackendFilters,
allTeams,
handleFilterChange,
handleFilterReset: handleFilterResetFromHook,
@ -291,7 +290,6 @@ export default function SpendLogsTable({
isButtonLoading={isButtonLoading}
onRefetch={() => logsQuery.refetch()}
filteredLogs={filteredLogs}
hasBackendFilters={hasBackendFilters}
/>
<DataTable
columns={createColumns({

View file

@ -1,14 +1,22 @@
import moment from "moment";
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useEffect, useState } from "react";
import { uiSpendLogsCall } from "../networking";
import { Team } from "../key_team_helpers/key_list";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
import { debounce } from "lodash";
import { defaultPageSize } from "../constants";
import { PaginatedResponse } from ".";
import type { LogsSortField } from "./columns";
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
export const FILTER_KEYS = {
TEAM_ID: "Team ID",
KEY_HASH: "Key Hash",
@ -75,88 +83,10 @@ export function useLogFilterLogic({
sortOrder?: "asc" | "desc";
currentPage?: number;
}) {
const [backendFilteredLogs, setBackendFilteredLogs] = useState<PaginatedResponse | null>(null);
const lastSearchTimestamp = useRef(0);
const performSearch = useCallback(
async (filters: LogFilterState, page = 1) => {
if (!accessToken) return;
console.log("Filters being sent to API:", filters);
const currentTimestamp = Date.now();
lastSearchTimestamp.current = currentTimestamp;
const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss");
const formattedEndTime = isCustomDate
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
try {
const response = await uiSpendLogsCall({
accessToken,
start_date: formattedStartTime,
end_date: formattedEndTime,
page,
page_size: pageSize,
params: {
api_key: filters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: filters[FILTER_KEYS.TEAM_ID] || undefined,
request_id: filters[FILTER_KEYS.REQUEST_ID] || undefined,
user_id: filters[FILTER_KEYS.USER_ID] || undefined,
end_user: filters[FILTER_KEYS.END_USER] || undefined,
status_filter: filters[FILTER_KEYS.STATUS] || undefined,
model_id: filters[FILTER_KEYS.MODEL] || undefined,
key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined,
error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined,
error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
sort_by: sortBy,
sort_order: sortOrder,
},
});
if (currentTimestamp === lastSearchTimestamp.current) {
setBackendFilteredLogs({
...response,
data: response.data ?? [],
});
}
} catch (error) {
console.error("Error searching users:", error);
setBackendFilteredLogs({
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
});
}
},
[accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder],
);
const debouncedSearch = useMemo(
() => debounce((filters: LogFilterState, page: number) => performSearch(filters, page), 300),
[performSearch],
);
useEffect(() => {
return () => debouncedSearch.cancel();
}, [debouncedSearch]);
// Determine when backend filters are active (server-side filtering)
const hasBackendFilters = useMemo(
() =>
!!(
filters[FILTER_KEYS.KEY_ALIAS] ||
filters[FILTER_KEYS.KEY_HASH] ||
filters[FILTER_KEYS.REQUEST_ID] ||
filters[FILTER_KEYS.USER_ID] ||
filters[FILTER_KEYS.END_USER] ||
filters[FILTER_KEYS.ERROR_CODE] ||
filters[FILTER_KEYS.ERROR_MESSAGE] ||
filters[FILTER_KEYS.MODEL]
),
[filters],
);
// Debounce filters so text inputs (Key Hash, Error Message) don't fire a
// request per keystroke. Dropdown selects get a 300ms delay too, which is
// imperceptible since the user just clicked an option.
const debouncedFilters = useDebouncedValue(filters, 300);
const logsQuery = useQuery<PaginatedResponse>({
queryKey: [
@ -166,11 +96,8 @@ export function useLogFilterLogic({
pageSize,
startTime,
endTime,
filters[FILTER_KEYS.TEAM_ID],
filters[FILTER_KEYS.KEY_HASH],
debouncedFilters,
filterByCurrentUser ? userID : null,
filters[FILTER_KEYS.STATUS],
filters[FILTER_KEYS.MODEL],
sortBy,
sortOrder,
],
@ -197,12 +124,16 @@ export function useLogFilterLogic({
page: currentPage,
page_size: pageSize,
params: {
api_key: filters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: filters[FILTER_KEYS.TEAM_ID] || undefined,
user_id: filterByCurrentUser ? userID ?? undefined : undefined,
end_user: filters[FILTER_KEYS.END_USER] || undefined,
status_filter: filters[FILTER_KEYS.STATUS] || undefined,
model_id: filters[FILTER_KEYS.MODEL] || undefined,
api_key: debouncedFilters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: debouncedFilters[FILTER_KEYS.TEAM_ID] || undefined,
request_id: debouncedFilters[FILTER_KEYS.REQUEST_ID] || undefined,
user_id: debouncedFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined),
end_user: debouncedFilters[FILTER_KEYS.END_USER] || undefined,
status_filter: debouncedFilters[FILTER_KEYS.STATUS] || undefined,
model_id: debouncedFilters[FILTER_KEYS.MODEL] || undefined,
key_alias: debouncedFilters[FILTER_KEYS.KEY_ALIAS] || undefined,
error_code: debouncedFilters[FILTER_KEYS.ERROR_CODE] || undefined,
error_message: debouncedFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
sort_by: sortBy,
sort_order: sortOrder,
},
@ -210,164 +141,53 @@ export function useLogFilterLogic({
return response;
},
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs" && !hasBackendFilters,
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false,
placeholderData: keepPreviousData,
refetchIntervalInBackground: true,
});
// Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query)
useEffect(() => {
if (hasBackendFilters && accessToken) {
// Cancel any pending debounced search to prevent it from overwriting this page's results
debouncedSearch.cancel();
performSearch(filters, currentPage);
}
// Intentionally omitted from deps:
// - `filters` / `debouncedSearch` / `performSearch`: filter changes are handled by
// handleFilterChange → debouncedSearch; adding them here would double-fetch on filter apply.
// - `hasBackendFilters` / `accessToken`: stable across sort/page/time changes; including them
// would cause spurious re-runs when the filter state first becomes active.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]);
const filteredLogs: PaginatedResponse = logsQuery.data ?? {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
// Compute client-side filtered logs directly from query data and filters
const spendLogsData = logsQuery.data;
const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => {
if (!spendLogsData) {
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
// If backend filters are on, don't perform client-side filtering here
if (hasBackendFilters) {
return spendLogsData;
}
let filteredData = [...spendLogsData.data];
if (filters[FILTER_KEYS.TEAM_ID]) {
filteredData = filteredData.filter((log) => log.team_id === filters[FILTER_KEYS.TEAM_ID]);
}
if (filters[FILTER_KEYS.STATUS]) {
filteredData = filteredData.filter((log) => {
if (filters[FILTER_KEYS.STATUS] === "success") {
return !log.status || log.status === "success";
}
return log.status === filters[FILTER_KEYS.STATUS];
});
}
if (filters[FILTER_KEYS.MODEL]) {
filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]);
}
if (filters[FILTER_KEYS.KEY_HASH]) {
filteredData = filteredData.filter((log) => log.api_key === filters[FILTER_KEYS.KEY_HASH]);
}
if (filters[FILTER_KEYS.END_USER]) {
filteredData = filteredData.filter((log) => log.end_user === filters[FILTER_KEYS.END_USER]);
}
if (filters[FILTER_KEYS.ERROR_CODE]) {
filteredData = filteredData.filter((log) => {
const metadata = log.metadata || {};
const errorInfo = metadata.error_information;
return errorInfo && errorInfo.error_code === filters[FILTER_KEYS.ERROR_CODE];
});
}
return {
data: filteredData,
total: spendLogsData.total,
page: spendLogsData.page,
page_size: spendLogsData.page_size,
total_pages: spendLogsData.total_pages,
};
}, [spendLogsData, filters, hasBackendFilters]);
// Choose which filtered logs to expose: backend result when active, otherwise client-derived
const filteredLogs: PaginatedResponse = useMemo(() => {
if (hasBackendFilters) {
// When backend filters are active, only show backend results.
// If search hasn't completed yet (null), show empty state rather than
// falling back to unfiltered logs — that caused filtered views to
// display mismatched data when the filter matched zero rows.
if (backendFilteredLogs !== null) {
return backendFilteredLogs;
}
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
return clientDerivedFilteredLogs;
}, [hasBackendFilters, backendFilteredLogs, clientDerivedFilteredLogs]);
// Fetch all teams and users for potential filter dropdowns (optional, can be adapted)
const { data: allTeams } = useQuery<Team[], Error>({
queryKey: ["allTeamsForLogFilters", accessToken],
queryFn: async () => {
if (!accessToken) return [];
// Use fetchAllTeams helper function for consistency and abstraction
// Assuming fetchAllTeams returns Team[] directly
const teamsData = await fetchAllTeams(accessToken);
return teamsData || []; // Ensure it returns an array
return teamsData || [];
},
enabled: !!accessToken,
});
// Update filters state
const handleFilterChange = (newFilters: Partial<LogFilterState>) => {
setFilters((prev) => {
const updatedFilters = { ...prev, ...newFilters };
// Ensure all keys in LogFilterState are present, defaulting to '' if not in newFilters
for (const key of Object.keys(defaultFilters) as Array<keyof LogFilterState>) {
if (!(key in updatedFilters)) {
updatedFilters[key] = defaultFilters[key];
}
}
// Only call debouncedSearch if filters have actually changed
if (JSON.stringify(updatedFilters) !== JSON.stringify(prev)) {
setCurrentPage(1);
setBackendFilteredLogs(null);
debouncedSearch(updatedFilters, 1);
}
return updatedFilters as LogFilterState;
});
};
const handleFilterReset = () => {
// Reset filters state
setFilters(defaultFilters);
// Clear backend filtered logs to ensure fresh render
setBackendFilteredLogs(null);
// Cancel any in-flight debounced search
debouncedSearch.cancel();
// Reset to first page so the unfiltered view starts at page 1
setCurrentPage(1);
};
return {
logsQuery,
filteredLogs,
hasBackendFilters,
allTeams,
handleFilterChange,
handleFilterReset,