From 8bff69108908842900d25d4e53aa3f116bef0564 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 30 Apr 2026 13:55:25 +0800 Subject: [PATCH] feat(ui): add reusable dashboard and log controls --- web/default/src/components/group-badge.tsx | 88 ++++++++ .../models/models-chart-preferences.tsx | 188 +++++++++++++++++ .../components/task-logs-filter-bar.tsx | 197 ++++++++++++++++++ 3 files changed, 473 insertions(+) create mode 100644 web/default/src/components/group-badge.tsx create mode 100644 web/default/src/features/dashboard/components/models/models-chart-preferences.tsx create mode 100644 web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx diff --git a/web/default/src/components/group-badge.tsx b/web/default/src/components/group-badge.tsx new file mode 100644 index 00000000..67e6b80a --- /dev/null +++ b/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 = ( + + ) + + if (ratio == null) { + return badge + } + + return ( + + {badge} + + + {ratio}x + + + ) +} diff --git a/web/default/src/features/dashboard/components/models/models-chart-preferences.tsx b/web/default/src/features/dashboard/components/models/models-chart-preferences.tsx new file mode 100644 index 00000000..76d46004 --- /dev/null +++ b/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( + props.preferences + ) + + useEffect(() => { + if (open) setDraft(props.preferences) + }, [open, props.preferences]) + + const handleSave = () => { + props.onPreferencesChange(draft) + setOpen(false) + } + + return ( + + + + + + + {t('Dashboard Preferences')} + + {t( + 'Choose the default charts, range, and time granularity for model analytics.' + )} + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + +
+
+ ) +} diff --git a/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx new file mode 100644 index 00000000..7176ad65 --- /dev/null +++ b/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 +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(() => { + 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 ( +
+
+ { + handleChange('startTime', start) + handleChange('endTime', end) + }} + className='col-span-2 lg:col-span-1' + /> + handleFilterChange(e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> + {isAdmin && ( + handleChange('channel', e.target.value)} + onKeyDown={handleKeyDown} + className='h-9' + /> + )} +
+ + + {props.viewOptions} +
+
+
+ ) +}