diff --git a/web/default/src/components/json-editor.tsx b/web/default/src/components/json-editor.tsx index c3323267..a109e456 100644 --- a/web/default/src/components/json-editor.tsx +++ b/web/default/src/components/json-editor.tsx @@ -43,6 +43,23 @@ type EditorRow = { value: string } +function parseJsonRows(json: string): EditorRow[] { + try { + if (!json.trim()) return [] + const parsed = JSON.parse(json) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return [] + } + return Object.entries(parsed).map(([key, val], index) => ({ + id: `${Date.now()}-${index}`, + key, + value: typeof val === 'object' ? JSON.stringify(val) : String(val), + })) + } catch { + return [] + } +} + export function JsonEditor({ value, onChange, @@ -63,27 +80,11 @@ export function JsonEditor({ const resolvedKeyLabel = keyLabel ?? t('Key') const resolvedValueLabel = valueLabel ?? t('Value') const [mode, setMode] = useState<'visual' | 'json'>('visual') - const [rows, setRows] = useState([]) + const [rows, setRows] = useState(() => parseJsonRows(value)) const [jsonValue, setJsonValue] = useState(value) const parseJsonToRows = (json: string) => { - try { - if (!json.trim()) { - setRows([]) - return - } - const parsed = JSON.parse(json) - const newRows: EditorRow[] = Object.entries(parsed).map( - ([key, val], index) => ({ - id: `${Date.now()}-${index}`, - key, - value: typeof val === 'object' ? JSON.stringify(val) : String(val), - }) - ) - setRows(newRows) - } catch (_error) { - // Invalid JSON, keep current rows - } + setRows(parseJsonRows(json)) } // Parse JSON to rows when value changes externally @@ -228,7 +229,7 @@ export function JsonEditor({
{resolvedKeyLabel}
{resolvedValueLabel}
-
+
{rows.map((row) => (
(null) const inputRef = React.useRef(null) const listRef = React.useRef(null) + const pointerFocusRef = React.useRef(false) const selectedOption = React.useMemo( () => options.find((option) => option.value === value), [options, value] @@ -175,9 +178,18 @@ export function ComboboxInput({ } if (!open) setOpen(true) }} + onPointerDown={() => { + pointerFocusRef.current = true + if (document.activeElement === inputRef.current && !open) { + setOpen(true) + } + }} onFocus={() => { setSearchValue(allowCustomValue && !selectedOption ? value : '') - setOpen(true) + if (openOnFocus || pointerFocusRef.current) { + setOpen(true) + } + pointerFocusRef.current = false }} onKeyDown={handleKeyDown} className={cn('pr-9', className)} diff --git a/web/default/src/components/ui/combobox.tsx b/web/default/src/components/ui/combobox.tsx index de750c91..cdce3ab7 100644 --- a/web/default/src/components/ui/combobox.tsx +++ b/web/default/src/components/ui/combobox.tsx @@ -47,6 +47,7 @@ type LegacyComboboxProps = { allowCustomValue?: boolean className?: string id?: string + openOnFocus?: boolean } function Combobox(props: LegacyComboboxProps): React.ReactElement @@ -69,6 +70,7 @@ function Combobox( emptyText={props.emptyText} className={props.className} allowCustomValue={props.allowCustomValue} + openOnFocus={props.openOnFocus} /> ) } diff --git a/web/default/src/features/channels/components/channel-card.tsx b/web/default/src/features/channels/components/channel-card.tsx index 89fc3a3e..1dabb1c6 100644 --- a/web/default/src/features/channels/components/channel-card.tsx +++ b/web/default/src/features/channels/components/channel-card.tsx @@ -16,11 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { memo } from 'react' import { flexRender, type Row } from '@tanstack/react-table' +import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { cn } from '@/lib/utils' + import { GroupBadge } from '@/components/group-badge' +import { cn } from '@/lib/utils' + import { CHANNEL_STATUS } from '../constants' import { isTagAggregateRow, parseGroupsList } from '../lib' import type { Channel } from '../types' @@ -87,86 +89,92 @@ function ChannelCardComponent({ row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED) return ( -
- {/* Row 1: selection + type, with status badge + actions menu */} -
-
- {!isTagRow && selectCell && ( - {selectCell} - )} -
{typeCell}
-
-
- {showStatusBadge && statusCell} - - {actionsCell} - -
-
- - {/* Body: left column (id/name + balance) paired with a right-aligned - column (priority/weight + response/test time). */} -
- {/* Left column */} -
-
- {!isTagRow && ( -
- #{sensitiveVisible ? row.original.id : SENSITIVE_MASK} -
+ +
+ {/* Row 1: selection + type, with status badge + actions menu */} +
+
+ {!isTagRow && selectCell && ( + {selectCell} )} - {nameCell} +
{typeCell}
-
-
{fieldLabels.balance}
-
- {balanceCell ?? -} +
+ {showStatusBadge && statusCell} + {actionsCell} +
+
+ + {/* Body: left column (id/name + balance) paired with a right-aligned + column (priority/weight + response/test time). */} +
+ {/* Left column */} +
+
+ {!isTagRow && ( +
+ #{sensitiveVisible ? row.original.id : SENSITIVE_MASK} +
+ )} + {nameCell} +
+
+
+ {fieldLabels.balance} +
+
+ {balanceCell ?? ( + - + )} +
+
+
+ + {/* Right column (sits on the right, content left-aligned). A single + grid with content-sized columns keeps Priority/Weight and + Response/Last Tested aligned without wasting horizontal space. */} +
+ {t('Priority')} + {t('Weight')} +
{priorityCell}
+
{weightCell}
+ + {fieldLabels.response_time} + + + {fieldLabels.test_time} + +
+ {responseCell ?? -} +
+
+ {testCell ?? -}
- {/* Right column (sits on the right, content left-aligned). A single - grid with content-sized columns keeps Priority/Weight and - Response/Last Tested aligned without wasting horizontal space. */} -
- {t('Priority')} - {t('Weight')} -
{priorityCell}
-
{weightCell}
- - {fieldLabels.response_time} - - {fieldLabels.test_time} -
- {responseCell ?? -} -
-
- {testCell ?? -} -
+ {/* Last row: groups span the full width, showing every group (no label) */} +
+ {groups.length > 0 ? ( +
+ {groups.map((g) => ( + + ))} +
+ ) : ( + - + )}
- - {/* Last row: groups span the full width, showing every group (no label) */} -
- {groups.length > 0 ? ( -
- {groups.map((g) => ( - - ))} -
- ) : ( - - - )} -
-
+ ) } diff --git a/web/default/src/features/channels/components/channel-row-actions-context.ts b/web/default/src/features/channels/components/channel-row-actions-context.ts index 0c729f0b..a9731e93 100644 --- a/web/default/src/features/channels/components/channel-row-actions-context.ts +++ b/web/default/src/features/channels/components/channel-row-actions-context.ts @@ -19,9 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import { createContext } from 'react' /** - * Where the channel row actions are being rendered. In the card view we - * surface the "Test Connection" action as an inline button next to the quick - * test; in the table it stays inside the overflow menu. + * Where channel row-derived controls are being rendered. Card view can tune + * compact display details while table view keeps the full desktop treatment. */ export type ChannelRowActionsLayout = 'table' | 'card' diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 09289c7e..807e35c6 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -1,13 +1,3 @@ -import { useQueryClient } from '@tanstack/react-query' -import type { ColumnDef } from '@tanstack/react-table' -import { - AlertTriangle, - ChevronDown, - ChevronRight, - ListOrdered, - Shuffle, - SlidersHorizontal, -} from 'lucide-react' /* Copyright (C) 2023-2026 QuantumNous @@ -27,7 +17,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ /* eslint-disable react-refresh/only-export-components */ -import { useState, useMemo } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import type { ColumnDef } from '@tanstack/react-table' +import { + AlertTriangle, + ChevronDown, + ChevronRight, + ListOrdered, + Shuffle, + SlidersHorizontal, +} from 'lucide-react' +import { useState, useMemo, useContext } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -51,16 +51,12 @@ import { formatQuotaWithCurrency, getCurrencyLabel, } from '@/lib/currency' -import { - formatTimestampToDate, - formatQuota as formatQuotaValue, -} from '@/lib/format' +import { formatTimestampToDate } from '@/lib/format' import { truncateText } from '@/lib/utils' import { getCodexUsage } from '../api' import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants' import { - formatBalance, formatRelativeTime, formatResponseTime, getBalanceVariant, @@ -79,6 +75,7 @@ import { } from '../lib' import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils' import type { Channel } from '../types' +import { ChannelRowActionsLayoutContext } from './channel-row-actions-context' import { useChannels } from './channels-provider' import { DataTableRowActions } from './data-table-row-actions' import { DataTableTagRowActions } from './data-table-tag-row-actions' @@ -299,6 +296,7 @@ const SENSITIVE_MASK = '••••' function BalanceCell({ channel }: { channel: Channel }) { const { t, i18n } = useTranslation() const queryClient = useQueryClient() + const layout = useContext(ChannelRowActionsLayoutContext) const { sensitiveVisible } = useChannels() const isTagRow = isTagAggregateRow(channel) const balance = channel.balance || 0 @@ -313,18 +311,43 @@ function BalanceCell({ channel }: { channel: Channel }) { tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value const locale = i18n.resolvedLanguage || i18n.language + const balanceFormatOptions = { + digitsLarge: 2, + digitsSmall: 4, + abbreviate: false, + showSymbol: layout !== 'card', + } as const // Precise values are kept for the tooltip; long values are shown compactly inline. - const usedFull = withSuffix(formatQuotaValue(usedQuota)) - const remainingFull = withSuffix(formatBalance(balance)) + const usedFull = withSuffix( + formatQuotaWithCurrency(usedQuota, { + digitsLarge: 2, + digitsSmall: 4, + abbreviate: true, + showSymbol: layout !== 'card', + }) + ) + const remainingFull = withSuffix( + formatCurrencyFromUSD(balance, balanceFormatOptions) + ) const usedDisplay = usedFull.length > MAX_INLINE_BALANCE_CHARS ? withSuffix( - formatQuotaWithCurrency(usedQuota, { compact: true, locale }) + formatQuotaWithCurrency(usedQuota, { + compact: true, + locale, + showSymbol: layout !== 'card', + }) ) : usedFull const remainingDisplay = remainingFull.length > MAX_INLINE_BALANCE_CHARS - ? withSuffix(formatCurrencyFromUSD(balance, { compact: true, locale })) + ? withSuffix( + formatCurrencyFromUSD(balance, { + compact: true, + locale, + showSymbol: layout !== 'card', + }) + ) : remainingFull const usedLabel = `${t('Used:')} ${usedFull}` const remainingLabel = `${t('Remaining:')} ${remainingFull}` @@ -488,9 +511,14 @@ function BalanceCell({ channel }: { channel: Channel }) { /** * Generate channels columns configuration */ -export function useChannelsColumns(): ColumnDef[] { +export function useChannelsColumns( + options: { + enableSelection?: boolean + } = {} +): ColumnDef[] { const { t, i18n } = useTranslation() const { sensitiveVisible } = useChannels() + const enableSelection = options.enableSelection ?? true const locale = i18n.resolvedLanguage || i18n.language // The column definitions only depend on the translation function, the active // locale, and sensitive-data visibility. Memoizing keeps the array (and every @@ -499,38 +527,42 @@ export function useChannelsColumns(): ColumnDef[] { return useMemo[]>( () => [ // Checkbox column - { - id: 'select', - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(!!value) - } - aria-label='Select all' - /> - ), - cell: ({ row }) => { - const isTagRow = isTagAggregateRow(row.original) + ...(enableSelection + ? [ + { + id: 'select', + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label={t('Select all')} + /> + ), + cell: ({ row }) => { + const isTagRow = isTagAggregateRow(row.original) - // Don't show checkbox for tag rows - if (isTagRow) { - return null - } + // Don't show checkbox for tag rows + if (isTagRow) { + return null + } - return ( - row.toggleSelected(!!value)} - aria-label='Select row' - /> - ) - }, - enableSorting: false, - enableHiding: false, - size: 40, - }, + return ( + row.toggleSelected(!!value)} + aria-label={t('Select row')} + /> + ) + }, + enableSorting: false, + enableHiding: false, + size: 40, + } satisfies ColumnDef, + ] + : []), // ID column { @@ -543,7 +575,6 @@ export function useChannelsColumns(): ColumnDef[] { }, size: 80, }, - // Name column { accessorKey: 'name', @@ -1119,6 +1150,6 @@ export function useChannelsColumns(): ColumnDef[] { meta: { pinned: 'right' as const }, }, ], - [t, locale, sensitiveVisible] + [enableSelection, t, locale, sensitiveVisible] ) } diff --git a/web/default/src/features/channels/components/channels-primary-buttons.tsx b/web/default/src/features/channels/components/channels-primary-buttons.tsx index 3e9d7c43..2999d336 100644 --- a/web/default/src/features/channels/components/channels-primary-buttons.tsx +++ b/web/default/src/features/channels/components/channels-primary-buttons.tsx @@ -16,7 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Plus, @@ -26,18 +25,16 @@ import { Tags, TestTube, DollarSign, + ListChecks, SortAsc, RefreshCw, ArrowUpFromLine, } from 'lucide-react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' + +import { ConfirmDialog } from '@/components/confirm-dialog' import { Button } from '@/components/ui/button' -import { - ADMIN_PERMISSION_ACTIONS, - ADMIN_PERMISSION_RESOURCES, - hasPermission, -} from '@/lib/admin-permissions' -import { useAuthStore } from '@/stores/auth-store' import { DropdownMenu, DropdownMenuContent, @@ -54,7 +51,13 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' -import { ConfirmDialog } from '@/components/confirm-dialog' +import { + ADMIN_PERMISSION_ACTIONS, + ADMIN_PERMISSION_RESOURCES, + hasPermission, +} from '@/lib/admin-permissions' +import { useAuthStore } from '@/stores/auth-store' + import { handleDeleteAllDisabled, handleFixAbilities, @@ -72,10 +75,14 @@ export function ChannelsPrimaryButtons() { setEnableTagMode, idSort, setIdSort, + batchMode, + setBatchMode, upstream, } = useChannels() const queryClient = useQueryClient() const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [showConsistencyDialog, setShowConsistencyDialog] = useState(false) + const [isRepairingConsistency, setIsRepairingConsistency] = useState(false) const currentUser = useAuthStore((s) => s.auth.user) const canEditSensitive = hasPermission( currentUser, @@ -93,10 +100,29 @@ export function ChannelsPrimaryButtons() { setIdSort(checked) } + const handleBatchModeToggle = (checked: boolean) => { + setBatchMode(checked) + } + return ( <>
{/* Desktop: Toggle switches visible */} +
+ + + +
+