feat(ui): add reusable dashboard and log controls
This commit is contained in:
+88
@@ -0,0 +1,88 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { StatusBadge, type StatusBadgeProps } from './status-badge'
|
||||||
|
|
||||||
|
type GroupBadgeProps = Omit<
|
||||||
|
StatusBadgeProps,
|
||||||
|
'autoColor' | 'label' | 'variant'
|
||||||
|
> & {
|
||||||
|
group?: string | null
|
||||||
|
label?: string
|
||||||
|
ratio?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupRatioClassName(ratio: number): string {
|
||||||
|
if (ratio > 1) {
|
||||||
|
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300'
|
||||||
|
}
|
||||||
|
if (ratio < 1) {
|
||||||
|
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300'
|
||||||
|
}
|
||||||
|
return 'border-border bg-muted text-muted-foreground'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupLabel(params: {
|
||||||
|
labelOverride?: string
|
||||||
|
groupName?: string
|
||||||
|
isAutoGroup: boolean
|
||||||
|
isEmptyGroup: boolean
|
||||||
|
t: (key: string) => string
|
||||||
|
}): string {
|
||||||
|
if (params.labelOverride) return params.labelOverride
|
||||||
|
if (params.isEmptyGroup) return params.t('User Group')
|
||||||
|
if (params.isAutoGroup) return params.t('Auto')
|
||||||
|
return params.groupName ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupBadge(props: GroupBadgeProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const {
|
||||||
|
group,
|
||||||
|
label: labelOverride,
|
||||||
|
ratio,
|
||||||
|
copyable = false,
|
||||||
|
showDot,
|
||||||
|
...badgeProps
|
||||||
|
} = props
|
||||||
|
const groupName = group?.trim()
|
||||||
|
const isAutoGroup = groupName === 'auto'
|
||||||
|
const isEmptyGroup = !groupName
|
||||||
|
const isSpecialGroup = isAutoGroup || isEmptyGroup
|
||||||
|
const label = getGroupLabel({
|
||||||
|
labelOverride,
|
||||||
|
groupName,
|
||||||
|
isAutoGroup,
|
||||||
|
isEmptyGroup,
|
||||||
|
t,
|
||||||
|
})
|
||||||
|
|
||||||
|
const badge = (
|
||||||
|
<StatusBadge
|
||||||
|
{...badgeProps}
|
||||||
|
copyable={copyable}
|
||||||
|
label={label}
|
||||||
|
showDot={showDot ?? (isSpecialGroup ? false : undefined)}
|
||||||
|
variant={isSpecialGroup ? 'neutral' : undefined}
|
||||||
|
autoColor={isSpecialGroup ? undefined : groupName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (ratio == null) {
|
||||||
|
return badge
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className='inline-flex items-center gap-2 text-xs'>
|
||||||
|
{badge}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
|
||||||
|
getGroupRatioClassName(ratio)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className='size-1 rounded-full bg-current opacity-60' />
|
||||||
|
<span>{ratio}x</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Save, Settings2 } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
|
||||||
|
MODEL_ANALYTICS_CHART_OPTIONS,
|
||||||
|
TIME_GRANULARITY_OPTIONS,
|
||||||
|
TIME_RANGE_PRESETS,
|
||||||
|
} from '@/features/dashboard/constants'
|
||||||
|
import type {
|
||||||
|
ConsumptionDistributionChartType,
|
||||||
|
DashboardChartPreferences,
|
||||||
|
ModelAnalyticsChartTab,
|
||||||
|
} from '@/features/dashboard/types'
|
||||||
|
import type { TimeGranularity } from '@/lib/time'
|
||||||
|
|
||||||
|
interface ModelsChartPreferencesProps {
|
||||||
|
preferences: DashboardChartPreferences
|
||||||
|
onPreferencesChange: (preferences: DashboardChartPreferences) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<DashboardChartPreferences>(
|
||||||
|
props.preferences
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDraft(props.preferences)
|
||||||
|
}, [open, props.preferences])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
props.onPreferencesChange(draft)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant='outline' size='sm'>
|
||||||
|
<Settings2 className='mr-2 h-4 w-4' />
|
||||||
|
{t('Preferences')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className='sm:max-w-md'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Dashboard Preferences')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
'Choose the default charts, range, and time granularity for model analytics.'
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className='grid gap-4 py-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
|
||||||
|
<Select
|
||||||
|
value={String(draft.defaultTimeRangeDays)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
defaultTimeRangeDays: Number(value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='default-time-range'>
|
||||||
|
<SelectValue placeholder={t('Select default range')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIME_RANGE_PRESETS.map((option) => (
|
||||||
|
<SelectItem key={option.days} value={String(option.days)}>
|
||||||
|
{t(option.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='default-time-granularity'>
|
||||||
|
{t('Default time granularity')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.defaultTimeGranularity}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
defaultTimeGranularity: value as TimeGranularity,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='default-time-granularity'>
|
||||||
|
<SelectValue placeholder={t('Select time granularity')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIME_GRANULARITY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{t(option.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='consumption-distribution-chart'>
|
||||||
|
{t('Default consumption chart')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.consumptionDistributionChart}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
consumptionDistributionChart:
|
||||||
|
value as ConsumptionDistributionChartType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='consumption-distribution-chart'>
|
||||||
|
<SelectValue placeholder={t('Select default chart')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{t(option.labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='model-analytics-chart'>
|
||||||
|
{t('Default model call chart')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.modelAnalyticsChart}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modelAnalyticsChart: value as ModelAnalyticsChartTab,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='model-analytics-chart'>
|
||||||
|
<SelectValue placeholder={t('Select default chart')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{t(option.labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleSave} type='button'>
|
||||||
|
<Save className='mr-2 h-4 w-4' />
|
||||||
|
{t('Save Preferences')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||||
|
import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
||||||
|
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
|
||||||
|
import { Loader2, RotateCcw, Search } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useIsAdmin } from '@/hooks/use-admin'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { buildSearchParams } from '../lib/filter'
|
||||||
|
import { getDefaultTimeRange } from '../lib/utils'
|
||||||
|
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
|
||||||
|
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
||||||
|
|
||||||
|
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||||
|
|
||||||
|
type TaskLikeLogCategory = Extract<LogCategory, 'drawing' | 'task'>
|
||||||
|
type TaskLogsFilters = DrawingLogFilters | TaskLogFilters
|
||||||
|
|
||||||
|
interface TaskLogsFilterBarProps {
|
||||||
|
logCategory: TaskLikeLogCategory
|
||||||
|
viewOptions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterPlaceholder(_logCategory: TaskLikeLogCategory): string {
|
||||||
|
return 'Filter by task ID'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterValue(
|
||||||
|
filters: TaskLogsFilters,
|
||||||
|
logCategory: TaskLikeLogCategory
|
||||||
|
): string {
|
||||||
|
if (logCategory === 'drawing') {
|
||||||
|
return (filters as DrawingLogFilters).mjId || ''
|
||||||
|
}
|
||||||
|
return (filters as TaskLogFilters).taskId || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilterValue(
|
||||||
|
filters: TaskLogsFilters,
|
||||||
|
logCategory: TaskLikeLogCategory,
|
||||||
|
value: string
|
||||||
|
): TaskLogsFilters {
|
||||||
|
if (logCategory === 'drawing') {
|
||||||
|
return { ...filters, mjId: value }
|
||||||
|
}
|
||||||
|
return { ...filters, taskId: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const searchParams = route.useSearch()
|
||||||
|
const isAdmin = useIsAdmin()
|
||||||
|
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<TaskLogsFilters>(() => {
|
||||||
|
const { start, end } = getDefaultTimeRange()
|
||||||
|
return { startTime: start, endTime: end }
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { start, end } = getDefaultTimeRange()
|
||||||
|
const baseFilters = {
|
||||||
|
startTime: searchParams.startTime ? new Date(searchParams.startTime) : start,
|
||||||
|
endTime: searchParams.endTime ? new Date(searchParams.endTime) : end,
|
||||||
|
...(searchParams.channel ? { channel: String(searchParams.channel) } : {}),
|
||||||
|
}
|
||||||
|
const next: TaskLogsFilters =
|
||||||
|
props.logCategory === 'drawing'
|
||||||
|
? {
|
||||||
|
...baseFilters,
|
||||||
|
...(searchParams.filter ? { mjId: searchParams.filter } : {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...baseFilters,
|
||||||
|
...(searchParams.filter ? { taskId: searchParams.filter } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilters(next)
|
||||||
|
}, [
|
||||||
|
props.logCategory,
|
||||||
|
searchParams.startTime,
|
||||||
|
searchParams.endTime,
|
||||||
|
searchParams.channel,
|
||||||
|
searchParams.filter,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof TaskLogsFilters, value: Date | string | undefined) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [field]: value }))
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleApply = useCallback(() => {
|
||||||
|
const filterParams = buildSearchParams(filters, props.logCategory)
|
||||||
|
navigate({
|
||||||
|
to: '/usage-logs/$section',
|
||||||
|
params: { section: props.logCategory },
|
||||||
|
search: {
|
||||||
|
...filterParams,
|
||||||
|
page: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['logs'] })
|
||||||
|
}, [filters, navigate, props.logCategory, queryClient])
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
const { start, end } = getDefaultTimeRange()
|
||||||
|
const resetFilters: TaskLogsFilters = { startTime: start, endTime: end }
|
||||||
|
setFilters(resetFilters)
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: '/usage-logs/$section',
|
||||||
|
params: { section: props.logCategory },
|
||||||
|
search: {
|
||||||
|
page: 1,
|
||||||
|
startTime: start.getTime(),
|
||||||
|
endTime: end.getTime(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['logs'] })
|
||||||
|
}, [navigate, props.logCategory, queryClient])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleApply()
|
||||||
|
},
|
||||||
|
[handleApply]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setFilters((prev) => setFilterValue(prev, props.logCategory, value))
|
||||||
|
},
|
||||||
|
[props.logCategory]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='grid grid-cols-2 gap-2 lg:grid-cols-[minmax(280px,2fr)_minmax(180px,1fr)_minmax(120px,0.8fr)_auto]'>
|
||||||
|
<CompactDateTimeRangePicker
|
||||||
|
start={filters.startTime}
|
||||||
|
end={filters.endTime}
|
||||||
|
onChange={({ start, end }) => {
|
||||||
|
handleChange('startTime', start)
|
||||||
|
handleChange('endTime', end)
|
||||||
|
}}
|
||||||
|
className='col-span-2 lg:col-span-1'
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label={t('Task ID')}
|
||||||
|
placeholder={t(getFilterPlaceholder(props.logCategory))}
|
||||||
|
value={getFilterValue(filters, props.logCategory)}
|
||||||
|
onChange={(e) => handleFilterChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className='h-9'
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<Input
|
||||||
|
placeholder={t('Channel ID')}
|
||||||
|
value={filters.channel || ''}
|
||||||
|
onChange={(e) => handleChange('channel', e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className='h-9'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className='col-span-2 flex shrink-0 items-center justify-end gap-2 lg:col-span-1'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='h-8'
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
<RotateCcw className='size-3.5' />
|
||||||
|
{t('Reset')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
className='h-8'
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={fetchingLogs > 0}
|
||||||
|
>
|
||||||
|
{fetchingLogs > 0 ? (
|
||||||
|
<Loader2 className='size-3.5 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Search className='size-3.5' />
|
||||||
|
)}
|
||||||
|
{t('Search')}
|
||||||
|
</Button>
|
||||||
|
{props.viewOptions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user