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 { ChevronLeft, ChevronRight, Download, Globe } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
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 TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, getWorkflowsSummaryApiV1WorkflowSummaryGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
||||||
import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse, WorkflowSummaryResponse } from '@/client/types.gen';
|
||||||
import { CallTypeCell } from '@/components/CallTypeCell';
|
import { CallTypeCell } from '@/components/CallTypeCell';
|
||||||
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
||||||
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
||||||
|
|
@ -24,14 +24,43 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { useUserConfig } from '@/context/UserConfigContext';
|
import { useUserConfig } from '@/context/UserConfigContext';
|
||||||
|
import { detailFromError } from '@/lib/apiError';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { usageFilterAttributes } from '@/lib/filterAttributes';
|
import { usageFilterAttributes } from '@/lib/filterAttributes';
|
||||||
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
|
||||||
import { ActiveFilter, DateRangeValue } from '@/types/filters';
|
import type { ActiveFilter, DateRangeValue, FilterAttribute, NumberFilterOption } from '@/types/filters';
|
||||||
|
|
||||||
// Get local timezone
|
// Get local timezone
|
||||||
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().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() {
|
export default function UsagePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -47,6 +76,12 @@ export default function UsagePage() {
|
||||||
});
|
});
|
||||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||||
const [isDownloadingReport, setIsDownloadingReport] = 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)
|
// Daily usage breakdown state (only for paid orgs)
|
||||||
const [dailyUsage, setDailyUsage] = useState<DailyUsageBreakdownResponse | null>(null);
|
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
|
// edits in the FilterBuilder; `appliedFilters` is what's actually been
|
||||||
// committed via Apply (and what drives fetching + the download button).
|
// committed via Apply (and what drives fetching + the download button).
|
||||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||||
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
|
return decodeFiltersFromURL(searchParams, availableUsageFilterAttributes);
|
||||||
});
|
});
|
||||||
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
|
const [appliedFilters, setAppliedFilters] = useState<ActiveFilter[]>(() => {
|
||||||
return decodeFiltersFromURL(searchParams, usageFilterAttributes);
|
return decodeFiltersFromURL(searchParams, availableUsageFilterAttributes);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Media preview dialog
|
// Media preview dialog
|
||||||
|
|
@ -93,7 +128,7 @@ export default function UsagePage() {
|
||||||
if (otherFilters.length > 0) {
|
if (otherFilters.length > 0) {
|
||||||
const filterData = otherFilters.map(filter => ({
|
const filterData = otherFilters.map(filter => ({
|
||||||
attribute: filter.attribute.id,
|
attribute: filter.attribute.id,
|
||||||
type: filter.attribute.type,
|
type: filter.attribute.type === 'numberSelect' ? 'number' : filter.attribute.type,
|
||||||
value: filter.value,
|
value: filter.value,
|
||||||
}));
|
}));
|
||||||
filterParam = JSON.stringify(filterData);
|
filterParam = JSON.stringify(filterData);
|
||||||
|
|
@ -150,6 +185,37 @@ export default function UsagePage() {
|
||||||
}
|
}
|
||||||
}, [auth.isAuthenticated, organizationPricing]);
|
}, [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 () => {
|
const fetchPreferences = useCallback(async () => {
|
||||||
if (!auth.isAuthenticated) return;
|
if (!auth.isAuthenticated) return;
|
||||||
|
|
||||||
|
|
@ -228,6 +294,33 @@ export default function UsagePage() {
|
||||||
fetchPreferences();
|
fetchPreferences();
|
||||||
}, [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
|
// Initial load - fetch when auth becomes available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
|
|
@ -420,7 +513,7 @@ export default function UsagePage() {
|
||||||
{/* Filter Builder */}
|
{/* Filter Builder */}
|
||||||
<div className="mb-6 space-y-3">
|
<div className="mb-6 space-y-3">
|
||||||
<FilterBuilder
|
<FilterBuilder
|
||||||
availableAttributes={usageFilterAttributes}
|
availableAttributes={availableUsageFilterAttributes}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
onFiltersChange={handleFiltersChange}
|
onFiltersChange={handleFiltersChange}
|
||||||
onApplyFilters={handleApplyFilters}
|
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 { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { DateRangeFilter } from "@/components/filters/DateRangeFilter";
|
import { DateRangeFilter } from "@/components/filters/DateRangeFilter";
|
||||||
import { MultiSelectFilter } from "@/components/filters/MultiSelectFilter";
|
import { MultiSelectFilter } from "@/components/filters/MultiSelectFilter";
|
||||||
import { NumberFilter } from "@/components/filters/NumberFilter";
|
import { NumberFilter } from "@/components/filters/NumberFilter";
|
||||||
import { NumberRangeFilter } from "@/components/filters/NumberRangeFilter";
|
import { NumberRangeFilter } from "@/components/filters/NumberRangeFilter";
|
||||||
|
import { NumberSelectFilter } from "@/components/filters/NumberSelectFilter";
|
||||||
import { RadioFilter } from "@/components/filters/RadioFilter";
|
import { RadioFilter } from "@/components/filters/RadioFilter";
|
||||||
import { TagInputFilter } from "@/components/filters/TagInputFilter";
|
import { TagInputFilter } from "@/components/filters/TagInputFilter";
|
||||||
import { TextFilter } from "@/components/filters/TextFilter";
|
import { TextFilter } from "@/components/filters/TextFilter";
|
||||||
|
|
@ -165,6 +166,8 @@ export const FilterBuilder: React.FC<FilterBuilderProps> = ({
|
||||||
case "number":
|
case "number":
|
||||||
case "numberRange":
|
case "numberRange":
|
||||||
return <Hash className="h-4 w-4" />;
|
return <Hash className="h-4 w-4" />;
|
||||||
|
case "numberSelect":
|
||||||
|
return <ListFilter className="h-4 w-4" />;
|
||||||
case "radio":
|
case "radio":
|
||||||
return <Radio className="h-4 w-4" />;
|
return <Radio className="h-4 w-4" />;
|
||||||
case "tags":
|
case "tags":
|
||||||
|
|
@ -188,6 +191,12 @@ export const FilterBuilder: React.FC<FilterBuilderProps> = ({
|
||||||
const value = filter.value as NumberValue;
|
const value = filter.value as NumberValue;
|
||||||
return value.value !== null ? value.value.toString() : "No value";
|
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":
|
case "numberRange":
|
||||||
return formatNumberRange(filter.value as NumberRangeValue, filter.attribute.config.unit);
|
return formatNumberRange(filter.value as NumberRangeValue, filter.attribute.config.unit);
|
||||||
case "radio": {
|
case "radio": {
|
||||||
|
|
@ -244,6 +253,18 @@ export const FilterBuilder: React.FC<FilterBuilderProps> = ({
|
||||||
step={filter.attribute.config.step}
|
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":
|
case "numberRange":
|
||||||
return (
|
return (
|
||||||
<NumberRangeFilter
|
<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":
|
case "multiSelect":
|
||||||
return { codes: [] };
|
return { codes: [] };
|
||||||
case "number":
|
case "number":
|
||||||
|
case "numberSelect":
|
||||||
return { value: null };
|
return { value: null };
|
||||||
case "numberRange":
|
case "numberRange":
|
||||||
return { min: null, max: null };
|
return { min: null, max: null };
|
||||||
|
|
@ -88,6 +89,13 @@ export const validateFilter = (filter: ActiveFilter): string | null => {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "numberSelect": {
|
||||||
|
const value = filter.value as NumberValue;
|
||||||
|
if (value.value === null) {
|
||||||
|
return "A value is required";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "radio": {
|
case "radio": {
|
||||||
const value = filter.value as RadioValue;
|
const value = filter.value as RadioValue;
|
||||||
if (!value.status) {
|
if (!value.status) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ export interface AttributeConfig {
|
||||||
|
|
||||||
// For number (single value)
|
// For number (single value)
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
numberSelectLabel?: string;
|
||||||
|
numberSelectOptions?: NumberFilterOption[];
|
||||||
|
numberSelectOptionsLoading?: boolean;
|
||||||
|
|
||||||
// For radio
|
// For radio
|
||||||
radioOptions?: { label: string; value: string }[];
|
radioOptions?: { label: string; value: string }[];
|
||||||
|
|
@ -30,12 +33,17 @@ export interface AttributeConfig {
|
||||||
|
|
||||||
export interface FilterAttribute {
|
export interface FilterAttribute {
|
||||||
id: string;
|
id: string;
|
||||||
type: "dateRange" | "multiSelect" | "numberRange" | "number" | "radio" | "tags" | "text";
|
type: "dateRange" | "multiSelect" | "numberRange" | "number" | "numberSelect" | "radio" | "tags" | "text";
|
||||||
label: string;
|
label: string;
|
||||||
field?: string; // Database field to filter on (optional, backend will resolve)
|
field?: string; // Database field to filter on (optional, backend will resolve)
|
||||||
config: AttributeConfig;
|
config: AttributeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NumberFilterOption {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Type-safe value types for each filter attribute
|
// Type-safe value types for each filter attribute
|
||||||
export interface DateRangeValue {
|
export interface DateRangeValue {
|
||||||
from: Date | null;
|
from: Date | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue