From cd0939d0720737af1068e26c3b01ac14239a7a01 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Thu, 2 Jul 2026 21:00:35 +0530 Subject: [PATCH] feat: allow filter by agents in agent runs --- ui/src/app/usage/page.tsx | 109 ++++++++++++++++-- ui/src/components/filters/FilterBuilder.tsx | 23 +++- .../components/filters/NumberSelectFilter.tsx | 74 ++++++++++++ ui/src/lib/filters.ts | 8 ++ ui/src/types/filters.ts | 10 +- 5 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 ui/src/components/filters/NumberSelectFilter.tsx diff --git a/ui/src/app/usage/page.tsx b/ui/src/app/usage/page.tsx index a5d69839..a4ab8acd 100644 --- a/ui/src/app/usage/page.tsx +++ b/ui/src/app/usage/page.tsx @@ -2,12 +2,12 @@ import { ChevronLeft, ChevronRight, Download, Globe } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useEffect, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select'; import { toast } from 'sonner'; -import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen'; -import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen'; +import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, getWorkflowsSummaryApiV1WorkflowSummaryGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen'; +import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse, WorkflowSummaryResponse } from '@/client/types.gen'; import { CallTypeCell } from '@/components/CallTypeCell'; import { DailyUsageTable } from '@/components/DailyUsageTable'; import { FilterBuilder } from '@/components/filters/FilterBuilder'; @@ -24,14 +24,43 @@ import { TableRow, } from '@/components/ui/table'; import { useUserConfig } from '@/context/UserConfigContext'; +import { detailFromError } from '@/lib/apiError'; import { useAuth } from '@/lib/auth'; import { usageFilterAttributes } from '@/lib/filterAttributes'; import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters'; -import { ActiveFilter, DateRangeValue } from '@/types/filters'; +import type { ActiveFilter, DateRangeValue, FilterAttribute, NumberFilterOption } from '@/types/filters'; // Get local timezone const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; +const buildAgentFilterAttributes = ( + agentOptions: NumberFilterOption[] | null, + isLoadingAgentOptions: boolean +): FilterAttribute[] => { + return usageFilterAttributes.map(attribute => { + if (attribute.id !== 'workflowId') { + return attribute; + } + + return { + ...attribute, + label: 'Agent', + type: 'numberSelect', + config: { + ...attribute.config, + placeholder: 'Select an agent', + numberSelectLabel: 'Agent', + ...(agentOptions || isLoadingAgentOptions + ? { + numberSelectOptions: agentOptions ?? [], + numberSelectOptionsLoading: isLoadingAgentOptions, + } + : {}), + }, + } satisfies FilterAttribute; + }); +}; + export default function UsagePage() { const router = useRouter(); const searchParams = useSearchParams(); @@ -47,6 +76,12 @@ export default function UsagePage() { }); const [isExecutingFilters, setIsExecutingFilters] = useState(false); const [isDownloadingReport, setIsDownloadingReport] = useState(false); + const [agentFilterOptions, setAgentFilterOptions] = useState(null); + const [isLoadingAgentFilterOptions, setIsLoadingAgentFilterOptions] = useState(false); + const availableUsageFilterAttributes = useMemo( + () => buildAgentFilterAttributes(agentFilterOptions, isLoadingAgentFilterOptions), + [agentFilterOptions, isLoadingAgentFilterOptions] + ); // Daily usage breakdown state (only for paid orgs) const [dailyUsage, setDailyUsage] = useState(null); @@ -56,10 +91,10 @@ export default function UsagePage() { // edits in the FilterBuilder; `appliedFilters` is what's actually been // committed via Apply (and what drives fetching + the download button). const [activeFilters, setActiveFilters] = useState(() => { - return decodeFiltersFromURL(searchParams, usageFilterAttributes); + return decodeFiltersFromURL(searchParams, availableUsageFilterAttributes); }); const [appliedFilters, setAppliedFilters] = useState(() => { - return decodeFiltersFromURL(searchParams, usageFilterAttributes); + return decodeFiltersFromURL(searchParams, availableUsageFilterAttributes); }); // Media preview dialog @@ -93,7 +128,7 @@ export default function UsagePage() { if (otherFilters.length > 0) { const filterData = otherFilters.map(filter => ({ attribute: filter.attribute.id, - type: filter.attribute.type, + type: filter.attribute.type === 'numberSelect' ? 'number' : filter.attribute.type, value: filter.value, })); filterParam = JSON.stringify(filterData); @@ -150,6 +185,37 @@ export default function UsagePage() { } }, [auth.isAuthenticated, organizationPricing]); + const fetchAgentFilterOptions = useCallback(async () => { + if (!auth.isAuthenticated) return; + + setIsLoadingAgentFilterOptions(true); + try { + const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({ + query: { + status: 'active', + }, + }); + if (response.error) { + throw new Error(detailFromError(response.error, 'Failed to load agents')); + } + + const options = [...(response.data ?? [])] + .sort((a: WorkflowSummaryResponse, b: WorkflowSummaryResponse) => ( + a.name.localeCompare(b.name) || a.id - b.id + )) + .map((workflow: WorkflowSummaryResponse) => ({ + label: `${workflow.name || 'Untitled Agent'} (#${workflow.id})`, + value: workflow.id, + })); + setAgentFilterOptions(options); + } catch (error) { + console.error('Failed to fetch agent filter options:', error); + setAgentFilterOptions(null); + } finally { + setIsLoadingAgentFilterOptions(false); + } + }, [auth.isAuthenticated]); + const fetchPreferences = useCallback(async () => { if (!auth.isAuthenticated) return; @@ -228,6 +294,33 @@ export default function UsagePage() { fetchPreferences(); }, [fetchPreferences]); + useEffect(() => { + fetchAgentFilterOptions(); + }, [fetchAgentFilterOptions]); + + useEffect(() => { + setActiveFilters(currentFilters => { + let changed = false; + const nextFilters = currentFilters.map(filter => { + const updatedAttribute = availableUsageFilterAttributes.find( + attribute => attribute.id === filter.attribute.id + ); + + if (!updatedAttribute || updatedAttribute === filter.attribute) { + return filter; + } + + changed = true; + return { + ...filter, + attribute: updatedAttribute, + }; + }); + + return changed ? nextFilters : currentFilters; + }); + }, [availableUsageFilterAttributes]); + // Initial load - fetch when auth becomes available useEffect(() => { if (auth.isAuthenticated) { @@ -420,7 +513,7 @@ export default function UsagePage() { {/* Filter Builder */}
= ({ case "number": case "numberRange": return ; + case "numberSelect": + return ; case "radio": return ; case "tags": @@ -188,6 +191,12 @@ export const FilterBuilder: React.FC = ({ const value = filter.value as NumberValue; return value.value !== null ? value.value.toString() : "No value"; } + case "numberSelect": { + const value = filter.value as NumberValue; + if (value.value === null) return "No value"; + return filter.attribute.config.numberSelectOptions?.find(option => option.value === value.value)?.label + || value.value.toString(); + } case "numberRange": return formatNumberRange(filter.value as NumberRangeValue, filter.attribute.config.unit); case "radio": { @@ -244,6 +253,18 @@ export const FilterBuilder: React.FC = ({ step={filter.attribute.config.step} /> ); + case "numberSelect": + return ( + updateFilter(index, value)} + error={error} + label={filter.attribute.config.numberSelectLabel} + placeholder={filter.attribute.config.placeholder} + options={filter.attribute.config.numberSelectOptions || []} + isLoading={filter.attribute.config.numberSelectOptionsLoading} + /> + ); case "numberRange": return ( void; + error?: string; + label?: string; + placeholder?: string; + options: NumberFilterOption[]; + isLoading?: boolean; +} + +export const NumberSelectFilter: React.FC = ({ + value, + onChange, + error, + label = "Option", + placeholder = "Select an option", + options, + isLoading = false, +}) => { + const selectedOptionExists = options.some(option => option.value === value.value); + const unavailableSelection = + value.value !== null && !selectedOptionExists + ? { label: `Unavailable agent (#${value.value})`, value: value.value } + : null; + + return ( +
+
+ + +
+ {error &&

{error}

} +
+ ); +}; diff --git a/ui/src/lib/filters.ts b/ui/src/lib/filters.ts index 9ab5eb83..bd200398 100644 --- a/ui/src/lib/filters.ts +++ b/ui/src/lib/filters.ts @@ -8,6 +8,7 @@ export const getDefaultValue = (type: FilterAttribute["type"]): FilterValue => { case "multiSelect": return { codes: [] }; case "number": + case "numberSelect": return { value: null }; case "numberRange": return { min: null, max: null }; @@ -88,6 +89,13 @@ export const validateFilter = (filter: ActiveFilter): string | null => { } break; } + case "numberSelect": { + const value = filter.value as NumberValue; + if (value.value === null) { + return "A value is required"; + } + break; + } case "radio": { const value = filter.value as RadioValue; if (!value.status) { diff --git a/ui/src/types/filters.ts b/ui/src/types/filters.ts index 3be0b0d7..44dc5a8d 100644 --- a/ui/src/types/filters.ts +++ b/ui/src/types/filters.ts @@ -19,6 +19,9 @@ export interface AttributeConfig { // For number (single value) placeholder?: string; + numberSelectLabel?: string; + numberSelectOptions?: NumberFilterOption[]; + numberSelectOptionsLoading?: boolean; // For radio radioOptions?: { label: string; value: string }[]; @@ -30,12 +33,17 @@ export interface AttributeConfig { export interface FilterAttribute { id: string; - type: "dateRange" | "multiSelect" | "numberRange" | "number" | "radio" | "tags" | "text"; + type: "dateRange" | "multiSelect" | "numberRange" | "number" | "numberSelect" | "radio" | "tags" | "text"; label: string; field?: string; // Database field to filter on (optional, backend will resolve) config: AttributeConfig; } +export interface NumberFilterOption { + label: string; + value: number; +} + // Type-safe value types for each filter attribute export interface DateRangeValue { from: Date | null;