Commit a5e41a89 by CaIon

feat(usage-logs): refine mobile layout and keep quick actions visible

parent 71c1fd7c
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, it } from 'vitest'
import { DataTablePagination } from '../pagination'
const rows = [{ id: 1 }, { id: 2 }, { id: 3 }]
const emptyRows: { id: number }[] = []
function Fixture(props: { empty?: boolean; compact?: boolean }) {
const table = useReactTable({
data: props.empty ? emptyRows : rows,
columns: [{ accessorKey: 'id' }],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageIndex: 0, pageSize: 2 } },
})
return <DataTablePagination table={table} compact={props.compact} />
}
it('moves between pages in compact mode and disables the boundary actions', async () => {
const user = userEvent.setup()
render(<Fixture compact />)
expect(screen.getByText('1 / 2')).toBeVisible()
expect(
screen.getByRole('button', { name: 'Go to previous page' })
).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Go to next page' }))
expect(screen.getByText('2 / 2')).toBeVisible()
expect(screen.getByRole('button', { name: 'Go to next page' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Go to previous page' }))
expect(screen.getByText('1 / 2')).toBeVisible()
})
it('shows a valid empty page with navigation disabled', () => {
render(<Fixture empty compact />)
expect(screen.getByText('1 / 1')).toBeVisible()
expect(
screen.getByRole('button', { name: 'Go to previous page' })
).toBeDisabled()
expect(screen.getByRole('button', { name: 'Go to next page' })).toBeDisabled()
})
it('keeps page size selection available in the default layout', () => {
render(<Fixture />)
expect(screen.getByRole('combobox')).toBeVisible()
})
......@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type Table } from '@tanstack/react-table'
import type { Table } from '@tanstack/react-table'
import {
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
......@@ -38,6 +38,7 @@ import { cn, getPageNumbers } from '@/lib/utils'
type DataTablePaginationProps<TData> = {
table: Table<TData>
compact?: boolean
}
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
......@@ -48,6 +49,7 @@ const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
export function DataTablePagination<TData>({
table,
compact = false,
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation()
const pagination = table.getState().pagination
......@@ -56,6 +58,48 @@ export function DataTablePagination<TData>({
const totalPages = table.getPageCount()
const totalRows = table.getRowCount()
const pageNumbers = getPageNumbers(currentPage, totalPages)
const pageItems = pageNumbers.map((page, index) => ({
page,
key: page === '...' ? `gap-after-${pageNumbers[index - 1]}` : String(page),
}))
if (compact) {
return (
<nav
aria-label={t('Page')}
className='flex w-full min-w-0 flex-wrap items-center justify-between gap-2 text-sm'
>
<span className='text-muted-foreground min-w-0 [overflow-wrap:anywhere]'>
{t('Total:')} {totalRows.toLocaleString()}
</span>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='size-11'
aria-label={t('Go to previous page')}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeftIcon />
</Button>
<span className='tabular-nums' aria-live='polite'>
{currentPage} / {Math.max(1, totalPages)}
</span>
<Button
variant='outline'
size='icon'
className='size-11'
aria-label={t('Go to next page')}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRightIcon />
</Button>
</div>
</nav>
)
}
return (
<div
......@@ -118,8 +162,8 @@ export function DataTablePagination<TData>({
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageItems.map(({ page: pageNumber, key }) => (
<div key={key} className='flex items-center'>
{pageNumber === '...' ? (
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
...
......
......@@ -206,6 +206,9 @@ export type DataTablePageProps<TData> = {
*/
showPagination?: boolean
/** Minimal previous/next pagination for narrow feature layouts. */
compactPagination?: boolean
/**
* Render pagination via `PageFooterPortal` (sticks to page footer).
* Defaults to `true`. Set `false` to render inline below the table.
......@@ -392,7 +395,12 @@ function renderPagination<TData>(
return null
}
const pagination = <DataTablePagination table={props.table} />
const pagination = (
<DataTablePagination
table={props.table}
compact={props.compactPagination}
/>
)
return props.paginationInFooter !== false ? (
<PageFooterPortal>{pagination}</PageFooterPortal>
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
getCoreRowModel,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table'
import { render, screen, within, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, it, vi } from 'vitest'
import { usageLogSchema, type UsageLog } from '../../data/schema'
import { useCommonLogsColumns } from '../columns/common-logs-columns'
import { UsageLogsMobileList } from '../usage-logs-mobile-card'
import { UsageLogsProvider, useUsageLogsContext } from '../usage-logs-provider'
const longName = 'enterprise-production-failover-2026-without-any-short-alias'
const log = usageLogSchema.parse({
id: 1,
user_id: 2,
created_at: 1788840000,
type: 2,
content: '',
model_name: longName,
username: 'production-admin',
channel: 372,
channel_name: longName,
token_name: 'backend-production-token',
group: 'enterprise-production',
quota: 123456789,
use_time: 1.5,
prompt_tokens: 1200,
completion_tokens: 800,
other: JSON.stringify({
cache_tokens: 300,
cache_creation_tokens_5m: 200,
model_ratio: 1,
}),
})
function Fixture(props: {
admin?: boolean
visibility?: VisibilityState
logs?: UsageLog[]
loading?: boolean
}) {
const columns = useCommonLogsColumns(props.admin ?? true, false)
const context = useUsageLogsContext()
const table = useReactTable({
data: props.logs ?? [log],
columns,
getCoreRowModel: getCoreRowModel(),
state: { columnVisibility: props.visibility ?? {} },
})
return (
<>
<button type='button' onClick={() => context.setSensitiveVisible(false)}>
Hide sensitive data
</button>
<UsageLogsMobileList
table={table}
logCategory='common'
isLoading={props.loading}
/>
</>
)
}
function renderLogs(props: Parameters<typeof Fixture>[0] = {}) {
return render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<UsageLogsProvider>
<Fixture {...props} />
</UsageLogsProvider>
</QueryClientProvider>
)
}
it('opens long channel text on tap and copies the complete value', async () => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
renderLogs()
await user.click(
screen.getByRole('button', { name: `Channel: ${longName} #372` })
)
const dialog = await screen.findByRole('dialog', { name: 'Channel' })
expect(within(dialog).getByText(`${longName} #372`)).toHaveClass(
'[overflow-wrap:anywhere]'
)
await user.click(
within(dialog).getByRole('button', { name: 'Copy to clipboard' })
)
expect(copy).toHaveBeenCalledWith(`${longName} #372`)
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(
screen.getByRole('button', { name: `Channel: ${longName} #372` })
).toHaveFocus()
})
it('keeps the model clamped to two lines and exposes its full value with keyboard input', async () => {
const user = userEvent.setup()
renderLogs()
const button = screen.getByRole('button', { name: `Model: ${longName}` })
expect(within(button).getByText(longName)).toHaveClass(
'line-clamp-2',
'[overflow-wrap:anywhere]'
)
button.focus()
await user.keyboard('{Enter}')
expect(
await screen.findByRole('dialog', { name: 'Model' })
).toHaveTextContent(longName)
})
it('hides sensitive names and disables full-text inspection when privacy is enabled', async () => {
const user = userEvent.setup()
renderLogs()
await user.click(screen.getByRole('button', { name: 'Hide sensitive data' }))
expect(screen.queryByText('production-admin')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Channel:/ })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Token:/ })
).not.toBeInTheDocument()
})
it('respects hidden columns and omits admin fields in the self view', () => {
renderLogs({
admin: false,
visibility: { token_name: false, model_name: false },
})
expect(
screen.queryByRole('button', {
name: /Channel:|User:|Token:|Group:|Model:/,
})
).not.toBeInTheDocument()
expect(screen.queryByText('enterprise-production')).not.toBeInTheDocument()
})
it('keeps input, output and cache quantities readable without empty metric cells', () => {
renderLogs()
expect(screen.getByText('Input')).toBeVisible()
expect(screen.getByText('Output')).toBeVisible()
expect(screen.getByText(/300/)).toBeVisible()
expect(screen.getByText('Cache ↑ 200')).toBeVisible()
})
it('shows the established empty state when no logs exist', () => {
renderLogs({ logs: [] })
expect(screen.getByText('No Logs Found')).toBeVisible()
})
it.each([false, true])(
'keeps timing in the right column for streaming=%s',
(streaming) => {
renderLogs({
logs: [
{
...log,
is_stream: streaming,
use_time: 58,
other: JSON.stringify({ frt: 58000 }),
},
],
})
const row = screen
.getByRole('button', { name: /^Time:/ })
.closest('[data-slot="log-time-and-timing"]')
expect(row).toHaveClass('grid', 'grid-cols-2')
expect(
screen.getByRole('button', { name: /^Time:/ }).parentElement
).toHaveClass('flex-col', 'justify-between')
expect(
within(row as HTMLElement)
.getByText('Duration')
.closest('.col-start-2')
).not.toBeNull()
if (streaming) {
expect(within(row as HTMLElement).getByText('First token')).toBeVisible()
}
}
)
it('retains the user avatar and model badge in the mobile summary', () => {
renderLogs()
expect(screen.getByText('P')).toBeVisible()
const modelButton = screen.getByRole('button', { name: `Model: ${longName}` })
expect(modelButton.querySelector('[data-slot="status-badge"]')).not.toBeNull()
})
it('omits unused token and throughput placeholders for async jobs', () => {
renderLogs({
logs: [
{
...log,
prompt_tokens: 0,
completion_tokens: 0,
other: JSON.stringify({ is_task: true }),
},
],
})
expect(screen.getByText('Async')).toBeVisible()
expect(screen.queryByText('Input')).not.toBeInTheDocument()
const timing = screen
.getByRole('button', { name: /^Time:/ })
.closest('[data-slot="log-time-and-timing"]')
expect(within(timing as HTMLElement).queryByText('—')).not.toBeInTheDocument()
})
it('shows mapped model names in full when inspecting a mobile model badge', async () => {
const user = userEvent.setup()
renderLogs({
logs: [
{
...log,
other: JSON.stringify({
is_model_mapped: true,
upstream_model_name:
'provider-production-mapped-model-with-a-long-name',
}),
},
],
})
await user.click(screen.getByRole('button', { name: `Model: ${longName}` }))
const dialog = await screen.findByRole('dialog', { name: 'Model' })
expect(within(dialog).getByText('Actual Model')).toBeVisible()
expect(
within(dialog).getByText(
'provider-production-mapped-model-with-a-long-name'
)
).toBeVisible()
})
it('shows loading placeholders without displaying stale log fields', () => {
renderLogs({ loading: true })
expect(screen.getByRole('status', { name: 'Loading' })).toHaveAttribute(
'aria-busy',
'true'
)
expect(
screen.queryByRole('button', { name: /^Model:/ })
).not.toBeInTheDocument()
})
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import {
render,
screen,
waitFor,
within,
fireEvent,
cleanup,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import i18next from 'i18next'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import zhTW from '@/i18n/locales/zh-TW.json'
import zh from '@/i18n/locales/zh.json'
import { api } from '@/lib/api'
import { CommonLogsFilterBar } from '../common-logs-filter-bar'
import { CompactDateTimeRangePicker } from '../compact-date-time-range-picker'
import { LogsFilterToolbar } from '../logs-filter-toolbar'
import { UsageLogsProvider } from '../usage-logs-provider'
const captureDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'setPointerCapture'
)
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
})
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
})
function Fixture() {
const table = useReactTable({
data: [],
columns: [],
getCoreRowModel: getCoreRowModel(),
})
return (
<UsageLogsProvider>
<CommonLogsFilterBar table={table} />
</UsageLogsProvider>
)
}
async function renderMobileFilter() {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: query === '(max-width: 640px)',
}))
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data: url === '/api/user/self/groups' ? {} : { quota: 0, rpm: 0, tpm: 0 },
},
}))
const root = createRootRoute()
const auth = createRoute({ getParentRoute: () => root, id: '_authenticated' })
const logs = createRoute({
getParentRoute: () => auth,
path: '/usage-logs/$section',
component: Fixture,
validateSearch: (search: Record<string, unknown>) => search,
})
const router = createRouter({
routeTree: root.addChildren([auth.addChildren([logs])]),
history: createMemoryHistory({
initialEntries: [
'/usage-logs/common?page=3&type=%5B%222%22%5D&group=default',
],
}),
})
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<RouterProvider router={router} />
</QueryClientProvider>
)
await screen.findByRole('button', { name: 'Filter' })
return router
}
afterEach(async () => {
if (captureDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
'setPointerCapture',
captureDescriptor
)
} else Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture')
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
await i18next.changeLanguage('en')
})
it('applies the selected mobile date range directly and resets pagination while retaining filters', async () => {
const router = await renderMobileFilter()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /^\d{4}-\d{2}/ }))
fireEvent.change(screen.getByLabelText('Start Time'), {
target: { value: '2026-09-07T09:30' },
})
fireEvent.change(screen.getByLabelText('End Time'), {
target: { value: '2026-09-08T17:45' },
})
await user.click(screen.getByRole('button', { name: 'Confirm' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({
page: 1,
group: 'default',
type: ['2'],
startTime: new Date('2026-09-07T09:30').getTime(),
endTime: new Date('2026-09-08T17:45').getTime(),
})
)
})
it('applies mobile drawer filters only when Search is pressed', async () => {
const router = await renderMobileFilter()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Filter' }))
const dialog = await screen.findByRole('dialog', { name: 'Filter' })
await user.type(
within(dialog).getByPlaceholderText('Model Name'),
'gemini-3.7-flash'
)
expect(router.state.location.search).not.toHaveProperty('model')
await user.click(within(dialog).getByRole('button', { name: 'Search' }))
await waitFor(() =>
expect(router.state.location.search).toMatchObject({
page: 1,
model: 'gemini-3.7-flash',
})
)
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'Filter' })
).not.toBeInTheDocument()
)
})
it('keeps all quick actions visible without opening a menu', async () => {
await renderMobileFilter()
const user = userEvent.setup()
for (const name of ['Hide', 'Filter', 'Search', 'View']) {
expect(screen.getByRole('button', { name })).toBeVisible()
}
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Hide' }))
expect(screen.getByRole('button', { name: 'Show' })).toBeVisible()
screen.getByRole('button', { name: 'Show' }).focus()
for (const name of ['Filter', 'Search', 'View']) {
await user.tab()
expect(screen.getByRole('button', { name })).toHaveFocus()
}
})
function LoadingFixture(props: { loading: boolean; onSearch: () => void }) {
const table = useReactTable({
data: [],
columns: [],
getCoreRowModel: getCoreRowModel(),
})
return (
<LogsFilterToolbar
table={table}
compactMobile
mobilePinnedFilters={<span>Date Range</span>}
primaryFilters={null}
hasActiveFilters={false}
onReset={() => {}}
onSearch={props.onSearch}
searchLoading={props.loading}
/>
)
}
it('keeps Search visible while loading and prevents repeated searches', async () => {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: query === '(max-width: 640px)',
}))
const onSearch = vi.fn()
const user = userEvent.setup()
const view = render(<LoadingFixture loading onSearch={onSearch} />)
const search = screen.getByRole('button', { name: 'Search' })
expect(search).toBeVisible()
expect(search).toBeDisabled()
expect(search).toHaveAttribute('aria-busy', 'true')
await user.click(search)
expect(onSearch).not.toHaveBeenCalled()
view.rerender(<LoadingFixture loading={false} onSearch={onSearch} />)
expect(search).toBeEnabled()
await user.click(search)
expect(onSearch).toHaveBeenCalledTimes(1)
})
it('collapses only date and statistics while keeping the right-hand quick actions visible', async () => {
await renderMobileFilter()
const user = userEvent.setup()
const date =
screen
.getByRole('button', { name: /^\d{4}-\d{2}/ })
.getAttribute('aria-label') ?? ''
expect(await screen.findByText('Usage')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute(
'aria-expanded',
'false'
)
expect(screen.queryByRole('button', { name: date })).not.toBeInTheDocument()
expect(screen.queryByText('Usage')).not.toBeInTheDocument()
const actions = screen.getByRole('group', { name: 'Actions' })
for (const name of ['Hide', 'Filter', 'Search', 'View']) {
expect(within(actions).getByRole('button', { name })).toBeVisible()
}
expect(
within(actions).queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute(
'aria-expanded',
'true'
)
expect(screen.getByRole('button', { name: date })).toBeVisible()
expect(await screen.findByText('Usage')).toBeVisible()
})
it.each([
{ language: 'zh', resources: zh.translation },
{ language: 'zh-TW', resources: zhTW.translation },
])(
'labels the calendar-month preset as 本月 in $language and selects the complete month',
async ({ language, resources }) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date(2026, 8, 8, 12))
i18next.addResourceBundle(language, 'translation', resources, true, true)
await i18next.changeLanguage(language)
const user = userEvent.setup()
const onChange = vi.fn()
render(
<CompactDateTimeRangePicker
start={new Date(2026, 7, 10)}
end={new Date(2026, 8, 8)}
onChange={onChange}
/>
)
await user.click(screen.getByRole('button', { name: /^2026/ }))
const preset = screen.getByRole('button', { name: '本月' })
expect(preset).toBeVisible()
await user.click(preset)
expect(onChange).toHaveBeenCalledWith({
start: new Date(2026, 8, 1),
end: new Date(2026, 8, 30, 23, 59, 59, 999),
})
}
)
......@@ -38,6 +38,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
import { buildSearchParams } from '../lib/filter'
......@@ -114,6 +115,7 @@ export function CommonLogsFilterBar<TData>(
props: CommonLogsFilterBarProps<TData>
) {
const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 640px)')
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
......@@ -186,8 +188,9 @@ export function CommonLogsFilterBar<TData>(
[searchState]
)
const handleApply = useCallback(() => {
const filterParams = buildSearchParams(filters, 'common')
const handleApply = useCallback(
(nextFilters: CommonLogFilters = filters) => {
const filterParams = buildSearchParams(nextFilters, 'common')
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
......@@ -199,7 +202,9 @@ export function CommonLogsFilterBar<TData>(
})
queryClient.invalidateQueries({ queryKey: ['logs'] })
queryClient.invalidateQueries({ queryKey: ['usage-logs-stats'] })
}, [filters, logType, navigate, queryClient])
},
[filters, logType, navigate, queryClient]
)
const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange()
......@@ -269,11 +274,7 @@ export function CommonLogsFilterBar<TData>(
'Only used to find historical logs. New records are available in Audit Logs.'
)
const statsBar = (
<div className='flex flex-wrap items-center gap-2'>
<CommonLogsStats />
</div>
)
const statsBar = <CommonLogsStats />
const sensitiveToggle = (
<Tooltip>
<TooltipTrigger
......@@ -283,7 +284,7 @@ export function CommonLogsFilterBar<TData>(
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-7'
className='text-muted-foreground hover:text-foreground size-7 max-sm:size-11'
/>
}
>
......@@ -303,6 +304,9 @@ export function CommonLogsFilterBar<TData>(
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
if (isMobile) {
handleApply({ ...filters, startTime: start, endTime: end })
}
}}
/>
</LogsFilterField>
......@@ -454,6 +458,7 @@ export function CommonLogsFilterBar<TData>(
return (
<LogsFilterToolbar
table={props.table}
compactMobile
stats={statsBar}
actionStart={sensitiveToggle}
primaryFilters={
......@@ -481,7 +486,7 @@ export function CommonLogsFilterBar<TData>(
hasAdvancedActiveFilters={hasExpandedFilters}
advancedFilterCount={expandedFilterCount}
hasActiveFilters={hasAdditionalFilters}
onSearch={handleApply}
onSearch={() => handleApply()}
searchLoading={fetchingLogs > 0}
onReset={handleReset}
/>
......
......@@ -69,6 +69,14 @@ export function CompactDateTimeRangePicker({
return `${startText} ~ ${endText}`
}, [end, start, t])
const mobileLabel = useMemo(() => {
if (!start || !end) return label
if (dayjs(start).isSame(end, 'day')) {
return `${dayjs(start).format('MM/DD HH:mm')}${dayjs(end).format('HH:mm')}`
}
return label
}, [start, end, label])
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setDraftStart(toInputValue(start))
......@@ -123,6 +131,7 @@ export function CompactDateTimeRangePicker({
<Button
type='button'
variant='outline'
aria-label={label}
className={cn(
'w-full justify-start gap-2 px-2.5 text-sm leading-5 font-normal tabular-nums',
!start && !end && 'text-muted-foreground',
......@@ -132,7 +141,10 @@ export function CompactDateTimeRangePicker({
}
>
<CalendarDays className='text-muted-foreground size-4 shrink-0' />
<span className='truncate'>{label}</span>
<span className='hidden truncate sm:block'>{label}</span>
<span className='min-w-0 [overflow-wrap:anywhere] whitespace-normal sm:hidden'>
{mobileLabel}
</span>
</PopoverTrigger>
<PopoverContent
align='start'
......@@ -213,7 +225,7 @@ export function CompactDateTimeRangePicker({
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('month')}
>
{t('This month')}
{t('Current month')}
</Button>
</div>
......
......@@ -41,6 +41,7 @@ interface LogsFilterToolbarProps<TData> {
table: Table<TData>
primaryFilters: ReactNode
advancedFilters?: ReactNode
compactMobile?: boolean
mobilePinnedFilters?: ReactNode
mobileFilters?: ReactNode
mobileFilterCount?: number
......@@ -138,8 +139,80 @@ export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
if (isMobile && props.mobilePinnedFilters != null) {
return (
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
{props.compactMobile ? (
<div
className={cn('bg-card/50 rounded-lg border p-2.5', props.className)}
className={cn(
'bg-card/50 min-w-0 space-y-2.5 rounded-lg border p-2.5',
props.className
)}
>
{!mobilePanelCollapsed && (
<>
{props.stats}
<div className='w-full min-w-0 [&_button]:min-h-9'>
{props.mobilePinnedFilters}
</div>
</>
)}
<div className='grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2'>
<Button
type='button'
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground size-9'
aria-expanded={!mobilePanelCollapsed}
aria-label={mobilePanelCollapsed ? t('Expand') : t('Collapse')}
onClick={() =>
setMobilePanelCollapsed((collapsed) => !collapsed)
}
>
<ChevronDown
aria-hidden='true'
className={cn(
'size-4 transition-transform',
!mobilePanelCollapsed && 'rotate-180'
)}
/>
</Button>
<div
role='group'
aria-label={t('Actions')}
className='flex min-w-0 flex-wrap items-center justify-end gap-1.5 [&_button]:h-auto [&_button]:min-h-9 [&_button]:max-w-full [&_button]:[overflow-wrap:anywhere] [&_button]:whitespace-normal'
>
{props.actionStart}
<DrawerTrigger asChild>
<Button
variant='ghost'
aria-label={t('Filter')}
className={cn(
'text-muted-foreground min-h-9 gap-1.5 px-2',
activeMobileFilterCount > 0 && 'text-primary'
)}
>
{t('Filter')}
{activeMobileFilterCount > 0 && (
<Badge>{activeMobileFilterCount}</Badge>
)}
</Button>
</DrawerTrigger>
<Button
onClick={props.onSearch}
disabled={props.searchLoading}
aria-busy={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
<DataTableViewOptions table={props.table} />
</div>
</div>
</div>
) : (
<div
className={cn(
'bg-card/50 rounded-lg border p-2.5',
props.className
)}
>
{!mobilePanelCollapsed && (
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
......@@ -161,7 +234,9 @@ export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
setMobilePanelCollapsed((collapsed) => !collapsed)
}
aria-expanded={!mobilePanelCollapsed}
aria-label={mobilePanelCollapsed ? t('Expand') : t('Collapse')}
aria-label={
mobilePanelCollapsed ? t('Expand') : t('Collapse')
}
className='text-muted-foreground hover:text-foreground mr-auto size-7'
>
<ChevronDown
......@@ -202,6 +277,7 @@ export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
</div>
</div>
</div>
)}
<DrawerContent className='max-h-[85dvh] p-0'>
<div className='mx-auto flex w-full max-w-md flex-1 flex-col overflow-hidden'>
......
......@@ -20,6 +20,7 @@ import { Route } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
......@@ -32,6 +33,8 @@ interface ModelBadgeProps {
modelName: string
actualModel?: string
className?: string
wrapText?: boolean
onInspect?: () => void
}
interface ModelProvider {
......@@ -128,16 +131,23 @@ function ModelBadgeContent(props: ModelBadgeProps) {
return (
<StatusBadge
copyText={props.modelName}
copyable={!props.onInspect}
size='sm'
showDot={!provider}
autoColor={provider ? undefined : props.modelName}
className={cn(
'border-border/60 bg-muted/30 h-6 max-w-none gap-1.5 rounded-md border px-2 [font-family:var(--font-body)]',
provider && 'text-foreground',
props.wrapText && 'h-auto min-h-6 max-w-full py-0.5 whitespace-normal',
props.className
)}
>
<span className='flex max-w-none items-center gap-1.5'>
<span
className={cn(
'flex items-center gap-1.5',
props.wrapText ? 'max-w-full min-w-0' : 'max-w-none'
)}
>
{provider && (
<span
className='flex h-[18px] w-[18px] shrink-0 items-center justify-center'
......@@ -147,7 +157,15 @@ function ModelBadgeContent(props: ModelBadgeProps) {
{getLobeIcon(provider.icon, 18)}
</span>
)}
<span className='whitespace-nowrap'>{props.modelName}</span>
<span
className={
props.wrapText
? 'line-clamp-2 [overflow-wrap:anywhere]'
: 'whitespace-nowrap'
}
>
{props.modelName}
</span>
</span>
</StatusBadge>
)
......@@ -156,6 +174,23 @@ function ModelBadgeContent(props: ModelBadgeProps) {
export function ModelBadge(props: ModelBadgeProps) {
const { t } = useTranslation()
if (props.onInspect) {
return (
<Button
variant='ghost'
aria-label={`${t('Model')}: ${props.modelName}`}
aria-haspopup='dialog'
onClick={props.onInspect}
className='h-auto min-h-8 max-w-full min-w-0 justify-start gap-1 px-0 py-0 text-left font-normal whitespace-normal'
>
<ModelBadgeContent {...props} />
{props.actualModel && (
<Route className='text-muted-foreground size-3 shrink-0' />
)}
</Button>
)
}
if (!props.actualModel) {
return <ModelBadgeContent {...props} />
}
......
......@@ -63,6 +63,7 @@ interface TimingMetricsCellProps {
* indicator used elsewhere on the mobile card.
*/
indicator?: 'bar' | 'dot'
compact?: boolean
}
export function TimingMetricsCell(props: TimingMetricsCellProps) {
......@@ -84,7 +85,13 @@ export function TimingMetricsCell(props: TimingMetricsCellProps) {
const totalTimeLabel = formatUseTime(props.useTimeSec)
const labels = (
<div className='flex min-h-8 min-w-0 flex-col justify-center gap-0.5 text-xs leading-tight'>
<div
className={cn(
'flex min-h-8 min-w-0 flex-col justify-center gap-0.5 text-xs leading-tight',
props.compact &&
'min-h-0 flex-row flex-wrap items-center gap-x-2.5 gap-y-1'
)}
>
{showFirstToken && (
<div className='flex items-baseline gap-1.5'>
{indicator === 'dot' && (
......@@ -151,6 +158,7 @@ export function TimingMetricsCell(props: TimingMetricsCellProps) {
interface StreamTpsCellProps {
isStream: boolean
compact?: boolean
/** Task logs are asynchronous jobs; stream vs non-stream does not apply. */
isTask?: boolean
tokensPerSecond?: number | null
......@@ -175,6 +183,7 @@ export function StreamTpsCell(props: StreamTpsCellProps) {
<div
className={cn(
'flex shrink-0 flex-col items-start justify-center gap-0.5 text-xs leading-tight',
props.compact && 'flex-row flex-wrap items-center gap-1.5',
props.className
)}
>
......@@ -208,9 +217,12 @@ export function StreamTpsCell(props: StreamTpsCellProps) {
</TooltipProvider>
)}
</span>
{(!props.compact ||
(props.isStream && props.tokensPerSecond != null)) && (
<span className='text-muted-foreground/60 px-0.5 tabular-nums'>
{tpsLabel}
</span>
)}
</div>
)
}
......@@ -191,6 +191,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
return (
<DataTablePage
table={table}
compactPagination={isMobile && isCommon}
columns={columns as ColumnDef<Record<string, unknown>>[]}
isLoading={isLoadingData}
isFetching={isFetching}
......
......@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "Current legacy JSON is invalid, cannot append",
"Current Level Only": "Current Level Only",
"Current models for the longest channel in this tag. May not include all models from all channels.": "Current models for the longest channel in this tag. May not include all models from all channels.",
"Current month": "Current month",
"Current Password": "Current Password",
"Current password is incorrect.": "Current password is incorrect.",
"Current Price": "Current Price",
......
......@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "Le JSON ancien format actuel n'est pas valide, impossible d'ajouter",
"Current Level Only": "Niveau actuel uniquement",
"Current models for the longest channel in this tag. May not include all models from all channels.": "Modèles actuels pour le canal le plus long de cette balise. Peut ne pas inclure tous les modèles de tous les canaux.",
"Current month": "Ce mois-ci",
"Current Password": "Mot de passe actuel",
"Current password is incorrect.": "Le mot de passe actuel est incorrect.",
"Current Price": "Prix actuel",
......
......@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "現在の旧形式JSONが無効なため、追加できません",
"Current Level Only": "現在の階層のみ",
"Current models for the longest channel in this tag. May not include all models from all channels.": "このタグ内の最も長いチャネルの現在のモデル。すべてのチャネルのすべてのモデルが含まれているわけではない場合があります。",
"Current month": "今月",
"Current Password": "現在のパスワード",
"Current password is incorrect.": "現在のパスワードが正しくありません。",
"Current Price": "現在の価格",
......
......@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "Текущий JSON старого формата невалиден, добавление невозможно",
"Current Level Only": "Только текущий уровень",
"Current models for the longest channel in this tag. May not include all models from all channels.": "Текущие модели для самого длинного канала в этом теге. Может не включать все модели из всех каналов.",
"Current month": "Текущий месяц",
"Current Password": "Текущий пароль",
"Current password is incorrect.": "Текущий пароль неверен.",
"Current Price": "Текущая цена",
......
......@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "JSON định dạng cũ hiện tại không hợp lệ, không thể thêm",
"Current Level Only": "Chỉ cấp hiện tại",
"Current models for the longest channel in this tag. May not include all models from all channels.": "Các mô hình hiện tại cho kênh dài nhất trong thẻ này. Có thể không bao gồm tất cả các mô hình từ tất cả các kênh.",
"Current month": "Tháng này",
"Current Password": "Mật khẩu hiện tại",
"Current password is incorrect.": "Mật khẩu hiện tại không đúng.",
"Current Price": "Giá hiện tại",
......
......@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "目前舊格式 JSON 不合法,無法追加模板",
"Current Level Only": "僅目前層",
"Current models for the longest channel in this tag. May not include all models from all channels.": "此標籤中最長渠道的目前模型。可能不包括所有渠道的所有模型。",
"Current month": "本月",
"Current Password": "目前密碼",
"Current password is incorrect.": "目前密碼不正確。",
"Current Price": "目前價格",
......
......@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "当前旧格式 JSON 不合法,无法追加模板",
"Current Level Only": "仅当前层",
"Current models for the longest channel in this tag. May not include all models from all channels.": "此标签中最长渠道的当前模型。可能不包括所有渠道的所有模型。",
"Current month": "本月",
"Current Password": "当前密码",
"Current password is incorrect.": "当前密码不正确。",
"Current Price": "当前价格",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment