diff --git a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx index 254e32438b73..2b99161f3af1 100644 --- a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx +++ b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx @@ -5,6 +5,12 @@ import type { ConsumptionDimension } from "@app/components/workspace/analytics/c import { DEFAULT_CONSUMPTION_DIMENSION } from "@app/components/workspace/analytics/consumption/consumptionDimensions"; import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; import { DEFAULT_CONSUMPTION_PERIOD } from "@app/lib/analytics/consumption_period"; +import { UsageFilterPanel } from "@app/components/workspace/analytics/UsageFilterPanel"; +import type { UsageFilter } from "@app/components/workspace/analytics/usageFilter"; +import { + USAGE_FILTER_MOCK_GROUPS, + USAGE_FILTER_MOCK_OPTIONS, +} from "@app/components/workspace/analytics/usageFilterMockData"; import { useFeatureFlags, useWorkspace } from "@app/lib/auth/AuthContext"; import { isNavigationLocked } from "@app/lib/navigation-lock"; import { BarChart01, cn, Page, SafeSuspense, safeLazy } from "@dust-tt/sparkle"; @@ -37,6 +43,7 @@ export function AnalyticsConsumptionPage() { const [dimension, setDimension] = useState( DEFAULT_CONSUMPTION_DIMENSION ); + const [filter, setFilter] = useState({}); if (!isEnabled) { return ( @@ -75,13 +82,24 @@ export function AnalyticsConsumptionPage() { />
- }> - - +
+
+ +
+ }> + + +
void; +} + +export function UsageFilterPanel({ + owner, + categoryOptions, + groups, + filter, + onFilterChange, +}: UsageFilterPanelProps) { + const [isOpen, setIsOpen] = useState(false); + // Selections are staged while the panel is open and only propagated + // when the user clicks Apply. + const { + draftFilter, + setDraftFilter, + clearAllCategories, + clearCategory, + toggleOption, + removeOption, + selectAllFiltered, + } = useUsageFilter(filter); + const [activeCategory, setActiveCategory] = + useState("agent"); + const [activeScope, setActiveScope] = useState( + USAGE_FILTER_SCOPES[0] + ); + const [activeTier, setActiveTier] = useState( + USAGE_MODEL_TIERS[0] + ); + const [searchText, setSearchText] = useState(""); + + const { members } = useSearchMembers({ + workspaceId: owner.sId, + searchTerm: activeCategory === "member" ? searchText : "", + pageIndex: 0, + pageSize: 100, + disabled: !isOpen || activeCategory !== "member", + }); + + const memberOptions = useMemo( + () => + members.map((m) => ({ + id: m.sId, + name: m.fullName, + kind: "member", + image: m.image, + })), + [members] + ); + + const resolvedCategoryOptions = useMemo<{ + [C in UsageFilterCategory]: UsageFilterOptionForCategory[]; + }>( + () => ({ + ...categoryOptions, + member: memberOptions, + }), + [categoryOptions, memberOptions] + ); + + const activeOptions = resolvedCategoryOptions[activeCategory]; + const filteredOptions = useMemo(() => { + const search = searchText.trim().toLowerCase(); + return activeOptions.filter((option) => { + if (option.kind === "agent" && option.scope !== activeScope) { + return false; + } + if (option.kind === "model" && option.tier !== activeTier) { + return false; + } + if (search && !option.name.toLowerCase().includes(search)) { + return false; + } + return true; + }); + }, [activeOptions, searchText, activeScope, activeTier]); + + const selectedIdsForActiveCategory = useMemo( + () => + new Set((draftFilter[activeCategory] ?? []).map((option) => option.id)), + [draftFilter, activeCategory] + ); + + const appliedSelectionCount = useMemo( + () => + USAGE_FILTER_CATEGORIES.reduce( + (count, category) => count + (filter[category]?.length ?? 0), + 0 + ), + [filter] + ); + + const categoriesWithSelection = useMemo( + () => + USAGE_FILTER_CATEGORIES.filter( + (category) => (draftFilter[category]?.length ?? 0) > 0 + ), + [draftFilter] + ); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (open) { + setDraftFilter(filter); + setSearchText(""); + } + }; + + const handleCategoryChange = (category: UsageFilterCategory) => { + setActiveCategory(category); + setSearchText(""); + }; + + const handleCancel = () => { + setIsOpen(false); + }; + + const handleApply = () => { + onFilterChange(draftFilter); + setIsOpen(false); + }; + + const activeCategorySelectionCount = draftFilter[activeCategory]?.length ?? 0; + + return ( + + +
+ + ); +} diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterCategoryNav.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterCategoryNav.tsx new file mode 100644 index 000000000000..4424ce903909 --- /dev/null +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterCategoryNav.tsx @@ -0,0 +1,60 @@ +import type { + UsageFilter, + UsageFilterCategory, +} from "@app/components/workspace/analytics/usageFilter"; +import { USAGE_FILTER_CATEGORY_LABEL } from "@app/components/workspace/analytics/usageFilter"; +import { + Counter, + NavigationList, + NavigationListItem, + NavigationListLabel, +} from "@dust-tt/sparkle"; + +interface UsageFilterCategoryNavProps { + categories: readonly UsageFilterCategory[]; + draftFilter: UsageFilter; + activeCategory: UsageFilterCategory; + onCategoryChange: (category: UsageFilterCategory) => void; +} + +export function UsageFilterCategoryNav({ + categories, + draftFilter, + activeCategory, + onCategoryChange, +}: UsageFilterCategoryNavProps) { + return ( +
+ + + {categories.map((category) => { + const selectionCount = draftFilter[category]?.length ?? 0; + return ( + + {USAGE_FILTER_CATEGORY_LABEL[category]} + + } + suffix={ + selectionCount > 0 ? ( + + ) : undefined + } + onClick={() => onCategoryChange(category)} + /> + ); + })} + +
+ ); +} diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterFooter.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterFooter.tsx new file mode 100644 index 000000000000..43aca8e96fd3 --- /dev/null +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterFooter.tsx @@ -0,0 +1,28 @@ +import { Button } from "@dust-tt/sparkle"; + +interface UsageFilterFooterProps { + onClearAll: () => void; + onCancel: () => void; + onApply: () => void; +} + +export function UsageFilterFooter({ + onClearAll, + onCancel, + onApply, +}: UsageFilterFooterProps) { + return ( +
+
+ + ); +} diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx new file mode 100644 index 000000000000..a3f836389806 --- /dev/null +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx @@ -0,0 +1,93 @@ +import type { UsageFilterGroup } from "@app/components/workspace/analytics/usageFilter"; +import { + addUsageFilterGroup, + removeUsageFilterGroup, +} from "@app/components/workspace/analytics/usageFilter"; +import { + Button, + Chip, + NavigationList, + NavigationListItem, + NavigationListLabel, + Plus, +} from "@dust-tt/sparkle"; +import { useMemo, useState } from "react"; + +interface UsageFilterMemberGroupsControlsProps { + groups: UsageFilterGroup[]; +} + +export function UsageFilterMemberGroupsControls({ + groups, +}: UsageFilterMemberGroupsControlsProps) { + const [isAddGroupOpen, setIsAddGroupOpen] = useState(false); + const [selectedGroups, setSelectedGroups] = useState([]); + + const availableGroups = useMemo( + () => + groups.filter( + (group) => !selectedGroups.some((selected) => selected.id === group.id) + ), + [groups, selectedGroups] + ); + + const handleAddGroup = (group: UsageFilterGroup) => { + setSelectedGroups((current) => addUsageFilterGroup(current, group)); + setIsAddGroupOpen(false); + }; + + const handleRemoveGroup = (id: string) => { + setSelectedGroups((current) => removeUsageFilterGroup(current, id)); + }; + + return ( + <> + setIsAddGroupOpen((current) => !current)} + /> + } + /> + {isAddGroupOpen && ( + + {availableGroups.length > 0 ? ( + availableGroups.map((group) => ( + + {group.name} + + } + onClick={() => handleAddGroup(group)} + /> + )) + ) : ( +
+ No more groups +
+ )} +
+ )} + {selectedGroups.length > 0 && ( +
+ {selectedGroups.map((group) => ( + handleRemoveGroup(group.id)} + /> + ))} +
+ )} + + ); +} diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterModelComplexityControls.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterModelComplexityControls.tsx new file mode 100644 index 000000000000..ceb7494e665c --- /dev/null +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterModelComplexityControls.tsx @@ -0,0 +1,204 @@ +import { getModelMakerLogo } from "@app/components/providers/types"; +import { useTheme } from "@app/components/sparkle/ThemeContext"; +import type { + UsageFilterModelOption, + UsageModelTier, +} from "@app/components/workspace/analytics/usageFilter"; +import { + USAGE_MODEL_TIER_LABEL, + USAGE_MODEL_TIERS, +} from "@app/components/workspace/analytics/usageFilter"; +import { + getModelMakerDisplayName, + MODEL_MAKER_IDS, +} from "@app/types/assistant/models/providers"; +import type { ModelMakerIdType } from "@app/types/assistant/models/types"; +import { + BarFull, + BarHalf, + BarLow, + Button, + Check, + ChevronDown, + ChevronRight, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSearchbar, + DropdownMenuTrigger, + Icon, + NavigationListLabel, +} from "@dust-tt/sparkle"; +import type { ComponentType } from "react"; +import { Fragment, useMemo, useState } from "react"; + +const MODEL_TIER_ICON: Record = { + fast: BarLow, + standard: BarHalf, + complex: BarFull, +}; + +interface UsageFilterModelComplexityControlsProps { + models: UsageFilterModelOption[]; + selectedModelIds: Set; + onToggleModel: (model: UsageFilterModelOption) => void; + activeTier: UsageModelTier; + onTierChange: (tier: UsageModelTier) => void; +} + +export function UsageFilterModelComplexityControls({ + models, + selectedModelIds, + onToggleModel, + activeTier, + onTierChange, +}: UsageFilterModelComplexityControlsProps) { + const { isDark } = useTheme(); + const [isMoreModelsOpen, setIsMoreModelsOpen] = useState(false); + const [moreModelsSearch, setMoreModelsSearch] = useState(""); + const [expandedModelLab, setExpandedModelLab] = + useState(null); + + const handleMoreModelsOpenChange = (open: boolean) => { + setIsMoreModelsOpen(open); + if (open) { + setMoreModelsSearch(""); + setExpandedModelLab(null); + } + }; + + const handleToggleExpandedModelLab = (lab: ModelMakerIdType) => { + setExpandedModelLab((current) => (current === lab ? null : lab)); + }; + + const moreModelsQuery = moreModelsSearch.trim().toLowerCase(); + const isSearchingMoreModels = moreModelsQuery !== ""; + + const moreModelsSearchResults = useMemo( + () => + isSearchingMoreModels + ? models.filter((model) => + model.name.toLowerCase().includes(moreModelsQuery) + ) + : [], + [models, isSearchingMoreModels, moreModelsQuery] + ); + + const moreModelsGroups = useMemo( + () => + MODEL_MAKER_IDS.flatMap((lab) => { + const labModels = models.filter((model) => model.lab === lab); + return labModels.length > 0 ? [{ lab, models: labModels }] : []; + }), + [models] + ); + + return ( + <> + + +