Просмотр исходного кода

feat(ui): add reusable dashboard and log controls

CaIon 1 неделя назад
Родитель
Сommit
8bff691089

+ 88 - 0
web/default/src/components/group-badge.tsx

@@ -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
web/default/src/features/dashboard/components/models/models-chart-preferences.tsx

@@ -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>
+  )
+}

+ 197 - 0
web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx

@@ -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>
+  )
+}