feat: refine channel management UI
This commit is contained in:
+82
-74
@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
{/* Row 1: selection + type, with status badge + actions menu */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{!isTagRow && selectCell && (
|
||||
<span className='shrink-0'>{selectCell}</span>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
{showStatusBadge && statusCell}
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
{actionsCell}
|
||||
</ChannelRowActionsLayoutContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: left column (id/name + balance) paired with a right-aligned
|
||||
column (priority/weight + response/test time). */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
{/* Left column */}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{!isTagRow && (
|
||||
<div className={labelClass}>
|
||||
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
|
||||
</div>
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
<div
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
{/* Row 1: selection + type, with status badge + actions menu */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{!isTagRow && selectCell && (
|
||||
<span className='shrink-0'>{selectCell}</span>
|
||||
)}
|
||||
{nameCell}
|
||||
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className={cn('mb-1', labelClass)}>{fieldLabels.balance}</div>
|
||||
<div className='min-w-0 overflow-hidden text-sm'>
|
||||
{balanceCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
{showStatusBadge && statusCell}
|
||||
{actionsCell}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: left column (id/name + balance) paired with a right-aligned
|
||||
column (priority/weight + response/test time). */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
{/* Left column */}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{!isTagRow && (
|
||||
<div className={labelClass}>
|
||||
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
|
||||
</div>
|
||||
)}
|
||||
{nameCell}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className={cn('mb-1', labelClass)}>
|
||||
{fieldLabels.balance}
|
||||
</div>
|
||||
<div className='min-w-0 overflow-hidden text-sm'>
|
||||
{balanceCell ?? (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
|
||||
<span className={labelClass}>{t('Priority')}</span>
|
||||
<span className={labelClass}>{t('Weight')}</span>
|
||||
<div className='flex justify-start'>{priorityCell}</div>
|
||||
<div className='flex justify-start'>{weightCell}</div>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.response_time}
|
||||
</span>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.test_time}
|
||||
</span>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{testCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
|
||||
<span className={labelClass}>{t('Priority')}</span>
|
||||
<span className={labelClass}>{t('Weight')}</span>
|
||||
<div className='flex justify-start'>{priorityCell}</div>
|
||||
<div className='flex justify-start'>{weightCell}</div>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.response_time}
|
||||
</span>
|
||||
<span className={cn('mt-2', labelClass)}>{fieldLabels.test_time}</span>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{testCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
{/* Last row: groups span the full width, showing every group (no label) */}
|
||||
<div className='min-w-0'>
|
||||
{groups.length > 0 ? (
|
||||
<div className='-ml-1.5 flex flex-wrap gap-1'>
|
||||
{groups.map((g) => (
|
||||
<GroupBadge
|
||||
key={g}
|
||||
group={g}
|
||||
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last row: groups span the full width, showing every group (no label) */}
|
||||
<div className='min-w-0'>
|
||||
{groups.length > 0 ? (
|
||||
<div className='-ml-1.5 flex flex-wrap gap-1'>
|
||||
{groups.map((g) => (
|
||||
<GroupBadge
|
||||
key={g}
|
||||
group={g}
|
||||
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChannelRowActionsLayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -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'
|
||||
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
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<Channel>[] {
|
||||
export function useChannelsColumns(
|
||||
options: {
|
||||
enableSelection?: boolean
|
||||
} = {}
|
||||
): ColumnDef<Channel>[] {
|
||||
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<Channel>[] {
|
||||
return useMemo<ColumnDef<Channel>[]>(
|
||||
() => [
|
||||
// Checkbox column
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
...(enableSelection
|
||||
? [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
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 (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='Select row'
|
||||
/>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t('Select row')}
|
||||
/>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
} satisfies ColumnDef<Channel>,
|
||||
]
|
||||
: []),
|
||||
|
||||
// ID column
|
||||
{
|
||||
@@ -543,7 +575,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
|
||||
// Name column
|
||||
{
|
||||
accessorKey: 'name',
|
||||
@@ -1119,6 +1150,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
],
|
||||
[t, locale, sensitiveVisible]
|
||||
[enableSelection, t, locale, sensitiveVisible]
|
||||
)
|
||||
}
|
||||
|
||||
+70
-14
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Desktop: Toggle switches visible */}
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<ListChecks className='text-muted-foreground h-4 w-4' />
|
||||
<Label
|
||||
htmlFor='channel-batch-mode'
|
||||
className='cursor-pointer text-sm'
|
||||
>
|
||||
{t('Batch Operations')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='channel-batch-mode'
|
||||
checked={batchMode}
|
||||
onCheckedChange={handleBatchModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<Tags className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
|
||||
@@ -152,6 +178,15 @@ export function ChannelsPrimaryButtons() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
{/* Mobile-only: toggle switches */}
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={batchMode}
|
||||
onCheckedChange={handleBatchModeToggle}
|
||||
>
|
||||
<ListChecks className='mr-2 h-4 w-4' />
|
||||
{t('Batch Operations')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={enableTagMode}
|
||||
@@ -219,14 +254,12 @@ export function ChannelsPrimaryButtons() {
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleFixAbilities(queryClient, (_result) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Fix abilities result:', _result)
|
||||
})
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setShowConsistencyDialog(true)
|
||||
}}
|
||||
>
|
||||
{t('Fix Abilities')}
|
||||
{t('Repair Channel Consistency')}
|
||||
<DropdownMenuShortcut>
|
||||
<Settings2 className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
@@ -269,6 +302,29 @@ export function ChannelsPrimaryButtons() {
|
||||
setShowDeleteDialog(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showConsistencyDialog}
|
||||
onOpenChange={setShowConsistencyDialog}
|
||||
title={t('Repair channel consistency?')}
|
||||
desc={t(
|
||||
'This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?'
|
||||
)}
|
||||
confirmText={t('Repair')}
|
||||
isLoading={isRepairingConsistency}
|
||||
handleConfirm={async () => {
|
||||
setIsRepairingConsistency(true)
|
||||
try {
|
||||
await handleFixAbilities(queryClient, (_result) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Repair channel consistency result:', _result)
|
||||
})
|
||||
setShowConsistencyDialog(false)
|
||||
} finally {
|
||||
setIsRepairingConsistency(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
@@ -24,7 +25,7 @@ import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
|
||||
import { channelsQueryKeys } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
@@ -59,6 +60,8 @@ type ChannelsContextType = {
|
||||
setEnableTagMode: (enabled: boolean) => void
|
||||
idSort: boolean
|
||||
setIdSort: (enabled: boolean) => void
|
||||
batchMode: boolean
|
||||
setBatchMode: (enabled: boolean) => void
|
||||
sensitiveVisible: boolean
|
||||
setSensitiveVisible: (visible: boolean) => void
|
||||
upstream: UpstreamUpdateState
|
||||
@@ -86,6 +89,7 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [idSort, setIdSort] = useState(() => {
|
||||
return localStorage.getItem('channels-id-sort') === 'true'
|
||||
})
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [sensitiveVisible, setSensitiveVisible] = useState(true)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
@@ -109,6 +113,8 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
batchMode,
|
||||
setBatchMode,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
upstream,
|
||||
@@ -119,6 +125,7 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
currentTag,
|
||||
enableTagMode,
|
||||
idSort,
|
||||
batchMode,
|
||||
sensitiveVisible,
|
||||
upstream,
|
||||
]
|
||||
|
||||
+27
-17
@@ -16,22 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import type { OnChangeFn, SortingState, Row } from '@tanstack/react-table'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
@@ -39,6 +30,17 @@ import {
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -53,14 +55,13 @@ import {
|
||||
getChannelTypeLabel,
|
||||
} from '../lib'
|
||||
import type { Channel, ChannelSortBy } from '../types'
|
||||
import { useChannelsColumns } from './channels-columns'
|
||||
import { ChannelCard } from './channel-card'
|
||||
import { useChannelsColumns } from './channels-columns'
|
||||
import { useChannels } from './channels-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
const route = getRouteApi('/_authenticated/channels/')
|
||||
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
|
||||
'channels:column-visibility'
|
||||
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY = 'channels:column-visibility'
|
||||
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
|
||||
|
||||
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
|
||||
@@ -83,6 +84,7 @@ export function ChannelsTable() {
|
||||
const {
|
||||
enableTagMode,
|
||||
idSort,
|
||||
batchMode,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
} = useChannels()
|
||||
@@ -268,7 +270,7 @@ export function ChannelsTable() {
|
||||
const typeCounts = data?.data?.type_counts
|
||||
|
||||
// Columns configuration
|
||||
const columns = useChannelsColumns()
|
||||
const columns = useChannelsColumns({ enableSelection: batchMode })
|
||||
|
||||
// React Table instance
|
||||
const { table } = useDataTable({
|
||||
@@ -284,7 +286,9 @@ export function ChannelsTable() {
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
|
||||
enableRowSelection: batchMode
|
||||
? (row: Row<Channel>) => !isTagAggregateRow(row.original)
|
||||
: false,
|
||||
onSortingChange: handleSortingChange,
|
||||
onColumnFiltersChange,
|
||||
onPaginationChange,
|
||||
@@ -297,6 +301,12 @@ export function ChannelsTable() {
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchMode) {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
}, [batchMode, table])
|
||||
|
||||
// Prepare filter options from existing channel types only.
|
||||
const typeFilterOptions = useMemo(() => {
|
||||
const counts = typeCounts || {}
|
||||
@@ -441,7 +451,7 @@ export function ChannelsTable() {
|
||||
}
|
||||
return DISABLED_ROW_DESKTOP
|
||||
}}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,24 +164,26 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
|
||||
return (
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit()
|
||||
}}
|
||||
aria-label={t('Edit')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className='size-4' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{layout !== 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit()
|
||||
}}
|
||||
aria-label={t('Edit')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className='size-4' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -262,6 +264,15 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{layout === 'card' && (
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Test Connection */}
|
||||
<DropdownMenuItem onClick={handleTest}>
|
||||
{t('Test Connection')}
|
||||
|
||||
+790
-402
File diff suppressed because it is too large
Load Diff
+31
-27
@@ -19,7 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
|
||||
import {
|
||||
copyChannel,
|
||||
deleteChannel,
|
||||
@@ -129,7 +131,7 @@ export async function handleEnableChannel(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
@@ -154,7 +156,7 @@ export async function handleDisableChannel(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
@@ -192,7 +194,7 @@ export async function handleDeleteChannel(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
}
|
||||
@@ -224,7 +226,7 @@ export async function handleUpdateChannelField(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
@@ -258,7 +260,7 @@ export async function handleUpdateTagField(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
@@ -355,7 +357,7 @@ export async function handleCopyChannel(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to copy channel'))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to copy channel'))
|
||||
}
|
||||
}
|
||||
@@ -425,7 +427,7 @@ export async function handleBatchDelete(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
}
|
||||
@@ -444,10 +446,7 @@ export async function handleBatchEnable(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await batchUpdateChannelStatus(
|
||||
ids,
|
||||
CHANNEL_STATUS.ENABLED
|
||||
)
|
||||
const response = await batchUpdateChannelStatus(ids, CHANNEL_STATUS.ENABLED)
|
||||
const successCount = response.success ? response.data || 0 : 0
|
||||
const failCount = ids.length - successCount
|
||||
|
||||
@@ -466,7 +465,7 @@ export async function handleBatchEnable(
|
||||
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to enable channels'))
|
||||
}
|
||||
}
|
||||
@@ -509,7 +508,7 @@ export async function handleBatchDisable(
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to disable channels'))
|
||||
}
|
||||
}
|
||||
@@ -537,7 +536,7 @@ export async function handleBatchSetTag(
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to set tag'))
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to set tag'))
|
||||
}
|
||||
}
|
||||
@@ -567,7 +566,7 @@ export async function handleEnableTagChannels(
|
||||
response.message || i18next.t('Failed to enable tag channels')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to enable tag channels'))
|
||||
}
|
||||
}
|
||||
@@ -593,7 +592,7 @@ export async function handleDisableTagChannels(
|
||||
response.message || i18next.t('Failed to disable tag channels')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to disable tag channels'))
|
||||
}
|
||||
}
|
||||
@@ -624,13 +623,13 @@ export async function handleDeleteAllDisabled(
|
||||
response.message || i18next.t('Failed to delete disabled channels')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to delete disabled channels'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix channel abilities
|
||||
* Repair channel consistency
|
||||
*/
|
||||
export async function handleFixAbilities(
|
||||
queryClient?: QueryClient,
|
||||
@@ -640,18 +639,23 @@ export async function handleFixAbilities(
|
||||
const response = await fixChannelAbilities()
|
||||
if (response.success && response.data) {
|
||||
toast.success(
|
||||
i18next.t('Fixed abilities: {{success}} succeeded, {{fails}} failed', {
|
||||
success: response.data.success,
|
||||
fails: response.data.fails,
|
||||
})
|
||||
i18next.t(
|
||||
'Channel consistency repaired: {{success}} succeeded, {{fails}} failed',
|
||||
{
|
||||
success: response.data.success,
|
||||
fails: response.data.fails,
|
||||
}
|
||||
)
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data)
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to fix abilities'))
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to repair channel consistency')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to fix abilities'))
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to repair channel consistency'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +681,7 @@ export async function handleTestAllChannels(
|
||||
response.message || i18next.t('Failed to start testing all channels')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to test all channels'))
|
||||
}
|
||||
}
|
||||
@@ -704,7 +708,7 @@ export async function handleUpdateAllBalances(
|
||||
response.message || i18next.t('Failed to update all balances')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(i18next.t('Failed to update all balances'))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user