♻️ refactor(web): consolidate design-system primitives and responsive data views
This commit is contained in:
@@ -41,6 +41,8 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
|
||||
import { Toggle } from '@/components/design-system/toggle'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
@@ -51,8 +53,6 @@ import {
|
||||
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,
|
||||
|
||||
@@ -20,8 +20,7 @@ import { Filter, X } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -30,7 +29,8 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
} from '@/components/design-system/command'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -108,7 +108,6 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
aria-label={t('Filter by node')}
|
||||
/>
|
||||
}
|
||||
@@ -116,9 +115,7 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
|
||||
<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>
|
||||
<Badge variant='secondary'>{selectedCount}</Badge>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -201,14 +198,14 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
|
||||
<Badge
|
||||
key={flowNodeFilterKey(option)}
|
||||
variant='secondary'
|
||||
className='max-w-[14rem] rounded-sm pr-1'
|
||||
className='max-w-[14rem] 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'
|
||||
className='hover:bg-muted-foreground/15 focus-visible:ring-ring flex size-4 shrink-0 items-center justify-center rounded-sm outline-none focus-visible:ring-2'
|
||||
aria-label={t('Remove node filter')}
|
||||
onClick={() =>
|
||||
props.onRemoveNode({ kind: option.kind, id: option.id })
|
||||
|
||||
+5
-5
@@ -20,9 +20,7 @@ import { Save, Settings2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,7 +28,9 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
} from '@/components/design-system/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
|
||||
MODEL_ANALYTICS_CHART_OPTIONS,
|
||||
@@ -71,7 +71,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
trigger={
|
||||
<Button variant='outline' size='sm'>
|
||||
<Button variant='outline'>
|
||||
<Settings2 className='mr-2 h-4 w-4' />
|
||||
{t('Preferences')}
|
||||
</Button>
|
||||
|
||||
+7
-8
@@ -21,11 +21,8 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,7 +30,10 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
} from '@/components/design-system/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
TIME_GRANULARITY_OPTIONS,
|
||||
TIME_RANGE_PRESETS,
|
||||
@@ -171,7 +171,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
trigger={
|
||||
<Button variant='outline' size='sm'>
|
||||
<Button variant='outline'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
{t('Filter')}
|
||||
</Button>
|
||||
@@ -210,7 +210,6 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
<Button
|
||||
key={range.days}
|
||||
type='button'
|
||||
size='sm'
|
||||
variant={selectedRange === range.days ? 'default' : 'outline'}
|
||||
onClick={() => handleQuickRange(range.days)}
|
||||
className={cn(
|
||||
|
||||
+23
-22
@@ -21,13 +21,14 @@ import { Gauge, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
formatLatency,
|
||||
formatThroughput,
|
||||
formatUptimePct,
|
||||
getSuccessRateDotClass,
|
||||
getSuccessRateLevel,
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||
@@ -35,6 +36,11 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const PERFORMANCE_WINDOW_HOURS = 24
|
||||
const TOP_MODEL_LIMIT = 6
|
||||
const METRIC_SKELETON_KEYS = [
|
||||
'success-rate-skeleton',
|
||||
'latency-skeleton',
|
||||
'throughput-skeleton',
|
||||
]
|
||||
|
||||
type WeightedMetric = 'avg_latency_ms' | 'avg_tps' | 'success_rate'
|
||||
|
||||
@@ -60,7 +66,7 @@ function simpleAverage(
|
||||
count++
|
||||
}
|
||||
|
||||
return count > 0 ? total / count : NaN
|
||||
return count > 0 ? total / count : Number.NaN
|
||||
}
|
||||
|
||||
function buildPerformanceSummary(rows: PerfModelSummary[]): PerformanceSummary {
|
||||
@@ -128,8 +134,8 @@ export function PerformanceOverview() {
|
||||
{/* 3 KPI inline metrics */}
|
||||
{loading ? (
|
||||
<div className='flex flex-wrap items-center gap-x-5 gap-y-2'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='flex items-center gap-1.5'>
|
||||
{METRIC_SKELETON_KEYS.map((key) => (
|
||||
<div key={key} className='flex items-center gap-1.5'>
|
||||
<Skeleton className='h-3 w-14' />
|
||||
<Skeleton className='h-4 w-16' />
|
||||
</div>
|
||||
@@ -201,27 +207,22 @@ function InlineMetric(props: {
|
||||
|
||||
function ModelBadge(props: { model: PerfModelSummary }) {
|
||||
const model = props.model
|
||||
const level = getSuccessRateLevel(model.success_rate)
|
||||
let variant: StatusVariant = 'neutral'
|
||||
if (level === 'excellent' || level === 'good') {
|
||||
variant = 'success'
|
||||
} else if (level === 'warning') {
|
||||
variant = 'warning'
|
||||
} else if (level === 'critical') {
|
||||
variant = 'destructive'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='bg-muted/50 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1'>
|
||||
<span className='max-w-[10rem] truncate font-mono text-xs'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
getSuccessRateDotClass(model.success_rate)
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold tabular-nums',
|
||||
getSuccessRateTextClass(model.success_rate)
|
||||
)}
|
||||
>
|
||||
<StatusBadge variant={variant}>
|
||||
<span className='mr-1 max-w-[10rem] truncate'>{model.model_name}</span>
|
||||
<span className='tabular-nums'>
|
||||
{formatUptimePct(model.success_rate)}
|
||||
</span>
|
||||
</span>
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,12 +20,9 @@ import { Zap, ExternalLink, Gauge } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getLatencyColorClass,
|
||||
openExternalSpeedTest,
|
||||
} from '@/features/dashboard/lib/api-info'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
|
||||
import { openExternalSpeedTest } from '@/features/dashboard/lib/api-info'
|
||||
import type { ApiInfoItem, PingStatus } from '@/features/dashboard/types'
|
||||
import { getBgColorClass } from '@/lib/colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -40,6 +37,12 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const item = props.item
|
||||
const status = props.status
|
||||
let latencyVariant: StatusVariant = 'success'
|
||||
if (status.latency !== null && status.latency >= 500) {
|
||||
latencyVariant = 'destructive'
|
||||
} else if (status.latency !== null && status.latency >= 200) {
|
||||
latencyVariant = 'warning'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group hover:bg-muted/40 flex items-center justify-between gap-2 px-3 py-2.5 transition-colors sm:gap-3 sm:px-5 sm:py-3'>
|
||||
@@ -69,36 +72,25 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<div className='flex items-center'>
|
||||
{status.testing && (
|
||||
<StatusBadge
|
||||
label={t('Testing...')}
|
||||
variant='warning'
|
||||
className='animate-pulse'
|
||||
copyable={false}
|
||||
/>
|
||||
<StatusBadge variant='warning'>{t('Testing...')}</StatusBadge>
|
||||
)}
|
||||
{status.latency !== null && !status.testing && (
|
||||
<StatusBadge
|
||||
variant='success'
|
||||
label={`${status.latency}${t('ms')}`}
|
||||
className={cn(
|
||||
'font-mono font-medium',
|
||||
getLatencyColorClass(status.latency)
|
||||
)}
|
||||
copyable={false}
|
||||
/>
|
||||
<StatusBadge variant={latencyVariant}>
|
||||
{status.latency}
|
||||
{t('ms')}
|
||||
</StatusBadge>
|
||||
)}
|
||||
{status.error && (
|
||||
<StatusBadge label={t('N/A')} variant='neutral' copyable={false} />
|
||||
<StatusBadge variant='neutral'>{t('N/A')}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-0.5'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
size='icon-sm'
|
||||
onClick={() => props.onTest(item.url)}
|
||||
disabled={status.testing}
|
||||
className='size-7 p-0'
|
||||
title={t('Test Latency')}
|
||||
>
|
||||
<Zap
|
||||
@@ -108,9 +100,9 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
size='icon-sm'
|
||||
onClick={() => openExternalSpeedTest(item.url)}
|
||||
className='hidden size-7 p-0 sm:inline-flex'
|
||||
className='hidden sm:inline-flex'
|
||||
title={t('External Speed Test')}
|
||||
>
|
||||
<Gauge className='size-3.5' />
|
||||
@@ -119,8 +111,7 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
<CopyButton
|
||||
value={item.url}
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='size-7 p-0'
|
||||
size='icon-sm'
|
||||
iconClassName='size-3.5'
|
||||
tooltip={t('Copy URL')}
|
||||
aria-label={t('Copy URL')}
|
||||
@@ -128,8 +119,8 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='hidden size-7 p-0 sm:inline-flex'
|
||||
size='icon-sm'
|
||||
className='hidden sm:inline-flex'
|
||||
title={t('Open in New Tab')}
|
||||
render={<a href={item.url} target='_blank' rel='noreferrer' />}
|
||||
>
|
||||
|
||||
+1
-681
@@ -16,47 +16,15 @@ 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 { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
Copy,
|
||||
CreditCard,
|
||||
FileText,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
RadioTower,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
Timer,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
CardStaggerContainer,
|
||||
CardStaggerItem,
|
||||
} from '@/components/page-transition'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
|
||||
import type { ApiKey } from '@/features/keys/types'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
useApiInfo,
|
||||
useDashboardContentVisibility,
|
||||
} from '../../hooks/use-status-data'
|
||||
import { useDashboardContentVisibility } from '../../hooks/use-status-data'
|
||||
import { AnnouncementsPanel } from './announcements-panel'
|
||||
import { ApiInfoPanel } from './api-info-panel'
|
||||
import { FAQPanel } from './faq-panel'
|
||||
@@ -64,670 +32,22 @@ import { PerformanceHealthPanel } from './performance-health-panel'
|
||||
import { SummaryCards } from './summary-cards'
|
||||
import { UptimePanel } from './uptime-panel'
|
||||
|
||||
const SETUP_GUIDE_VISIBILITY_STORAGE_KEY =
|
||||
'dashboard_overview_setup_guide_expanded'
|
||||
|
||||
const SETUP_GUIDE_CODE_PATTERN = [
|
||||
'const request = await client.responses.create({',
|
||||
" model: 'gpt-4.1-mini',",
|
||||
" input: 'Start routing traffic',",
|
||||
'})',
|
||||
'',
|
||||
'if (request.output_text) {',
|
||||
' console.log(request.output_text)',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
type DashboardActionPath =
|
||||
| '/keys'
|
||||
| '/wallet'
|
||||
| '/playground'
|
||||
| '/channels'
|
||||
| '/usage-logs'
|
||||
| '/pricing'
|
||||
|
||||
interface StartStep {
|
||||
title: string
|
||||
description: string
|
||||
to: DashboardActionPath
|
||||
icon: LucideIcon
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
title: string
|
||||
description: string
|
||||
to: DashboardActionPath
|
||||
icon: LucideIcon
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
interface RequestExample {
|
||||
endpoint: string
|
||||
model: string
|
||||
keyName: string
|
||||
keyId?: number
|
||||
displayKey: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
interface HeroSignal {
|
||||
label: string
|
||||
value: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
function getSavedSetupGuideExpanded(): boolean | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const saved = window.localStorage.getItem(SETUP_GUIDE_VISIBILITY_STORAGE_KEY)
|
||||
if (saved === 'expanded') return true
|
||||
if (saved === 'collapsed') return false
|
||||
return null
|
||||
}
|
||||
|
||||
function saveSetupGuideExpanded(expanded: boolean): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(
|
||||
SETUP_GUIDE_VISIBILITY_STORAGE_KEY,
|
||||
expanded ? 'expanded' : 'collapsed'
|
||||
)
|
||||
}
|
||||
|
||||
function getCurrentOrigin(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
function normalizeEndpoint(sourceUrl?: string): string {
|
||||
const fallback = `${getCurrentOrigin()}/v1/chat/completions`
|
||||
const trimmed = sourceUrl?.trim()
|
||||
if (!trimmed) return fallback
|
||||
|
||||
const withoutTrailingSlash = trimmed.replace(/\/+$/, '')
|
||||
if (withoutTrailingSlash.endsWith('/v1/chat/completions')) {
|
||||
return withoutTrailingSlash
|
||||
}
|
||||
if (withoutTrailingSlash.endsWith('/v1')) {
|
||||
return `${withoutTrailingSlash}/chat/completions`
|
||||
}
|
||||
return `${withoutTrailingSlash}/v1/chat/completions`
|
||||
}
|
||||
|
||||
function getPreferredKey(keys: ApiKey[]): ApiKey | null {
|
||||
return keys.find((item) => item.status === 1) ?? keys[0] ?? null
|
||||
}
|
||||
|
||||
function formatDisplayKey(key?: string): string {
|
||||
if (!key) return 'sk-...'
|
||||
if (key.length <= 14) return key
|
||||
return `${key.slice(0, 7)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
function buildCurlCommand(args: {
|
||||
endpoint: string
|
||||
apiKey: string
|
||||
model: string
|
||||
}): string {
|
||||
return [
|
||||
`curl ${args.endpoint} \\`,
|
||||
' -H "Content-Type: application/json" \\',
|
||||
` -H "Authorization: Bearer ${args.apiKey}" \\`,
|
||||
` -d '{"model":"${args.model}","messages":[{"role":"user","content":"Say hello in one sentence."}]}'`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function SetupGuideBackdrop(props: { compact?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_48%_120%_at_78%_0%,color-mix(in_oklch,var(--primary)_8%,transparent)_0%,transparent_62%),linear-gradient(112deg,color-mix(in_oklch,var(--card)_98%,var(--primary)_2%)_0%,color-mix(in_oklch,var(--card)_94%,var(--muted)_6%)_48%,color-mix(in_oklch,var(--background)_92%,var(--accent)_8%)_100%)] dark:opacity-65',
|
||||
props.compact
|
||||
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
|
||||
: 'opacity-85'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground/5 dark:text-foreground/8 pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono sm:block',
|
||||
props.compact ? 'w-1/2 opacity-45' : 'w-[58%] opacity-75'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
'absolute right-3 [mask-image:linear-gradient(90deg,transparent_0%,black_30%,black_82%,transparent_100%)] text-right tracking-[0.38em] whitespace-pre',
|
||||
props.compact
|
||||
? '-top-6 text-[9px] leading-4'
|
||||
: 'top-1 text-xs leading-5'
|
||||
)}
|
||||
>
|
||||
{SETUP_GUIDE_CODE_PATTERN}
|
||||
</pre>
|
||||
</div>
|
||||
<div
|
||||
className='from-background/35 to-background/70 dark:from-background/20 dark:to-background/80 pointer-events-none absolute inset-0 bg-linear-to-b via-transparent'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StartStepItem(props: {
|
||||
step: StartStep
|
||||
index: number
|
||||
isLast: boolean
|
||||
}) {
|
||||
const Icon = props.step.icon
|
||||
const StatusIcon = props.step.completed ? Check : Circle
|
||||
|
||||
return (
|
||||
<li className='relative flex gap-3 pb-2.5 last:pb-0'>
|
||||
{!props.isLast && (
|
||||
<span
|
||||
className='bg-border absolute top-9 bottom-0 left-4 w-px'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'bg-background relative z-10 flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs',
|
||||
props.step.completed && 'border-success/30 bg-success/10'
|
||||
)}
|
||||
>
|
||||
<StatusIcon
|
||||
className={props.step.completed ? 'text-success size-4' : 'size-4'}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</span>
|
||||
|
||||
<Link
|
||||
to={props.step.to}
|
||||
className='bg-background/70 hover:bg-muted/50 focus-visible:ring-ring flex min-w-0 flex-1 items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left shadow-xs transition-colors outline-none focus-visible:ring-2'
|
||||
>
|
||||
<span className='flex min-w-0 items-start gap-2.5'>
|
||||
<span className='bg-muted mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg'>
|
||||
<Icon className='size-3.5' aria-hidden='true' />
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='flex items-center gap-2 text-sm font-medium'>
|
||||
<span className='text-muted-foreground text-xs tabular-nums'>
|
||||
{props.index + 1}.
|
||||
</span>
|
||||
<span className='truncate'>{props.step.title}</span>
|
||||
</span>
|
||||
<span className='text-muted-foreground line-clamp-1 text-xs'>
|
||||
{props.step.description}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight
|
||||
className='text-muted-foreground size-4 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestPreview(props: {
|
||||
example: RequestExample
|
||||
signals: HeroSignal[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
const { copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
const previewCurl = buildCurlCommand({
|
||||
endpoint: props.example.endpoint,
|
||||
apiKey: props.example.displayKey,
|
||||
model: props.example.model,
|
||||
})
|
||||
const previewLines = previewCurl.split('\n')
|
||||
const handleCopyRequest = async () => {
|
||||
if (!props.example.keyId || isCopying) return
|
||||
|
||||
setIsCopying(true)
|
||||
try {
|
||||
const result = await fetchTokenKey(props.example.keyId)
|
||||
const key = result.success && result.data?.key ? result.data.key : ''
|
||||
if (!key) {
|
||||
toast.error(result.message || t('Failed to copy to clipboard'))
|
||||
return
|
||||
}
|
||||
|
||||
const realCurl = buildCurlCommand({
|
||||
endpoint: props.example.endpoint,
|
||||
apiKey: `sk-${key}`,
|
||||
model: props.example.model,
|
||||
})
|
||||
const copied = await copyToClipboard(realCurl)
|
||||
if (copied) {
|
||||
toast.success(t('Copied to clipboard'))
|
||||
} else {
|
||||
toast.error(t('Failed to copy to clipboard'))
|
||||
}
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-background relative overflow-hidden rounded-2xl border p-3'>
|
||||
<div className='flex items-center justify-between gap-3 border-b pb-3'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<span className='bg-muted flex size-8 shrink-0 items-center justify-center rounded-lg'>
|
||||
<TerminalSquare className='size-4' aria-hidden='true' />
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate text-sm font-medium'>
|
||||
{t('First API request')}
|
||||
</div>
|
||||
<div className='text-muted-foreground truncate text-xs'>
|
||||
{props.example.ready
|
||||
? props.example.keyName
|
||||
: t('Create an API key to unlock the real request')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{props.example.ready ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 gap-1.5 px-2 text-xs'
|
||||
disabled={isCopying}
|
||||
onClick={handleCopyRequest}
|
||||
aria-label={t('Copy ready-to-run curl')}
|
||||
>
|
||||
<Copy data-icon='inline-start' />
|
||||
{isCopying ? t('Loading') : t('Copy')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size='sm' variant='outline' render={<Link to='/keys' />}>
|
||||
{t('Create API Key')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='bg-foreground/[0.035] my-3 rounded-xl p-3 font-mono text-xs'>
|
||||
<div className='mb-2 flex items-center gap-1.5'>
|
||||
<span className='bg-destructive size-2 rounded-full' />
|
||||
<span className='bg-warning size-2 rounded-full' />
|
||||
<span className='bg-success size-2 rounded-full' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 overflow-hidden'>
|
||||
{previewLines.map((line, index) => (
|
||||
<code
|
||||
key={`${line}-${index}`}
|
||||
className='text-muted-foreground truncate'
|
||||
title={line}
|
||||
>
|
||||
{line}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
{props.signals.map((signal) => {
|
||||
const Icon = signal.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
key={signal.label}
|
||||
className='bg-muted/40 flex items-center justify-between gap-3 rounded-xl px-3 py-2'
|
||||
>
|
||||
<span className='flex min-w-0 items-center gap-2'>
|
||||
<Icon
|
||||
className='text-muted-foreground size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='truncate text-xs font-medium'>
|
||||
{signal.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className='text-muted-foreground shrink-0 text-xs'>
|
||||
{signal.value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActionItem(props: { action: QuickAction }) {
|
||||
const Icon = props.action.icon
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-auto justify-start rounded-xl px-3 py-3 text-left'
|
||||
render={<Link to={props.action.to} />}
|
||||
>
|
||||
<span className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-lg'>
|
||||
<Icon className='size-4' aria-hidden='true' />
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<span className='truncate text-sm font-medium'>
|
||||
{props.action.title}
|
||||
</span>
|
||||
<span className='text-muted-foreground line-clamp-2 text-xs leading-relaxed'>
|
||||
{props.action.description}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactQuickAction(props: { action: QuickAction }) {
|
||||
const Icon = props.action.icon
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='bg-background/70 h-8 min-w-24 gap-1.5 px-2.5'
|
||||
render={<Link to={props.action.to} />}
|
||||
>
|
||||
<Icon data-icon='inline-start' />
|
||||
<span>{props.action.title}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverviewDashboard() {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const { items: apiInfoItems } = useApiInfo()
|
||||
const {
|
||||
apiInfo: showApiInfoPanel,
|
||||
announcements: showAnnouncementsPanel,
|
||||
faq: showFAQPanel,
|
||||
uptimeKuma: showUptimePanel,
|
||||
} = useDashboardContentVisibility()
|
||||
const [manualSetupGuideExpanded, setManualSetupGuideExpanded] = useState<
|
||||
boolean | null
|
||||
>(() => getSavedSetupGuideExpanded())
|
||||
|
||||
const requestCount = Number(user?.request_count ?? 0)
|
||||
const remainQuota = Number(user?.quota ?? 0)
|
||||
const usedQuota = Number(user?.used_quota ?? 0)
|
||||
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
|
||||
|
||||
const apiKeysQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview', 'api-keys'],
|
||||
queryFn: async () => {
|
||||
const result = await getApiKeys({ p: 1, size: 10 })
|
||||
return result.success ? (result.data?.items ?? []) : []
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview', 'user-models'],
|
||||
queryFn: async () => {
|
||||
const result = await getUserModels()
|
||||
return result.success ? (result.data ?? []) : []
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const preferredKey = useMemo(
|
||||
() => getPreferredKey(apiKeysQuery.data ?? []),
|
||||
[apiKeysQuery.data]
|
||||
)
|
||||
|
||||
const startSteps = useMemo<StartStep[]>(
|
||||
() => [
|
||||
{
|
||||
title: t('Create API Key'),
|
||||
description: t('Create a key for your app or service'),
|
||||
to: '/keys',
|
||||
icon: KeyRound,
|
||||
completed: Boolean(preferredKey),
|
||||
},
|
||||
{
|
||||
title: t('Add credits'),
|
||||
description: t('Keep enough balance before production traffic'),
|
||||
to: '/wallet',
|
||||
icon: CreditCard,
|
||||
completed: remainQuota > 0 || usedQuota > 0,
|
||||
},
|
||||
{
|
||||
title: t('Send a request'),
|
||||
description: t('Verify routing with Playground or your client'),
|
||||
to: '/playground',
|
||||
icon: TerminalSquare,
|
||||
completed: requestCount > 0,
|
||||
},
|
||||
],
|
||||
[preferredKey, remainQuota, requestCount, t, usedQuota]
|
||||
)
|
||||
|
||||
const quickActions = useMemo<QuickAction[]>(
|
||||
() => [
|
||||
{
|
||||
title: t('API Keys'),
|
||||
description: t('Create a key for your app or service'),
|
||||
to: '/keys',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
title: t('Channels'),
|
||||
description: t('Configure upstream providers and routing.'),
|
||||
to: '/channels',
|
||||
icon: RadioTower,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: t('Usage Logs'),
|
||||
description: t('Inspect requests, errors, and billing details'),
|
||||
to: '/usage-logs',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: t('Pricing'),
|
||||
description: t('Review model rates before scaling traffic'),
|
||||
to: '/pricing',
|
||||
icon: BookOpen,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const visibleQuickActions = useMemo(
|
||||
() => quickActions.filter((action) => !action.adminOnly || isAdmin),
|
||||
[isAdmin, quickActions]
|
||||
)
|
||||
|
||||
const heroSignals = useMemo<HeroSignal[]>(
|
||||
() => [
|
||||
{
|
||||
label: t('Route active'),
|
||||
value: apiInfoItems.length > 0 ? t('Online') : t('Current domain'),
|
||||
icon: RadioTower,
|
||||
},
|
||||
{
|
||||
label: t('Auth configured'),
|
||||
value: preferredKey ? t('Secured') : t('Needs API key'),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: t('Model selected'),
|
||||
value: modelsQuery.data?.[0] ?? t('Loading'),
|
||||
icon: Timer,
|
||||
},
|
||||
],
|
||||
[apiInfoItems.length, modelsQuery.data, preferredKey, t]
|
||||
)
|
||||
|
||||
const requestExample = useMemo<RequestExample>(() => {
|
||||
const endpoint = normalizeEndpoint(apiInfoItems[0]?.url)
|
||||
const model = modelsQuery.data?.[0] ?? 'gpt-4o-mini'
|
||||
const keyName = preferredKey?.name ?? t('No API key yet')
|
||||
const ready = Boolean(preferredKey?.id && model)
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
model,
|
||||
keyName,
|
||||
keyId: preferredKey?.id,
|
||||
displayKey: preferredKey
|
||||
? formatDisplayKey(`sk-${preferredKey.key}`)
|
||||
: 'sk-...',
|
||||
ready,
|
||||
}
|
||||
}, [apiInfoItems, modelsQuery.data, preferredKey, t])
|
||||
|
||||
const completedStepCount = startSteps.filter((step) => step.completed).length
|
||||
const setupComplete = completedStepCount === startSteps.length
|
||||
const setupStatusReady = apiKeysQuery.isFetched && Boolean(user)
|
||||
const setupGuideExpanded =
|
||||
manualSetupGuideExpanded ?? (setupStatusReady && !setupComplete)
|
||||
const showLeftContentPanels =
|
||||
isAdmin || showApiInfoPanel || showAnnouncementsPanel || showFAQPanel
|
||||
const showContentPanels = showLeftContentPanels || showUptimePanel
|
||||
|
||||
const handleSetupGuideToggle = () => {
|
||||
const nextExpanded = !setupGuideExpanded
|
||||
setManualSetupGuideExpanded(nextExpanded)
|
||||
saveSetupGuideExpanded(nextExpanded)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{setupGuideExpanded ? (
|
||||
<CardStaggerContainer className='grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
|
||||
<CardStaggerItem className='bg-card h-full overflow-hidden rounded-2xl border shadow-xs'>
|
||||
<div className='relative h-full overflow-hidden p-4 sm:p-5'>
|
||||
<SetupGuideBackdrop />
|
||||
<div className='relative grid gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]'>
|
||||
<div className='flex min-w-0 flex-col gap-5'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='flex max-w-2xl flex-col gap-1'>
|
||||
<div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'>
|
||||
<ListChecks className='size-3.5' aria-hidden='true' />
|
||||
{t('Get started')}
|
||||
</div>
|
||||
<h3 className='text-xl font-semibold tracking-tight sm:text-2xl'>
|
||||
{t('Build on your API gateway in minutes')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground max-w-xl text-sm leading-relaxed'>
|
||||
{t(
|
||||
'A focused home for keys, balance, routing, and service health.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleSetupGuideToggle}
|
||||
>
|
||||
<ChevronUp data-icon='inline-start' />
|
||||
{t('Hide setup guide')}
|
||||
</Button>
|
||||
<Button size='sm' render={<Link to='/keys' />}>
|
||||
<KeyRound data-icon='inline-start' />
|
||||
{t('Create API Key')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className='bg-background/45 rounded-2xl border p-2 backdrop-blur'>
|
||||
{startSteps.map((step, index) => (
|
||||
<StartStepItem
|
||||
key={step.title}
|
||||
step={step}
|
||||
index={index}
|
||||
isLast={index === startSteps.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<RequestPreview
|
||||
example={requestExample}
|
||||
signals={heroSignals}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardStaggerItem>
|
||||
|
||||
<CardStaggerItem className='bg-card h-full rounded-2xl border p-4 shadow-xs sm:p-5'>
|
||||
<div className='flex h-full flex-col gap-4'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
{t('Recommended actions')}
|
||||
</div>
|
||||
<h3 className='text-lg font-semibold tracking-tight'>
|
||||
{t('Keep the platform ready')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
{visibleQuickActions.map((action) => (
|
||||
<QuickActionItem key={action.title} action={action} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardStaggerItem>
|
||||
</CardStaggerContainer>
|
||||
) : (
|
||||
<CardStaggerContainer>
|
||||
<CardStaggerItem className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
|
||||
<div className='relative overflow-hidden px-4 py-3 sm:px-5'>
|
||||
<SetupGuideBackdrop compact />
|
||||
<div className='relative flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 items-center gap-3'>
|
||||
<span className='bg-background/70 flex size-9 shrink-0 items-center justify-center rounded-xl border shadow-xs'>
|
||||
<Check className='text-success size-4' aria-hidden='true' />
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h3 className='truncate text-sm font-semibold'>
|
||||
{setupComplete
|
||||
? t('Setup guide complete')
|
||||
: t('Setup guide')}
|
||||
</h3>
|
||||
<span className='text-muted-foreground bg-background/60 rounded-md border px-2 py-0.5 text-xs'>
|
||||
{t('Setup progress: {{completed}}/{{total}}', {
|
||||
completed: completedStepCount,
|
||||
total: startSteps.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-xs'>
|
||||
{setupComplete
|
||||
? t(
|
||||
'Your setup guide is collapsed so usage stays in focus.'
|
||||
)
|
||||
: t('Setup guide is collapsed. Expand it anytime.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{visibleQuickActions.map((action) => (
|
||||
<CompactQuickAction key={action.title} action={action} />
|
||||
))}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='bg-background/70 h-8 min-w-28'
|
||||
onClick={handleSetupGuideToggle}
|
||||
>
|
||||
<ChevronDown data-icon='inline-start' />
|
||||
{t('Show setup guide')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardStaggerItem>
|
||||
</CardStaggerContainer>
|
||||
)}
|
||||
|
||||
<SummaryCards />
|
||||
|
||||
{showContentPanels && (
|
||||
|
||||
@@ -22,8 +22,8 @@ import { ArrowRight, Flame, ShieldCheck, TrendingDown } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { StaggerContainer, StaggerItem } from '@/components/page-transition'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getUserQuotaDates } from '@/features/dashboard/api'
|
||||
import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
||||
import type { QuotaDataItem } from '@/features/dashboard/types'
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Activity, RotateCw } from 'lucide-react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { getUptimeStatus } from '@/features/dashboard/api'
|
||||
import type {
|
||||
@@ -110,10 +110,9 @@ export function UptimePanel() {
|
||||
headerActions={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
size='icon-sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className='size-7 p-0'
|
||||
>
|
||||
<RotateCw
|
||||
className={cn('size-3.5', refreshing && 'animate-spin')}
|
||||
|
||||
@@ -22,8 +22,8 @@ import { Users, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
|
||||
import {
|
||||
|
||||
+4
-4
@@ -21,11 +21,11 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useState, useCallback, useMemo, lazy, Suspense } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -265,7 +265,7 @@ export function Dashboard() {
|
||||
? t('Hide sensitive data')
|
||||
: t('Show sensitive data')
|
||||
}
|
||||
className='text-muted-foreground hover:text-foreground size-8'
|
||||
className='text-muted-foreground hover:text-foreground'
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -298,7 +298,7 @@ export function Dashboard() {
|
||||
<div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
|
||||
{showSectionTabs ? (
|
||||
<Tabs value={activeSection} onValueChange={handleSectionChange}>
|
||||
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
|
||||
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-auto'>
|
||||
{visibleSections.map((section) => (
|
||||
<TabsTrigger key={section} value={section}>
|
||||
{t(SECTION_META[section].titleKey)}
|
||||
|
||||
@@ -1,56 +1,13 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import type { FlowUserFilterOption } from '../types'
|
||||
import {
|
||||
compactFlowSelectionLabel,
|
||||
flowDisplayState,
|
||||
requireSuccessfulFlowRows,
|
||||
visibleFlowUsers,
|
||||
} from './flow-selection'
|
||||
|
||||
const users: FlowUserFilterOption[] = [
|
||||
{
|
||||
value: 'user:1',
|
||||
label: 'dry',
|
||||
valueLabel: '100',
|
||||
valueRaw: 100,
|
||||
color: '#1664ff',
|
||||
},
|
||||
{
|
||||
value: 'user:2',
|
||||
label: 'jrc',
|
||||
valueLabel: '70',
|
||||
valueRaw: 70,
|
||||
color: '#1ac6ff',
|
||||
},
|
||||
]
|
||||
|
||||
describe('dashboard flow selection helpers', () => {
|
||||
test('limits user chips to currently visible users', () => {
|
||||
assert.deepEqual(
|
||||
visibleFlowUsers(users, []).map((user) => user.value),
|
||||
['user:1', 'user:2']
|
||||
)
|
||||
assert.deepEqual(
|
||||
visibleFlowUsers(users, ['user:2']).map((user) => user.value),
|
||||
['user:2']
|
||||
)
|
||||
})
|
||||
|
||||
test('filters visible users without mutating the source options', () => {
|
||||
const visible = visibleFlowUsers(users, ['user:1'])
|
||||
|
||||
assert.deepEqual(
|
||||
visible.map((user) => user.value),
|
||||
['user:1']
|
||||
)
|
||||
assert.deepEqual(
|
||||
users.map((user) => user.value),
|
||||
['user:1', 'user:2']
|
||||
)
|
||||
})
|
||||
|
||||
test('formats compact selected counts for flow multiselect summaries', () => {
|
||||
assert.equal(compactFlowSelectionLabel(0), '*')
|
||||
assert.equal(compactFlowSelectionLabel(1), '1')
|
||||
|
||||
+1
-13
@@ -16,10 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type {
|
||||
FlowQuotaDataItem,
|
||||
FlowUserFilterOption,
|
||||
} from '@/features/dashboard/types'
|
||||
import type { FlowQuotaDataItem } from '@/features/dashboard/types'
|
||||
|
||||
export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart'
|
||||
|
||||
@@ -55,12 +52,3 @@ export function flowDisplayState(options: {
|
||||
export function compactFlowSelectionLabel(count: number): string {
|
||||
return count > 0 ? String(count) : '*'
|
||||
}
|
||||
|
||||
export function visibleFlowUsers(
|
||||
users: FlowUserFilterOption[],
|
||||
selectedUsers: string[]
|
||||
): FlowUserFilterOption[] {
|
||||
if (selectedUsers.length === 0) return users
|
||||
const selected = new Set(selectedUsers)
|
||||
return users.filter((user) => selected.has(user.value))
|
||||
}
|
||||
|
||||
+4
-6
@@ -2,11 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import type { FlowQuotaDataItem } from '../types'
|
||||
import {
|
||||
buildDashboardFlowData,
|
||||
buildFlowFilterOptions,
|
||||
buildFlowSankeySpec,
|
||||
} from './flow'
|
||||
import { buildDashboardFlowData, buildFlowSankeySpec } from './flow'
|
||||
|
||||
const rows: FlowQuotaDataItem[] = [
|
||||
{
|
||||
@@ -276,7 +272,9 @@ describe('dashboard flow data', () => {
|
||||
})
|
||||
|
||||
test('builds user filter options with stable values', () => {
|
||||
const options = buildFlowFilterOptions(rows, 'quota')
|
||||
const options = buildDashboardFlowData(rows, 'quota', {
|
||||
role: 'admin',
|
||||
}).filterOptions
|
||||
|
||||
assert.deepEqual(
|
||||
options.users.map((user) => [user.value, user.label, user.valueLabel]),
|
||||
|
||||
-22
@@ -947,28 +947,6 @@ function buildNodeFilterOptions(
|
||||
)
|
||||
}
|
||||
|
||||
export function buildFlowFilterOptions(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric = 'quota',
|
||||
palette?: readonly string[],
|
||||
role: FlowRole = DEFAULT_FLOW_ROLE,
|
||||
visibleStages?: FlowNodeKind[],
|
||||
selectedNodes?: readonly FlowNodeFilter[]
|
||||
): FlowFilterOptions {
|
||||
return {
|
||||
users: buildUserFilterOptions(rows, metric, palette),
|
||||
nodes: buildNodeFilterOptions(
|
||||
rows,
|
||||
metric,
|
||||
role,
|
||||
visibleStages,
|
||||
palette,
|
||||
EMPTY_FLOW_PATH_CONTEXT,
|
||||
selectedNodes
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDashboardFlowData(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric = 'quota',
|
||||
|
||||
Reference in New Issue
Block a user