From d42e6badd77df72166047958ae956b4d33ca3222 Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Thu, 6 Aug 2026 12:18:59 +0200 Subject: [PATCH 01/11] Add usage analytics filter panel component with preview route Introduces a filtering React component for usage analytics with mock data and a preview page/route to iterate on the UI in isolation. --- front-spa/src/app/routes.tsx | 2 + front-spa/src/app/routes/previewRoutes.tsx | 19 + .../workspace/UsageFilterPanelPreviewPage.tsx | 44 ++ .../workspace/analytics/UsageFilterPanel.tsx | 675 ++++++++++++++++++ .../workspace/analytics/usageFilter.ts | 149 ++++ .../analytics/usageFilterMockData.ts | 179 +++++ 6 files changed, 1068 insertions(+) create mode 100644 front-spa/src/app/routes/previewRoutes.tsx create mode 100644 front/components/pages/workspace/UsageFilterPanelPreviewPage.tsx create mode 100644 front/components/workspace/analytics/UsageFilterPanel.tsx create mode 100644 front/components/workspace/analytics/usageFilter.ts create mode 100644 front/components/workspace/analytics/usageFilterMockData.ts diff --git a/front-spa/src/app/routes.tsx b/front-spa/src/app/routes.tsx index f44da43edb68..871debb489ca 100644 --- a/front-spa/src/app/routes.tsx +++ b/front-spa/src/app/routes.tsx @@ -24,6 +24,7 @@ import { } from "@spa/app/routes/loginRoutes"; import { onboardingRoutes } from "@spa/app/routes/onboardingRoutes"; import { podsRoutes } from "@spa/app/routes/podsRoutes"; +import { previewRoutes } from "@spa/app/routes/previewRoutes"; import { spacesRedirectRoutes, spacesRoutes, @@ -65,6 +66,7 @@ export const routes: RouteObject[] = [ ...conversationRoutes, ...adminRoutes, ...labsRoutes, + ...previewRoutes, ...spacesRoutes, ...appsRoutes, ...builderContentRoutes, diff --git a/front-spa/src/app/routes/previewRoutes.tsx b/front-spa/src/app/routes/previewRoutes.tsx new file mode 100644 index 000000000000..661ee9888c13 --- /dev/null +++ b/front-spa/src/app/routes/previewRoutes.tsx @@ -0,0 +1,19 @@ +import { withSuspense } from "@spa/app/routes/withSuspense"; +import type { RouteObject } from "react-router-dom"; + +// Temporary routes for previewing in-progress components against mock data. +// Remove entries once the components they preview are wired for real. +const UsageFilterPanelPreviewPage = withSuspense( + () => + import( + "@dust-tt/front/components/pages/workspace/UsageFilterPanelPreviewPage" + ), + "UsageFilterPanelPreviewPage" +); + +export const previewRoutes: RouteObject[] = [ + { + path: "preview/usage-filter-panel", + element: , + }, +]; diff --git a/front/components/pages/workspace/UsageFilterPanelPreviewPage.tsx b/front/components/pages/workspace/UsageFilterPanelPreviewPage.tsx new file mode 100644 index 000000000000..57a05c604763 --- /dev/null +++ b/front/components/pages/workspace/UsageFilterPanelPreviewPage.tsx @@ -0,0 +1,44 @@ +import { + useSetContentWidth, + useSetPageTitle, +} from "@app/components/sparkle/AppLayoutContext"; +import { UsageFilterPanel } from "@app/components/workspace/analytics/UsageFilterPanel"; +import type { UsageFilter } from "@app/components/workspace/analytics/usageFilter"; +import { + USAGE_FILTER_MOCK_ENTITIES, + USAGE_FILTER_MOCK_GROUPS, +} from "@app/components/workspace/analytics/usageFilterMockData"; +import { useWorkspace } from "@app/lib/auth/AuthContext"; +import { Page } from "@dust-tt/sparkle"; +import { useState } from "react"; + +// Temporary preview page for the UsageFilterPanel component. Agents/models/ +// tools/skills/sources are still mock data; members are fetched live (the +// panel is rendered inside the real authenticated workspace shell). +export function UsageFilterPanelPreviewPage() { + const owner = useWorkspace(); + const [filter, setFilter] = useState({}); + + useSetContentWidth("centered"); + useSetPageTitle("Dust - Usage filter panel preview"); + + return ( + <> + + +
+ +
+            {JSON.stringify(filter, null, 2)}
+          
+
+
+ + ); +} diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx new file mode 100644 index 000000000000..36ebdb6cff25 --- /dev/null +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -0,0 +1,675 @@ +import { useTheme } from "@app/components/sparkle/ThemeContext"; +import type { + UsageFilter, + UsageFilterCategory, + UsageFilterEntity, + UsageFilterGroup, + UsageFilterScope, + UsageModelTier, +} from "@app/components/workspace/analytics/usageFilter"; +import { + addUsageFilterGroup, + clearUsageFilterCategory, + removeUsageFilterEntity, + removeUsageFilterGroup, + selectAllUsageFilterEntities, + toggleUsageFilterEntity, + USAGE_FILTER_CATEGORIES, + USAGE_FILTER_CATEGORY_LABEL, + USAGE_FILTER_SCOPE_LABEL, + USAGE_FILTER_SCOPES, + USAGE_MODEL_LAB_LABEL, + USAGE_MODEL_LABS, + USAGE_MODEL_TIER_LABEL, + USAGE_MODEL_TIERS, +} from "@app/components/workspace/analytics/usageFilter"; +import { getConnectorProviderLogoWithFallback } from "@app/lib/connector_providers_ui"; +import { useSearchMembers } from "@app/lib/swr/memberships"; +import type { LightWorkspaceType } from "@app/types/user"; +import { + Avatar, + BarFull, + BarHalf, + BarLow, + Button, + Checkbox, + ChevronDown, + ChevronRight, + Chip, + Collapsible, + CollapsibleContent, + Counter, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSearchbar, + DropdownMenuTrigger, + FilterFunnel01, + Icon, + Label, + NavigationList, + NavigationListItem, + NavigationListLabel, + Plus, + PopoverContent, + PopoverRoot, + PopoverTrigger, + SearchInput, + XClose, +} from "@dust-tt/sparkle"; +import type { ComponentType } from "react"; +import { useMemo, useState } from "react"; + +const MODEL_TIER_ICON: Record = { + fast: BarLow, + standard: BarHalf, + complex: BarFull, +}; + +interface UsageFilterPanelProps { + owner: LightWorkspaceType; + // Agents/models/tools/skills/sources are still mock data (see + // usageFilterMockData.ts — sources are fake connectors standing in for a + // real db call); members are fetched live below (useSearchMembers). + categoryEntities: Record< + "agent" | "model" | "tool" | "skill" | "source", + UsageFilterEntity[] + >; + groups: UsageFilterGroup[]; + filter: UsageFilter; + onFilterChange: (next: UsageFilter) => void; +} + +export function UsageFilterPanel({ + owner, + categoryEntities, + groups, + filter, + onFilterChange, +}: UsageFilterPanelProps) { + const { isDark } = useTheme(); + const [isOpen, setIsOpen] = useState(false); + // Selections are staged here while the panel is open and only propagated to + // `onFilterChange` when the user clicks Apply. Cancel (or dismissing the + // popover any other way) simply drops the draft: it gets re-synced from + // `filter` the next time the panel opens. + const [draftFilter, setDraftFilter] = useState(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 [isAddGroupOpen, setIsAddGroupOpen] = useState(false); + const [isMoreModelsOpen, setIsMoreModelsOpen] = useState(false); + const [moreModelsSearch, setMoreModelsSearch] = useState(""); + // Only used for the "member" category: narrows the displayed members down + // to those belonging to at least one of these groups. Not currently backed + // by real group-membership data (the search endpoint has no such concept + // yet), so it's UI-only until that exists. + const [selectedGroups, setSelectedGroups] = useState([]); + // Sections are open by default; a category lands here once the user + // collapses it. + const [collapsedCategories, setCollapsedCategories] = useState< + Set + >(new Set()); + + const { members } = useSearchMembers({ + workspaceId: owner.sId, + searchTerm: activeCategory === "member" ? searchText : "", + pageIndex: 0, + pageSize: 100, + disabled: !isOpen || activeCategory !== "member", + }); + + const memberEntities = useMemo( + () => members.map((m) => ({ id: m.sId, name: m.fullName, image: m.image })), + [members] + ); + + const resolvedCategoryEntities = useMemo< + Record + >( + () => ({ + ...categoryEntities, + member: memberEntities, + }), + [categoryEntities, memberEntities] + ); + + const activeEntities = resolvedCategoryEntities[activeCategory]; + const filteredEntities = useMemo(() => { + const search = searchText.trim().toLowerCase(); + return activeEntities.filter((entity) => { + if (activeCategory === "agent" && entity.scope !== activeScope) { + return false; + } + if (activeCategory === "model" && entity.tier !== activeTier) { + return false; + } + if (search && !entity.name.toLowerCase().includes(search)) { + return false; + } + return true; + }); + }, [activeEntities, searchText, activeScope, activeTier, activeCategory]); + + // "More models" browses every model grouped by maker, independent of the + // Fast/Standard/Complex quick filter above. + const moreModelsGroups = useMemo(() => { + const search = moreModelsSearch.trim().toLowerCase(); + return USAGE_MODEL_LABS.flatMap((lab) => { + const models = categoryEntities.model.filter( + (entity) => + entity.lab === lab && + (!search || entity.name.toLowerCase().includes(search)) + ); + return models.length > 0 ? [{ lab, models }] : []; + }); + }, [categoryEntities, moreModelsSearch]); + + const availableGroups = useMemo( + () => + groups.filter( + (group) => !selectedGroups.some((selected) => selected.id === group.id) + ), + [groups, selectedGroups] + ); + + const selectedIdsForActiveCategory = useMemo( + () => + new Set((draftFilter[activeCategory] ?? []).map((entity) => entity.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(""); + setIsAddGroupOpen(false); + setIsMoreModelsOpen(false); + } + }; + + const handleCategoryChange = (category: UsageFilterCategory) => { + setActiveCategory(category); + setSearchText(""); + setIsAddGroupOpen(false); + setIsMoreModelsOpen(false); + }; + + const handleAddGroup = (group: UsageFilterGroup) => { + setSelectedGroups((current) => addUsageFilterGroup(current, group)); + setIsAddGroupOpen(false); + }; + + const handleRemoveGroup = (id: string) => { + setSelectedGroups((current) => removeUsageFilterGroup(current, id)); + }; + + const handleClearActiveCategory = () => { + setDraftFilter(clearUsageFilterCategory(draftFilter, activeCategory)); + }; + + const handleClearCategory = (category: UsageFilterCategory) => { + setDraftFilter(clearUsageFilterCategory(draftFilter, category)); + }; + + const handleToggleCategoryOpen = (category: UsageFilterCategory) => { + setCollapsedCategories((current) => { + const next = new Set(current); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; + + const handleSelectAllFiltered = () => { + setDraftFilter( + selectAllUsageFilterEntities( + draftFilter, + activeCategory, + filteredEntities + ) + ); + }; + + const handleClearAll = () => { + setDraftFilter({}); + }; + + const handleCancel = () => { + setIsOpen(false); + }; + + const handleApply = () => { + onFilterChange(draftFilter); + setIsOpen(false); + }; + + const activeCategorySelectionCount = draftFilter[activeCategory]?.length ?? 0; + + const renderEntityIcon = ( + category: UsageFilterCategory, + entity: UsageFilterEntity + ) => { + if (category === "member") { + return ( + + ); + } + if (category === "source") { + const logo = getConnectorProviderLogoWithFallback({ + provider: entity.connectorProvider ?? null, + isDark, + }); + return ; + } + return null; + }; + + return ( + + +