mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-04 10:52:17 +02:00
feat: allow filter by agents in agent runs
This commit is contained in:
parent
9966940624
commit
cd0939d072
5 changed files with 214 additions and 10 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
74
ui/src/components/filters/NumberSelectFilter.tsx
Normal file
74
ui/src/components/filters/NumberSelectFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue