From 6f415428d3e6ac7d136f44808fc76e0f3aebab1e Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 11 Jun 2026 02:36:41 +0800 Subject: [PATCH] perf(web): improve frontend table rendering and pinned columns/UI table (#5405) * refactor(web): centralize data table implementation - route all TanStack table setup through a shared data-table hook to remove repeated state and row model wiring. - move table rendering, static table wrappers, empty states, and primitive exports behind the data-table module. - update feature tables and configuration editors to share the same table UX while preserving their existing workflows. * refactor(web): trim data table public API - remove unused data-table exports and dead static table helper types. - keep internal table header, skeleton, empty state, and faceted filter helpers private to the data-table module. - route feature imports through the data-table barrel to avoid subpath coupling. * refactor(web): unify table rendering components - centralize static table headers, bodies, empty states, and shared class names behind the data-table package. - migrate settings, pricing, channel, key, subscription, and model tables to the shared table APIs. - remove data-table exports for low-level table primitives so feature code uses one supported abstraction. * perf(web): keep list tables fixed within page content - make shared data table pages fill available height and scroll row data inside the table body. - add a fixed content layout mode so selected list pages avoid page-level scrolling. - apply the fixed table behavior to keys, logs, channels, models, users, redemptions, and subscriptions. * perf(web): refine table pagination controls - show total row counts instead of redundant page range text. - tighten visible page buttons so pagination fits constrained table widths. - align pagination controls and tune text hierarchy for clearer scanning. * perf(web): stabilize model pricing table columns - keep model pricing columns at fixed widths so headers do not collapse in narrow layouts. - truncate long model names and pricing summaries within their cells instead of squeezing adjacent columns. * refactor(web): simplify data table rendering internals - split table body rendering into focused helpers for loading, empty, and row states. - extract static table row and cell class resolution to reduce branching in the main component. - reuse a single pagination page-size option list to avoid duplicated constants. * perf(pricing): reduce dynamic pricing table render work - reuse dynamic pricing field metadata instead of rebuilding it inside table columns. - precompute formatted dynamic prices per tier and group to avoid repeated entry mapping for each cell. - simplify select option construction in related dialogs while preserving the same choices. * refactor(web): streamline pricing table rendering - reuse translated endpoint select options between trigger data and menu items. - precompute dynamic pricing maps per group so table cells only resolve formatted values. - add local dynamic pricing type aliases to keep helper signatures readable. * refactor(web): merge pricing table imports * refactor(web): merge upstream ratio table imports * refactor(web): merge channel selector table imports * refactor(web): simplify tiered pricing select items * refactor(web): reuse model ratio row state * refactor(web): rely on table view row defaults * refactor(web): reuse pagination state values * refactor(web): hoist pagination size select items * refactor(web): clarify static table body rows * refactor(web): extract table page pagination rendering * fix(web): remove direct hast type dependency - rely on Shiki transformer contextual typing for line nodes. - allow frontend typecheck to pass without an undeclared hast package. * refactor(web): trim data table hook return API - return only the TanStack table instance from useDataTable. - keep internal state handling private because callers do not consume it directly. * refactor(web): keep static table empty row private - stop exporting the internal StaticDataTableEmptyRow helper. - keep the public static table API focused on the table component and column type. * refactor(web): hide data table view props from barrel * refactor(web): remove stale long text lint override * fix(web): keep pinned table columns opaque - apply pinned column background classes after custom column classes. - use an opaque hover background so scrolled content cannot show through fixed cells. * refactor(data-table): organize shared table components - group table primitives, page composition, toolbar controls, static tables, and hooks by responsibility. - split shared view types, row rendering, header rendering, and pinned-column styling out of the main table view. - keep the public data-table barrel stable while documenting the new ownership boundaries. * fix(web): stabilize split table column sizing - derive default colgroup widths from visible columns when split headers or header sizing are enabled. - apply a fixed table layout with computed minimum width so header and body columns stay aligned. - keep split-header containers from leaking horizontal overflow and avoid extra pinned-column borders. * fix(web): set stable table utility column widths - assign fixed widths to selection columns so shared colgroup sizing keeps checkbox cells compact. - size id columns in redemption and user tables to keep split headers aligned with body rows. * fix(web): align model metadata icon cells - render compact provider avatars in the metadata icon column instead of wide wordmarks. - position icons in a fixed-size wrapper so they line up with the existing icon header alignment. * fix(status-badge): hide status dot by default * fix(web): prevent user invite info overlap - give the invite info and created-at columns explicit widths so table sizing reserves enough space. - allow invite badges to wrap within the cell instead of spilling into adjacent columns. * perf(data-table): cache pinned column class resolution - reuse the pinned column lookup while table props stay stable to reduce repeated per-render work. - share the resolved column class handler across unified and split-header table layouts. - localize page-number screen reader labels so pagination remains accessible in every locale. * refactor(data-table): tighten static table modes - make StaticDataTable distinguish data-driven and children-only usage through explicit prop shapes. - remove unsupported columns-without-data fallback after confirming no repository callers rely on it. - default manual table modes away from unused local row models to reduce repeated table work. * fix(data-table): make pinned edit column opaque - use an opaque muted background for the active action column so sticky cells do not reveal scrolled content underneath. * fix(data-table): prevent narrow column overlap - apply stable header sizing to remaining desktop data table pages so constrained layouts scroll instead of compressing cells. - add explicit widths for key, quota, badge, and timestamp columns that contain fixed-format content. - constrain masked values and timestamp cells with truncation to keep content inside its assigned column. * fix(table): align table cell content with headers - remove extra inline padding from masked table text buttons so values start at the cell edge. - tag status badges and offset leading badges inside table cells to match header text alignment. * fix(table): prevent admin list column overflow - widen redemption and subscription table columns so masked codes, timestamps, and localized headers fit. - localize subscription ID headers and add Received amount translations across supported locales. * fix(provider-badge): unify provider icon spacing - add a shared provider badge component for icon and status label layout. - reuse it in channel type and model vendor columns so OpenAI icons align consistently. --- .../src/components/ai-elements/code-block.tsx | 3 +- .../src/components/data-table/README.md | 17 + .../data-table/{ => core}/column-header.tsx | 0 .../data-table/core/column-pinning.ts | 73 ++++ .../data-table/core/data-table-colgroup.tsx | 33 ++ .../data-table/core/data-table-header.tsx | 61 +++ .../data-table/core/data-table-row.tsx | 52 +++ .../data-table/core/data-table-view.tsx | 310 ++++++++++++++ .../data-table/{ => core}/pagination.tsx | 84 ++-- .../data-table/{ => core}/table-empty.tsx | 0 .../data-table/core/table-sizing.ts | 30 ++ .../data-table/{ => core}/table-skeleton.tsx | 0 .../src/components/data-table/core/types.ts | 71 ++++ .../data-table/hooks/use-data-table.ts | 234 +++++++++++ .../hooks/use-debounced-column-filter.ts | 110 +++++ .../src/components/data-table/index.ts | 34 +- .../{ => layout}/data-table-page.tsx | 197 ++++----- .../{ => layout}/mobile-card-list.tsx | 0 .../static/static-data-table-classnames.ts | 46 ++ .../data-table/static/static-data-table.tsx | 206 +++++++++ .../data-table/{ => toolbar}/bulk-actions.tsx | 0 .../{ => toolbar}/faceted-filter.tsx | 0 .../data-table/{ => toolbar}/toolbar.tsx | 0 .../data-table/{ => toolbar}/view-options.tsx | 0 .../layout/components/section-page-layout.tsx | 9 +- web/default/src/components/long-text.tsx | 1 - .../src/components/masked-value-display.tsx | 10 +- web/default/src/components/provider-badge.tsx | 44 ++ web/default/src/components/status-badge.tsx | 3 +- web/default/src/components/ui/table.tsx | 2 +- .../channels/components/channels-columns.tsx | 17 +- .../channels/components/channels-table.tsx | 143 ++----- .../dialogs/channel-test-dialog.tsx | 172 +++----- .../dialogs/multi-key-manage-dialog.tsx | 98 +++-- web/default/src/features/channels/index.tsx | 2 +- .../keys/components/api-keys-cells.tsx | 6 +- .../keys/components/api-keys-columns.tsx | 17 +- .../keys/components/api-keys-table.tsx | 82 +--- web/default/src/features/keys/index.tsx | 2 +- .../models/components/deployments-columns.tsx | 2 +- .../models/components/deployments-table.tsx | 33 +- .../prefill-group-management-dialog.tsx | 222 +++++----- .../dialogs/upstream-conflict-dialog.tsx | 87 +--- .../models/components/models-columns.tsx | 30 +- .../models/components/models-table.tsx | 48 +-- web/default/src/features/models/index.tsx | 36 +- .../components/dynamic-pricing-breakdown.tsx | 147 +++---- .../pricing/components/model-details-api.tsx | 174 ++++---- .../pricing/components/model-details-apps.tsx | 129 +++--- .../components/model-details-modalities.tsx | 127 +++--- .../components/model-details-performance.tsx | 109 +++-- .../pricing/components/model-details.tsx | 344 ++++++++------- .../pricing/components/pricing-columns.tsx | 6 +- .../pricing/components/pricing-table.tsx | 102 ++--- .../components/redemptions-columns.tsx | 14 +- .../components/redemptions-table.tsx | 48 +-- .../src/features/redemption-codes/index.tsx | 2 +- .../dialogs/user-subscriptions-dialog.tsx | 224 +++++----- .../components/subscriptions-columns.tsx | 12 +- .../components/subscriptions-table.tsx | 25 +- .../src/features/subscriptions/index.tsx | 26 +- .../components/provider-table.tsx | 152 +++---- .../content/announcements-section.tsx | 208 +++++---- .../content/api-info-section.tsx | 194 ++++----- .../content/chat-settings-visual-editor.tsx | 101 +++-- .../system-settings/content/faq-section.tsx | 143 +++---- .../content/uptime-kuma-section.tsx | 154 ++++--- .../general/channel-affinity/index.tsx | 230 +++++----- .../amount-discount-visual-editor.tsx | 146 +++---- .../creem-products-visual-editor.tsx | 148 +++---- .../payment-methods-visual-editor.tsx | 174 ++++---- .../integrations/waffo-settings-section.tsx | 145 +++---- .../models/channel-selector-dialog.tsx | 93 +--- .../models/conflict-confirm-dialog.tsx | 79 ++-- .../models/group-ratio-visual-editor.tsx | 396 +++++++++--------- .../models/model-ratio-table-columns.tsx | 21 +- .../models/model-ratio-visual-editor.tsx | 154 +++---- .../models/tiered-pricing-editor.tsx | 2 +- .../models/tool-price-settings.tsx | 127 +++--- .../models/upstream-ratio-sync-table.tsx | 85 +--- .../rate-limit-visual-editor.tsx | 133 +++--- .../columns/common-logs-columns.tsx | 7 +- .../columns/drawing-logs-columns.tsx | 5 +- .../components/columns/task-logs-columns.tsx | 7 +- .../components/usage-logs-mobile-card.tsx | 83 ++-- .../components/usage-logs-table.tsx | 56 +-- web/default/src/features/usage-logs/index.tsx | 8 +- .../users/components/users-columns.tsx | 12 +- .../features/users/components/users-table.tsx | 47 +-- web/default/src/features/users/index.tsx | 2 +- web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 2 + web/default/src/i18n/locales/ja.json | 2 + web/default/src/i18n/locales/ru.json | 2 + web/default/src/i18n/locales/vi.json | 6 +- web/default/src/i18n/locales/zh.json | 2 + web/default/src/lib/utils.ts | 31 +- 97 files changed, 3963 insertions(+), 3312 deletions(-) create mode 100644 web/default/src/components/data-table/README.md rename web/default/src/components/data-table/{ => core}/column-header.tsx (100%) create mode 100644 web/default/src/components/data-table/core/column-pinning.ts create mode 100644 web/default/src/components/data-table/core/data-table-colgroup.tsx create mode 100644 web/default/src/components/data-table/core/data-table-header.tsx create mode 100644 web/default/src/components/data-table/core/data-table-row.tsx create mode 100644 web/default/src/components/data-table/core/data-table-view.tsx rename web/default/src/components/data-table/{ => core}/pagination.tsx (61%) rename web/default/src/components/data-table/{ => core}/table-empty.tsx (100%) create mode 100644 web/default/src/components/data-table/core/table-sizing.ts rename web/default/src/components/data-table/{ => core}/table-skeleton.tsx (100%) create mode 100644 web/default/src/components/data-table/core/types.ts create mode 100644 web/default/src/components/data-table/hooks/use-data-table.ts create mode 100644 web/default/src/components/data-table/hooks/use-debounced-column-filter.ts rename web/default/src/components/data-table/{ => layout}/data-table-page.tsx (71%) rename web/default/src/components/data-table/{ => layout}/mobile-card-list.tsx (100%) create mode 100644 web/default/src/components/data-table/static/static-data-table-classnames.ts create mode 100644 web/default/src/components/data-table/static/static-data-table.tsx rename web/default/src/components/data-table/{ => toolbar}/bulk-actions.tsx (100%) rename web/default/src/components/data-table/{ => toolbar}/faceted-filter.tsx (100%) rename web/default/src/components/data-table/{ => toolbar}/toolbar.tsx (100%) rename web/default/src/components/data-table/{ => toolbar}/view-options.tsx (100%) create mode 100644 web/default/src/components/provider-badge.tsx diff --git a/web/default/src/components/ai-elements/code-block.tsx b/web/default/src/components/ai-elements/code-block.tsx index 43fb9b7d..69bcb156 100644 --- a/web/default/src/components/ai-elements/code-block.tsx +++ b/web/default/src/components/ai-elements/code-block.tsx @@ -27,7 +27,6 @@ import { useEffect, useState, } from 'react' -import type { Element } from 'hast' import { CheckIcon, CopyIcon } from 'lucide-react' import { type BundledLanguage, @@ -53,7 +52,7 @@ const CodeBlockContext = createContext({ const lineNumberTransformer: ShikiTransformer = { name: 'line-numbers', - line(node: Element, line: number) { + line(node, line) { node.children.unshift({ type: 'element', tagName: 'span', diff --git a/web/default/src/components/data-table/README.md b/web/default/src/components/data-table/README.md new file mode 100644 index 00000000..fb055306 --- /dev/null +++ b/web/default/src/components/data-table/README.md @@ -0,0 +1,17 @@ +# Data Table Components + +This package keeps a stable public API through `index.ts`; feature code should +continue importing from `@/components/data-table`. + +- `core/`: TanStack table rendering primitives, headers, rows, pagination, + loading, empty states, and pinned-column behavior. +- `layout/`: responsive page-level composition that combines toolbar, desktop + table, mobile list, bulk actions, and pagination placement. +- `toolbar/`: filter/search/view-option controls and selection action toolbar. +- `static/`: lightweight table rendering for local/static arrays that do not + need TanStack state. +- `hooks/`: table state and filter hooks. + +Keep feature-specific columns, actions, and dialogs inside their feature +folders. Shared table code belongs here only when it is reusable across more +than one feature. diff --git a/web/default/src/components/data-table/column-header.tsx b/web/default/src/components/data-table/core/column-header.tsx similarity index 100% rename from web/default/src/components/data-table/column-header.tsx rename to web/default/src/components/data-table/core/column-header.tsx diff --git a/web/default/src/components/data-table/core/column-pinning.ts b/web/default/src/components/data-table/core/column-pinning.ts new file mode 100644 index 00000000..ed86ea14 --- /dev/null +++ b/web/default/src/components/data-table/core/column-pinning.ts @@ -0,0 +1,73 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { cn } from '@/lib/utils' +import type { DataTableColumnClassName, DataTablePinnedColumn } from './types' + +export function getResolvedColumnClassName( + getColumnClassName?: DataTableColumnClassName, + pinnedColumns?: DataTablePinnedColumn[] +): DataTableColumnClassName { + return getResolvedColumnClassNameFromMap( + getColumnClassName, + getPinnedColumnMap(pinnedColumns) + ) +} + +export function getResolvedColumnClassNameFromMap( + getColumnClassName?: DataTableColumnClassName, + pinnedColumnById?: Map +): DataTableColumnClassName { + return (columnId, kind) => { + const customClassName = getColumnClassName?.(columnId, kind) + const pinnedColumn = pinnedColumnById?.get(columnId) + + if (!pinnedColumn) return customClassName + + return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind)) + } +} + +export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) { + if (!pinnedColumns?.length) return undefined + + return new Map(pinnedColumns.map((column) => [column.columnId, column])) +} + +function getPinnedColumnClassName( + pinnedColumn: DataTablePinnedColumn, + kind: 'header' | 'cell' +) { + const edgeClassName = + pinnedColumn.side === 'left' + ? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]' + : 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]' + + return cn( + 'sticky whitespace-nowrap', + pinnedColumn.side === 'left' ? 'left-0' : 'right-0', + edgeClassName, + kind === 'header' + ? 'bg-background z-30' + : 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted', + pinnedColumn.className, + kind === 'header' + ? pinnedColumn.headerClassName + : pinnedColumn.cellClassName + ) +} diff --git a/web/default/src/components/data-table/core/data-table-colgroup.tsx b/web/default/src/components/data-table/core/data-table-colgroup.tsx new file mode 100644 index 00000000..26c57ba3 --- /dev/null +++ b/web/default/src/components/data-table/core/data-table-colgroup.tsx @@ -0,0 +1,33 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { Table as TanstackTable } from '@tanstack/react-table' + +export function DataTableColgroup({ + table, +}: { + table: TanstackTable +}) { + return ( + + {table.getVisibleLeafColumns().map((column) => ( + + ))} + + ) +} diff --git a/web/default/src/components/data-table/core/data-table-header.tsx b/web/default/src/components/data-table/core/data-table-header.tsx new file mode 100644 index 00000000..63d04bdb --- /dev/null +++ b/web/default/src/components/data-table/core/data-table-header.tsx @@ -0,0 +1,61 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { flexRender, type Table as TanstackTable } from '@tanstack/react-table' +import { TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { DataTableColumnClassName } from './types' + +type DataTableHeaderProps = { + table: TanstackTable + applyHeaderSize?: boolean + className?: string + rowClassName?: string + getColumnClassName?: DataTableColumnClassName +} + +export function DataTableHeader({ + table, + applyHeaderSize, + className, + rowClassName, + getColumnClassName, +}: DataTableHeaderProps) { + return ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + ) +} diff --git a/web/default/src/components/data-table/core/data-table-row.tsx b/web/default/src/components/data-table/core/data-table-row.tsx new file mode 100644 index 00000000..8ad703ae --- /dev/null +++ b/web/default/src/components/data-table/core/data-table-row.tsx @@ -0,0 +1,52 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type * as React from 'react' +import { flexRender, type Row } from '@tanstack/react-table' +import { TableCell, TableRow } from '@/components/ui/table' +import type { DataTableColumnClassName } from './types' + +type DataTableRowProps = { + row: Row + className?: string + getColumnClassName?: DataTableColumnClassName +} & Omit, 'children'> + +export function DataTableRow({ + row, + className, + getColumnClassName, + ...rowProps +}: DataTableRowProps) { + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx new file mode 100644 index 00000000..978dabb5 --- /dev/null +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -0,0 +1,310 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import * as React from 'react' +import { type Row } from '@tanstack/react-table' +import { cn } from '@/lib/utils' +import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table' +import { + getPinnedColumnMap, + getResolvedColumnClassNameFromMap, +} from './column-pinning' +import { DataTableColgroup } from './data-table-colgroup' +import { DataTableHeader } from './data-table-header' +import { DataTableRow } from './data-table-row' +import { TableEmpty } from './table-empty' +import { getTableSizeStyle } from './table-sizing' +import { TableSkeleton } from './table-skeleton' +import type { + DataTableColumnClassName, + DataTablePinnedColumn, + DataTableViewProps, +} from './types' + +export type { + DataTableColumnClassName, + DataTablePinnedColumn, + DataTableRenderRowHelpers, + DataTableViewProps, +} from './types' +export { DataTableRow } from './data-table-row' + +export function DataTableView(props: DataTableViewProps) { + const rows = props.rows ?? props.table.getRowModel().rows + const colSpan = props.table.getVisibleLeafColumns().length + const columnClassName = useResolvedColumnClassName( + props.getColumnClassName, + props.pinnedColumns + ) + + return ( +
+ {props.splitHeader ? ( + + ) : ( + + )} +
+ ) +} + +function UnifiedTableView({ + props, + rows, + colSpan, + getColumnClassName, +}: { + props: DataTableViewProps + rows: Row[] + colSpan: number + getColumnClassName: DataTableColumnClassName +}) { + const tableSizing = getTableSizing(props) + + return ( +
+ + {tableSizing.colgroup} + + {renderTableBody(props, rows, colSpan, getColumnClassName)} +
+
+ ) +} + +function SplitHeaderTableView({ + props, + rows, + colSpan, + getColumnClassName, +}: { + props: DataTableViewProps + rows: Row[] + colSpan: number + getColumnClassName: DataTableColumnClassName +}) { + const headerHostRef = React.useRef(null) + const bodyHostRef = React.useRef(null) + const tableSizing = getTableSizing(props) + + React.useEffect(() => { + const headerScroller = headerHostRef.current?.querySelector( + '[data-slot=table-container]' + ) + const bodyScroller = bodyHostRef.current?.querySelector( + '[data-slot=table-container]' + ) + + if (!headerScroller || !bodyScroller) return + + const syncHeaderScroll = () => { + headerScroller.scrollLeft = bodyScroller.scrollLeft + } + + syncHeaderScroll() + bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true }) + + return () => { + bodyScroller.removeEventListener('scroll', syncHeaderScroll) + } + }, [rows.length, props.tableClassName, props.colgroup]) + + return ( +
+
+
+ + {tableSizing.colgroup} + +
+
+
+ + {tableSizing.colgroup} + {renderTableBody(props, rows, colSpan, getColumnClassName)} +
+
+
+
+ ) +} + +function useResolvedColumnClassName( + getColumnClassName?: DataTableColumnClassName, + pinnedColumns?: DataTablePinnedColumn[] +) { + const pinnedColumnById = React.useMemo( + () => getPinnedColumnMap(pinnedColumns), + [pinnedColumns] + ) + + return React.useMemo( + () => + getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById), + [getColumnClassName, pinnedColumnById] + ) +} + +function getTableSizing(props: DataTableViewProps): { + colgroup?: React.ReactNode + style?: React.CSSProperties +} { + if (props.colgroup) { + return { colgroup: props.colgroup } + } + + if (!props.splitHeader && !props.applyHeaderSize) { + return {} + } + + return { + colgroup: , + style: getTableSizeStyle(props.table), + } +} + +function renderTableBody( + props: DataTableViewProps, + rows: Row[], + colSpan: number, + getColumnClassName: DataTableColumnClassName +) { + return ( + + {renderTableBodyContent(props, rows, colSpan, getColumnClassName)} + + ) +} + +function renderTableBodyContent( + props: DataTableViewProps, + rows: Row[], + colSpan: number, + getColumnClassName: DataTableColumnClassName +) { + if (props.isLoading) { + return ( + + ) + } + + if (rows.length === 0) { + return renderEmptyState(props, colSpan) + } + + return rows.map((row) => + props.renderRow + ? props.renderRow(row, { + getCellClassName: (columnId, className) => + cn(getColumnClassName(columnId, 'cell'), className), + }) + : renderDefaultRow(props, row, getColumnClassName) + ) +} + +function renderEmptyState( + props: DataTableViewProps, + colSpan: number +) { + if (props.emptyContent) { + return ( + + + {props.emptyContent} + + + ) + } + + return ( + + {props.emptyAction} + + ) +} + +function renderDefaultRow( + props: DataTableViewProps, + row: Row, + getColumnClassName: DataTableColumnClassName +) { + return ( + + ) +} diff --git a/web/default/src/components/data-table/pagination.tsx b/web/default/src/components/data-table/core/pagination.tsx similarity index 61% rename from web/default/src/components/data-table/pagination.tsx rename to web/default/src/components/data-table/core/pagination.tsx index 929fbfe7..4b57dc67 100644 --- a/web/default/src/components/data-table/pagination.tsx +++ b/web/default/src/components/data-table/core/pagination.tsx @@ -39,48 +39,55 @@ type DataTablePaginationProps = { table: Table } +const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const +const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({ + value: `${pageSize}`, + label: pageSize, +})) + export function DataTablePagination({ table, }: DataTablePaginationProps) { const { t } = useTranslation() - const currentPage = table.getState().pagination.pageIndex + 1 + const pagination = table.getState().pagination + const currentPage = pagination.pageIndex + 1 + const pageSize = pagination.pageSize const totalPages = table.getPageCount() + const totalRows = table.getRowCount() const pageNumbers = getPageNumbers(currentPage, totalPages) return (
-
-
- {t('Page {{current}} of {{total}}', { - current: currentPage, - total: totalPages, - })} +
+
+ {t('Total:')} + + {totalRows.toLocaleString()} +
-
+ +
+

+ {t('Rows per page')} +

-

- {t('Rows per page')} -

-
-
-
- {t('Page {{current}} of {{total}}', { - current: currentPage, - total: totalPages, - })} -
-
+
- {/* Page number buttons */} {pageNumbers.map((pageNumber, index) => (
{pageNumber === '...' ? ( - ... + + ... + ) : ( )} @@ -141,7 +145,7 @@ export function DataTablePagination({
) : ( -
- - - - {t('Index')} - {t('Status')} - - {t('Disabled Reason')} - - - {t('Disabled Time')} - - - {t('Actions')} - - - - - {keys.map((key) => ( - - - #{key.index + 1} - - {renderStatusBadge(key.status)} - - {key.reason || '-'} - - - {formatKeyTimestamp(key.disabled_time)} - - - - - - ))} - -
-
+ key.index} + columns={[ + { + id: 'index', + header: t('Index'), + className: 'w-20', + cellClassName: 'font-mono text-sm', + cell: (key) => `#${key.index + 1}`, + }, + { + id: 'status', + header: t('Status'), + className: 'w-32', + cell: (key) => renderStatusBadge(key.status), + }, + { + id: 'reason', + header: t('Disabled Reason'), + className: 'min-w-[200px]', + cellClassName: 'max-w-xs truncate text-sm', + cell: (key) => key.reason || '-', + }, + { + id: 'disabled-time', + header: t('Disabled Time'), + className: 'w-44', + cellClassName: 'text-muted-foreground text-sm', + cell: (key) => formatKeyTimestamp(key.disabled_time), + }, + { + id: 'actions', + header: t('Actions'), + className: 'w-44 text-right', + cell: (key) => ( + + ), + }, + ]} + /> )}
diff --git a/web/default/src/features/channels/index.tsx b/web/default/src/features/channels/index.tsx index 4099c105..11a64402 100644 --- a/web/default/src/features/channels/index.tsx +++ b/web/default/src/features/channels/index.tsx @@ -27,7 +27,7 @@ export function Channels() { const { t } = useTranslation() return ( - + {t('Channels')} diff --git a/web/default/src/features/keys/components/api-keys-cells.tsx b/web/default/src/features/keys/components/api-keys-cells.tsx index e86291c3..6e414724 100644 --- a/web/default/src/features/keys/components/api-keys-cells.tsx +++ b/web/default/src/features/keys/components/api-keys-cells.tsx @@ -76,18 +76,18 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) { }, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t]) return ( -
+
} > - {maskedKey} + {maskedKey} [] { ), enableSorting: false, enableHiding: false, + size: 40, meta: { label: t('Select') }, }, { @@ -104,6 +105,7 @@ export function useApiKeysColumns(): ColumnDef[] { {row.getValue('name')}
), + size: 180, meta: { label: t('Name'), mobileTitle: true }, }, { @@ -123,6 +125,7 @@ export function useApiKeysColumns(): ColumnDef[] { ) }, filterFn: (row, id, value) => value.includes(String(row.getValue(id))), + size: 120, meta: { label: t('Status'), mobileBadge: true }, }, { @@ -131,6 +134,7 @@ export function useApiKeysColumns(): ColumnDef[] { header: t('API Key'), cell: ({ row }) => , enableSorting: false, + size: 260, meta: { label: t('API Key') }, }, { @@ -189,6 +193,7 @@ export function useApiKeysColumns(): ColumnDef[] { ) }, + size: 170, meta: { label: t('Quota') }, }, { @@ -230,6 +235,7 @@ export function useApiKeysColumns(): ColumnDef[] { } return }, + size: 160, meta: { label: t('Group'), mobileHidden: true }, }, { @@ -240,6 +246,7 @@ export function useApiKeysColumns(): ColumnDef[] { ), cell: ({ row }) => , enableSorting: false, + size: 160, meta: { label: t('Models'), mobileHidden: true }, }, { @@ -250,6 +257,7 @@ export function useApiKeysColumns(): ColumnDef[] { ), cell: ({ row }) => , enableSorting: false, + size: 160, meta: { label: t('IP Restriction'), mobileHidden: true }, }, { @@ -258,10 +266,11 @@ export function useApiKeysColumns(): ColumnDef[] { ), cell: ({ row }) => ( - + {formatTimestampToDate(row.getValue('created_time'))} ), + size: 180, meta: { label: t('Created'), mobileHidden: true }, }, { @@ -275,11 +284,12 @@ export function useApiKeysColumns(): ColumnDef[] { return - } return ( - + {formatTimestampToDate(accessedTime)} ) }, + size: 180, meta: { label: t('Last Used'), mobileHidden: true }, }, { @@ -302,7 +312,7 @@ export function useApiKeysColumns(): ColumnDef[] { return ( @@ -310,6 +320,7 @@ export function useApiKeysColumns(): ColumnDef[] { ) }, + size: 180, meta: { label: t('Expires'), mobileHidden: true }, }, { diff --git a/web/default/src/features/keys/components/api-keys-table.tsx b/web/default/src/features/keys/components/api-keys-table.tsx index 90a1752f..5375fd8a 100644 --- a/web/default/src/features/keys/components/api-keys-table.tsx +++ b/web/default/src/features/keys/components/api-keys-table.tsx @@ -16,21 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' -import { - type SortingState, - type VisibilityState, - getCoreRowModel, - getFacetedRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table' -import { useDebounce } from '@/hooks' +import { type Table as TanstackTable } from '@tanstack/react-table' import { Database } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -50,6 +38,8 @@ import { DISABLED_ROW_DESKTOP, DISABLED_ROW_MOBILE, DataTablePage, + useDebouncedColumnFilter, + useDataTable, } from '@/components/data-table' import { StatusBadge } from '@/components/status-badge' import { getApiKeys, searchApiKeys } from '../api' @@ -99,7 +89,7 @@ function ApiKeysMobileList({ table, isLoading, }: { - table: ReturnType> + table: TanstackTable isLoading: boolean }) { const { t } = useTranslation() @@ -192,9 +182,6 @@ export function ApiKeysTable() { const { t } = useTranslation() const { refreshTrigger } = useApiKeys() const columns = useApiKeysColumns() - const [rowSelection, setRowSelection] = useState({}) - const [sorting, setSorting] = useState([]) - const [columnVisibility, setColumnVisibility] = useState({}) const { globalFilter, @@ -215,27 +202,15 @@ export function ApiKeysTable() { ], }) - const tokenFilterFromUrl = - (columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || '' - const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl) - const debouncedTokenFilter = useDebounce(tokenFilterInput, 500) - - useEffect(() => { - setTokenFilterInput(tokenFilterFromUrl) - }, [tokenFilterFromUrl]) - - useEffect(() => { - if (debouncedTokenFilter !== tokenFilterFromUrl) { - onColumnFiltersChange((prev) => { - const filtered = prev.filter((f) => f.id !== '_tokenSearch') - return debouncedTokenFilter - ? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }] - : filtered - }) - } - }, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange]) - - const tokenFilter = tokenFilterFromUrl + const { + value: tokenFilter, + inputValue: tokenFilterInput, + setInputValue: setTokenFilterInput, + } = useDebouncedColumnFilter({ + columnFilters, + columnId: '_tokenSearch', + onColumnFiltersChange, + }) const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim()) // Fetch data with React Query @@ -284,40 +259,22 @@ export function ApiKeysTable() { const apiKeys = data?.items || [] - const table = useReactTable({ + const { table } = useDataTable({ data: apiKeys, columns, - state: { - sorting, - columnVisibility, - rowSelection, - columnFilters, - globalFilter, - pagination, - }, enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, + columnFilters, + globalFilter, + pagination, globalFilterFn: () => true, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), onPaginationChange, onGlobalFilterChange, onColumnFiltersChange, manualPagination: true, - pageCount: Math.ceil((data?.total || 0) / pagination.pageSize), + totalCount: data?.total || 0, + ensurePageInRange, }) - const pageCount = table.getPageCount() - useEffect(() => { - ensurePageInRange(pageCount) - }, [pageCount, ensurePageInRange]) - return ( - + {t('API Keys')} diff --git a/web/default/src/features/models/components/deployments-columns.tsx b/web/default/src/features/models/components/deployments-columns.tsx index 0ed12b45..32d5eff0 100644 --- a/web/default/src/features/models/components/deployments-columns.tsx +++ b/web/default/src/features/models/components/deployments-columns.tsx @@ -21,7 +21,7 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { formatTimestampToDate } from '@/lib/format' import { Button } from '@/components/ui/button' -import { DataTableColumnHeader } from '@/components/data-table/column-header' +import { DataTableColumnHeader } from '@/components/data-table' import { StatusBadge } from '@/components/status-badge' import { TableId } from '@/components/table-id' import { getDeploymentStatusConfig } from '../constants' diff --git a/web/default/src/features/models/components/deployments-table.tsx b/web/default/src/features/models/components/deployments-table.tsx index 60ce31f6..c69ed096 100644 --- a/web/default/src/features/models/components/deployments-table.tsx +++ b/web/default/src/features/models/components/deployments-table.tsx @@ -16,14 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' -import { - getCoreRowModel, - useReactTable, - type VisibilityState, -} from '@tanstack/react-table' import { useMediaQuery } from '@/hooks' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -38,7 +33,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { DataTablePage } from '@/components/data-table' +import { DataTablePage, useDataTable } from '@/components/data-table' import { deleteDeployment, listDeployments, searchDeployments } from '../api' import { getDeploymentStatusOptions } from '../constants' import { deploymentsQueryKeys } from '../lib' @@ -167,8 +162,6 @@ export function DeploymentsTable() { } } - const [columnVisibility, setColumnVisibility] = useState({}) - const columns = useDeploymentsColumns({ onViewLogs: (id) => { setLogsDeploymentId(id) @@ -197,30 +190,22 @@ export function DeploymentsTable() { }, }) - const table = useReactTable({ + const { table } = useDataTable({ data: deployments, columns, - pageCount: Math.ceil(totalCount / pagination.pageSize), - state: { - columnFilters, - columnVisibility, - pagination, - globalFilter, - }, + totalCount, + columnFilters, + pagination, + globalFilter, onColumnFiltersChange, - onColumnVisibilityChange: setColumnVisibility, onPaginationChange, onGlobalFilterChange, - getCoreRowModel: getCoreRowModel(), manualPagination: true, manualFiltering: true, + withSortedRowModel: false, + ensurePageInRange, }) - const pageCount = table.getPageCount() - useEffect(() => { - ensurePageInRange(pageCount) - }, [ensurePageInRange, pageCount]) - const statusFilterOptions = useMemo(() => { return [...getDeploymentStatusOptions(t)].map((opt) => ({ label: opt.label, diff --git a/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx b/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx index 276de51f..c0efafa7 100644 --- a/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx @@ -46,15 +46,8 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/empty' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' import { ConfirmDialog } from '@/components/confirm-dialog' +import { StaticDataTable } from '@/components/data-table' import { Dialog } from '@/components/dialog' import { StatusBadge } from '@/components/status-badge' import { TableId } from '@/components/table-id' @@ -344,110 +337,117 @@ export function PrefillGroupManagementDialog({ ))}
) : ( -
-
- - - - {t('Group')} - {t('Type')} - - {t('Items')} - - - {t('Actions')} - - - - - {normalizedGroups.map(({ group, meta, parsedItems }) => ( - - -
-
- {group.name} - -
- {group.description ? ( -

- {group.description} -

- ) : ( -

- No description provided -

+ group.id} + columns={[ + { + id: 'group', + header: t('Group'), + cellClassName: 'align-top whitespace-normal', + cell: ({ group }) => ( +
+
+ {group.name} + +
+ {group.description ? ( +

+ {group.description} +

+ ) : ( +

+ No description provided +

+ )} +
+ ), + }, + { + id: 'type', + header: t('Type'), + cellClassName: 'align-top', + cell: ({ meta }) => ( + + ), + }, + { + id: 'items', + header: t('Items'), + className: 'min-w-[240px]', + cellClassName: 'align-top whitespace-normal', + cell: ({ group, parsedItems }) => ( + <> +
+ {parsedItems.length > 0 ? ( + <> + {parsedItems.slice(0, 6).map((item) => ( + + ))} + {parsedItems.length > 6 && ( + )} -
- - - - - -
- {parsedItems.length > 0 ? ( - <> - {parsedItems.slice(0, 6).map((item) => ( - - ))} - {parsedItems.length > 6 && ( - - )} - - ) : ( -

- {group.type === 'endpoint' - ? 'No endpoint mappings configured.' - : 'No items configured yet.'} -

- )} -
-
- {parsedItems.length} item - {parsedItems.length === 1 ? '' : 's'} -
-
- -
- - -
-
- - ))} - -
-
-
+ + ) : ( +

+ {group.type === 'endpoint' + ? 'No endpoint mappings configured.' + : 'No items configured yet.'} +

+ )} +
+
+ {parsedItems.length} item + {parsedItems.length === 1 ? '' : 's'} +
+ + ), + }, + { + id: 'actions', + header: t('Actions'), + className: 'w-[120px] text-right', + cellClassName: 'align-top', + cell: ({ group }) => ( +
+ + +
+ ), + }, + ]} + /> )}
diff --git a/web/default/src/features/models/components/dialogs/upstream-conflict-dialog.tsx b/web/default/src/features/models/components/dialogs/upstream-conflict-dialog.tsx index d655543c..beb9d92e 100644 --- a/web/default/src/features/models/components/dialogs/upstream-conflict-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/upstream-conflict-dialog.tsx @@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { useEffect, useMemo, useState, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { - flexRender, - getCoreRowModel, - useReactTable, - type ColumnDef, - type RowSelectionState, -} from '@tanstack/react-table' +import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table' import { Search, Info, @@ -51,14 +45,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' +import { DataTableView, useDataTable } from '@/components/data-table' import { Dialog } from '@/components/dialog' import { StatusBadge } from '@/components/status-badge' import { applyUpstreamOverwrite } from '../../api' @@ -78,6 +65,8 @@ const FIELD_LABELS: Record = { enable_groups: 'Enable Groups', } +const PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const + const formatValue = (value: unknown) => { if (value === null || value === undefined) return '—' if (typeof value === 'string') return value || '—' @@ -341,16 +330,17 @@ export function UpstreamConflictDialog({ ] }, [isMobile]) - const table = useReactTable({ + const { table } = useDataTable({ data: conflictRows, columns, - state: { - rowSelection, - }, + rowSelection, enableRowSelection: true, onRowSelectionChange: setRowSelection, - getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, + withFilteredRowModel: false, + withPaginationRowModel: false, + withSortedRowModel: false, + withFacetedRowModel: false, }) const totalSelectedFields = table.getSelectedRowModel().rows.length @@ -536,43 +526,14 @@ export function UpstreamConflictDialog({ ) : (
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {paginatedRows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ))} - -
-
+
@@ -587,12 +548,10 @@ export function UpstreamConflictDialog({ {t('Rows per page')} - updateRow(row._id, 'name', event.target.value) - } - aria-invalid={duplicateNames.includes( - row.name.trim() - )} - /> - - - - updateRow( - row._id, - 'ratio', - normalizeRatio(event.target.value) - ) - } - /> - - -
- - updateRow(row._id, 'selectable', checked === true) - } - aria-label={t('User selectable')} - /> -
-
- - {row.selectable ? ( - - updateRow( - row._id, - 'description', - event.target.value - ) - } - /> - ) : ( - - - - - )} - - - - - - )) - )} - - -
+ row._id} + emptyClassName='text-muted-foreground h-20 text-sm' + emptyContent={t('No groups yet. Add a group to get started.')} + columns={[ + { + id: 'group', + header: t('Group name'), + className: 'min-w-40', + cell: (row) => ( + + updateRow(row._id, 'name', event.target.value) + } + aria-invalid={duplicateNames.includes(row.name.trim())} + /> + ), + }, + { + id: 'ratio', + header: t('Ratio'), + className: 'w-28', + cell: (row) => ( + + updateRow( + row._id, + 'ratio', + normalizeRatio(event.target.value) + ) + } + /> + ), + }, + { + id: 'selectable', + header: t('User selectable'), + className: 'w-28 text-center', + cell: (row) => ( +
+ + updateRow(row._id, 'selectable', checked === true) + } + aria-label={t('User selectable')} + /> +
+ ), + }, + { + id: 'description', + header: t('Description'), + className: 'min-w-56', + cell: (row) => + row.selectable ? ( + + updateRow(row._id, 'description', event.target.value) + } + /> + ) : ( + + - + + ), + }, + { + id: 'actions', + header: t('Actions'), + className: 'w-16 text-right', + cellClassName: 'text-right', + cell: (row) => ( + + ), + }, + ]} + /> {duplicateNames.length > 0 && (

diff --git a/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx index 982a352c..0c8389fe 100644 --- a/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx @@ -71,6 +71,7 @@ export function buildModelRatioColumns({ ), enableSorting: false, enableHiding: false, + size: 40, meta: { label: t('Select') }, }, { @@ -79,16 +80,22 @@ export function buildModelRatioColumns({ ), cell: ({ row }) => ( -

- {row.getValue('name')} +
+ {row.getValue('name')} {row.original.billingMode === 'tiered_expr' && ( - + )} {row.original.hasConflict && ( )}
@@ -119,11 +126,11 @@ export function buildModelRatioColumns({ ), cell: ({ row }) => ( -
- +
+ {getPriceSummary(row.original, t)} - + {getPriceDetail(row.original, t)}
@@ -136,7 +143,7 @@ export function buildModelRatioColumns({ }, { id: 'actions', - header: () =>
{t('Actions')}
, + header: () =>
{t('Actions')}
, cell: ({ row }) => (
- - - )) - )} - - -
+ row.id} + emptyClassName='text-muted-foreground py-8' + emptyContent={t('No tools configured')} + columns={[ + { + id: 'tool', + header: t('Tool identifier'), + cell: (row) => ( + updateRow(row.id, 'key', e.target.value)} + /> + ), + }, + { + id: 'price', + header: t('Price ($/1K calls)'), + className: 'w-[200px]', + cell: (row) => ( + + updateRow(row.id, 'price', Number(e.target.value) || 0) + } + /> + ), + }, + { + id: 'actions', + header: t('Actions'), + className: 'w-[80px] text-right', + cellClassName: 'text-right', + cell: (row) => ( + + ), + }, + ]} + /> ) : (