feat: allow filter by agents in agent runs

This commit is contained in:
Abhishek Kumar 2026-07-02 21:00:35 +05:30
parent 9966940624
commit cd0939d072
5 changed files with 214 additions and 10 deletions

View file

@ -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<NumberFilterOption[] | null>(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<DailyUsageBreakdownResponse | null>(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<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
return decodeFiltersFromURL(searchParams, availableUsageFilterAttributes);
});
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
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 */}
<div className="mb-6 space-y-3">
<FilterBuilder
availableAttributes={usageFilterAttributes}
availableAttributes={availableUsageFilterAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}

View file

@ -1,10 +1,11 @@
import { AlertCircle, Calendar, CheckSquare, Hash, Radio, RefreshCw, Tag, X } from "lucide-react";
import { AlertCircle, Calendar, CheckSquare, Hash, ListFilter, Radio, RefreshCw, Tag, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { DateRangeFilter } from "@/components/filters/DateRangeFilter";
import { MultiSelectFilter } from "@/components/filters/MultiSelectFilter";
import { NumberFilter } from "@/components/filters/NumberFilter";
import { NumberRangeFilter } from "@/components/filters/NumberRangeFilter";
import { NumberSelectFilter } from "@/components/filters/NumberSelectFilter";
import { RadioFilter } from "@/components/filters/RadioFilter";
import { TagInputFilter } from "@/components/filters/TagInputFilter";
import { TextFilter } from "@/components/filters/TextFilter";
@ -165,6 +166,8 @@ export const FilterBuilder: React.FC<FilterBuilderProps> = ({
case "number":
case "numberRange":
return <Hash className="h-4 w-4" />;
case "numberSelect":
return <ListFilter className="h-4 w-4" />;
case "radio":
return <Radio className="h-4 w-4" />;
case "tags":
@ -188,6 +191,12 @@ export const FilterBuilder: React.FC<FilterBuilderProps> = ({
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<FilterBuilderProps> = ({
step={filter.attribute.config.step}
/>
);
case "numberSelect":
return (
<NumberSelectFilter
value={filter.value as NumberValue}
onChange={(value) => 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 (
<NumberRangeFilter

View file

@ -0,0 +1,74 @@
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { NumberFilterOption, NumberValue } from "@/types/filters";
interface NumberSelectFilterProps {
value: NumberValue;
onChange: (value: NumberValue) => void;
error?: string;
label?: string;
placeholder?: string;
options: NumberFilterOption[];
isLoading?: boolean;
}
export const NumberSelectFilter: React.FC<NumberSelectFilterProps> = ({
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 (
<div className="space-y-2">
<div className="space-y-2">
<Label>{label}</Label>
<Select
value={value.value === null ? "" : value.value.toString()}
onValueChange={(selectedValue) => {
const numericValue = parseInt(selectedValue, 10);
onChange({ value: Number.isNaN(numericValue) ? null : numericValue });
}}
disabled={isLoading || options.length === 0}
>
<SelectTrigger className={error ? "border-red-500" : ""}>
<SelectValue placeholder={isLoading ? "Loading options..." : placeholder} />
</SelectTrigger>
<SelectContent>
{unavailableSelection && (
<SelectItem value={unavailableSelection.value.toString()} disabled>
{unavailableSelection.label}
</SelectItem>
)}
{options.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No options found
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
};

View file

@ -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) {

View file

@ -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;