729 lines
23 KiB
TypeScript
Vendored
729 lines
23 KiB
TypeScript
Vendored
/*
|
|
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 { useQuery } from '@tanstack/react-query'
|
|
import { VChart } from '@visactor/react-vchart'
|
|
import type { EventParamsDefinition, IVChart } from '@visactor/vchart'
|
|
import {
|
|
Activity,
|
|
ChevronRight,
|
|
CircleAlert,
|
|
EyeOff,
|
|
GitBranch,
|
|
Hash,
|
|
Info,
|
|
Loader2,
|
|
Route,
|
|
WalletCards,
|
|
} from 'lucide-react'
|
|
import {
|
|
Fragment,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import { MultiSelect } from '@/components/multi-select'
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
import {
|
|
Empty,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from '@/components/ui/empty'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { Toggle } from '@/components/ui/toggle'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import { getFlowQuotaDates } from '@/features/dashboard/api'
|
|
import {
|
|
buildDashboardFlowData,
|
|
buildFlowSankeySpec,
|
|
buildQueryParams,
|
|
flowNodeFilterFromSankeyDatum,
|
|
flowSankeyDatumValue,
|
|
getDefaultDays,
|
|
getFlowStages,
|
|
} from '@/features/dashboard/lib'
|
|
import {
|
|
compactFlowSelectionLabel,
|
|
flowDisplayState,
|
|
requireSuccessfulFlowRows,
|
|
} from '@/features/dashboard/lib/flow-selection'
|
|
import type {
|
|
DashboardFilters,
|
|
FlowLinkSelection,
|
|
FlowMetric,
|
|
FlowNodeFilter,
|
|
FlowNodeKind,
|
|
FlowOverflowMode,
|
|
FlowRole,
|
|
} from '@/features/dashboard/types'
|
|
import { formatQuota } from '@/lib/format'
|
|
import { ROLE } from '@/lib/roles'
|
|
import { computeTimeRange } from '@/lib/time'
|
|
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
|
|
// When false, sensitive node labels are masked in the rendered Sankey.
|
|
sensitiveVisible?: boolean
|
|
}
|
|
|
|
const FLOW_METRIC_OPTIONS = [
|
|
{ value: 'quota', labelKey: 'By quota', icon: WalletCards },
|
|
{ value: 'tokens', labelKey: 'By tokens', icon: Hash },
|
|
{ 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
|
|
|
|
const FLOW_OVERFLOW_MODE_OPTIONS = [
|
|
{ value: 'aggregate', labelKey: 'Merge into Other' },
|
|
{ value: 'hide', labelKey: 'Hide' },
|
|
] as const
|
|
|
|
// A Sankey needs at least two columns to render any link.
|
|
const MIN_VISIBLE_STAGES = 2
|
|
|
|
const FLOW_STAGE_META: Record<
|
|
FlowNodeKind,
|
|
{ labelKey: string; descKey: string }
|
|
> = {
|
|
user: {
|
|
labelKey: 'User',
|
|
descKey: 'The user who made the requests',
|
|
},
|
|
node: {
|
|
labelKey: 'Node',
|
|
descKey: 'The deployment node that handled the requests',
|
|
},
|
|
token: {
|
|
labelKey: 'Token',
|
|
descKey: 'The API key used for the requests',
|
|
},
|
|
group: {
|
|
labelKey: 'Group',
|
|
descKey: 'The user group applied to the requests',
|
|
},
|
|
model: {
|
|
labelKey: 'Model',
|
|
descKey: 'The model that was requested',
|
|
},
|
|
channel: {
|
|
labelKey: 'Channel',
|
|
descKey: 'The upstream channel that served the requests',
|
|
},
|
|
}
|
|
|
|
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',
|
|
token: 'Other tokens',
|
|
group: 'Other groups',
|
|
model: 'Other models',
|
|
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)
|
|
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])
|
|
const visibleStages = useMemo(
|
|
() => 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)
|
|
if (hidden.has(stage)) {
|
|
hidden.delete(stage)
|
|
} else {
|
|
const remaining = stages.filter((item) => !hidden.has(item)).length
|
|
if (remaining <= MIN_VISIBLE_STAGES) return prev
|
|
hidden.add(stage)
|
|
}
|
|
return stages.filter((item) => hidden.has(item))
|
|
})
|
|
}
|
|
|
|
const timeRange = useMemo(
|
|
() =>
|
|
computeTimeRange(
|
|
getDefaultDays(props.filters?.time_granularity),
|
|
props.filters?.start_timestamp,
|
|
props.filters?.end_timestamp
|
|
),
|
|
[
|
|
props.filters?.end_timestamp,
|
|
props.filters?.start_timestamp,
|
|
props.filters?.time_granularity,
|
|
]
|
|
)
|
|
const flowQueryParams = useMemo(
|
|
() => buildQueryParams(timeRange, props.filters),
|
|
[props.filters, timeRange]
|
|
)
|
|
|
|
const {
|
|
data: flowRows,
|
|
error: flowError,
|
|
isError,
|
|
isLoading,
|
|
} = useQuery({
|
|
queryKey: ['dashboard', 'flow', flowQueryParams, flowRole],
|
|
queryFn: () => getFlowQuotaDates(flowQueryParams, isAdmin),
|
|
select: (res) =>
|
|
requireSuccessfulFlowRows(res, t('Please try again later.')),
|
|
staleTime: 60_000,
|
|
})
|
|
|
|
const maskSensitive = props.sensitiveVisible === false
|
|
const flowData = useMemo(
|
|
() =>
|
|
buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, {
|
|
role: flowRole,
|
|
selectedUsers,
|
|
selectedNodes,
|
|
activeNode: activeFlowNode,
|
|
activeLink: activeFlowLink,
|
|
visibleStages,
|
|
topNodeLimit,
|
|
overflowMode,
|
|
maskSensitive,
|
|
deletedTokenLabel: (tokenId) => t('Deleted ({{id}})', { id: tokenId }),
|
|
otherNodeLabel: (kind) => t(FLOW_OTHER_NODE_LABEL_KEYS[kind]),
|
|
}),
|
|
[
|
|
flowRole,
|
|
flowRows,
|
|
isLoading,
|
|
metric,
|
|
overflowMode,
|
|
activeFlowNode,
|
|
activeFlowLink,
|
|
selectedNodes,
|
|
selectedUsers,
|
|
topNodeLimit,
|
|
visibleStages,
|
|
maskSensitive,
|
|
t,
|
|
]
|
|
)
|
|
const userFilterOptions = useMemo(
|
|
() =>
|
|
flowData.filterOptions.users.map((user) => ({
|
|
label: `${user.label} · ${user.valueLabel}`,
|
|
value: user.value,
|
|
})),
|
|
[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(
|
|
() =>
|
|
buildFlowSankeySpec(flowData.flow, chartTitle, formatQuota, {
|
|
quota: t('Quota'),
|
|
tokens: t('Tokens'),
|
|
requests: t('Requests'),
|
|
share: t('Share'),
|
|
}),
|
|
[chartTitle, flowData.flow, t]
|
|
)
|
|
const chartTheme = resolvedTheme === 'dark' ? 'dark' : 'light'
|
|
const chartKey = [
|
|
metric,
|
|
topNodeLimit,
|
|
overflowMode,
|
|
flowRole,
|
|
activeFlowNode ? flowNodeFilterKey(activeFlowNode) : '',
|
|
activeFlowLink
|
|
? `${activeFlowLink.source}\u0000${activeFlowLink.target}`
|
|
: '',
|
|
selectedNodes.map(flowNodeFilterKey).join(','),
|
|
selectedUsers.join(','),
|
|
visibleStages.join(','),
|
|
maskSensitive ? 'masked' : 'plain',
|
|
flowRows?.length ?? 0,
|
|
resolvedTheme,
|
|
].join('-')
|
|
const displayState = flowDisplayState({
|
|
isLoading,
|
|
isError,
|
|
linkCount: flowData.flow.links.length,
|
|
themeReady,
|
|
})
|
|
const flowErrorMessage =
|
|
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'>
|
|
<div className='flex flex-col gap-2 xl:flex-row xl:items-end xl:justify-between'>
|
|
<div className='flex min-w-0 flex-wrap items-end gap-2'>
|
|
<div className='flex min-w-0 flex-col gap-1.5'>
|
|
<div className='flex items-center gap-1.5'>
|
|
<span className='text-muted-foreground text-xs font-medium'>
|
|
{t('Flow width metric')}
|
|
</span>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
type='button'
|
|
className='text-muted-foreground/60 hover:text-foreground flex size-5 shrink-0 items-center justify-center rounded-md'
|
|
aria-label={t('Flow width metric')}
|
|
/>
|
|
}
|
|
>
|
|
<Info className='size-3.5' />
|
|
</TooltipTrigger>
|
|
<TooltipContent className='max-w-[14rem]'>
|
|
{t('Choose how flow widths are calculated.')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<Tabs
|
|
value={metric}
|
|
onValueChange={(value) => setMetric(value as FlowMetric)}
|
|
className='shrink-0'
|
|
>
|
|
<TabsList aria-label={t('Flow width metric')}>
|
|
{FLOW_METRIC_OPTIONS.map((option) => {
|
|
const Icon = option.icon
|
|
return (
|
|
<TabsTrigger
|
|
key={option.value}
|
|
value={option.value}
|
|
className='gap-1.5 px-2.5 text-xs'
|
|
>
|
|
<Icon data-icon='inline-start' aria-hidden='true' />
|
|
{t(option.labelKey)}
|
|
</TabsTrigger>
|
|
)
|
|
})}
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<div className='flex min-w-0 flex-col gap-1.5'>
|
|
<span className='text-muted-foreground text-xs font-medium'>
|
|
{t('Display limit')}
|
|
</span>
|
|
<Tabs
|
|
value={String(topNodeLimit)}
|
|
onValueChange={(value) => setTopNodeLimit(Number(value))}
|
|
className='shrink-0'
|
|
>
|
|
<TabsList aria-label={t('Display limit')}>
|
|
{FLOW_TOP_LIMIT_OPTIONS.map((limit) => (
|
|
<TabsTrigger
|
|
key={limit}
|
|
value={String(limit)}
|
|
className='px-2.5 text-xs'
|
|
>
|
|
{t('Top {{count}}', { count: limit })}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<div className='flex min-w-0 flex-col gap-1.5'>
|
|
<span className='text-muted-foreground text-xs font-medium'>
|
|
{t('Overflow items')}
|
|
</span>
|
|
<Tabs
|
|
value={overflowMode}
|
|
onValueChange={(value) =>
|
|
setOverflowMode(value as FlowOverflowMode)
|
|
}
|
|
className='shrink-0'
|
|
>
|
|
<TabsList aria-label={t('Overflow items')}>
|
|
{FLOW_OVERFLOW_MODE_OPTIONS.map((option) => (
|
|
<TabsTrigger
|
|
key={option.value}
|
|
value={option.value}
|
|
className='px-2.5 text-xs'
|
|
>
|
|
{t(option.labelKey)}
|
|
</TabsTrigger>
|
|
))}
|
|
</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'>
|
|
{isAdmin && (
|
|
<div className='flex min-w-0 flex-col gap-2 sm:flex-row xl:w-[min(24rem,34vw)]'>
|
|
<MultiSelect
|
|
options={userFilterOptions}
|
|
selected={selectedUsers}
|
|
onChange={setSelectedUsers}
|
|
placeholder={t('All users')}
|
|
emptyText={t('No users')}
|
|
maxVisibleChips={2}
|
|
renderSelectedSummary={(values) =>
|
|
compactFlowSelectionLabel(values.length)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{isLoading && (
|
|
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='overflow-hidden rounded-lg border'>
|
|
<div className='flex w-full flex-col gap-2 border-b px-3 py-2 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
|
|
<div className='flex min-w-0 items-center gap-2'>
|
|
<GitBranch className='text-muted-foreground/60 size-4 shrink-0' />
|
|
<div className='text-sm font-semibold'>{chartTitle}</div>
|
|
</div>
|
|
<TooltipProvider>
|
|
<div className='flex min-w-0 items-center gap-1 overflow-x-auto pb-1 lg:justify-end lg:pb-0'>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
type='button'
|
|
className='text-muted-foreground/60 hover:text-foreground flex size-6 shrink-0 items-center justify-center rounded-md'
|
|
aria-label={t('Show or hide flow columns')}
|
|
/>
|
|
}
|
|
>
|
|
<Info className='size-3.5' />
|
|
</TooltipTrigger>
|
|
<TooltipContent className='max-w-[16rem]'>
|
|
{t('Click a stage to show or hide that column')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
{stages.map((stage, index) => {
|
|
const meta = FLOW_STAGE_META[stage]
|
|
const visible = !hiddenStages.includes(stage)
|
|
return (
|
|
<Fragment key={stage}>
|
|
{index > 0 && (
|
|
<ChevronRight className='text-muted-foreground/40 size-3.5 shrink-0' />
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Toggle
|
|
variant='outline'
|
|
size='sm'
|
|
pressed={visible}
|
|
onPressedChange={() => toggleStage(stage)}
|
|
aria-label={t(meta.labelKey)}
|
|
className={cn('shrink-0', !visible && 'opacity-50')}
|
|
/>
|
|
}
|
|
>
|
|
{!visible && <EyeOff className='size-3' />}
|
|
{t(meta.labelKey)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t(meta.descKey)}</TooltipContent>
|
|
</Tooltip>
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
</TooltipProvider>
|
|
</div>
|
|
<div className='h-[560px] p-1.5 sm:h-[680px] sm:p-2 2xl:h-[760px]'>
|
|
{chartContent}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|