feat(dashboard): interactive sankey highlighting and persistent filters
- Highlight full paths through a clicked node or link in the flow Sankey, dimming unrelated nodes/links instead of removing them - Disable VChart built-in emphasis to avoid crash, use custom highlight sets - Initialize models filter dialog from currently applied filters so manual time ranges are not overridden by preferences; auto-pick granularity by range - Lift user charts time range/granularity/limit to dashboard as controlled state
This commit is contained in:
+269
-33
@@ -16,9 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import type { EventParamsDefinition, IVChart } from '@visactor/vchart'
|
||||
import {
|
||||
Activity,
|
||||
ChevronRight,
|
||||
@@ -56,6 +64,8 @@ import {
|
||||
buildDashboardFlowData,
|
||||
buildFlowSankeySpec,
|
||||
buildQueryParams,
|
||||
flowNodeFilterFromSankeyDatum,
|
||||
flowSankeyDatumValue,
|
||||
getDefaultDays,
|
||||
getFlowStages,
|
||||
} from '@/features/dashboard/lib'
|
||||
@@ -66,7 +76,9 @@ import {
|
||||
} from '@/features/dashboard/lib/flow-selection'
|
||||
import type {
|
||||
DashboardFilters,
|
||||
FlowLinkSelection,
|
||||
FlowMetric,
|
||||
FlowNodeFilter,
|
||||
FlowNodeKind,
|
||||
FlowOverflowMode,
|
||||
FlowRole,
|
||||
@@ -78,6 +90,7 @@ import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { FlowNodeFilterControl } from './flow-node-filter'
|
||||
|
||||
interface FlowChartsProps {
|
||||
filters?: DashboardFilters
|
||||
@@ -89,6 +102,12 @@ const FLOW_METRIC_OPTIONS = [
|
||||
{ value: 'requests', labelKey: 'By requests', icon: Activity },
|
||||
] as const
|
||||
|
||||
const FLOW_METRIC_LABEL_KEYS: Record<FlowMetric, string> = {
|
||||
quota: 'Quota',
|
||||
tokens: 'Tokens',
|
||||
requests: 'Requests',
|
||||
}
|
||||
|
||||
const FLOW_TOP_LIMIT_OPTIONS = [10, 20, 50, 100] as const
|
||||
|
||||
const DEFAULT_FLOW_TOP_NODE_LIMIT = 50
|
||||
@@ -131,6 +150,15 @@ const FLOW_STAGE_META: Record<
|
||||
},
|
||||
}
|
||||
|
||||
const FLOW_STAGE_LABEL_KEYS: Record<FlowNodeKind, string> = {
|
||||
user: FLOW_STAGE_META.user.labelKey,
|
||||
node: FLOW_STAGE_META.node.labelKey,
|
||||
token: FLOW_STAGE_META.token.labelKey,
|
||||
group: FLOW_STAGE_META.group.labelKey,
|
||||
model: FLOW_STAGE_META.model.labelKey,
|
||||
channel: FLOW_STAGE_META.channel.labelKey,
|
||||
}
|
||||
|
||||
const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
|
||||
user: 'Other users',
|
||||
node: 'Other nodes',
|
||||
@@ -140,18 +168,113 @@ const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
|
||||
channel: 'Other channels',
|
||||
}
|
||||
|
||||
type FlowChartPointerEvent = EventParamsDefinition['pointerdown']
|
||||
|
||||
function chartRecordValue(
|
||||
value: unknown
|
||||
): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function looksLikeFlowDatum(value: unknown): boolean {
|
||||
const record = chartRecordValue(value)
|
||||
if (!record) return false
|
||||
return (
|
||||
(record.key !== undefined && record.kind !== undefined) ||
|
||||
(record.source !== undefined && record.target !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function chartGraphicDatum(value: unknown): unknown {
|
||||
const record = chartRecordValue(value)
|
||||
const context = chartRecordValue(record?.context)
|
||||
const data = context?.data
|
||||
if (Array.isArray(data)) return data[0]
|
||||
return data
|
||||
}
|
||||
|
||||
function flowChartEventDatum(event: FlowChartPointerEvent): unknown {
|
||||
const record = chartRecordValue(event)
|
||||
if (!record) return undefined
|
||||
|
||||
if (record.datum !== undefined && record.datum !== null) return record.datum
|
||||
|
||||
const itemRecord = chartRecordValue(record.item)
|
||||
if (itemRecord?.datum !== undefined && itemRecord.datum !== null) {
|
||||
return itemRecord.datum
|
||||
}
|
||||
|
||||
const graphicDatum = chartGraphicDatum(record.item)
|
||||
if (graphicDatum !== undefined && graphicDatum !== null) return graphicDatum
|
||||
|
||||
const itemData = itemRecord?.data
|
||||
if (Array.isArray(itemData)) return itemData[0]
|
||||
if (itemData !== undefined && itemData !== null) return itemData
|
||||
|
||||
return looksLikeFlowDatum(record) ? record : undefined
|
||||
}
|
||||
|
||||
function flowNodeFilterKey(filter: FlowNodeFilter): string {
|
||||
return `${filter.kind}\u0000${filter.id}`
|
||||
}
|
||||
|
||||
function isSameFlowNodeFilter(
|
||||
a: FlowNodeFilter | undefined,
|
||||
b: FlowNodeFilter
|
||||
): boolean {
|
||||
return Boolean(a && a.kind === b.kind && a.id === b.id)
|
||||
}
|
||||
|
||||
function toggleSelectedValue(values: string[], value: string): string[] {
|
||||
return values.includes(value)
|
||||
? values.filter((item) => item !== value)
|
||||
: [...values, value]
|
||||
}
|
||||
|
||||
function toggleSelectedNodeFilter(
|
||||
filters: FlowNodeFilter[],
|
||||
filter: FlowNodeFilter
|
||||
): FlowNodeFilter[] {
|
||||
const key = flowNodeFilterKey(filter)
|
||||
const hasFilter = filters.some((item) => flowNodeFilterKey(item) === key)
|
||||
return hasFilter
|
||||
? filters.filter((item) => flowNodeFilterKey(item) !== key)
|
||||
: [...filters, filter]
|
||||
}
|
||||
|
||||
function formatFlowMetricNumber(value: number): string {
|
||||
return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(
|
||||
value
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowCharts(props: FlowChartsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
const chartInstanceRef = useRef<IVChart | null>(null)
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const isRoot = Boolean(user?.role && user.role >= ROLE.SUPER_ADMIN)
|
||||
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
|
||||
const flowRole: FlowRole = isRoot ? 'root' : isAdmin ? 'admin' : 'user'
|
||||
let flowRole: FlowRole = 'user'
|
||||
if (isRoot) {
|
||||
flowRole = 'root'
|
||||
} else if (isAdmin) {
|
||||
flowRole = 'admin'
|
||||
}
|
||||
const [metric, setMetric] = useState<FlowMetric>('quota')
|
||||
const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT)
|
||||
const [overflowMode, setOverflowMode] =
|
||||
useState<FlowOverflowMode>('aggregate')
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||
const [selectedNodes, setSelectedNodes] = useState<FlowNodeFilter[]>([])
|
||||
const [activeFlowNode, setActiveFlowNode] = useState<
|
||||
FlowNodeFilter | undefined
|
||||
>()
|
||||
const [activeFlowLink, setActiveFlowLink] = useState<
|
||||
FlowLinkSelection | undefined
|
||||
>()
|
||||
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
|
||||
|
||||
const stages = useMemo(() => getFlowStages(flowRole), [flowRole])
|
||||
@@ -159,6 +282,19 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
() => stages.filter((stage) => !hiddenStages.includes(stage)),
|
||||
[stages, hiddenStages]
|
||||
)
|
||||
useEffect(() => {
|
||||
const visible = new Set(visibleStages)
|
||||
setSelectedNodes((prev) => {
|
||||
const next = prev.filter((filter) => visible.has(filter.kind))
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
setActiveFlowNode((prev) =>
|
||||
prev && visible.has(prev.kind) ? prev : undefined
|
||||
)
|
||||
// The graph reshapes when columns are toggled, so any highlighted edge may
|
||||
// no longer exist. Drop the link selection rather than leave it dangling.
|
||||
setActiveFlowLink(undefined)
|
||||
}, [visibleStages])
|
||||
const toggleStage = (stage: FlowNodeKind) => {
|
||||
setHiddenStages((prev) => {
|
||||
const hidden = new Set(prev)
|
||||
@@ -209,6 +345,9 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, {
|
||||
role: flowRole,
|
||||
selectedUsers,
|
||||
selectedNodes,
|
||||
activeNode: activeFlowNode,
|
||||
activeLink: activeFlowLink,
|
||||
visibleStages,
|
||||
topNodeLimit,
|
||||
overflowMode,
|
||||
@@ -221,6 +360,9 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
isLoading,
|
||||
metric,
|
||||
overflowMode,
|
||||
activeFlowNode,
|
||||
activeFlowLink,
|
||||
selectedNodes,
|
||||
selectedUsers,
|
||||
topNodeLimit,
|
||||
visibleStages,
|
||||
@@ -235,6 +377,75 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
})),
|
||||
[flowData.filterOptions.users]
|
||||
)
|
||||
const nodeFilterStages = useMemo(
|
||||
() => visibleStages.filter((stage) => stage !== 'user'),
|
||||
[visibleStages]
|
||||
)
|
||||
const nodeFilterOptions = useMemo(
|
||||
() =>
|
||||
flowData.filterOptions.nodes.filter((option) => option.kind !== 'user'),
|
||||
[flowData.filterOptions.nodes]
|
||||
)
|
||||
const metricLabel = t(FLOW_METRIC_LABEL_KEYS[metric])
|
||||
const formatNodeMetricValue = useCallback(
|
||||
(value: number) =>
|
||||
metric === 'quota' ? formatQuota(value) : formatFlowMetricNumber(value),
|
||||
[metric]
|
||||
)
|
||||
// Explicit filters (the chips/dropdown control) narrow the rows that feed the
|
||||
// chart. They are intentionally independent from the click-to-highlight state
|
||||
// below so selecting a filter never dims a node, it removes unrelated rows.
|
||||
const toggleFlowNodeFilter = useCallback((filter: FlowNodeFilter) => {
|
||||
if (filter.kind === 'user') {
|
||||
setSelectedUsers((prev) => toggleSelectedValue(prev, filter.id))
|
||||
return
|
||||
}
|
||||
setSelectedNodes((prev) => toggleSelectedNodeFilter(prev, filter))
|
||||
}, [])
|
||||
const removeFlowNodeFilter = useCallback((filter: FlowNodeFilter) => {
|
||||
if (filter.kind === 'user') {
|
||||
setSelectedUsers((prev) => prev.filter((item) => item !== filter.id))
|
||||
return
|
||||
}
|
||||
const key = flowNodeFilterKey(filter)
|
||||
setSelectedNodes((prev) =>
|
||||
prev.filter((item) => flowNodeFilterKey(item) !== key)
|
||||
)
|
||||
}, [])
|
||||
const clearFlowNodeFilters = useCallback(() => {
|
||||
setSelectedNodes([])
|
||||
}, [])
|
||||
// Clicking a node only drives the highlight: keep every node/link on screen
|
||||
// but emphasize the full paths through the clicked node and dim the rest.
|
||||
// Clicking the active node again, or clicking empty space, clears it.
|
||||
const handleChartPointerDown = useCallback((event: FlowChartPointerEvent) => {
|
||||
const datum = flowChartEventDatum(event)
|
||||
const filter = flowNodeFilterFromSankeyDatum(datum)
|
||||
if (filter) {
|
||||
setActiveFlowLink(undefined)
|
||||
setActiveFlowNode((prev) =>
|
||||
isSameFlowNodeFilter(prev, filter) ? undefined : filter
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const source = flowSankeyDatumValue(datum, 'source')
|
||||
const target = flowSankeyDatumValue(datum, 'target')
|
||||
if (typeof source === 'string' && typeof target === 'string') {
|
||||
setActiveFlowNode(undefined)
|
||||
setActiveFlowLink((prev) =>
|
||||
prev && prev.source === source && prev.target === target
|
||||
? undefined
|
||||
: { source, target }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setActiveFlowNode(undefined)
|
||||
setActiveFlowLink(undefined)
|
||||
chartInstanceRef.current?.clearState('selected')
|
||||
chartInstanceRef.current?.clearState('blur')
|
||||
}, [])
|
||||
const chartTitle = t('Flow')
|
||||
const flowSpec = useMemo(
|
||||
() =>
|
||||
@@ -252,6 +463,9 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
topNodeLimit,
|
||||
overflowMode,
|
||||
flowRole,
|
||||
activeFlowNode ? flowNodeFilterKey(activeFlowNode) : '',
|
||||
activeFlowLink ? `${activeFlowLink.source}\u0000${activeFlowLink.target}` : '',
|
||||
selectedNodes.map(flowNodeFilterKey).join(','),
|
||||
selectedUsers.join(','),
|
||||
visibleStages.join(','),
|
||||
flowRows?.length ?? 0,
|
||||
@@ -267,6 +481,46 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
flowError instanceof Error
|
||||
? flowError.message
|
||||
: t('Please try again later.')
|
||||
let chartContent = (
|
||||
<VChart
|
||||
key={`flow-${chartKey}`}
|
||||
spec={{
|
||||
...flowSpec,
|
||||
theme: chartTheme,
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
onReady={(instance: IVChart) => {
|
||||
chartInstanceRef.current = instance
|
||||
}}
|
||||
onPointerDown={handleChartPointerDown}
|
||||
/>
|
||||
)
|
||||
if (displayState === 'loading') {
|
||||
chartContent = <Skeleton className='h-full w-full' />
|
||||
} else if (displayState === 'error') {
|
||||
chartContent = (
|
||||
<div className='flex h-full items-center justify-center p-4'>
|
||||
<Alert variant='destructive' className='max-w-md'>
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('Failed to load')}</AlertTitle>
|
||||
<AlertDescription>{flowErrorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
} else if (displayState === 'empty') {
|
||||
chartContent = (
|
||||
<Empty className='h-full border-0 py-12'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Route />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
|
||||
<EmptyDescription>{t('No data available')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
@@ -366,6 +620,18 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<FlowNodeFilterControl
|
||||
stages={nodeFilterStages}
|
||||
stageLabels={FLOW_STAGE_LABEL_KEYS}
|
||||
metricLabel={metricLabel}
|
||||
formatMetricValue={formatNodeMetricValue}
|
||||
options={nodeFilterOptions}
|
||||
selectedNodes={selectedNodes}
|
||||
onToggleNode={toggleFlowNodeFilter}
|
||||
onRemoveNode={removeFlowNodeFilter}
|
||||
onClearNodes={clearFlowNodeFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 items-center gap-2 xl:justify-end'>
|
||||
@@ -447,37 +713,7 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className='h-[560px] p-1.5 sm:h-[680px] sm:p-2 2xl:h-[760px]'>
|
||||
{displayState === 'loading' ? (
|
||||
<Skeleton className='h-full w-full' />
|
||||
) : displayState === 'error' ? (
|
||||
<div className='flex h-full items-center justify-center p-4'>
|
||||
<Alert variant='destructive' className='max-w-md'>
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('Failed to load')}</AlertTitle>
|
||||
<AlertDescription>{flowErrorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : displayState === 'empty' ? (
|
||||
<Empty className='h-full border-0 py-12'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Route />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
|
||||
<EmptyDescription>{t('No data available')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<VChart
|
||||
key={`flow-${chartKey}`}
|
||||
spec={{
|
||||
...flowSpec,
|
||||
theme: chartTheme,
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
{chartContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { Filter, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import type {
|
||||
FlowNodeFilter,
|
||||
FlowNodeFilterOption,
|
||||
FlowNodeKind,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
interface FlowNodeFilterControlProps {
|
||||
stages: FlowNodeKind[]
|
||||
stageLabels: Record<FlowNodeKind, string>
|
||||
metricLabel: string
|
||||
formatMetricValue: (value: number) => string
|
||||
options: FlowNodeFilterOption[]
|
||||
selectedNodes: FlowNodeFilter[]
|
||||
onToggleNode: (filter: FlowNodeFilter) => void
|
||||
onRemoveNode: (filter: FlowNodeFilter) => void
|
||||
onClearNodes: () => void
|
||||
}
|
||||
|
||||
function flowNodeFilterKey(filter: FlowNodeFilter): string {
|
||||
return `${filter.kind}\u0000${filter.id}`
|
||||
}
|
||||
|
||||
export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
|
||||
const { t } = useTranslation()
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(props.selectedNodes.map(flowNodeFilterKey)),
|
||||
[props.selectedNodes]
|
||||
)
|
||||
const optionLabels = useMemo(() => {
|
||||
const labels = new Map<string, FlowNodeFilterOption>()
|
||||
for (const option of props.options) {
|
||||
labels.set(
|
||||
flowNodeFilterKey({ kind: option.kind, id: option.value }),
|
||||
option
|
||||
)
|
||||
}
|
||||
return labels
|
||||
}, [props.options])
|
||||
const optionsByStage = useMemo(
|
||||
() =>
|
||||
props.stages
|
||||
.map((stage) => ({
|
||||
stage,
|
||||
options: props.options.filter((option) => option.kind === stage),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0),
|
||||
[props.options, props.stages]
|
||||
)
|
||||
const selectedOptions = props.selectedNodes.map((filter) => {
|
||||
const option = optionLabels.get(flowNodeFilterKey(filter))
|
||||
return {
|
||||
...filter,
|
||||
label: option?.label ?? filter.id,
|
||||
}
|
||||
})
|
||||
const selectedCount = selectedOptions.length
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Node filters')}
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
aria-label={t('Filter by node')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Filter data-icon='inline-start' aria-hidden='true' />
|
||||
{selectedCount > 0 ? t('Selected nodes') : t('All nodes')}
|
||||
{selectedCount > 0 && (
|
||||
<Badge variant='secondary' className='rounded-sm px-1'>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-[min(28rem,calc(100vw-2rem))] p-0'
|
||||
align='start'
|
||||
>
|
||||
<PopoverHeader className='px-3 pt-3'>
|
||||
<PopoverTitle>{t('Node filters')}</PopoverTitle>
|
||||
<PopoverDescription>
|
||||
{t('Value metric')}: {props.metricLabel}
|
||||
</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<Command>
|
||||
<CommandInput placeholder={t('Filter by node')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('No nodes')}</CommandEmpty>
|
||||
{optionsByStage.map((group) => {
|
||||
const stageLabel = t(props.stageLabels[group.stage])
|
||||
return (
|
||||
<CommandGroup key={group.stage} heading={stageLabel}>
|
||||
{group.options.map((option) => {
|
||||
const key = flowNodeFilterKey({
|
||||
kind: option.kind,
|
||||
id: option.value,
|
||||
})
|
||||
const metricValueLabel = props.formatMetricValue(
|
||||
option.valueRaw
|
||||
)
|
||||
return (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={`${stageLabel} ${option.label} ${props.metricLabel} ${metricValueLabel}`}
|
||||
data-checked={selectedKeys.has(key)}
|
||||
onSelect={() =>
|
||||
props.onToggleNode({
|
||||
kind: option.kind,
|
||||
id: option.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span
|
||||
className='size-2.5 shrink-0 rounded-full'
|
||||
style={{ backgroundColor: option.color }}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{option.label}
|
||||
</span>
|
||||
<span className='text-muted-foreground flex shrink-0 items-center gap-1 text-xs'>
|
||||
<span>{props.metricLabel}</span>
|
||||
<span className='font-mono'>
|
||||
{metricValueLabel}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={props.onClearNodes}
|
||||
className='justify-center text-center'
|
||||
>
|
||||
{t('Clear node filters')}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selectedOptions.map((option) => (
|
||||
<Badge
|
||||
key={flowNodeFilterKey(option)}
|
||||
variant='secondary'
|
||||
className='max-w-[14rem] rounded-sm pr-1'
|
||||
>
|
||||
<span className='truncate'>
|
||||
{t(props.stageLabels[option.kind])}: {option.label}
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-muted-foreground/15 flex size-4 shrink-0 items-center justify-center rounded-sm'
|
||||
aria-label={t('Remove node filter')}
|
||||
onClick={() =>
|
||||
props.onRemoveNode({ kind: option.kind, id: option.id })
|
||||
}
|
||||
>
|
||||
<X aria-hidden='true' />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{selectedCount > 1 && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='xs'
|
||||
onClick={props.onClearNodes}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+37
-10
@@ -51,12 +51,36 @@ import type {
|
||||
|
||||
interface ModelsFilterProps {
|
||||
preferences: DashboardChartPreferences
|
||||
// The filters currently applied to the dashboard. The dialog edits a copy of
|
||||
// these so reopening it never discards a manually picked range.
|
||||
currentFilters: DashboardFilters
|
||||
onFilterChange: (filters: DashboardFilters) => void
|
||||
onReset: () => void
|
||||
titleKey?: string
|
||||
descriptionKey?: string
|
||||
}
|
||||
|
||||
// Quick-range presets imply a sensible granularity (matching the app's
|
||||
// range<->granularity pairing), so picking "7 Days" requests daily buckets
|
||||
// instead of leaving the granularity on its previous value (e.g. hourly).
|
||||
function granularityForRangeDays(days: number): TimeGranularity {
|
||||
if (days <= 1) return 'hour'
|
||||
if (days >= 29) return 'week'
|
||||
return 'day'
|
||||
}
|
||||
|
||||
// Highlights the matching quick-range button when the applied range spans an
|
||||
// exact preset; custom ranges leave every quick button unselected.
|
||||
function detectQuickRangeDays(
|
||||
filters: DashboardFilters | undefined
|
||||
): number | null {
|
||||
const start = filters?.start_timestamp
|
||||
const end = filters?.end_timestamp
|
||||
if (!start || !end) return null
|
||||
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000)
|
||||
return TIME_RANGE_PRESETS.some((preset) => preset.days === days) ? days : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Section divider component for better visual organization
|
||||
*/
|
||||
@@ -78,20 +102,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
const isAdmin = user?.role && user.role >= 10
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(() =>
|
||||
buildDefaultDashboardFilters(props.preferences)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(
|
||||
() => props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
|
||||
)
|
||||
const [selectedRange, setSelectedRange] = useState<number | null>(
|
||||
() => props.preferences.defaultTimeRangeDays
|
||||
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
|
||||
detectQuickRangeDays(props.currentFilters)
|
||||
)
|
||||
|
||||
const resetFiltersFromPreferences = () => {
|
||||
setFilters(buildDefaultDashboardFilters(props.preferences))
|
||||
setSelectedRange(props.preferences.defaultTimeRangeDays)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) resetFiltersFromPreferences()
|
||||
// Sync the editing state from the applied filters every time the dialog
|
||||
// opens so a previously applied manual range is preserved.
|
||||
if (nextOpen) {
|
||||
const applied =
|
||||
props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
|
||||
setFilters(applied)
|
||||
setSelectedRange(detectQuickRangeDays(applied))
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
@@ -133,6 +159,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
...prev,
|
||||
start_timestamp: start,
|
||||
end_timestamp: end,
|
||||
time_granularity: granularityForRangeDays(days),
|
||||
}))
|
||||
setSelectedRange(days)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,13 @@ import {
|
||||
} from '@/features/dashboard/constants'
|
||||
import {
|
||||
getDefaultDays,
|
||||
getSavedGranularity,
|
||||
saveGranularity,
|
||||
processUserChartData,
|
||||
} from '@/features/dashboard/lib'
|
||||
import type { ProcessedUserChartData } from '@/features/dashboard/types'
|
||||
import type {
|
||||
ProcessedUserChartData,
|
||||
UserChartsFilters,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
@@ -62,7 +64,12 @@ const USER_CHARTS: {
|
||||
|
||||
const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50]
|
||||
|
||||
export function UserCharts() {
|
||||
interface UserChartsProps {
|
||||
filters: UserChartsFilters
|
||||
onFiltersChange: (filters: UserChartsFilters) => void
|
||||
}
|
||||
|
||||
export function UserCharts(props: UserChartsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
@@ -70,41 +77,45 @@ export function UserCharts() {
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
>(null)
|
||||
|
||||
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(() =>
|
||||
getSavedGranularity()
|
||||
)
|
||||
const [selectedRange, setSelectedRange] = useState<number>(() =>
|
||||
getDefaultDays(timeGranularity)
|
||||
)
|
||||
const [topUserLimit, setTopUserLimit] = useState(10)
|
||||
const [timeRange, setTimeRange] = useState(() => {
|
||||
const days = getDefaultDays(timeGranularity)
|
||||
const { start, end } = getRollingDateRange(days)
|
||||
// The selection is owned by the dashboard parent so it persists across
|
||||
// sub-section switches; the rolling window is derived from the chosen range.
|
||||
const timeGranularity = props.filters.timeGranularity
|
||||
const selectedRange = props.filters.selectedRange
|
||||
const topUserLimit = props.filters.topUserLimit
|
||||
const onFiltersChange = props.onFiltersChange
|
||||
|
||||
const timeRange = useMemo(() => {
|
||||
const { start, end } = getRollingDateRange(selectedRange)
|
||||
return {
|
||||
start_timestamp: Math.floor(start.getTime() / 1000),
|
||||
end_timestamp: Math.floor(end.getTime() / 1000),
|
||||
}
|
||||
})
|
||||
}, [selectedRange])
|
||||
|
||||
const handleRangeChange = useCallback((days: number) => {
|
||||
setSelectedRange(days)
|
||||
const { start, end } = getRollingDateRange(days)
|
||||
setTimeRange({
|
||||
start_timestamp: Math.floor(start.getTime() / 1000),
|
||||
end_timestamp: Math.floor(end.getTime() / 1000),
|
||||
})
|
||||
}, [])
|
||||
const handleRangeChange = useCallback(
|
||||
(days: number) => {
|
||||
onFiltersChange({ ...props.filters, selectedRange: days })
|
||||
},
|
||||
[onFiltersChange, props.filters]
|
||||
)
|
||||
|
||||
const handleGranularityChange = useCallback(
|
||||
(g: TimeGranularity) => {
|
||||
setTimeGranularity(g)
|
||||
saveGranularity(g)
|
||||
const days = getDefaultDays(g)
|
||||
if (days !== selectedRange) {
|
||||
handleRangeChange(days)
|
||||
}
|
||||
onFiltersChange({
|
||||
...props.filters,
|
||||
timeGranularity: g,
|
||||
selectedRange: getDefaultDays(g),
|
||||
})
|
||||
},
|
||||
[selectedRange, handleRangeChange]
|
||||
[onFiltersChange, props.filters]
|
||||
)
|
||||
|
||||
const handleTopUserLimitChange = useCallback(
|
||||
(limit: number) => {
|
||||
onFiltersChange({ ...props.filters, topUserLimit: limit })
|
||||
},
|
||||
[onFiltersChange, props.filters]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -184,7 +195,7 @@ export function UserCharts() {
|
||||
|
||||
<Tabs
|
||||
value={String(topUserLimit)}
|
||||
onValueChange={(value) => setTopUserLimit(Number(value))}
|
||||
onValueChange={(value) => handleTopUserLimitChange(Number(value))}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList>
|
||||
|
||||
Reference in New Issue
Block a user