✨ 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.
This commit is contained in:
@@ -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}
|
||||
{t('Verify and Sign In')}
|
||||
</Button>
|
||||
|
||||
+6
-5
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Save, Settings2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
@@ -45,9 +45,10 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
|
||||
props.preferences
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDraft(props.preferences)
|
||||
}, [open, props.preferences])
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) setDraft(props.preferences)
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
props.onPreferencesChange(draft)
|
||||
@@ -55,7 +56,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<Settings2 className='mr-2 h-4 w-4' />
|
||||
{t('Preferences')}
|
||||
|
||||
+9
-4
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
@@ -73,10 +73,15 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
() => props.preferences.defaultTimeRangeDays
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const resetFiltersFromPreferences = () => {
|
||||
setFilters(buildDefaultDashboardFilters(props.preferences))
|
||||
setSelectedRange(props.preferences.defaultTimeRangeDays)
|
||||
}, [props.preferences])
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) resetFiltersFromPreferences()
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
props.onFilterChange(
|
||||
@@ -121,7 +126,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
{t('Filter')}
|
||||
|
||||
+6
-4
@@ -44,13 +44,15 @@ export function AnnouncementsPanel() {
|
||||
{t('Announcements')}
|
||||
</span>
|
||||
}
|
||||
description={t('Latest platform updates and notices')}
|
||||
loading={loading}
|
||||
empty={!list.length}
|
||||
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'>
|
||||
<div className='-mx-3 sm:-mx-5'>
|
||||
<ScrollArea className='h-72'>
|
||||
<div>
|
||||
{list.map((item: AnnouncementItem, idx: number) => {
|
||||
const key = item.id ?? `announcement-${idx}`
|
||||
return (
|
||||
@@ -65,7 +67,7 @@ export function AnnouncementsPanel() {
|
||||
>
|
||||
<div className='flex items-start gap-2.5'>
|
||||
<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'>
|
||||
{getPreviewText(item.content)}
|
||||
</p>
|
||||
|
||||
@@ -34,13 +34,15 @@ export function ApiInfoPanel() {
|
||||
{t('API Info')}
|
||||
</span>
|
||||
}
|
||||
description={t('Configured routes and latency checks')}
|
||||
loading={loading}
|
||||
empty={!list.length}
|
||||
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'>
|
||||
<div className='-mx-3 sm:-mx-5'>
|
||||
<ScrollArea className='h-72'>
|
||||
<div>
|
||||
{list.map((item: ApiInfoItem, idx: number) => (
|
||||
<div
|
||||
key={item.url}
|
||||
|
||||
@@ -24,13 +24,15 @@ export function FAQPanel() {
|
||||
{t('FAQ')}
|
||||
</span>
|
||||
}
|
||||
description={t('Answers for common access and billing questions')}
|
||||
loading={loading}
|
||||
empty={!list.length}
|
||||
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'>
|
||||
<Accordion className='w-full'>
|
||||
<ScrollArea className='h-80'>
|
||||
<Accordion className='w-full px-4 sm:px-5'>
|
||||
{list.map((item: FAQItem, idx: number) => {
|
||||
const key = item.id ?? `faq-${idx}`
|
||||
const value = `item-${key}`
|
||||
|
||||
+722
@@ -0,0 +1,722 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
CreditCard,
|
||||
FileText,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
Play,
|
||||
RadioTower,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
Timer,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { motion, useReducedMotion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
import { MOTION_TRANSITION } from '@/lib/motion'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import {
|
||||
CardStaggerContainer,
|
||||
CardStaggerItem,
|
||||
} from '@/components/page-transition'
|
||||
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
|
||||
import type { ApiKey } from '@/features/keys/types'
|
||||
import { useApiInfo } from '../../hooks/use-status-data'
|
||||
import { AnnouncementsPanel } from './announcements-panel'
|
||||
import { ApiInfoPanel } from './api-info-panel'
|
||||
import { FAQPanel } from './faq-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
|
||||
displayKey: string
|
||||
curl: 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-[linear-gradient(112deg,oklch(0.97_0.04_250/.92)_0%,oklch(0.95_0.08_315/.82)_38%,oklch(0.96_0.12_92/.78)_74%,oklch(0.94_0.1_132/.62)_100%)] dark:opacity-25',
|
||||
props.compact
|
||||
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
|
||||
: 'opacity-85'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono text-lime-100/75 sm:block dark:text-lime-200/25',
|
||||
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-[11px] 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-full border shadow-xs',
|
||||
props.step.completed && 'border-emerald-500/30 bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<StatusIcon
|
||||
className={
|
||||
props.step.completed ? 'size-4 text-emerald-600' : '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 font-mono 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 shouldReduceMotion = useReducedMotion()
|
||||
const previewLines = props.example.curl.split('\n').map((line) => {
|
||||
if (line.includes('Authorization: Bearer')) {
|
||||
return ` -H "Authorization: Bearer ${props.example.displayKey}" \\`
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={shouldReduceMotion ? undefined : { opacity: 1, y: 0, scale: 1 }}
|
||||
transition={MOTION_TRANSITION.slow}
|
||||
className='bg-background/75 relative overflow-hidden rounded-2xl border p-3 shadow-sm backdrop-blur'
|
||||
>
|
||||
{!shouldReduceMotion && (
|
||||
<motion.div
|
||||
className='via-foreground/30 pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent to-transparent'
|
||||
animate={{ x: ['-100%', '100%'] }}
|
||||
transition={{ duration: 3.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 ? (
|
||||
<CopyButton
|
||||
value={props.example.curl}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 gap-1.5 px-2 text-xs'
|
||||
tooltip={t('Copy ready-to-run curl')}
|
||||
successTooltip={t('Copied!')}
|
||||
aria-label={t('Copy ready-to-run curl')}
|
||||
>
|
||||
{t('Copy')}
|
||||
</CopyButton>
|
||||
) : (
|
||||
<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='size-2 rounded-full bg-red-400' />
|
||||
<span className='size-2 rounded-full bg-amber-400' />
|
||||
<span className='size-2 rounded-full bg-emerald-400' />
|
||||
</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>
|
||||
</motion.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 [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 realKeyQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview', 'token-key', preferredKey?.id],
|
||||
queryFn: async () => {
|
||||
if (!preferredKey?.id) return ''
|
||||
const result = await fetchTokenKey(preferredKey.id)
|
||||
return result.success && result.data?.key ? `sk-${result.data.key}` : ''
|
||||
},
|
||||
enabled: Boolean(preferredKey?.id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
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('Playground'),
|
||||
description: t('Test models and prompts from the browser'),
|
||||
to: '/playground',
|
||||
icon: Play,
|
||||
},
|
||||
{
|
||||
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 apiKey = realKeyQuery.data ?? ''
|
||||
const keyName = preferredKey?.name ?? t('No API key yet')
|
||||
const ready = Boolean(apiKey && model)
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
model,
|
||||
keyName,
|
||||
displayKey: formatDisplayKey(apiKey),
|
||||
ready,
|
||||
curl: buildCurlCommand({
|
||||
endpoint,
|
||||
apiKey: apiKey || 'sk-...',
|
||||
model,
|
||||
}),
|
||||
}
|
||||
}, [apiInfoItems, modelsQuery.data, preferredKey, realKeyQuery.data, t])
|
||||
|
||||
const completedStepCount = startSteps.filter((step) => step.completed).length
|
||||
const setupComplete = completedStepCount === startSteps.length
|
||||
const setupGuideExpanded = manualSetupGuideExpanded ?? !setupComplete
|
||||
|
||||
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='size-4 text-emerald-600'
|
||||
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-full 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 />
|
||||
|
||||
<CardStaggerContainer className='grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
|
||||
<div className='grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
<CardStaggerItem>
|
||||
<ApiInfoPanel />
|
||||
</CardStaggerItem>
|
||||
<CardStaggerItem>
|
||||
<AnnouncementsPanel />
|
||||
</CardStaggerItem>
|
||||
<CardStaggerItem>
|
||||
<FAQPanel />
|
||||
</CardStaggerItem>
|
||||
</div>
|
||||
<CardStaggerItem>
|
||||
<UptimePanel />
|
||||
</CardStaggerItem>
|
||||
</CardStaggerContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+170
-35
@@ -1,21 +1,98 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { CreditCard } from 'lucide-react'
|
||||
import { ArrowRight, CreditCard } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StaggerContainer, StaggerItem } from '@/components/page-transition'
|
||||
import { getUserQuotaDates } from '@/features/dashboard/api'
|
||||
import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
||||
import type { QuotaDataItem } from '@/features/dashboard/types'
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
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 remainQuota = Number(user?.quota ?? 0)
|
||||
const usedQuota = Number(user?.used_quota ?? 0)
|
||||
@@ -39,46 +116,104 @@ export function SummaryCards() {
|
||||
: currencyEnabledFromStore
|
||||
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({
|
||||
...summaryValues,
|
||||
currencyEnabled,
|
||||
currencyLabel,
|
||||
}).map((config, index) => ({
|
||||
title: config.title,
|
||||
value: config.value,
|
||||
desc: config.description,
|
||||
icon: config.icon,
|
||||
isBalance: index === 0,
|
||||
}))
|
||||
}).map((config, index) => {
|
||||
const tones = ['rose', 'teal', 'gray'] as const
|
||||
|
||||
return {
|
||||
title: config.title,
|
||||
value: config.value,
|
||||
desc: config.description,
|
||||
icon: config.icon,
|
||||
tone: tones[index] ?? 'gray',
|
||||
sparkline:
|
||||
config.key === 'balance'
|
||||
? sparklineData.balance
|
||||
: config.key === 'usage'
|
||||
? sparklineData.usage
|
||||
: sparklineData.requests,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<StaggerContainer className='divide-border/60 grid grid-cols-3 divide-x'>
|
||||
{items.map((it) => (
|
||||
<StaggerItem key={it.title} className='px-3 py-3 sm:px-5 sm:py-4'>
|
||||
<StatCard
|
||||
title={it.title}
|
||||
value={it.value}
|
||||
description={it.desc}
|
||||
icon={it.icon}
|
||||
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>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
<div className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
|
||||
<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) => (
|
||||
<StaggerItem
|
||||
key={it.title}
|
||||
className='bg-background/60 rounded-xl border p-3'
|
||||
>
|
||||
<StatCard
|
||||
title={it.title}
|
||||
value={it.value}
|
||||
description={it.desc}
|
||||
icon={it.icon}
|
||||
tone={it.tone}
|
||||
sparkline={it.sparkline}
|
||||
loading={loading}
|
||||
/>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
</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() {
|
||||
{t('Uptime')}
|
||||
</span>
|
||||
}
|
||||
description={t('Grouped monitor status from Uptime Kuma')}
|
||||
loading={loading}
|
||||
empty={!groups.length}
|
||||
emptyMessage={t('No uptime monitoring configured')}
|
||||
height='h-64 sm:h-80'
|
||||
height='h-80'
|
||||
contentClassName='p-0'
|
||||
headerActions={
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -100,8 +102,8 @@ export function UptimePanel() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ScrollArea className='h-64 sm:h-80'>
|
||||
<div className='-mx-3 space-y-0 sm:-mx-5'>
|
||||
<ScrollArea className='h-80'>
|
||||
<div>
|
||||
{groups.map((group, groupIdx) => (
|
||||
<div key={group.categoryName}>
|
||||
<div className='bg-muted/30 border-border/60 border-b px-3 py-2 sm:px-5'>
|
||||
|
||||
@@ -1,29 +1,63 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface PanelWrapperProps {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
loading?: boolean
|
||||
empty?: boolean
|
||||
emptyMessage?: string
|
||||
height?: string
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
headerActions?: 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) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedEmptyMessage = props.emptyMessage ?? t('No data available')
|
||||
const height = props.height ?? 'h-64'
|
||||
const frameClassName = cn(
|
||||
'overflow-hidden rounded-2xl border bg-card shadow-xs',
|
||||
props.className
|
||||
)
|
||||
|
||||
if (props.loading) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
</div>
|
||||
<div className='p-3 sm:p-5'>
|
||||
<div className={frameClassName}>
|
||||
<PanelHeader title={props.title} description={props.description} />
|
||||
<div className={cn('p-4 sm:p-5', props.contentClassName)}>
|
||||
<Skeleton className={`w-full ${height}`} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,12 +66,14 @@ export function PanelWrapper(props: PanelWrapperProps) {
|
||||
|
||||
if (props.empty) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
</div>
|
||||
<div className={frameClassName}>
|
||||
<PanelHeader title={props.title} description={props.description} />
|
||||
<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}
|
||||
</div>
|
||||
@@ -46,18 +82,15 @@ export function PanelWrapper(props: PanelWrapperProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
|
||||
{props.headerActions ? (
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
{props.headerActions}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
)}
|
||||
<div className={frameClassName}>
|
||||
<PanelHeader
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
actions={props.headerActions}
|
||||
/>
|
||||
<div className={cn('p-4 sm:p-5', props.contentClassName)}>
|
||||
{props.children}
|
||||
</div>
|
||||
<div className='p-3 sm:p-5'>{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+53
-12
@@ -1,53 +1,94 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
type StatCardTone = 'rose' | 'teal' | 'gray'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
sparkline?: number[]
|
||||
tone?: StatCardTone
|
||||
loading?: 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) {
|
||||
const Icon = props.icon
|
||||
const tone = props.tone ?? 'gray'
|
||||
const sparkline = normalizeSparkline(props.sparkline)
|
||||
|
||||
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='text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wider uppercase sm:gap-2'>
|
||||
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
|
||||
<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'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='line-clamp-2 leading-snug'>{props.title}</span>
|
||||
</div>
|
||||
{props.action && <div className='shrink-0'>{props.action}</div>}
|
||||
</div>
|
||||
|
||||
{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-3.5 w-32' />
|
||||
</div>
|
||||
) : 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>
|
||||
<p className='text-muted-foreground/60 hidden text-xs md:block'>
|
||||
<p className='text-muted-foreground/60 text-xs'>
|
||||
{props.description}
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='text-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-foreground font-mono text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
||||
{props.value}
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 hidden text-xs md:block'>
|
||||
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
|
||||
{props.description}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-29
@@ -6,18 +6,10 @@ import { ROLE } from '@/lib/roles'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import {
|
||||
CardStaggerContainer,
|
||||
CardStaggerItem,
|
||||
FadeIn,
|
||||
} from '@/components/page-transition'
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
import { ModelsChartPreferences } from './components/models/models-chart-preferences'
|
||||
import { ModelsFilter } from './components/models/models-filter-dialog'
|
||||
import { AnnouncementsPanel } from './components/overview/announcements-panel'
|
||||
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 { OverviewDashboard } from './components/overview/overview-dashboard'
|
||||
import { DEFAULT_TIME_GRANULARITY } from './constants'
|
||||
import {
|
||||
buildDefaultDashboardFilters,
|
||||
@@ -215,25 +207,7 @@ export function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'overview' && (
|
||||
<>
|
||||
<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 === 'overview' && <OverviewDashboard />}
|
||||
{activeSection === 'models' && (
|
||||
<>
|
||||
<FadeIn>
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
import { getRollingDateRange } from '@/lib/time'
|
||||
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
|
||||
import {
|
||||
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
|
||||
DEFAULT_DASHBOARD_CHART_PREFERENCES,
|
||||
|
||||
@@ -9,8 +9,7 @@ export type ModelPerfBadgeData = {
|
||||
avg_tps: number
|
||||
}
|
||||
|
||||
export interface ModelPerfBadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export interface ModelPerfBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
perf: ModelPerfBadgeData | undefined
|
||||
}
|
||||
|
||||
@@ -47,7 +46,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
<div className='text-muted-foreground/55 text-[10px] leading-4'>
|
||||
{t('Latency short')}
|
||||
</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) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +54,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
|
||||
{t('Throughput short')}
|
||||
</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)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { parseCurrencyDisplayType } from '@/lib/currency'
|
||||
import type { BillingSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { CheckinSettingsSection } from '../general/checkin-settings-section'
|
||||
import { PricingSection } from '../general/pricing-section'
|
||||
import { QuotaSettingsSection } from '../general/quota-settings-section'
|
||||
import { PaymentSettingsSection } from '../integrations/payment-settings-section'
|
||||
import { RatioSettingsCard } from '../models/ratio-settings-card'
|
||||
import type { BillingSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
|
||||
const getModelDefaults = (settings: BillingSettings) => ({
|
||||
ModelPrice: settings.ModelPrice,
|
||||
@@ -161,8 +161,7 @@ const BILLING_SECTIONS = [
|
||||
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
|
||||
WaffoPancakeWebhookPublicKey:
|
||||
settings.WaffoPancakeWebhookPublicKey ?? '',
|
||||
WaffoPancakeWebhookTestKey:
|
||||
settings.WaffoPancakeWebhookTestKey ?? '',
|
||||
WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
|
||||
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
|
||||
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
|
||||
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
|
||||
@@ -191,14 +190,15 @@ const BILLING_SECTIONS = [
|
||||
|
||||
export type BillingSectionId = (typeof BILLING_SECTIONS)[number]['id']
|
||||
|
||||
const billingRegistry = createSectionRegistry<BillingSectionId, BillingSettings>(
|
||||
{
|
||||
sections: BILLING_SECTIONS,
|
||||
defaultSection: 'quota',
|
||||
basePath: '/system-settings/billing',
|
||||
urlStyle: 'path',
|
||||
}
|
||||
)
|
||||
const billingRegistry = createSectionRegistry<
|
||||
BillingSectionId,
|
||||
BillingSettings
|
||||
>({
|
||||
sections: BILLING_SECTIONS,
|
||||
defaultSection: 'quota',
|
||||
basePath: '/system-settings/billing',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const BILLING_SECTION_IDS = billingRegistry.sectionIds
|
||||
export const BILLING_DEFAULT_SECTION = billingRegistry.defaultSection
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import * as z from 'zod'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import * as z from 'zod'
|
||||
import type { Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -2,13 +2,13 @@ import { memo, useCallback, useState } from 'react'
|
||||
import { type UseFormReturn } from 'react-hook-form'
|
||||
import { Code2, Eye, HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -27,6 +25,8 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
|
||||
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
|
||||
|
||||
@@ -72,11 +72,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setGuideOpen(true)}
|
||||
>
|
||||
<Button variant='outline' size='sm' onClick={() => setGuideOpen(true)}>
|
||||
<HelpCircle className='mr-2 h-4 w-4' />
|
||||
{t('Usage guide')}
|
||||
</Button>
|
||||
@@ -435,7 +431,9 @@ vip 0.5 ${t('No')} ${t('Assigned by administrator on
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='usable'>
|
||||
<AccordionTrigger>{t('Special usable group rules')}</AccordionTrigger>
|
||||
<AccordionTrigger>
|
||||
{t('Special usable group rules')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{t(
|
||||
|
||||
+5
-7
@@ -9,12 +9,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -830,7 +830,9 @@ function GroupPricingTable({
|
||||
<div>
|
||||
<CardTitle>{t('Pricing groups')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('Edit billing ratios and user-selectable groups in one table.')}
|
||||
{t(
|
||||
'Edit billing ratios and user-selectable groups in one table.'
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={addRow} size='sm' className='sm:self-start'>
|
||||
@@ -900,11 +902,7 @@ function GroupPricingTable({
|
||||
<Checkbox
|
||||
checked={row.selectable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'selectable',
|
||||
checked === true
|
||||
)
|
||||
updateRow(row._id, 'selectable', checked === true)
|
||||
}
|
||||
aria-label={t('User selectable')}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ChannelAffinitySection } from '../general/channel-affinity'
|
||||
import { IoNetDeploymentSettingsSection } from '../integrations/ionet-deployment-settings-section'
|
||||
import type { ModelSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { ClaudeSettingsCard } from './claude-settings-card'
|
||||
import { GeminiSettingsCard } from './gemini-settings-card'
|
||||
import { GlobalSettingsCard } from './global-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) {
|
||||
const raw = (value ?? '').toString().trim()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { OperationsSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { SystemBehaviorSection } from '../general/system-behavior-section'
|
||||
import { EmailSettingsSection } from '../integrations/email-settings-section'
|
||||
import { MonitoringSettingsSection } from '../integrations/monitoring-settings-section'
|
||||
@@ -7,6 +5,8 @@ import { WorkerSettingsSection } from '../integrations/worker-settings-section'
|
||||
import { LogSettingsSection } from '../maintenance/log-settings-section'
|
||||
import { PerformanceSection } from '../maintenance/performance-section'
|
||||
import { UpdateCheckerSection } from '../maintenance/update-checker-section'
|
||||
import type { OperationsSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
|
||||
const OPERATIONS_SECTIONS = [
|
||||
{
|
||||
@@ -159,5 +159,4 @@ export const OPERATIONS_SECTION_IDS = operationsRegistry.sectionIds
|
||||
export const OPERATIONS_DEFAULT_SECTION = operationsRegistry.defaultSection
|
||||
export const getOperationsSectionNavItems =
|
||||
operationsRegistry.getSectionNavItems
|
||||
export const getOperationsSectionContent =
|
||||
operationsRegistry.getSectionContent
|
||||
export const getOperationsSectionContent = operationsRegistry.getSectionContent
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SecuritySettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { RateLimitSection } from '../request-limits/rate-limit-section'
|
||||
import { SensitiveWordsSection } from '../request-limits/sensitive-words-section'
|
||||
import { SSRFSection } from '../request-limits/ssrf-section'
|
||||
import type { SecuritySettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
|
||||
const SECURITY_SECTIONS = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SiteSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { SystemInfoSection } from '../general/system-info-section'
|
||||
import {
|
||||
parseHeaderNavModules,
|
||||
parseSidebarModulesAdmin,
|
||||
@@ -9,7 +8,8 @@ import {
|
||||
import { HeaderNavigationSection } from '../maintenance/header-navigation-section'
|
||||
import { NoticeSection } from '../maintenance/notice-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 = [
|
||||
{
|
||||
|
||||
@@ -44,12 +44,12 @@ export type SettingsRouteConfigOptions<
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* export const Route = createFileRoute('/_authenticated/system-settings/site')(
|
||||
* export const Route = createFileRoute('/_authenticated/system-settings/site')(
|
||||
* createSettingsRouteConfig({
|
||||
* sectionIds: SITE_SECTION_IDS,
|
||||
* defaultSection: SITE_DEFAULT_SECTION,
|
||||
* component: SiteSettings,
|
||||
* routePath: '/system-settings/site',
|
||||
* sectionIds: SITE_SECTION_IDS,
|
||||
* defaultSection: SITE_DEFAULT_SECTION,
|
||||
* component: SiteSettings,
|
||||
* routePath: '/system-settings/site',
|
||||
* redirectToDefault: true,
|
||||
* })
|
||||
* )
|
||||
|
||||
Reference in New Issue
Block a user