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/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { type Table } from '@tanstack/react-table' import type { Table } from '@tanstack/react-table'
import { import {
ChevronLeft as ChevronLeftIcon, ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon, ChevronRight as ChevronRightIcon,
...@@ -38,6 +38,7 @@ import { cn, getPageNumbers } from '@/lib/utils' ...@@ -38,6 +38,7 @@ import { cn, getPageNumbers } from '@/lib/utils'
type DataTablePaginationProps<TData> = { type DataTablePaginationProps<TData> = {
table: Table<TData> table: Table<TData>
compact?: boolean
} }
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const 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) => ({ ...@@ -48,6 +49,7 @@ const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
export function DataTablePagination<TData>({ export function DataTablePagination<TData>({
table, table,
compact = false,
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
const { t } = useTranslation() const { t } = useTranslation()
const pagination = table.getState().pagination const pagination = table.getState().pagination
...@@ -56,6 +58,48 @@ export function DataTablePagination<TData>({ ...@@ -56,6 +58,48 @@ export function DataTablePagination<TData>({
const totalPages = table.getPageCount() const totalPages = table.getPageCount()
const totalRows = table.getRowCount() const totalRows = table.getRowCount()
const pageNumbers = getPageNumbers(currentPage, totalPages) 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 ( return (
<div <div
...@@ -118,8 +162,8 @@ export function DataTablePagination<TData>({ ...@@ -118,8 +162,8 @@ export function DataTablePagination<TData>({
<ChevronLeftIcon className='h-4 w-4' /> <ChevronLeftIcon className='h-4 w-4' />
</Button> </Button>
{pageNumbers.map((pageNumber, index) => ( {pageItems.map(({ page: pageNumber, key }) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'> <div key={key} className='flex items-center'>
{pageNumber === '...' ? ( {pageNumber === '...' ? (
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'> <span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
... ...
......
...@@ -206,6 +206,9 @@ export type DataTablePageProps<TData> = { ...@@ -206,6 +206,9 @@ export type DataTablePageProps<TData> = {
*/ */
showPagination?: boolean showPagination?: boolean
/** Minimal previous/next pagination for narrow feature layouts. */
compactPagination?: boolean
/** /**
* Render pagination via `PageFooterPortal` (sticks to page footer). * Render pagination via `PageFooterPortal` (sticks to page footer).
* Defaults to `true`. Set `false` to render inline below the table. * Defaults to `true`. Set `false` to render inline below the table.
...@@ -392,7 +395,12 @@ function renderPagination<TData>( ...@@ -392,7 +395,12 @@ function renderPagination<TData>(
return null return null
} }
const pagination = <DataTablePagination table={props.table} /> const pagination = (
<DataTablePagination
table={props.table}
compact={props.compactPagination}
/>
)
return props.paginationInFooter !== false ? ( return props.paginationInFooter !== false ? (
<PageFooterPortal>{pagination}</PageFooterPortal> <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),
})
}
)
/*
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 { flexRender, type Cell } from '@tanstack/react-table'
import { ChevronRight, KeyRound } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import dayjs from '@/lib/dayjs'
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
import type { UsageLog } from '../data/schema'
import { formatModelName, parseLogOther } from '../lib/format'
import {
getLogTypeConfig,
isDisplayableLogType,
isTimingLogType,
} from '../lib/utils'
import { ModelBadge } from './model-badge'
import { StreamTpsCell, TimingMetricsCell } from './timing-metrics-cell'
import { useUsageLogsContext } from './usage-logs-provider'
type FieldName =
| 'model'
| 'cost'
| 'user'
| 'channel'
| 'token'
| 'group'
| 'time'
type LogField = {
label: string
value: string
visible: boolean
sensitive?: boolean
}
/** Mobile summaries use tappable fields; desktop tooltip cells cannot reveal full text on touch. */
export function CommonLogMobileCard<TData>(props: {
log: UsageLog
cells: Map<string, Cell<TData, unknown>>
}) {
const { t } = useTranslation()
const context = useUsageLogsContext()
const [selectedField, setSelectedField] = useState<FieldName | null>(null)
const log = props.log
const other = parseLogOther(log.other)
const displayable = isDisplayableLogType(log.type)
const timing = isTimingLogType(log.type)
const model = formatModelName(log)
const config = getLogTypeConfig(log.type)
const group = log.group || other?.group || ''
const groupRatio =
other?.user_group_ratio != null && other.user_group_ratio !== -1
? other.user_group_ratio
: other?.group_ratio
const fields: Record<FieldName, LogField> = {
model: {
label: t('Model'),
value: model.name,
visible: displayable && props.cells.has('model_name') && !!model.name,
},
cost: {
label: t('Cost'),
value: formatLogQuota(log.quota),
visible: displayable && props.cells.has('quota'),
},
time: {
label: t('Time'),
value: formatTimestampToDate(log.created_at),
visible: props.cells.has('created_at'),
},
user: {
label: t('User'),
value: log.username,
visible: props.cells.has('user') && !!log.username,
sensitive: true,
},
channel: {
label: t('Channel'),
value: [log.channel_name, `#${log.channel}`].filter(Boolean).join(' '),
visible: displayable && props.cells.has('channel'),
sensitive: true,
},
token: {
label: t('Token'),
value: log.token_name,
visible: displayable && props.cells.has('token_name') && !!log.token_name,
sensitive: true,
},
group: {
label: t('Group'),
value: group,
visible: displayable && props.cells.has('token_name') && !!group,
sensitive: true,
},
}
const selected = selectedField ? fields[selectedField] : undefined
const activeField =
selected?.visible && (!selected.sensitive || context.sensitiveVisible)
? selected
: undefined
const metadata: FieldName[] = ['user', 'channel', 'token', 'group']
const visibleMetadata = metadata.filter((id) => fields[id].visible)
const costCell = props.cells.get('quota')
const contentCell = props.cells.get('content')
const channelCell = props.cells.get('channel')
const cacheRead = other?.cache_tokens || 0
const cacheWrite =
(other?.cache_creation_tokens_5m || 0) +
(other?.cache_creation_tokens_1h || 0) ||
other?.cache_creation_tokens ||
0
const showTokens =
displayable &&
props.cells.has('prompt_tokens') &&
(log.prompt_tokens > 0 ||
log.completion_tokens > 0 ||
cacheRead > 0 ||
cacheWrite > 0)
return (
<div className='min-w-0 space-y-2.5 text-sm leading-5'>
<div className='flex min-w-0 flex-wrap items-start gap-x-3 gap-y-2'>
{fields.model.visible && (
<div className='min-w-0 flex-[1_1_10rem]'>
<ModelBadge
modelName={model.name}
actualModel={model.actualModel}
wrapText
onInspect={() => setSelectedField('model')}
/>
</div>
)}
{fields.cost.visible && costCell && (
<div className='ml-auto max-w-full min-w-0 self-center [overflow-wrap:anywhere] [&_.inline-flex]:h-auto [&_.inline-flex]:min-h-6 [&_.inline-flex]:max-w-full [&_.inline-flex]:flex-wrap'>
{flexRender(costCell.column.columnDef.cell, costCell.getContext())}
</div>
)}
</div>
<div
className='grid min-w-0 grid-cols-2 items-stretch gap-x-3'
data-slot='log-time-and-timing'
>
{fields.time.visible && (
<div className='flex min-w-0 flex-col items-start justify-between gap-1'>
<StatusBadge
label={t(config.label)}
variant={config.color as StatusVariant}
copyable={false}
showDot
className='h-5 px-0 text-xs'
/>
<Button
variant='ghost'
aria-label={`${t('Time')}: ${fields.time.value}`}
aria-haspopup='dialog'
onClick={() => setSelectedField('time')}
className='text-muted-foreground h-auto min-h-6 px-0 py-0 text-xs font-normal whitespace-normal tabular-nums'
>
{dayjs.unix(log.created_at).format('MM-DD HH:mm:ss')}
</Button>
</div>
)}
{timing &&
(props.cells.has('use_time') || props.cells.has('is_stream')) && (
<div className='col-start-2 flex min-w-0 flex-col items-end gap-1 [overflow-wrap:anywhere]'>
{props.cells.has('is_stream') && (
<StreamTpsCell
compact
className='min-h-5 max-w-full min-w-0 justify-end'
isStream={log.is_stream}
isTask={other?.is_task === true}
tokensPerSecond={
log.use_time > 0 && log.completion_tokens > 0
? log.completion_tokens / log.use_time
: null
}
streamStatus={other?.stream_status}
/>
)}
{props.cells.has('use_time') && (
<TimingMetricsCell
useTimeSec={log.use_time}
completionTokens={log.completion_tokens}
frtMs={other?.frt}
isStream={log.is_stream}
indicator='dot'
compact
className='min-h-6 max-w-full min-w-0 items-center justify-end [&>div]:justify-end'
/>
)}
</div>
)}
</div>
{visibleMetadata.length > 0 && (
<div className='grid min-w-0 grid-cols-2 gap-x-4 gap-y-0.5'>
{visibleMetadata.map((id) => {
const field = fields[id]
let fieldContent = <span className='truncate'>{field.value}</span>
if (id === 'group') {
fieldContent = (
<GroupBadge
group={field.value}
type='text'
className='max-w-full text-sm'
/>
)
} else if (id === 'token') {
fieldContent = (
<StatusBadge
label={field.value}
copyable={false}
icon={KeyRound}
className='border-border/60 bg-muted/30 text-foreground max-w-full rounded-md border px-1.5 py-0.5 text-sm'
/>
)
}
return (
<div key={id} className='flex min-w-0 items-center gap-2'>
<span className='text-muted-foreground max-w-[40%] shrink-0 text-xs [overflow-wrap:anywhere]'>
{id === 'user' ? (
<Avatar className='ring-border/60 size-6 shrink-0 ring-1'>
<AvatarFallback
className='text-[11px] font-semibold'
style={
context.sensitiveVisible
? getUserAvatarStyle(log.username)
: undefined
}
>
{context.sensitiveVisible
? getUserAvatarFallback(log.username)
: '•'}
</AvatarFallback>
</Avatar>
) : (
field.label
)}
</span>
{context.sensitiveVisible ? (
<Button
variant='ghost'
aria-label={`${field.label}: ${field.value}`}
aria-haspopup='dialog'
onClick={() => setSelectedField(id)}
className='text-foreground h-auto min-h-8 min-w-0 flex-1 shrink justify-start px-0 py-1 text-left text-sm font-normal'
>
{fieldContent}
</Button>
) : (
<span className='min-w-0 py-1.5'>••••</span>
)}
</div>
)
})}
{groupRatio != null &&
groupRatio !== 1 &&
Number.isFinite(groupRatio) &&
props.cells.has('token_name') && (
<div className='text-muted-foreground col-span-2 [overflow-wrap:anywhere]'>
{t('Group Ratio')}: {groupRatio}×
</div>
)}
</div>
)}
{showTokens && (
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs [overflow-wrap:anywhere]'>
<span>
{t('Input')}{' '}
<span className='text-foreground tabular-nums'>
{log.prompt_tokens.toLocaleString()}
</span>
</span>
<span>
{t('Output')}{' '}
<span className='text-foreground tabular-nums'>
{log.completion_tokens.toLocaleString()}
</span>
</span>
{cacheRead > 0 && (
<span>
{t('Cache')}{cacheRead.toLocaleString()}
</span>
)}
{cacheWrite > 0 && (
<span>
{t('Cache')}{cacheWrite.toLocaleString()}
</span>
)}
</div>
)}
{contentCell && (
<div className='relative min-w-0 border-t pt-2 [&_button]:min-h-8 [&_button]:w-full [&_button]:max-w-full [&_button]:pr-5 [&_button]:text-sm [&_button>span]:line-clamp-2 [&_button>span]:[overflow-wrap:anywhere] [&_button>span]:whitespace-normal'>
{flexRender(
contentCell.column.columnDef.cell,
contentCell.getContext()
)}
<ChevronRight
aria-hidden='true'
className='text-primary pointer-events-none absolute top-4 right-0 size-4'
/>
</div>
)}
<Dialog
open={!!activeField}
onOpenChange={(open) => {
if (!open) setSelectedField(null)
}}
title={activeField?.label ?? t('Details')}
contentClassName='max-sm:top-auto max-sm:bottom-0 max-sm:max-h-[85dvh] max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none max-sm:rounded-t-2xl max-sm:pb-[max(1rem,env(safe-area-inset-bottom))] [&_[data-slot=dialog-close]]:size-11'
footer={
activeField && (
<CopyButton
value={activeField.value}
variant='default'
size='default'
className='min-h-11 w-full'
>
{t('Copy')}
</CopyButton>
)
}
>
{activeField && (
<div className='space-y-4'>
<p className='bg-muted rounded-lg p-4 text-base [overflow-wrap:anywhere] whitespace-pre-wrap'>
{activeField.value}
</p>
{selectedField === 'model' && model.actualModel && (
<div className='space-y-2'>
<p className='text-muted-foreground'>{t('Actual Model')}</p>
<p className='text-base [overflow-wrap:anywhere]'>
{model.actualModel}
</p>
<CopyButton value={model.actualModel} />
</div>
)}
{selectedField === 'channel' && channelCell && (
<div>
{flexRender(
channelCell.column.columnDef.cell,
channelCell.getContext()
)}
</div>
)}
{selectedField === 'user' && (
<Button
variant='outline'
className='min-h-11'
onClick={() => {
setSelectedField(null)
context.setSelectedUserId(log.user_id)
context.setUserInfoDialogOpen(true)
}}
>
{t('User Information')}
</Button>
)}
</div>
)}
</Dialog>
</div>
)
}
...@@ -38,6 +38,7 @@ import { ...@@ -38,6 +38,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants' import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
import { buildSearchParams } from '../lib/filter' import { buildSearchParams } from '../lib/filter'
...@@ -114,6 +115,7 @@ export function CommonLogsFilterBar<TData>( ...@@ -114,6 +115,7 @@ export function CommonLogsFilterBar<TData>(
props: CommonLogsFilterBarProps<TData> props: CommonLogsFilterBarProps<TData>
) { ) {
const { t } = useTranslation() const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 640px)')
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const searchParams = route.useSearch() const searchParams = route.useSearch()
...@@ -186,20 +188,23 @@ export function CommonLogsFilterBar<TData>( ...@@ -186,20 +188,23 @@ export function CommonLogsFilterBar<TData>(
[searchState] [searchState]
) )
const handleApply = useCallback(() => { const handleApply = useCallback(
const filterParams = buildSearchParams(filters, 'common') (nextFilters: CommonLogFilters = filters) => {
navigate({ const filterParams = buildSearchParams(nextFilters, 'common')
to: '/usage-logs/$section', navigate({
params: { section: 'common' }, to: '/usage-logs/$section',
search: { params: { section: 'common' },
...filterParams, search: {
type: [logType], ...filterParams,
page: 1, type: [logType],
}, page: 1,
}) },
queryClient.invalidateQueries({ queryKey: ['logs'] }) })
queryClient.invalidateQueries({ queryKey: ['usage-logs-stats'] }) queryClient.invalidateQueries({ queryKey: ['logs'] })
}, [filters, logType, navigate, queryClient]) queryClient.invalidateQueries({ queryKey: ['usage-logs-stats'] })
},
[filters, logType, navigate, queryClient]
)
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange() const { start, end } = getDefaultTimeRange()
...@@ -269,11 +274,7 @@ export function CommonLogsFilterBar<TData>( ...@@ -269,11 +274,7 @@ export function CommonLogsFilterBar<TData>(
'Only used to find historical logs. New records are available in Audit Logs.' 'Only used to find historical logs. New records are available in Audit Logs.'
) )
const statsBar = ( const statsBar = <CommonLogsStats />
<div className='flex flex-wrap items-center gap-2'>
<CommonLogsStats />
</div>
)
const sensitiveToggle = ( const sensitiveToggle = (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
...@@ -283,7 +284,7 @@ export function CommonLogsFilterBar<TData>( ...@@ -283,7 +284,7 @@ export function CommonLogsFilterBar<TData>(
size='icon' size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)} onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')} 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>( ...@@ -303,6 +304,9 @@ export function CommonLogsFilterBar<TData>(
onChange={({ start, end }) => { onChange={({ start, end }) => {
handleChange('startTime', start) handleChange('startTime', start)
handleChange('endTime', end) handleChange('endTime', end)
if (isMobile) {
handleApply({ ...filters, startTime: start, endTime: end })
}
}} }}
/> />
</LogsFilterField> </LogsFilterField>
...@@ -454,6 +458,7 @@ export function CommonLogsFilterBar<TData>( ...@@ -454,6 +458,7 @@ export function CommonLogsFilterBar<TData>(
return ( return (
<LogsFilterToolbar <LogsFilterToolbar
table={props.table} table={props.table}
compactMobile
stats={statsBar} stats={statsBar}
actionStart={sensitiveToggle} actionStart={sensitiveToggle}
primaryFilters={ primaryFilters={
...@@ -481,7 +486,7 @@ export function CommonLogsFilterBar<TData>( ...@@ -481,7 +486,7 @@ export function CommonLogsFilterBar<TData>(
hasAdvancedActiveFilters={hasExpandedFilters} hasAdvancedActiveFilters={hasExpandedFilters}
advancedFilterCount={expandedFilterCount} advancedFilterCount={expandedFilterCount}
hasActiveFilters={hasAdditionalFilters} hasActiveFilters={hasAdditionalFilters}
onSearch={handleApply} onSearch={() => handleApply()}
searchLoading={fetchingLogs > 0} searchLoading={fetchingLogs > 0}
onReset={handleReset} onReset={handleReset}
/> />
......
...@@ -69,6 +69,14 @@ export function CompactDateTimeRangePicker({ ...@@ -69,6 +69,14 @@ export function CompactDateTimeRangePicker({
return `${startText} ~ ${endText}` return `${startText} ~ ${endText}`
}, [end, start, t]) }, [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) => { const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) { if (nextOpen) {
setDraftStart(toInputValue(start)) setDraftStart(toInputValue(start))
...@@ -123,6 +131,7 @@ export function CompactDateTimeRangePicker({ ...@@ -123,6 +131,7 @@ export function CompactDateTimeRangePicker({
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
aria-label={label}
className={cn( className={cn(
'w-full justify-start gap-2 px-2.5 text-sm leading-5 font-normal tabular-nums', 'w-full justify-start gap-2 px-2.5 text-sm leading-5 font-normal tabular-nums',
!start && !end && 'text-muted-foreground', !start && !end && 'text-muted-foreground',
...@@ -132,7 +141,10 @@ export function CompactDateTimeRangePicker({ ...@@ -132,7 +141,10 @@ export function CompactDateTimeRangePicker({
} }
> >
<CalendarDays className='text-muted-foreground size-4 shrink-0' /> <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> </PopoverTrigger>
<PopoverContent <PopoverContent
align='start' align='start'
...@@ -213,7 +225,7 @@ export function CompactDateTimeRangePicker({ ...@@ -213,7 +225,7 @@ export function CompactDateTimeRangePicker({
className='h-7 flex-1 px-2 text-xs' className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('month')} onClick={() => applyPreset('month')}
> >
{t('This month')} {t('Current month')}
</Button> </Button>
</div> </div>
......
...@@ -41,6 +41,7 @@ interface LogsFilterToolbarProps<TData> { ...@@ -41,6 +41,7 @@ interface LogsFilterToolbarProps<TData> {
table: Table<TData> table: Table<TData>
primaryFilters: ReactNode primaryFilters: ReactNode
advancedFilters?: ReactNode advancedFilters?: ReactNode
compactMobile?: boolean
mobilePinnedFilters?: ReactNode mobilePinnedFilters?: ReactNode
mobileFilters?: ReactNode mobileFilters?: ReactNode
mobileFilterCount?: number mobileFilterCount?: number
...@@ -138,70 +139,145 @@ export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) { ...@@ -138,70 +139,145 @@ export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
if (isMobile && props.mobilePinnedFilters != null) { if (isMobile && props.mobilePinnedFilters != null) {
return ( return (
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}> <Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
<div {props.compactMobile ? (
className={cn('bg-card/50 rounded-lg border p-2.5', props.className)}
>
{!mobilePanelCollapsed && (
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
)}
<div <div
className={cn( className={cn(
'flex flex-col gap-2', 'bg-card/50 min-w-0 space-y-2.5 rounded-lg border p-2.5',
!mobilePanelCollapsed && 'mt-2' props.className
)} )}
> >
{!mobilePanelCollapsed && props.stats} {!mobilePanelCollapsed && (
<div className='flex items-center justify-end gap-1.5'> <>
{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 <Button
type='button' type='button'
variant='ghost' variant='ghost'
size='icon' size='icon'
className='text-muted-foreground hover:text-foreground size-9'
aria-expanded={!mobilePanelCollapsed}
aria-label={mobilePanelCollapsed ? t('Expand') : t('Collapse')}
onClick={() => onClick={() =>
setMobilePanelCollapsed((collapsed) => !collapsed) setMobilePanelCollapsed((collapsed) => !collapsed)
} }
aria-expanded={!mobilePanelCollapsed}
aria-label={mobilePanelCollapsed ? t('Expand') : t('Collapse')}
className='text-muted-foreground hover:text-foreground mr-auto size-7'
> >
<ChevronDown <ChevronDown
aria-hidden='true'
className={cn( className={cn(
'size-3.5 transition-transform duration-200', 'size-4 transition-transform',
!mobilePanelCollapsed && 'rotate-180' !mobilePanelCollapsed && 'rotate-180'
)} )}
/> />
</Button> </Button>
{props.actionStart} <div
<DrawerTrigger asChild> 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>
)}
<div
className={cn(
'flex flex-col gap-2',
!mobilePanelCollapsed && 'mt-2'
)}
>
{!mobilePanelCollapsed && props.stats}
<div className='flex items-center justify-end gap-1.5'>
<Button <Button
type='button' type='button'
variant='ghost' variant='ghost'
className={cn( size='icon'
'text-muted-foreground hover:text-foreground gap-1 px-2', onClick={() =>
activeMobileFilterCount > 0 && setMobilePanelCollapsed((collapsed) => !collapsed)
'text-primary hover:text-primary' }
)} aria-expanded={!mobilePanelCollapsed}
aria-label={
mobilePanelCollapsed ? t('Expand') : t('Collapse')
}
className='text-muted-foreground hover:text-foreground mr-auto size-7'
> >
{t('Filter')} <ChevronDown
{activeMobileFilterCount > 0 && ( className={cn(
<Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'> 'size-3.5 transition-transform duration-200',
{activeMobileFilterCount} !mobilePanelCollapsed && 'rotate-180'
</Badge> )}
)} />
</Button> </Button>
</DrawerTrigger> {props.actionStart}
<Button <DrawerTrigger asChild>
type='button' <Button
onClick={props.onSearch} type='button'
disabled={props.searchLoading} variant='ghost'
> className={cn(
{props.searchLoading && <Loader2 className='animate-spin' />} 'text-muted-foreground hover:text-foreground gap-1 px-2',
{t('Search')} activeMobileFilterCount > 0 &&
</Button> 'text-primary hover:text-primary'
<DataTableViewOptions table={props.table} /> )}
>
{t('Filter')}
{activeMobileFilterCount > 0 && (
<Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'>
{activeMobileFilterCount}
</Badge>
)}
</Button>
</DrawerTrigger>
<Button
type='button'
onClick={props.onSearch}
disabled={props.searchLoading}
>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
<DataTableViewOptions table={props.table} />
</div>
</div> </div>
</div> </div>
</div> )}
<DrawerContent className='max-h-[85dvh] p-0'> <DrawerContent className='max-h-[85dvh] p-0'>
<div className='mx-auto flex w-full max-w-md flex-1 flex-col overflow-hidden'> <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' ...@@ -20,6 +20,7 @@ import { Route } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
...@@ -32,6 +33,8 @@ interface ModelBadgeProps { ...@@ -32,6 +33,8 @@ interface ModelBadgeProps {
modelName: string modelName: string
actualModel?: string actualModel?: string
className?: string className?: string
wrapText?: boolean
onInspect?: () => void
} }
interface ModelProvider { interface ModelProvider {
...@@ -128,16 +131,23 @@ function ModelBadgeContent(props: ModelBadgeProps) { ...@@ -128,16 +131,23 @@ function ModelBadgeContent(props: ModelBadgeProps) {
return ( return (
<StatusBadge <StatusBadge
copyText={props.modelName} copyText={props.modelName}
copyable={!props.onInspect}
size='sm' size='sm'
showDot={!provider} showDot={!provider}
autoColor={provider ? undefined : props.modelName} autoColor={provider ? undefined : props.modelName}
className={cn( 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)]', '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', provider && 'text-foreground',
props.wrapText && 'h-auto min-h-6 max-w-full py-0.5 whitespace-normal',
props.className 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 && ( {provider && (
<span <span
className='flex h-[18px] w-[18px] shrink-0 items-center justify-center' className='flex h-[18px] w-[18px] shrink-0 items-center justify-center'
...@@ -147,7 +157,15 @@ function ModelBadgeContent(props: ModelBadgeProps) { ...@@ -147,7 +157,15 @@ function ModelBadgeContent(props: ModelBadgeProps) {
{getLobeIcon(provider.icon, 18)} {getLobeIcon(provider.icon, 18)}
</span> </span>
)} )}
<span className='whitespace-nowrap'>{props.modelName}</span> <span
className={
props.wrapText
? 'line-clamp-2 [overflow-wrap:anywhere]'
: 'whitespace-nowrap'
}
>
{props.modelName}
</span>
</span> </span>
</StatusBadge> </StatusBadge>
) )
...@@ -156,6 +174,23 @@ function ModelBadgeContent(props: ModelBadgeProps) { ...@@ -156,6 +174,23 @@ function ModelBadgeContent(props: ModelBadgeProps) {
export function ModelBadge(props: ModelBadgeProps) { export function ModelBadge(props: ModelBadgeProps) {
const { t } = useTranslation() 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) { if (!props.actualModel) {
return <ModelBadgeContent {...props} /> return <ModelBadgeContent {...props} />
} }
......
...@@ -63,6 +63,7 @@ interface TimingMetricsCellProps { ...@@ -63,6 +63,7 @@ interface TimingMetricsCellProps {
* indicator used elsewhere on the mobile card. * indicator used elsewhere on the mobile card.
*/ */
indicator?: 'bar' | 'dot' indicator?: 'bar' | 'dot'
compact?: boolean
} }
export function TimingMetricsCell(props: TimingMetricsCellProps) { export function TimingMetricsCell(props: TimingMetricsCellProps) {
...@@ -84,7 +85,13 @@ export function TimingMetricsCell(props: TimingMetricsCellProps) { ...@@ -84,7 +85,13 @@ export function TimingMetricsCell(props: TimingMetricsCellProps) {
const totalTimeLabel = formatUseTime(props.useTimeSec) const totalTimeLabel = formatUseTime(props.useTimeSec)
const labels = ( 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 && ( {showFirstToken && (
<div className='flex items-baseline gap-1.5'> <div className='flex items-baseline gap-1.5'>
{indicator === 'dot' && ( {indicator === 'dot' && (
...@@ -151,6 +158,7 @@ export function TimingMetricsCell(props: TimingMetricsCellProps) { ...@@ -151,6 +158,7 @@ export function TimingMetricsCell(props: TimingMetricsCellProps) {
interface StreamTpsCellProps { interface StreamTpsCellProps {
isStream: boolean isStream: boolean
compact?: boolean
/** Task logs are asynchronous jobs; stream vs non-stream does not apply. */ /** Task logs are asynchronous jobs; stream vs non-stream does not apply. */
isTask?: boolean isTask?: boolean
tokensPerSecond?: number | null tokensPerSecond?: number | null
...@@ -175,6 +183,7 @@ export function StreamTpsCell(props: StreamTpsCellProps) { ...@@ -175,6 +183,7 @@ export function StreamTpsCell(props: StreamTpsCellProps) {
<div <div
className={cn( className={cn(
'flex shrink-0 flex-col items-start justify-center gap-0.5 text-xs leading-tight', '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 props.className
)} )}
> >
...@@ -208,9 +217,12 @@ export function StreamTpsCell(props: StreamTpsCellProps) { ...@@ -208,9 +217,12 @@ export function StreamTpsCell(props: StreamTpsCellProps) {
</TooltipProvider> </TooltipProvider>
)} )}
</span> </span>
<span className='text-muted-foreground/60 px-0.5 tabular-nums'> {(!props.compact ||
{tpsLabel} (props.isStream && props.tokensPerSecond != null)) && (
</span> <span className='text-muted-foreground/60 px-0.5 tabular-nums'>
{tpsLabel}
</span>
)}
</div> </div>
) )
} }
...@@ -21,12 +21,6 @@ import { Database } from 'lucide-react' ...@@ -21,12 +21,6 @@ import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
dotColorMap,
textColorMap,
type StatusVariant,
} from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Empty, Empty,
EmptyDescription, EmptyDescription,
EmptyHeader, EmptyHeader,
...@@ -34,22 +28,13 @@ import { ...@@ -34,22 +28,13 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { LOG_TYPE_ENUM } from '../constants' import { LOG_TYPE_ENUM } from '../constants'
import type { UsageLog } from '../data/schema' import type { UsageLog } from '../data/schema'
import { parseLogOther } from '../lib/format'
import { TASK_MOBILE_SUMMARY_FIELDS } from '../lib/task-mobile-layout' import { TASK_MOBILE_SUMMARY_FIELDS } from '../lib/task-mobile-layout'
import {
getLogTypeConfig,
isDisplayableLogType,
isTimingLogType,
} from '../lib/utils'
import type { LogCategory } from '../types' import type { LogCategory } from '../types'
import { StreamTpsCell, TimingMetricsCell } from './timing-metrics-cell' import { CommonLogMobileCard } from './common-log-mobile-card'
import { useUsageLogsContext } from './usage-logs-provider'
const logTypeRowTint: Record<number, string> = { const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: [LOG_TYPE_ENUM.ERROR]:
...@@ -66,13 +51,27 @@ interface UsageLogsMobileListProps<TData> { ...@@ -66,13 +51,27 @@ interface UsageLogsMobileListProps<TData> {
logCategory: LogCategory logCategory: LogCategory
} }
function UsageLogsMobileSkeleton() { function UsageLogsMobileSkeleton(props: { separate: boolean }) {
const { t } = useTranslation()
return ( return (
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'> <div
role='status'
aria-label={t('Loading')}
aria-busy='true'
className={
props.separate
? 'min-w-0 space-y-3'
: 'border-border/50 bg-card overflow-hidden rounded-lg border'
}
>
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className='border-border/40 space-y-2.5 border-b p-3 last:border-b-0' className={
props.separate
? 'border-border/60 bg-card space-y-3 rounded-xl border p-3.5'
: 'border-border/40 space-y-2.5 border-b p-3 last:border-b-0'
}
> >
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<Skeleton className='h-5 w-40 rounded-md' /> <Skeleton className='h-5 w-40 rounded-md' />
...@@ -154,223 +153,6 @@ function SummaryField<TData>({ ...@@ -154,223 +153,6 @@ function SummaryField<TData>({
) )
} }
function MobileLogTimeStatus({
createdAt,
type,
}: {
createdAt: unknown
type: unknown
}) {
const { t } = useTranslation()
const timestamp = typeof createdAt === 'number' ? createdAt : undefined
const logType = typeof type === 'number' ? type : undefined
const config = getLogTypeConfig(logType ?? LOG_TYPE_ENUM.UNKNOWN)
const variant = config.color as StatusVariant
return (
<div className='space-y-1'>
<div className='font-mono text-xs leading-tight tabular-nums'>
{formatTimestampToDate(timestamp)}
</div>
<div
className={cn(
'inline-flex items-center gap-1 text-xs leading-none font-medium',
textColorMap[variant]
)}
>
<span
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap[variant])}
aria-hidden='true'
/>
<span>{t(config.label)}</span>
</div>
</div>
)
}
/** Mobile-only Tokens block: always show cache ↓/↑ when present (no label). */
function MobileTokensField({ log }: { log: UsageLog }) {
const { t } = useTranslation()
if (!isDisplayableLogType(log.type)) return null
const promptTokens = log.prompt_tokens || 0
const completionTokens = log.completion_tokens || 0
if (promptTokens === 0 && completionTokens === 0) {
return (
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<span className='text-muted-foreground text-xs'>-</span>
</div>
)
}
const other = parseLogOther(log.other)
const cacheReadTokens = other?.cache_tokens || 0
const cacheWrite5m = other?.cache_creation_tokens_5m || 0
const cacheWrite1h = other?.cache_creation_tokens_1h || 0
const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
const cacheWriteTokens = hasSplitCache
? cacheWrite5m + cacheWrite1h
: other?.cache_creation_tokens || 0
const showCache = cacheReadTokens > 0 || cacheWriteTokens > 0
return (
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium tabular-nums'>
{promptTokens.toLocaleString()} / {completionTokens.toLocaleString()}
</span>
{showCache ? (
<div className='text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-none'>
{cacheReadTokens > 0 && (
<span>
{t('Cache')}{cacheReadTokens.toLocaleString()}
</span>
)}
{cacheWriteTokens > 0 && (
<span>{cacheWriteTokens.toLocaleString()}</span>
)}
</div>
) : (
<span className='text-muted-foreground/50 text-[11px] leading-none'>
</span>
)}
</div>
</div>
)
}
/** Mobile-only User block: own layout so avatar/name always line up on the same baseline. */
function MobileUserField({ log }: { log: UsageLog }) {
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
useUsageLogsContext()
if (!log.username) return null
return (
<button
type='button'
className='bg-muted/20 flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 shrink-0 ring-1'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible ? getUserAvatarStyle(log.username) : undefined
}
>
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
</AvatarFallback>
</Avatar>
<span className='text-foreground min-w-0 truncate text-sm'>
{sensitiveVisible ? log.username : '••••'}
</span>
</button>
)
}
/** Merge stream badge + TPS with first-token / duration on one row. */
function MobileStreamTimingField({ log }: { log: UsageLog }) {
if (!isTimingLogType(log.type)) return null
const other = parseLogOther(log.other)
const useTime = log.use_time || 0
const tokensPerSecond =
useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime
: null
return (
<div className='bg-muted/20 flex min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5'>
<TimingMetricsCell
useTimeSec={useTime}
completionTokens={log.completion_tokens}
frtMs={other?.frt}
isStream={log.is_stream}
indicator='dot'
className='min-w-0 flex-1'
/>
<StreamTpsCell
isStream={log.is_stream}
isTask={other?.is_task === true}
tokensPerSecond={tokensPerSecond}
streamStatus={other?.stream_status}
className='shrink-0'
/>
</div>
)
}
function CommonLogsCard<TData>({
cells,
}: {
cells: Map<string, Cell<TData, unknown>>
}) {
const { t } = useTranslation()
const modelCell = cells.get('model_name')
const quotaCell = cells.get('quota')
const rowData = cells.get('created_at')?.row.original as UsageLog | undefined
return (
<div className='space-y-2.5'>
<div className='flex min-w-0 items-center justify-between gap-3'>
<CompactCell cell={modelCell} className='flex-1' />
<CompactCell
cell={quotaCell}
className='shrink-0 text-right [&_.flex-col]:items-end'
/>
</div>
<div className='grid grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)] gap-1.5'>
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<MobileLogTimeStatus
createdAt={rowData?.created_at}
type={rowData?.type}
/>
</div>
<SummaryField
cell={cells.get('channel')}
valueClassName='[&_.flex-col]:max-w-none'
/>
{rowData && cells.has('user') ? (
<MobileUserField log={rowData} />
) : (
<SummaryField cell={cells.get('user')} />
)}
<SummaryField
cell={cells.get('token_name')}
valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
/>
{rowData ? (
<MobileStreamTimingField log={rowData} />
) : (
<SummaryField cell={cells.get('use_time')} />
)}
{rowData ? (
<MobileTokensField log={rowData} />
) : (
<SummaryField cell={cells.get('prompt_tokens')} />
)}
<SummaryField
label={t('Details')}
cell={cells.get('content')}
className='col-span-2 bg-transparent px-0 py-0'
/>
</div>
</div>
)
}
function TaskLogsCard<TData>({ function TaskLogsCard<TData>({
cells, cells,
}: { }: {
...@@ -469,7 +251,7 @@ export function UsageLogsMobileList<TData>({ ...@@ -469,7 +251,7 @@ export function UsageLogsMobileList<TData>({
t('No usage logs available. Logs will appear here once API calls are made.') t('No usage logs available. Logs will appear here once API calls are made.')
if (isLoading) { if (isLoading) {
return <UsageLogsMobileSkeleton /> return <UsageLogsMobileSkeleton separate={logCategory === 'common'} />
} }
const rows = table.getRowModel().rows const rows = table.getRowModel().rows
...@@ -491,7 +273,13 @@ export function UsageLogsMobileList<TData>({ ...@@ -491,7 +273,13 @@ export function UsageLogsMobileList<TData>({
} }
return ( return (
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'> <div
className={cn(
logCategory === 'common'
? 'min-w-0 space-y-3'
: 'border-border/50 bg-card overflow-hidden rounded-lg border'
)}
>
{rows.map((row) => { {rows.map((row) => {
const cells = new Map( const cells = new Map(
row.getVisibleCells().map((cell) => [cell.column.id, cell]) row.getVisibleCells().map((cell) => [cell.column.id, cell])
...@@ -506,11 +294,18 @@ export function UsageLogsMobileList<TData>({ ...@@ -506,11 +294,18 @@ export function UsageLogsMobileList<TData>({
<div <div
key={row.id} key={row.id}
className={cn( className={cn(
'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0', logCategory === 'common'
? 'border-border/60 bg-card min-w-0 rounded-xl border p-3.5'
: 'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0',
tintClass tintClass
)} )}
> >
{logCategory === 'common' && <CommonLogsCard cells={cells} />} {logCategory === 'common' && (
<CommonLogMobileCard
log={row.original as UsageLog}
cells={cells}
/>
)}
{logCategory === 'task' && <TaskLogsCard cells={cells} />} {logCategory === 'task' && <TaskLogsCard cells={cells} />}
{logCategory === 'drawing' && <DrawingLogsCard cells={cells} />} {logCategory === 'drawing' && <DrawingLogsCard cells={cells} />}
</div> </div>
......
...@@ -191,6 +191,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { ...@@ -191,6 +191,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
return ( return (
<DataTablePage <DataTablePage
table={table} table={table}
compactPagination={isMobile && isCommon}
columns={columns as ColumnDef<Record<string, unknown>>[]} columns={columns as ColumnDef<Record<string, unknown>>[]}
isLoading={isLoadingData} isLoading={isLoadingData}
isFetching={isFetching} isFetching={isFetching}
......
...@@ -1392,6 +1392,7 @@ ...@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "Current legacy JSON is invalid, cannot append", "Current legacy JSON is invalid, cannot append": "Current legacy JSON is invalid, cannot append",
"Current Level Only": "Current Level Only", "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 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": "Current Password",
"Current password is incorrect.": "Current password is incorrect.", "Current password is incorrect.": "Current password is incorrect.",
"Current Price": "Current Price", "Current Price": "Current Price",
......
...@@ -1392,6 +1392,7 @@ ...@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "Le JSON ancien format actuel n'est pas valide, impossible d'ajouter", "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 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 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": "Mot de passe actuel",
"Current password is incorrect.": "Le mot de passe actuel est incorrect.", "Current password is incorrect.": "Le mot de passe actuel est incorrect.",
"Current Price": "Prix actuel", "Current Price": "Prix actuel",
......
...@@ -1392,6 +1392,7 @@ ...@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "現在の旧形式JSONが無効なため、追加できません", "Current legacy JSON is invalid, cannot append": "現在の旧形式JSONが無効なため、追加できません",
"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 Password": "現在のパスワード", "Current Password": "現在のパスワード",
"Current password is incorrect.": "現在のパスワードが正しくありません。", "Current password is incorrect.": "現在のパスワードが正しくありません。",
"Current Price": "現在の価格", "Current Price": "現在の価格",
......
...@@ -1392,6 +1392,7 @@ ...@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "Текущий JSON старого формата невалиден, добавление невозможно", "Current legacy JSON is invalid, cannot append": "Текущий JSON старого формата невалиден, добавление невозможно",
"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 Password": "Текущий пароль", "Current Password": "Текущий пароль",
"Current password is incorrect.": "Текущий пароль неверен.", "Current password is incorrect.": "Текущий пароль неверен.",
"Current Price": "Текущая цена", "Current Price": "Текущая цена",
......
...@@ -1392,6 +1392,7 @@ ...@@ -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 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 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 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": "Mật khẩu hiện tại",
"Current password is incorrect.": "Mật khẩu hiện tại không đúng.", "Current password is incorrect.": "Mật khẩu hiện tại không đúng.",
"Current Price": "Giá hiện tại", "Current Price": "Giá hiện tại",
......
...@@ -1392,6 +1392,7 @@ ...@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "目前舊格式 JSON 不合法,無法追加模板", "Current legacy JSON is invalid, cannot append": "目前舊格式 JSON 不合法,無法追加模板",
"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 Password": "目前密碼", "Current Password": "目前密碼",
"Current password is incorrect.": "目前密碼不正確。", "Current password is incorrect.": "目前密碼不正確。",
"Current Price": "目前價格", "Current Price": "目前價格",
......
...@@ -1392,6 +1392,7 @@ ...@@ -1392,6 +1392,7 @@
"Current legacy JSON is invalid, cannot append": "当前旧格式 JSON 不合法,无法追加模板", "Current legacy JSON is invalid, cannot append": "当前旧格式 JSON 不合法,无法追加模板",
"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 Password": "当前密码", "Current Password": "当前密码",
"Current password is incorrect.": "当前密码不正确。", "Current password is incorrect.": "当前密码不正确。",
"Current Price": "当前价格", "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