Commit a7d019e3 by t0ng7u

feat(default): redesign dashboard overview

Refresh the overview page with an actionable Get Started guide, live API request details, real usage sparklines, and OpenAI-inspired dashboard panels. Add collapsible setup state, role-aware quick actions, and localized copy so returning users can focus on platform health.
parent e8cfb546
...@@ -45,6 +45,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -45,6 +45,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
) )
let title: ReactNode = null let title: ReactNode = null
let description: ReactNode = null
let actions: ReactNode = null let actions: ReactNode = null
let content: ReactNode = null let content: ReactNode = null
let breadcrumb: ReactNode = null let breadcrumb: ReactNode = null
...@@ -53,6 +54,8 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -53,6 +54,8 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
if (!isValidElement(node)) return if (!isValidElement(node)) return
const child = node as ReactElement<SlotProps> const child = node as ReactElement<SlotProps>
if (child.type === SectionPageLayoutTitle) title = child.props.children if (child.type === SectionPageLayoutTitle) title = child.props.children
else if (child.type === SectionPageLayoutDescription)
description = child.props.children
else if (child.type === SectionPageLayoutActions) else if (child.type === SectionPageLayoutActions)
actions = child.props.children actions = child.props.children
else if (child.type === SectionPageLayoutContent) else if (child.type === SectionPageLayoutContent)
...@@ -73,6 +76,11 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -73,6 +76,11 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'> <h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
{title} {title}
</h2> </h2>
{description != null && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-sm'>
{description}
</p>
)}
</div> </div>
{actions != null && ( {actions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'> <div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'>
......
...@@ -183,7 +183,11 @@ export function OtpForm({ className, ...props }: OtpFormProps) { ...@@ -183,7 +183,11 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
)} )}
/> />
<Button type='submit' className='mt-2 w-full' disabled={!isFormValid || isLoading}> <Button
type='submit'
className='mt-2 w-full'
disabled={!isFormValid || isLoading}
>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null} {isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Verify and Sign In')} {t('Verify and Sign In')}
</Button> </Button>
......
import { useEffect, useState } from 'react' import { useState } from 'react'
import { Save, Settings2 } from 'lucide-react' import { Save, Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time' import type { TimeGranularity } from '@/lib/time'
...@@ -45,9 +45,10 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) { ...@@ -45,9 +45,10 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
props.preferences props.preferences
) )
useEffect(() => { const handleOpenChange = (nextOpen: boolean) => {
if (open) setDraft(props.preferences) if (nextOpen) setDraft(props.preferences)
}, [open, props.preferences]) setOpen(nextOpen)
}
const handleSave = () => { const handleSave = () => {
props.onPreferencesChange(draft) props.onPreferencesChange(draft)
...@@ -55,7 +56,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) { ...@@ -55,7 +56,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}> <DialogTrigger render={<Button variant='outline' size='sm' />}>
<Settings2 className='mr-2 h-4 w-4' /> <Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')} {t('Preferences')}
......
import { useEffect, useState } from 'react' import { useState } from 'react'
import { Filter, RotateCcw, Calendar, Search } from 'lucide-react' import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
...@@ -73,10 +73,15 @@ export function ModelsFilter(props: ModelsFilterProps) { ...@@ -73,10 +73,15 @@ export function ModelsFilter(props: ModelsFilterProps) {
() => props.preferences.defaultTimeRangeDays () => props.preferences.defaultTimeRangeDays
) )
useEffect(() => { const resetFiltersFromPreferences = () => {
setFilters(buildDefaultDashboardFilters(props.preferences)) setFilters(buildDefaultDashboardFilters(props.preferences))
setSelectedRange(props.preferences.defaultTimeRangeDays) setSelectedRange(props.preferences.defaultTimeRangeDays)
}, [props.preferences]) }
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) resetFiltersFromPreferences()
setOpen(nextOpen)
}
const handleApply = () => { const handleApply = () => {
props.onFilterChange( props.onFilterChange(
...@@ -121,7 +126,7 @@ export function ModelsFilter(props: ModelsFilterProps) { ...@@ -121,7 +126,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}> <DialogTrigger render={<Button variant='outline' size='sm' />}>
<Filter className='mr-2 h-4 w-4' /> <Filter className='mr-2 h-4 w-4' />
{t('Filter')} {t('Filter')}
......
...@@ -44,13 +44,15 @@ export function AnnouncementsPanel() { ...@@ -44,13 +44,15 @@ export function AnnouncementsPanel() {
{t('Announcements')} {t('Announcements')}
</span> </span>
} }
description={t('Latest platform updates and notices')}
loading={loading} loading={loading}
empty={!list.length} empty={!list.length}
emptyMessage={t('No announcements at this time')} emptyMessage={t('No announcements at this time')}
height='h-56 sm:h-64' height='h-72'
contentClassName='p-0'
> >
<ScrollArea className='h-56 sm:h-64'> <ScrollArea className='h-72'>
<div className='-mx-3 sm:-mx-5'> <div>
{list.map((item: AnnouncementItem, idx: number) => { {list.map((item: AnnouncementItem, idx: number) => {
const key = item.id ?? `announcement-${idx}` const key = item.id ?? `announcement-${idx}`
return ( return (
...@@ -65,7 +67,7 @@ export function AnnouncementsPanel() { ...@@ -65,7 +67,7 @@ export function AnnouncementsPanel() {
> >
<div className='flex items-start gap-2.5'> <div className='flex items-start gap-2.5'>
<AnnouncementStatusDot type={item.type} /> <AnnouncementStatusDot type={item.type} />
<div className='min-w-0 flex-1 space-y-1'> <div className='flex min-w-0 flex-1 flex-col gap-1'>
<p className='line-clamp-1 text-sm font-medium'> <p className='line-clamp-1 text-sm font-medium'>
{getPreviewText(item.content)} {getPreviewText(item.content)}
</p> </p>
......
...@@ -34,13 +34,15 @@ export function ApiInfoPanel() { ...@@ -34,13 +34,15 @@ export function ApiInfoPanel() {
{t('API Info')} {t('API Info')}
</span> </span>
} }
description={t('Configured routes and latency checks')}
loading={loading} loading={loading}
empty={!list.length} empty={!list.length}
emptyMessage={t('No API routes configured')} emptyMessage={t('No API routes configured')}
height='h-56 sm:h-64' height='h-72'
contentClassName='p-0'
> >
<ScrollArea className='h-56 sm:h-64'> <ScrollArea className='h-72'>
<div className='-mx-3 sm:-mx-5'> <div>
{list.map((item: ApiInfoItem, idx: number) => ( {list.map((item: ApiInfoItem, idx: number) => (
<div <div
key={item.url} key={item.url}
......
...@@ -24,13 +24,15 @@ export function FAQPanel() { ...@@ -24,13 +24,15 @@ export function FAQPanel() {
{t('FAQ')} {t('FAQ')}
</span> </span>
} }
description={t('Answers for common access and billing questions')}
loading={loading} loading={loading}
empty={!list.length} empty={!list.length}
emptyMessage={t('No FAQ entries available')} emptyMessage={t('No FAQ entries available')}
height='h-64 sm:h-80' height='h-80'
contentClassName='p-0'
> >
<ScrollArea className='h-64 sm:h-80'> <ScrollArea className='h-80'>
<Accordion className='w-full'> <Accordion className='w-full px-4 sm:px-5'>
{list.map((item: FAQItem, idx: number) => { {list.map((item: FAQItem, idx: number) => {
const key = item.id ?? `faq-${idx}` const key = item.id ?? `faq-${idx}`
const value = `item-${key}` const value = `item-${key}`
......
import { useMemo } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { CreditCard } from 'lucide-react' import { ArrowRight, CreditCard } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency' import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
import { formatNumber, formatQuota } from '@/lib/format' import { formatNumber, formatQuota } from '@/lib/format'
import { computeTimeRange } from '@/lib/time'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { StaggerContainer, StaggerItem } from '@/components/page-transition' import { StaggerContainer, StaggerItem } from '@/components/page-transition'
import { getUserQuotaDates } from '@/features/dashboard/api'
import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config' import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
import type { QuotaDataItem } from '@/features/dashboard/types'
import { StatCard } from '../ui/stat-card' import { StatCard } from '../ui/stat-card'
const SUMMARY_SPARKLINE_BUCKETS = 12
type SummarySparklineKey = 'balance' | 'usage' | 'requests'
function getBucketIndex(
timestamp: number,
start: number,
end: number,
bucketCount: number
): number {
if (end <= start) return 0
const ratio = (timestamp - start) / (end - start)
return Math.min(bucketCount - 1, Math.max(0, Math.floor(ratio * bucketCount)))
}
function buildSummarySparklines(
data: QuotaDataItem[],
currentBalance: number,
start: number,
end: number
): Record<SummarySparklineKey, number[]> {
const usage = Array.from({ length: SUMMARY_SPARKLINE_BUCKETS }, () => 0)
const requests = Array.from({ length: SUMMARY_SPARKLINE_BUCKETS }, () => 0)
for (const item of data) {
const timestamp = Number(item.created_at) || start
const index = getBucketIndex(
timestamp,
start,
end,
SUMMARY_SPARKLINE_BUCKETS
)
usage[index] += Number(item.quota) || 0
requests[index] += Number(item.count) || 0
}
let balance = currentBalance
const balanceTrend = Array.from(
{ length: SUMMARY_SPARKLINE_BUCKETS },
() => 0
)
for (let index = SUMMARY_SPARKLINE_BUCKETS - 1; index >= 0; index--) {
balanceTrend[index] = Math.max(0, balance)
balance += usage[index]
}
return {
balance: balanceTrend,
usage,
requests,
}
}
export function SummaryCards() { export function SummaryCards() {
const { t } = useTranslation() const { t } = useTranslation()
const user = useAuthStore((state) => state.auth.user) const user = useAuthStore((state) => state.auth.user)
const { status, loading } = useStatus() const { status, loading } = useStatus()
const summaryTimeRange = useMemo(() => computeTimeRange(1), [])
const usageTrendQuery = useQuery({
queryKey: [
'dashboard',
'overview',
'summary-sparklines',
summaryTimeRange.start_timestamp,
summaryTimeRange.end_timestamp,
],
queryFn: async () =>
getUserQuotaDates({
start_timestamp: summaryTimeRange.start_timestamp,
end_timestamp: summaryTimeRange.end_timestamp,
default_time: 'hour',
}),
staleTime: 60 * 1000,
})
const summaryValues = useMemo(() => { const summaryValues = useMemo(() => {
const remainQuota = Number(user?.quota ?? 0) const remainQuota = Number(user?.quota ?? 0)
const usedQuota = Number(user?.used_quota ?? 0) const usedQuota = Number(user?.used_quota ?? 0)
...@@ -39,46 +116,104 @@ export function SummaryCards() { ...@@ -39,46 +116,104 @@ export function SummaryCards() {
: currencyEnabledFromStore : currencyEnabledFromStore
const currencyLabel = currencyEnabled ? getCurrencyLabel() : 'Tokens' const currencyLabel = currencyEnabled ? getCurrencyLabel() : 'Tokens'
const sparklineData = useMemo(
() =>
buildSummarySparklines(
usageTrendQuery.data?.data ?? [],
Number(user?.quota ?? 0),
summaryTimeRange.start_timestamp,
summaryTimeRange.end_timestamp
),
[
summaryTimeRange.end_timestamp,
summaryTimeRange.start_timestamp,
usageTrendQuery.data?.data,
user?.quota,
]
)
const items = useSummaryCardsConfig({ const items = useSummaryCardsConfig({
...summaryValues, ...summaryValues,
currencyEnabled, currencyEnabled,
currencyLabel, currencyLabel,
}).map((config, index) => ({ }).map((config, index) => {
const tones = ['rose', 'teal', 'gray'] as const
return {
title: config.title, title: config.title,
value: config.value, value: config.value,
desc: config.description, desc: config.description,
icon: config.icon, icon: config.icon,
isBalance: index === 0, tone: tones[index] ?? 'gray',
})) sparkline:
config.key === 'balance'
? sparklineData.balance
: config.key === 'usage'
? sparklineData.usage
: sparklineData.requests,
}
})
return ( return (
<div className='overflow-hidden rounded-lg border'> <div className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
<StaggerContainer className='divide-border/60 grid grid-cols-3 divide-x'> <div className='grid xl:grid-cols-[minmax(0,1fr)_19rem]'>
<div className='flex flex-col gap-3 p-4 sm:p-5'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex flex-col gap-1'>
<h3 className='text-base font-semibold'>
{t('Usage at a glance')}
</h3>
<p className='text-muted-foreground text-sm'>
{t('Monitor balance, usage, and request volume')}
</p>
</div>
</div>
<StaggerContainer className='grid gap-3 md:grid-cols-3'>
{items.map((it) => ( {items.map((it) => (
<StaggerItem key={it.title} className='px-3 py-3 sm:px-5 sm:py-4'> <StaggerItem
key={it.title}
className='bg-background/60 rounded-xl border p-3'
>
<StatCard <StatCard
title={it.title} title={it.title}
value={it.value} value={it.value}
description={it.desc} description={it.desc}
icon={it.icon} icon={it.icon}
tone={it.tone}
sparkline={it.sparkline}
loading={loading} loading={loading}
action={
it.isBalance ? (
<Button
variant='outline'
size='sm'
className='hidden h-6 gap-1 px-2 text-xs sm:inline-flex'
render={<Link to='/wallet' />}
>
<CreditCard className='size-3' />
{t('Recharge')}
</Button>
) : undefined
}
/> />
</StaggerItem> </StaggerItem>
))} ))}
</StaggerContainer> </StaggerContainer>
</div> </div>
<div className='flex flex-col justify-between gap-5 border-t bg-amber-50/80 p-4 sm:p-5 xl:border-t-0 xl:border-l dark:bg-amber-950/20'>
<div className='flex flex-col gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Credit remaining')}
</div>
<div className='flex items-center gap-2'>
<span className='font-mono text-2xl font-semibold tracking-tight'>
{summaryValues.remainDisplay}
</span>
<CreditCard
className='text-muted-foreground size-4'
aria-hidden='true'
/>
</div>
<p className='text-muted-foreground text-sm leading-relaxed'>
{currencyEnabled
? `${t('Displayed in')} ${currencyLabel}`
: t('Balance is shown in quota units')}
</p>
</div>
<Button className='justify-between' render={<Link to='/wallet' />}>
<span>{t('Recharge')}</span>
<ArrowRight data-icon='inline-end' />
</Button>
</div>
</div>
</div>
) )
} }
...@@ -81,10 +81,12 @@ export function UptimePanel() { ...@@ -81,10 +81,12 @@ export function UptimePanel() {
{t('Uptime')} {t('Uptime')}
</span> </span>
} }
description={t('Grouped monitor status from Uptime Kuma')}
loading={loading} loading={loading}
empty={!groups.length} empty={!groups.length}
emptyMessage={t('No uptime monitoring configured')} emptyMessage={t('No uptime monitoring configured')}
height='h-64 sm:h-80' height='h-80'
contentClassName='p-0'
headerActions={ headerActions={
<Button <Button
variant='ghost' variant='ghost'
...@@ -100,8 +102,8 @@ export function UptimePanel() { ...@@ -100,8 +102,8 @@ export function UptimePanel() {
</Button> </Button>
} }
> >
<ScrollArea className='h-64 sm:h-80'> <ScrollArea className='h-80'>
<div className='-mx-3 space-y-0 sm:-mx-5'> <div>
{groups.map((group, groupIdx) => ( {groups.map((group, groupIdx) => (
<div key={group.categoryName}> <div key={group.categoryName}>
<div className='bg-muted/30 border-border/60 border-b px-3 py-2 sm:px-5'> <div className='bg-muted/30 border-border/60 border-b px-3 py-2 sm:px-5'>
......
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
interface PanelWrapperProps { interface PanelWrapperProps {
title: ReactNode title: ReactNode
description?: ReactNode
loading?: boolean loading?: boolean
empty?: boolean empty?: boolean
emptyMessage?: string emptyMessage?: string
height?: string height?: string
className?: string
contentClassName?: string
headerActions?: ReactNode headerActions?: ReactNode
children?: ReactNode children?: ReactNode
} }
function PanelHeader(props: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
}) {
const heading = (
<div className='flex flex-col gap-1'>
<div className='text-sm font-semibold'>{props.title}</div>
{props.description != null && (
<div className='text-muted-foreground text-xs'>{props.description}</div>
)}
</div>
)
return (
<div className='border-b px-4 py-3 sm:px-5'>
{props.actions != null ? (
<div className='flex items-start justify-between gap-2'>
{heading}
{props.actions}
</div>
) : (
heading
)}
</div>
)
}
export function PanelWrapper(props: PanelWrapperProps) { export function PanelWrapper(props: PanelWrapperProps) {
const { t } = useTranslation() const { t } = useTranslation()
const resolvedEmptyMessage = props.emptyMessage ?? t('No data available') const resolvedEmptyMessage = props.emptyMessage ?? t('No data available')
const height = props.height ?? 'h-64' const height = props.height ?? 'h-64'
const frameClassName = cn(
'overflow-hidden rounded-2xl border bg-card shadow-xs',
props.className
)
if (props.loading) { if (props.loading) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <div className={frameClassName}>
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'> <PanelHeader title={props.title} description={props.description} />
<div className='text-sm font-semibold'>{props.title}</div> <div className={cn('p-4 sm:p-5', props.contentClassName)}>
</div>
<div className='p-3 sm:p-5'>
<Skeleton className={`w-full ${height}`} /> <Skeleton className={`w-full ${height}`} />
</div> </div>
</div> </div>
...@@ -32,12 +66,14 @@ export function PanelWrapper(props: PanelWrapperProps) { ...@@ -32,12 +66,14 @@ export function PanelWrapper(props: PanelWrapperProps) {
if (props.empty) { if (props.empty) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <div className={frameClassName}>
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'> <PanelHeader title={props.title} description={props.description} />
<div className='text-sm font-semibold'>{props.title}</div>
</div>
<div <div
className={`text-muted-foreground flex items-center justify-center text-sm ${height}`} className={cn(
'text-muted-foreground flex items-center justify-center px-4 text-sm',
height,
props.contentClassName
)}
> >
{resolvedEmptyMessage} {resolvedEmptyMessage}
</div> </div>
...@@ -46,18 +82,15 @@ export function PanelWrapper(props: PanelWrapperProps) { ...@@ -46,18 +82,15 @@ export function PanelWrapper(props: PanelWrapperProps) {
} }
return ( return (
<div className='overflow-hidden rounded-lg border'> <div className={frameClassName}>
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'> <PanelHeader
{props.headerActions ? ( title={props.title}
<div className='flex items-center justify-between gap-2'> description={props.description}
<div className='text-sm font-semibold'>{props.title}</div> actions={props.headerActions}
{props.headerActions} />
</div> <div className={cn('p-4 sm:p-5', props.contentClassName)}>
) : ( {props.children}
<div className='text-sm font-semibold'>{props.title}</div>
)}
</div> </div>
<div className='p-3 sm:p-5'>{props.children}</div>
</div> </div>
) )
} }
import type { ReactNode } from 'react'
import { type LucideIcon } from 'lucide-react' import { type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
type StatCardTone = 'rose' | 'teal' | 'gray'
interface StatCardProps { interface StatCardProps {
title: string title: string
value: string | number value: string | number
description: string description: string
icon: LucideIcon icon: LucideIcon
sparkline?: number[]
tone?: StatCardTone
loading?: boolean loading?: boolean
error?: boolean error?: boolean
action?: React.ReactNode action?: ReactNode
}
const TONE_CLASSES: Record<StatCardTone, string> = {
rose: 'from-rose-500/80 via-rose-300/70 to-rose-200/20 dark:from-rose-400/70 dark:via-rose-500/30 dark:to-rose-500/5',
teal: 'from-teal-500/80 via-teal-300/70 to-teal-200/20 dark:from-teal-400/70 dark:via-teal-500/30 dark:to-teal-500/5',
gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20',
}
function normalizeSparkline(values?: number[]): number[] {
if (!values?.length) return []
const sanitized = values.map((value) => Math.max(0, Number(value) || 0))
const max = Math.max(...sanitized)
if (max <= 0) return sanitized.map(() => 0)
return sanitized.map((value) => Math.max(8, (value / max) * 100))
} }
export function StatCard(props: StatCardProps) { export function StatCard(props: StatCardProps) {
const Icon = props.icon const Icon = props.icon
const tone = props.tone ?? 'gray'
const sparkline = normalizeSparkline(props.sparkline)
return ( return (
<div className='group flex flex-col gap-1'> <div className='group flex min-h-32 flex-col justify-between gap-3'>
<div className='flex items-start justify-between gap-1'> <div className='flex items-start justify-between gap-1'>
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wider uppercase sm:gap-2'> <div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium sm:gap-2'>
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' /> <Icon
className='text-muted-foreground/60 size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='line-clamp-2 leading-snug'>{props.title}</span> <span className='line-clamp-2 leading-snug'>{props.title}</span>
</div> </div>
{props.action && <div className='shrink-0'>{props.action}</div>} {props.action && <div className='shrink-0'>{props.action}</div>}
</div> </div>
{props.loading ? ( {props.loading ? (
<div className='space-y-1.5'> <div className='flex flex-col gap-1.5'>
<Skeleton className='h-7 w-24' /> <Skeleton className='h-7 w-24' />
<Skeleton className='h-3.5 w-32' /> <Skeleton className='h-3.5 w-32' />
</div> </div>
) : props.error ? ( ) : props.error ? (
<> <div className='flex flex-col gap-1'>
<div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'> <div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
-- --
</div> </div>
<p className='text-muted-foreground/60 hidden text-xs md:block'> <p className='text-muted-foreground/60 text-xs'>
{props.description} {props.description}
</p> </p>
</> </div>
) : ( ) : (
<> <div className='flex flex-col gap-1'>
<div className='text-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'> <div className='text-foreground font-mono text-2xl font-semibold tracking-tight break-all tabular-nums'>
{props.value} {props.value}
</div> </div>
<p className='text-muted-foreground/60 hidden text-xs md:block'> <p className='text-muted-foreground/60 text-xs leading-relaxed'>
{props.description} {props.description}
</p> </p>
</> </div>
)} )}
<div className='flex h-8 items-end gap-1' aria-hidden='true'>
{sparkline.map((height, index) => (
<span
key={`${props.title}-spark-${index}`}
className={cn(
'flex-1 rounded-t-sm bg-linear-to-t',
height <= 0 && 'opacity-20',
TONE_CLASSES[tone]
)}
style={{ height: `${height}%` }}
/>
))}
</div>
</div> </div>
) )
} }
...@@ -6,18 +6,10 @@ import { ROLE } from '@/lib/roles' ...@@ -6,18 +6,10 @@ import { ROLE } from '@/lib/roles'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SectionPageLayout } from '@/components/layout' import { SectionPageLayout } from '@/components/layout'
import { import { FadeIn } from '@/components/page-transition'
CardStaggerContainer,
CardStaggerItem,
FadeIn,
} from '@/components/page-transition'
import { ModelsChartPreferences } from './components/models/models-chart-preferences' import { ModelsChartPreferences } from './components/models/models-chart-preferences'
import { ModelsFilter } from './components/models/models-filter-dialog' import { ModelsFilter } from './components/models/models-filter-dialog'
import { AnnouncementsPanel } from './components/overview/announcements-panel' import { OverviewDashboard } from './components/overview/overview-dashboard'
import { ApiInfoPanel } from './components/overview/api-info-panel'
import { FAQPanel } from './components/overview/faq-panel'
import { SummaryCards } from './components/overview/summary-cards'
import { UptimePanel } from './components/overview/uptime-panel'
import { DEFAULT_TIME_GRANULARITY } from './constants' import { DEFAULT_TIME_GRANULARITY } from './constants'
import { import {
buildDefaultDashboardFilters, buildDefaultDashboardFilters,
...@@ -215,25 +207,7 @@ export function Dashboard() { ...@@ -215,25 +207,7 @@ export function Dashboard() {
)} )}
</div> </div>
)} )}
{activeSection === 'overview' && ( {activeSection === 'overview' && <OverviewDashboard />}
<>
<SummaryCards />
<CardStaggerContainer className='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2'>
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
<CardStaggerItem>
<AnnouncementsPanel />
</CardStaggerItem>
<CardStaggerItem>
<FAQPanel />
</CardStaggerItem>
<CardStaggerItem>
<UptimePanel />
</CardStaggerItem>
</CardStaggerContainer>
</>
)}
{activeSection === 'models' && ( {activeSection === 'models' && (
<> <>
<FadeIn> <FadeIn>
......
import type { TimeGranularity } from '@/lib/time' import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
import { getRollingDateRange } from '@/lib/time'
import { import {
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY, DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
DEFAULT_DASHBOARD_CHART_PREFERENCES, DEFAULT_DASHBOARD_CHART_PREFERENCES,
......
...@@ -9,8 +9,7 @@ export type ModelPerfBadgeData = { ...@@ -9,8 +9,7 @@ export type ModelPerfBadgeData = {
avg_tps: number avg_tps: number
} }
export interface ModelPerfBadgeProps export interface ModelPerfBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
extends React.HTMLAttributes<HTMLDivElement> {
perf: ModelPerfBadgeData | undefined perf: ModelPerfBadgeData | undefined
} }
...@@ -47,7 +46,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge( ...@@ -47,7 +46,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
<div className='text-muted-foreground/55 text-[10px] leading-4'> <div className='text-muted-foreground/55 text-[10px] leading-4'>
{t('Latency short')} {t('Latency short')}
</div> </div>
<div className='text-muted-foreground/80 whitespace-nowrap font-mono text-xs leading-4'> <div className='text-muted-foreground/80 font-mono text-xs leading-4 whitespace-nowrap'>
{avg_latency_ms > 0 ? formatLatency(avg_latency_ms) : '—'} {avg_latency_ms > 0 ? formatLatency(avg_latency_ms) : '—'}
</div> </div>
</div> </div>
...@@ -55,7 +54,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge( ...@@ -55,7 +54,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'> <div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
{t('Throughput short')} {t('Throughput short')}
</div> </div>
<div className='text-muted-foreground/80 whitespace-nowrap font-mono text-xs leading-4'> <div className='text-muted-foreground/80 font-mono text-xs leading-4 whitespace-nowrap'>
{formatCompactThroughput(avg_tps)} {formatCompactThroughput(avg_tps)}
</div> </div>
</div> </div>
......
import { parseCurrencyDisplayType } from '@/lib/currency' import { parseCurrencyDisplayType } from '@/lib/currency'
import type { BillingSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { CheckinSettingsSection } from '../general/checkin-settings-section' import { CheckinSettingsSection } from '../general/checkin-settings-section'
import { PricingSection } from '../general/pricing-section' import { PricingSection } from '../general/pricing-section'
import { QuotaSettingsSection } from '../general/quota-settings-section' import { QuotaSettingsSection } from '../general/quota-settings-section'
import { PaymentSettingsSection } from '../integrations/payment-settings-section' import { PaymentSettingsSection } from '../integrations/payment-settings-section'
import { RatioSettingsCard } from '../models/ratio-settings-card' import { RatioSettingsCard } from '../models/ratio-settings-card'
import type { BillingSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
const getModelDefaults = (settings: BillingSettings) => ({ const getModelDefaults = (settings: BillingSettings) => ({
ModelPrice: settings.ModelPrice, ModelPrice: settings.ModelPrice,
...@@ -161,8 +161,7 @@ const BILLING_SECTIONS = [ ...@@ -161,8 +161,7 @@ const BILLING_SECTIONS = [
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '', WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
WaffoPancakeWebhookPublicKey: WaffoPancakeWebhookPublicKey:
settings.WaffoPancakeWebhookPublicKey ?? '', settings.WaffoPancakeWebhookPublicKey ?? '',
WaffoPancakeWebhookTestKey: WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
settings.WaffoPancakeWebhookTestKey ?? '',
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '', WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '', WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '', WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
...@@ -191,14 +190,15 @@ const BILLING_SECTIONS = [ ...@@ -191,14 +190,15 @@ const BILLING_SECTIONS = [
export type BillingSectionId = (typeof BILLING_SECTIONS)[number]['id'] export type BillingSectionId = (typeof BILLING_SECTIONS)[number]['id']
const billingRegistry = createSectionRegistry<BillingSectionId, BillingSettings>( const billingRegistry = createSectionRegistry<
{ BillingSectionId,
BillingSettings
>({
sections: BILLING_SECTIONS, sections: BILLING_SECTIONS,
defaultSection: 'quota', defaultSection: 'quota',
basePath: '/system-settings/billing', basePath: '/system-settings/billing',
urlStyle: 'path', urlStyle: 'path',
} })
)
export const BILLING_SECTION_IDS = billingRegistry.sectionIds export const BILLING_SECTION_IDS = billingRegistry.sectionIds
export const BILLING_DEFAULT_SECTION = billingRegistry.defaultSection export const BILLING_DEFAULT_SECTION = billingRegistry.defaultSection
......
import * as z from 'zod'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import * as z from 'zod'
import type { Resolver } from 'react-hook-form' import type { Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
......
...@@ -2,13 +2,13 @@ import { memo, useCallback, useState } from 'react' ...@@ -2,13 +2,13 @@ import { memo, useCallback, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form' import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye, HelpCircle } from 'lucide-react' import { Code2, Eye, HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from '@/components/ui/accordion' } from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import { import {
Form, Form,
FormControl, FormControl,
...@@ -18,8 +18,6 @@ import { ...@@ -18,8 +18,6 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
...@@ -27,6 +25,8 @@ import { ...@@ -27,6 +25,8 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor' import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor' import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
...@@ -72,11 +72,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({ ...@@ -72,11 +72,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<div className='flex flex-wrap justify-end gap-2'> <div className='flex flex-wrap justify-end gap-2'>
<Button <Button variant='outline' size='sm' onClick={() => setGuideOpen(true)}>
variant='outline'
size='sm'
onClick={() => setGuideOpen(true)}
>
<HelpCircle className='mr-2 h-4 w-4' /> <HelpCircle className='mr-2 h-4 w-4' />
{t('Usage guide')} {t('Usage guide')}
</Button> </Button>
...@@ -435,7 +431,9 @@ vip 0.5 ${t('No')} ${t('Assigned by administrator on ...@@ -435,7 +431,9 @@ vip 0.5 ${t('No')} ${t('Assigned by administrator on
</AccordionItem> </AccordionItem>
<AccordionItem value='usable'> <AccordionItem value='usable'>
<AccordionTrigger>{t('Special usable group rules')}</AccordionTrigger> <AccordionTrigger>
{t('Special usable group rules')}
</AccordionTrigger>
<AccordionContent className='space-y-3'> <AccordionContent className='space-y-3'>
<p className='text-muted-foreground text-sm leading-6'> <p className='text-muted-foreground text-sm leading-6'>
{t( {t(
......
...@@ -9,12 +9,12 @@ import { ...@@ -9,12 +9,12 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Checkbox } from '@/components/ui/checkbox'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
...@@ -830,7 +830,9 @@ function GroupPricingTable({ ...@@ -830,7 +830,9 @@ function GroupPricingTable({
<div> <div>
<CardTitle>{t('Pricing groups')}</CardTitle> <CardTitle>{t('Pricing groups')}</CardTitle>
<CardDescription> <CardDescription>
{t('Edit billing ratios and user-selectable groups in one table.')} {t(
'Edit billing ratios and user-selectable groups in one table.'
)}
</CardDescription> </CardDescription>
</div> </div>
<Button onClick={addRow} size='sm' className='sm:self-start'> <Button onClick={addRow} size='sm' className='sm:self-start'>
...@@ -900,11 +902,7 @@ function GroupPricingTable({ ...@@ -900,11 +902,7 @@ function GroupPricingTable({
<Checkbox <Checkbox
checked={row.selectable} checked={row.selectable}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateRow( updateRow(row._id, 'selectable', checked === true)
row._id,
'selectable',
checked === true
)
} }
aria-label={t('User selectable')} aria-label={t('User selectable')}
/> />
......
import { ChannelAffinitySection } from '../general/channel-affinity'
import { IoNetDeploymentSettingsSection } from '../integrations/ionet-deployment-settings-section'
import type { ModelSettings } from '../types' import type { ModelSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry' import { createSectionRegistry } from '../utils/section-registry'
import { ClaudeSettingsCard } from './claude-settings-card' import { ClaudeSettingsCard } from './claude-settings-card'
import { GeminiSettingsCard } from './gemini-settings-card' import { GeminiSettingsCard } from './gemini-settings-card'
import { GlobalSettingsCard } from './global-settings-card' import { GlobalSettingsCard } from './global-settings-card'
import { GrokSettingsCard } from './grok-settings-card' import { GrokSettingsCard } from './grok-settings-card'
import { ChannelAffinitySection } from '../general/channel-affinity'
import { IoNetDeploymentSettingsSection } from '../integrations/ionet-deployment-settings-section'
function formatJsonForEditor(value: string, fallback: string) { function formatJsonForEditor(value: string, fallback: string) {
const raw = (value ?? '').toString().trim() const raw = (value ?? '').toString().trim()
......
import type { OperationsSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { SystemBehaviorSection } from '../general/system-behavior-section' import { SystemBehaviorSection } from '../general/system-behavior-section'
import { EmailSettingsSection } from '../integrations/email-settings-section' import { EmailSettingsSection } from '../integrations/email-settings-section'
import { MonitoringSettingsSection } from '../integrations/monitoring-settings-section' import { MonitoringSettingsSection } from '../integrations/monitoring-settings-section'
...@@ -7,6 +5,8 @@ import { WorkerSettingsSection } from '../integrations/worker-settings-section' ...@@ -7,6 +5,8 @@ import { WorkerSettingsSection } from '../integrations/worker-settings-section'
import { LogSettingsSection } from '../maintenance/log-settings-section' import { LogSettingsSection } from '../maintenance/log-settings-section'
import { PerformanceSection } from '../maintenance/performance-section' import { PerformanceSection } from '../maintenance/performance-section'
import { UpdateCheckerSection } from '../maintenance/update-checker-section' import { UpdateCheckerSection } from '../maintenance/update-checker-section'
import type { OperationsSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
const OPERATIONS_SECTIONS = [ const OPERATIONS_SECTIONS = [
{ {
...@@ -159,5 +159,4 @@ export const OPERATIONS_SECTION_IDS = operationsRegistry.sectionIds ...@@ -159,5 +159,4 @@ export const OPERATIONS_SECTION_IDS = operationsRegistry.sectionIds
export const OPERATIONS_DEFAULT_SECTION = operationsRegistry.defaultSection export const OPERATIONS_DEFAULT_SECTION = operationsRegistry.defaultSection
export const getOperationsSectionNavItems = export const getOperationsSectionNavItems =
operationsRegistry.getSectionNavItems operationsRegistry.getSectionNavItems
export const getOperationsSectionContent = export const getOperationsSectionContent = operationsRegistry.getSectionContent
operationsRegistry.getSectionContent
import type { SecuritySettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { RateLimitSection } from '../request-limits/rate-limit-section' import { RateLimitSection } from '../request-limits/rate-limit-section'
import { SensitiveWordsSection } from '../request-limits/sensitive-words-section' import { SensitiveWordsSection } from '../request-limits/sensitive-words-section'
import { SSRFSection } from '../request-limits/ssrf-section' import { SSRFSection } from '../request-limits/ssrf-section'
import type { SecuritySettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
const SECURITY_SECTIONS = [ const SECURITY_SECTIONS = [
{ {
......
import type { SiteSettings } from '../types' import { SystemInfoSection } from '../general/system-info-section'
import { createSectionRegistry } from '../utils/section-registry'
import { import {
parseHeaderNavModules, parseHeaderNavModules,
parseSidebarModulesAdmin, parseSidebarModulesAdmin,
...@@ -9,7 +8,8 @@ import { ...@@ -9,7 +8,8 @@ import {
import { HeaderNavigationSection } from '../maintenance/header-navigation-section' import { HeaderNavigationSection } from '../maintenance/header-navigation-section'
import { NoticeSection } from '../maintenance/notice-section' import { NoticeSection } from '../maintenance/notice-section'
import { SidebarModulesSection } from '../maintenance/sidebar-modules-section' import { SidebarModulesSection } from '../maintenance/sidebar-modules-section'
import { SystemInfoSection } from '../general/system-info-section' import type { SiteSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
const SITE_SECTIONS = [ const SITE_SECTIONS = [
{ {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment