Commit 50b8f2a2 by CaIon

refactor: update styling and improve code readability across multiple components

parent a0de4b56
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
"src/routeTree.gen.ts" "src/routeTree.gen.ts"
], ],
"rules": { "rules": {
"curly": "error", "curly": ["error", "multi-line"],
"eqeqeq": [ "eqeqeq": [
"error", "error",
"always", "always",
......
...@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types' import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
export function getResolvedColumnClassName( export function getResolvedColumnClassName(
...@@ -37,14 +38,18 @@ export function getResolvedColumnClassNameFromMap( ...@@ -37,14 +38,18 @@ export function getResolvedColumnClassNameFromMap(
const customClassName = getColumnClassName?.(columnId, kind) const customClassName = getColumnClassName?.(columnId, kind)
const pinnedColumn = pinnedColumnById?.get(columnId) const pinnedColumn = pinnedColumnById?.get(columnId)
if (!pinnedColumn) return customClassName if (!pinnedColumn) {
return customClassName
}
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind)) return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
} }
} }
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) { export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
if (!pinnedColumns?.length) return undefined if (!pinnedColumns?.length) {
return undefined
}
return new Map(pinnedColumns.map((column) => [column.columnId, column])) return new Map(pinnedColumns.map((column) => [column.columnId, column]))
} }
...@@ -63,7 +68,7 @@ function getPinnedColumnClassName( ...@@ -63,7 +68,7 @@ function getPinnedColumnClassName(
pinnedColumn.side === 'left' ? 'left-0' : 'right-0', pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
edgeClassName, edgeClassName,
kind === 'header' kind === 'header'
? '[background-color:var(--table-header-bg,var(--background))] group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] z-30' ? '[background-color:var(--table-header-bg,var(--table-header))] group-hover:[background-color:var(--table-header-hover)] z-30'
: 'bg-background z-10 group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted', : 'bg-background z-10 group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted',
pinnedColumn.className, pinnedColumn.className,
kind === 'header' kind === 'header'
......
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -17,9 +18,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,9 +18,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import { type Row, type Table as TanstackTable } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
import { import {
getPinnedColumnMap, getPinnedColumnMap,
getResolvedColumnClassNameFromMap, getResolvedColumnClassNameFromMap,
...@@ -136,7 +138,7 @@ function SplitHeaderTableView<TData>({ ...@@ -136,7 +138,7 @@ function SplitHeaderTableView<TData>({
<div <div
className={cn( className={cn(
'min-h-0 flex-1 overflow-auto', 'min-h-0 flex-1 overflow-auto',
'**:data-[slot=table-header]:[--table-header-bg:color-mix(in_oklch,var(--muted)_30%,var(--background))]', '**:data-[slot=table-header]:[--table-header-bg:var(--table-header)]',
'**:data-[slot=table-header]:bg-(--table-header-bg)', '**:data-[slot=table-header]:bg-(--table-header-bg)',
props.splitHeaderScrollClassName, props.splitHeaderScrollClassName,
props.bodyContainerClassName props.bodyContainerClassName
...@@ -192,7 +194,9 @@ function getMetaPinnedColumns<TData>( ...@@ -192,7 +194,9 @@ function getMetaPinnedColumns<TData>(
): DataTablePinnedColumn[] { ): DataTablePinnedColumn[] {
return table.getAllColumns().flatMap((column) => { return table.getAllColumns().flatMap((column) => {
const side = column.columnDef.meta?.pinned const side = column.columnDef.meta?.pinned
if (!side) return [] if (!side) {
return []
}
return [{ columnId: column.id, side }] return [{ columnId: column.id, side }]
}) })
......
...@@ -61,7 +61,7 @@ export { ...@@ -61,7 +61,7 @@ export {
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter' export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
export const DISABLED_ROW_DESKTOP = export const DISABLED_ROW_DESKTOP =
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1' '[--data-table-card-bg:var(--table-disabled)] hover:[--data-table-card-bg:var(--table-disabled-hover)] [background-color:var(--table-disabled)] hover:[background-color:var(--table-disabled-hover)] [&>td:first-child]:[border-left-color:var(--table-disabled-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
export const DISABLED_ROW_MOBILE = export const DISABLED_ROW_MOBILE =
'border-l-4 border-l-muted-foreground/35 bg-muted/85' '[--data-table-card-bg:var(--table-disabled)] [background-color:var(--table-disabled)] border-l-4 [border-left-color:var(--table-disabled-border)]'
import type { Row, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -17,10 +19,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,10 +19,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import type { Row, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
...@@ -29,6 +29,8 @@ import { ...@@ -29,6 +29,8 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { tableHasCompactMeta } from './card-cell-utils' import { tableHasCompactMeta } from './card-cell-utils'
import { CardRowContent } from './card-row-content' import { CardRowContent } from './card-row-content'
...@@ -78,7 +80,7 @@ function CardGridSkeleton(props: { ...@@ -78,7 +80,7 @@ function CardGridSkeleton(props: {
{[1, 2, 3, 4, 5, 6].map((i) => ( {[1, 2, 3, 4, 5, 6].map((i) => (
<div <div
key={`${prefix}-${i}`} key={`${prefix}-${i}`}
className='bg-card space-y-3 rounded-lg border p-3' className='space-y-3 rounded-lg border [background-color:var(--table-row)] p-3'
> >
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<Skeleton className='h-4 w-32' /> <Skeleton className='h-4 w-32' />
...@@ -157,8 +159,9 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) { ...@@ -157,8 +159,9 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
return ( return (
<div <div
key={key} key={key}
data-slot='data-table-card'
className={cn( className={cn(
'bg-card rounded-lg border px-3 py-2.5', 'rounded-lg border [background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
props.getRowClassName?.(row) props.getRowClassName?.(row)
)} )}
> >
......
import type {
ColumnDef,
Row,
Table as TanstackTable,
} from '@tanstack/react-table'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -17,14 +22,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,14 +22,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import {
type ColumnDef, import { PageFooterPortal } from '@/components/layout'
type Row,
type Table as TanstackTable,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { PageFooterPortal } from '@/components/layout'
import { import {
DataTableView, DataTableView,
type DataTableColumnClassName, type DataTableColumnClassName,
...@@ -32,15 +34,15 @@ import { ...@@ -32,15 +34,15 @@ import {
type DataTableRenderRowHelpers, type DataTableRenderRowHelpers,
} from '../core/data-table-view' } from '../core/data-table-view'
import { DataTablePagination } from '../core/pagination' import { DataTablePagination } from '../core/pagination'
import { DataTableToolbar } from '../toolbar/toolbar'
import { DataTableViewModeToggle } from '../toolbar/view-mode-toggle'
import { import {
DATA_TABLE_VIEW_MODES, DATA_TABLE_VIEW_MODES,
useDataTableViewMode, useDataTableViewMode,
type DataTableViewMode, type DataTableViewMode,
} from '../hooks/use-data-table-view-mode' } from '../hooks/use-data-table-view-mode'
import { MobileCardList } from './mobile-card-list' import { DataTableToolbar } from '../toolbar/toolbar'
import { DataTableViewModeToggle } from '../toolbar/view-mode-toggle'
import { DataTableCardGrid } from './card-grid' import { DataTableCardGrid } from './card-grid'
import { MobileCardList } from './mobile-card-list'
/** /**
* Pass-through configuration for the default {@link DataTableToolbar}. * Pass-through configuration for the default {@link DataTableToolbar}.
...@@ -376,7 +378,9 @@ function renderToolbar<TData>( ...@@ -376,7 +378,9 @@ function renderToolbar<TData>(
function renderPagination<TData>( function renderPagination<TData>(
props: DataTablePageProps<TData> props: DataTablePageProps<TData>
): React.ReactNode { ): React.ReactNode {
if (props.showPagination === false) return null if (props.showPagination === false) {
return null
}
const pagination = <DataTablePagination table={props.table} /> const pagination = <DataTablePagination table={props.table} />
...@@ -391,7 +395,9 @@ function renderMobile<TData>( ...@@ -391,7 +395,9 @@ function renderMobile<TData>(
props: DataTablePageProps<TData>, props: DataTablePageProps<TData>,
showMobile: boolean showMobile: boolean
): React.ReactNode { ): React.ReactNode {
if (!showMobile) return null if (!showMobile) {
return null
}
const ownGetRowClassName = props.getRowClassName const ownGetRowClassName = props.getRowClassName
const mobileGetRowClassName = const mobileGetRowClassName =
...@@ -436,7 +442,9 @@ function renderDesktop<TData>( ...@@ -436,7 +442,9 @@ function renderDesktop<TData>(
cardViewActive: boolean, cardViewActive: boolean,
viewMode: DataTableViewMode viewMode: DataTableViewMode
): React.ReactNode { ): React.ReactNode {
if (showMobile) return null if (showMobile) {
return null
}
const isFetchingOnly = props.isFetching && !props.isLoading const isFetchingOnly = props.isFetching && !props.isLoading
const fixedHeight = props.fixedHeight !== false const fixedHeight = props.fixedHeight !== false
...@@ -481,8 +489,7 @@ function renderDesktop<TData>( ...@@ -481,8 +489,7 @@ function renderDesktop<TData>(
splitHeader={fixedHeight} splitHeader={fixedHeight}
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined} tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
tableHeaderClassName={cn( tableHeaderClassName={cn(
fixedHeight && fixedHeight && '[background-color:var(--table-header)]',
'[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
props.tableHeaderClassName props.tableHeaderClassName
)} )}
getColumnClassName={props.getColumnClassName} getColumnClassName={props.getColumnClassName}
......
import type { Row, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -17,10 +19,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,10 +19,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import type { Row, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
...@@ -29,6 +29,8 @@ import { ...@@ -29,6 +29,8 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { tableHasCompactMeta } from './card-cell-utils' import { tableHasCompactMeta } from './card-cell-utils'
import { CardRowContent } from './card-row-content' import { CardRowContent } from './card-row-content'
...@@ -143,7 +145,10 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) { ...@@ -143,7 +145,10 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
return ( return (
<div <div
key={key} key={key}
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))} className={cn(
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
getRowClassName?.(row)
)}
> >
<CardRowContent row={row} compact={hasCompactMeta} /> <CardRowContent row={row} compact={hasCompactMeta} />
</div> </div>
......
...@@ -23,7 +23,7 @@ export const staticDataTableClassNames = { ...@@ -23,7 +23,7 @@ export const staticDataTableClassNames = {
compactTable: 'text-sm', compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent', compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow: mutedHeaderRow:
'[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))] hover:[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]', '[background-color:var(--table-header)] hover:[background-color:var(--table-header-hover)]',
compactHeaderCell: compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase', 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight: compactHeaderCellRight:
......
import type { LucideIcon } from 'lucide-react'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -18,10 +19,10 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,10 +19,10 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import * as React from 'react' import * as React from 'react'
import { type LucideIcon } from 'lucide-react'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { stringToColor } from '@/lib/colors' import { stringToColor } from '@/lib/colors'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
export const dotColorMap = { export const dotColorMap = {
success: 'bg-success', success: 'bg-success',
...@@ -37,7 +38,7 @@ export const dotColorMap = { ...@@ -37,7 +38,7 @@ export const dotColorMap = {
grey: 'bg-neutral', grey: 'bg-neutral',
indigo: 'bg-chart-1', indigo: 'bg-chart-1',
'light-blue': 'bg-info', 'light-blue': 'bg-info',
'light-green': 'bg-success', 'light-green': 'bg-emerald-400',
lime: 'bg-chart-3', lime: 'bg-chart-3',
orange: 'bg-warning', orange: 'bg-warning',
pink: 'bg-chart-5', pink: 'bg-chart-5',
...@@ -61,7 +62,7 @@ export const textColorMap = { ...@@ -61,7 +62,7 @@ export const textColorMap = {
grey: 'text-muted-foreground', grey: 'text-muted-foreground',
indigo: 'text-chart-1', indigo: 'text-chart-1',
'light-blue': 'text-info', 'light-blue': 'text-info',
'light-green': 'text-success', 'light-green': 'text-emerald-500 dark:text-emerald-300',
lime: 'text-chart-3', lime: 'text-chart-3',
orange: 'text-warning', orange: 'text-warning',
pink: 'text-chart-5', pink: 'text-chart-5',
......
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 Copyright (C) 2023-2026 QuantumNous
...@@ -18,18 +28,24 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,18 +28,24 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { useState } from 'react' import { useState } 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 { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { BadgeListCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
formatCurrencyFromUSD, formatCurrencyFromUSD,
formatQuotaWithCurrency, formatQuotaWithCurrency,
...@@ -40,21 +56,7 @@ import { ...@@ -40,21 +56,7 @@ import {
formatQuota as formatQuotaValue, formatQuota as formatQuotaValue,
} from '@/lib/format' } from '@/lib/format'
import { truncateText } from '@/lib/utils' import { truncateText } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { BadgeListCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text'
import { getCodexUsage } from '../api' import { getCodexUsage } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants' import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import { import {
...@@ -90,7 +92,9 @@ function parseIonetMeta(otherInfo: string | null | undefined): null | { ...@@ -90,7 +92,9 @@ function parseIonetMeta(otherInfo: string | null | undefined): null | {
source?: string source?: string
deployment_id?: string deployment_id?: string
} { } {
if (!otherInfo) return null if (!otherInfo) {
return null
}
try { try {
const parsed = JSON.parse(otherInfo) const parsed = JSON.parse(otherInfo)
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === 'object') {
...@@ -107,14 +111,20 @@ function parseIonetMeta(otherInfo: string | null | undefined): null | { ...@@ -107,14 +111,20 @@ function parseIonetMeta(otherInfo: string | null | undefined): null | {
*/ */
function UpstreamUpdateTags({ channel }: { channel: Channel }) { function UpstreamUpdateTags({ channel }: { channel: Channel }) {
const { upstream, setCurrentRow } = useChannels() const { upstream, setCurrentRow } = useChannels()
if (!MODEL_FETCHABLE_TYPES.has(channel.type)) return null if (!MODEL_FETCHABLE_TYPES.has(channel.type)) {
return null
}
const meta = parseUpstreamUpdateMeta(channel.settings) const meta = parseUpstreamUpdateMeta(channel.settings)
if (!meta.enabled) return null if (!meta.enabled) {
return null
}
const addCount = meta.pendingAddModels.length const addCount = meta.pendingAddModels.length
const removeCount = meta.pendingRemoveModels.length const removeCount = meta.pendingRemoveModels.length
if (addCount === 0 && removeCount === 0) return null if (addCount === 0 && removeCount === 0) {
return null
}
return ( return (
<div className='flex items-center gap-0.5'> <div className='flex items-center gap-0.5'>
...@@ -300,7 +310,9 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -300,7 +310,9 @@ function BalanceCell({ channel }: { channel: Channel }) {
const remainingFull = withSuffix(formatBalance(balance)) const remainingFull = withSuffix(formatBalance(balance))
const usedDisplay = const usedDisplay =
usedFull.length > MAX_INLINE_BALANCE_CHARS usedFull.length > MAX_INLINE_BALANCE_CHARS
? withSuffix(formatQuotaWithCurrency(usedQuota, { compact: true, locale })) ? withSuffix(
formatQuotaWithCurrency(usedQuota, { compact: true, locale })
)
: usedFull : usedFull
const remainingDisplay = const remainingDisplay =
remainingFull.length > MAX_INLINE_BALANCE_CHARS remainingFull.length > MAX_INLINE_BALANCE_CHARS
...@@ -338,7 +350,9 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -338,7 +350,9 @@ function BalanceCell({ channel }: { channel: Channel }) {
const variant = getBalanceVariant(balance) const variant = getBalanceVariant(balance)
const handleClickUpdate = async () => { const handleClickUpdate = async () => {
if (isUpdating) return if (isUpdating) {
return
}
setIsUpdating(true) setIsUpdating(true)
if (channel.type === 57) { if (channel.type === 57) {
...@@ -362,6 +376,18 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -362,6 +376,18 @@ function BalanceCell({ channel }: { channel: Channel }) {
await handleUpdateChannelBalance(channel.id, queryClient) await handleUpdateChannelBalance(channel.id, queryClient)
setIsUpdating(false) setIsUpdating(false)
} }
let remainingBadgeLabel = remainingDisplay
if (isUpdating) {
remainingBadgeLabel = t('Updating...')
} else if (channel.type === 57) {
remainingBadgeLabel = t('Account Info')
}
let remainingBadgeVariant: StatusBadgeProps['variant'] = variant
if (channel.type === 57) {
remainingBadgeVariant = 'info'
} else if (isUpdating) {
remainingBadgeVariant = 'neutral'
}
return ( return (
<TooltipProvider> <TooltipProvider>
...@@ -387,20 +413,8 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -387,20 +413,8 @@ function BalanceCell({ channel }: { channel: Channel }) {
<TooltipTrigger <TooltipTrigger
render={ render={
<StatusBadge <StatusBadge
label={ label={remainingBadgeLabel}
isUpdating variant={remainingBadgeVariant}
? t('Updating...')
: channel.type === 57
? t('Account Info')
: remainingDisplay
}
variant={
channel.type === 57
? 'info'
: isUpdating
? 'neutral'
: variant
}
size='sm' size='sm'
copyable={false} copyable={false}
showDot={false} showDot={false}
...@@ -427,7 +441,9 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -427,7 +441,9 @@ function BalanceCell({ channel }: { channel: Channel }) {
channelId={channel.id} channelId={channel.id}
response={codexUsageResponse} response={codexUsageResponse}
onRefresh={async () => { onRefresh={async () => {
if (isUpdating) return if (isUpdating) {
return
}
setIsUpdating(true) setIsUpdating(true)
try { try {
const res = await getCodexUsage(channel.id) const res = await getCodexUsage(channel.id)
...@@ -565,7 +581,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -565,7 +581,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
render={ render={
<AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' /> <AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' />
} }
></TooltipTrigger> />
<TooltipContent side='top'> <TooltipContent side='top'>
{t( {t(
'Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.' 'Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.'
...@@ -581,7 +597,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -581,7 +597,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
render={ render={
<SlidersHorizontal className='text-info h-3.5 w-3.5 flex-shrink-0' /> <SlidersHorizontal className='text-info h-3.5 w-3.5 flex-shrink-0' />
} }
></TooltipTrigger> />
<TooltipContent side='top'> <TooltipContent side='top'>
{t('Override request parameters')} {t('Override request parameters')}
</TooltipContent> </TooltipContent>
...@@ -698,7 +714,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -698,7 +714,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
className='flex cursor-pointer items-center gap-1.5 text-xs font-medium' className='flex cursor-pointer items-center gap-1.5 text-xs font-medium'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!deploymentId) return if (!deploymentId) {
return
}
const targetUrl = `/models/deployments?dFilter=${encodeURIComponent(String(deploymentId))}` const targetUrl = `/models/deployments?dFilter=${encodeURIComponent(String(deploymentId))}`
window.open(targetUrl, '_blank', 'noopener') window.open(targetUrl, '_blank', 'noopener')
}} }}
...@@ -735,7 +753,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -735,7 +753,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
) )
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true if (!value || value.length === 0 || value.includes('all')) {
return true
}
return value.includes(String(row.getValue(id))) return value.includes(String(row.getValue(id)))
}, },
size: 220, size: 220,
...@@ -856,10 +876,16 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -856,10 +876,16 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
) )
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true if (!value || value.length === 0 || value.includes('all')) {
return true
}
const status = row.getValue(id) as number const status = row.getValue(id) as number
if (value.includes('enabled')) return status === 1 if (value.includes('enabled')) {
if (value.includes('disabled')) return status !== 1 return status === 1
}
if (value.includes('disabled')) {
return status !== 1
}
return false return false
}, },
size: 120, size: 120,
...@@ -876,9 +902,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -876,9 +902,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const modelArray = parseModelsList(models) const modelArray = parseModelsList(models)
return ( return (
<BadgeListCell <BadgeListCell
items={modelArray.map((model, idx) => ( items={modelArray.map((model) => (
<StatusBadge <StatusBadge
key={idx} key={model}
label={model} label={model}
autoColor={model} autoColor={model}
size='sm' size='sm'
...@@ -909,7 +935,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -909,7 +935,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
) )
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true if (!value || value.length === 0 || value.includes('all')) {
return true
}
const group = row.getValue(id) as string const group = row.getValue(id) as string
const groupArray = parseGroupsList(group) const groupArray = parseGroupsList(group)
return groupArray.some((g) => value.includes(g)) return groupArray.some((g) => value.includes(g))
...@@ -925,8 +953,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -925,8 +953,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
meta: { mobileHidden: true }, meta: { mobileHidden: true },
cell: ({ row }) => { cell: ({ row }) => {
const tag = row.getValue('tag') as string | null const tag = row.getValue('tag') as string | null
if (!tag) if (!tag) {
return <span className='text-muted-foreground text-xs'>-</span> return <span className='text-muted-foreground text-xs'>-</span>
}
return ( return (
<StatusBadge <StatusBadge
...@@ -1012,11 +1041,15 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -1012,11 +1041,15 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<span className='text-muted-foreground cursor-pointer font-mono text-sm' /> <StatusBadge
label={timeText}
variant='neutral'
size='sm'
copyable={false}
className='-ml-1.5 cursor-pointer'
/>
} }
> />
{timeText}
</TooltipTrigger>
<TooltipContent side='top'> <TooltipContent side='top'>
<p className='font-mono text-sm'>{fullDate}</p> <p className='font-mono text-sm'>{fullDate}</p>
</TooltipContent> </TooltipContent>
......
import {
Copy,
Check,
RefreshCw,
ChevronDown,
ChevronUp,
RotateCcw,
AlertTriangle,
} from 'lucide-react'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -17,20 +26,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,20 +26,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { type ReactNode, useCallback, useMemo, useState } from 'react' import { type ReactNode, useCallback, useMemo, useState } from 'react'
import {
Copy,
Check,
RefreshCw,
ChevronDown,
ChevronUp,
RotateCcw,
AlertTriangle,
} from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format' import { ConfirmDialog } from '@/components/confirm-dialog'
import { cn } from '@/lib/utils' import { Dialog } from '@/components/dialog'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -55,9 +55,11 @@ import { ...@@ -55,9 +55,11 @@ import {
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ConfirmDialog } from '@/components/confirm-dialog' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Dialog } from '@/components/dialog' import dayjs from '@/lib/dayjs'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge' import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { import {
getCodexResetCredits, getCodexResetCredits,
resetCodexUsage, resetCodexUsage,
...@@ -153,9 +155,13 @@ function formatUnixSeconds(unixSeconds: unknown): string { ...@@ -153,9 +155,13 @@ function formatUnixSeconds(unixSeconds: unknown): string {
} }
function formatIsoTimestamp(value: unknown): string { function formatIsoTimestamp(value: unknown): string {
if (typeof value !== 'string' || value.trim() === '') return '-' if (typeof value !== 'string' || value.trim() === '') {
return '-'
}
const d = dayjs(value) const d = dayjs(value)
if (!d.isValid()) return value if (!d.isValid()) {
return value
}
return formatDateTimeStr(d.toDate()) return formatDateTimeStr(d.toDate())
} }
...@@ -164,15 +170,21 @@ function formatDurationSeconds( ...@@ -164,15 +170,21 @@ function formatDurationSeconds(
t: (key: string) => string t: (key: string) => string
): string { ): string {
const s = Number(seconds) const s = Number(seconds)
if (!Number.isFinite(s) || s <= 0) return '-' if (!Number.isFinite(s) || s <= 0) {
return '-'
}
const total = Math.floor(s) const total = Math.floor(s)
const hours = Math.floor(total / 3600) const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60) const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60 const secs = total % 60
if (hours > 0) return `${hours}${t('h')} ${minutes}${t('m')}` if (hours > 0) {
if (minutes > 0) return `${minutes}${t('m')} ${secs}${t('s')}` return `${hours}${t('h')} ${minutes}${t('m')}`
}
if (minutes > 0) {
return `${minutes}${t('m')} ${secs}${t('s')}`
}
return `${secs}${t('s')}` return `${secs}${t('s')}`
} }
...@@ -180,12 +192,18 @@ function formatTimeLeftUntil( ...@@ -180,12 +192,18 @@ function formatTimeLeftUntil(
value: unknown, value: unknown,
t: (key: string) => string t: (key: string) => string
): string { ): string {
if (typeof value !== 'string' || value.trim() === '') return '-' if (typeof value !== 'string' || value.trim() === '') {
return '-'
}
const expiresAt = dayjs(value) const expiresAt = dayjs(value)
if (!expiresAt.isValid()) return '-' if (!expiresAt.isValid()) {
return '-'
}
const secondsLeft = expiresAt.diff(dayjs(), 'second') const secondsLeft = expiresAt.diff(dayjs(), 'second')
if (secondsLeft <= 0) return t('Expired') if (secondsLeft <= 0) {
return t('Expired')
}
const days = Math.floor(secondsLeft / (24 * 60 * 60)) const days = Math.floor(secondsLeft / (24 * 60 * 60))
const remainingSeconds = secondsLeft % (24 * 60 * 60) const remainingSeconds = secondsLeft % (24 * 60 * 60)
...@@ -198,7 +216,9 @@ function formatTimeLeftUntil( ...@@ -198,7 +216,9 @@ function formatTimeLeftUntil(
} }
function normalizePlanType(value: unknown): string { function normalizePlanType(value: unknown): string {
if (value == null) return '' if (value == null) {
return ''
}
return String(value).trim().toLowerCase() return String(value).trim().toLowerCase()
} }
...@@ -220,15 +240,21 @@ function sortResetCredits(credits: CodexResetCredit[]): CodexResetCredit[] { ...@@ -220,15 +240,21 @@ function sortResetCredits(credits: CodexResetCredit[]): CodexResetCredit[] {
return [...credits].sort((a, b) => { return [...credits].sort((a, b) => {
const aAvailable = normalizeResetCreditStatus(a.status) === 'available' const aAvailable = normalizeResetCreditStatus(a.status) === 'available'
const bAvailable = normalizeResetCreditStatus(b.status) === 'available' const bAvailable = normalizeResetCreditStatus(b.status) === 'available'
if (aAvailable !== bAvailable) return aAvailable ? -1 : 1 if (aAvailable !== bAvailable) {
return aAvailable ? -1 : 1
}
const expiresDiff = const expiresDiff =
parseTimeValue(a.expires_at) - parseTimeValue(b.expires_at) parseTimeValue(a.expires_at) - parseTimeValue(b.expires_at)
if (expiresDiff !== 0) return expiresDiff if (expiresDiff !== 0) {
return expiresDiff
}
const grantedDiff = const grantedDiff =
parseTimeValue(a.granted_at) - parseTimeValue(b.granted_at) parseTimeValue(a.granted_at) - parseTimeValue(b.granted_at)
if (grantedDiff !== 0) return grantedDiff if (grantedDiff !== 0) {
return grantedDiff
}
return String(a.id || '').localeCompare(String(b.id || '')) return String(a.id || '').localeCompare(String(b.id || ''))
}) })
...@@ -238,7 +264,9 @@ function classifyWindowByDuration( ...@@ -238,7 +264,9 @@ function classifyWindowByDuration(
windowData?: CodexRateLimitWindow | null windowData?: CodexRateLimitWindow | null
): 'weekly' | 'fiveHour' | null { ): 'weekly' | 'fiveHour' | null {
const seconds = Number(windowData?.limit_window_seconds) const seconds = Number(windowData?.limit_window_seconds)
if (!Number.isFinite(seconds) || seconds <= 0) return null if (!Number.isFinite(seconds) || seconds <= 0) {
return null
}
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour' return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour'
} }
...@@ -272,7 +300,9 @@ function resolveRateLimitWindows(data: RateLimitSource | null): { ...@@ -272,7 +300,9 @@ function resolveRateLimitWindows(data: RateLimitSource | null): {
} }
if (planType === 'free') { if (planType === 'free') {
if (!weeklyWindow) weeklyWindow = primary ?? secondary ?? null if (!weeklyWindow) {
weeklyWindow = primary ?? secondary ?? null
}
return { fiveHourWindow: null, weeklyWindow } return { fiveHourWindow: null, weeklyWindow }
} }
...@@ -338,8 +368,12 @@ function getResetCreditStatusBadge( ...@@ -338,8 +368,12 @@ function getResetCreditStatusBadge(
function windowLabel(windowData?: CodexRateLimitWindow | null) { function windowLabel(windowData?: CodexRateLimitWindow | null) {
const percent = clampPercent(windowData?.used_percent) const percent = clampPercent(windowData?.used_percent)
const variant: StatusBadgeProps['variant'] = let variant: StatusBadgeProps['variant'] = 'info'
percent >= 95 ? 'danger' : percent >= 80 ? 'warning' : 'info' if (percent >= 95) {
variant = 'danger'
} else if (percent >= 80) {
variant = 'warning'
}
return { percent, variant } return { percent, variant }
} }
...@@ -381,7 +415,7 @@ const percentTextClassName: Record< ...@@ -381,7 +415,7 @@ const percentTextClassName: Record<
grey: 'text-muted-foreground', grey: 'text-muted-foreground',
indigo: 'text-chart-1', indigo: 'text-chart-1',
'light-blue': 'text-info', 'light-blue': 'text-info',
'light-green': 'text-success', 'light-green': 'text-emerald-500 dark:text-emerald-300',
lime: 'text-chart-3', lime: 'text-chart-3',
orange: 'text-warning', orange: 'text-warning',
pink: 'text-chart-5', pink: 'text-chart-5',
...@@ -708,6 +742,51 @@ function ResetCreditsPanel(props: { ...@@ -708,6 +742,51 @@ function ResetCreditsPanel(props: {
? String(props.payload?.total_earned_count) ? String(props.payload?.total_earned_count)
: '-' : '-'
const canReset = Number(availableCount) > 0 const canReset = Number(availableCount) > 0
let creditsContent: ReactNode
if (props.errorMessage) {
creditsContent = (
<div className='border-destructive/40 bg-destructive/10 text-destructive rounded-lg border px-3 py-2 text-sm'>
{props.errorMessage}
</div>
)
} else if (props.isLoading) {
creditsContent = (
<div className='flex flex-col gap-2'>
<Skeleton className='h-24 w-full' />
<Skeleton className='h-24 w-full' />
</div>
)
} else if (credits.length > 0) {
creditsContent = (
<div className='flex flex-col gap-2'>
{credits.map((credit, index) => (
<ResetCreditItem
key={
credit.id ??
credit.expires_at ??
credit.granted_at ??
credit.title ??
credit.reset_type ??
''
}
credit={credit}
index={index}
/>
))}
</div>
)
} else {
creditsContent = (
<Empty className='min-h-32 border'>
<EmptyHeader>
<EmptyTitle>{t('No reset credits')}</EmptyTitle>
<EmptyDescription>
{t('Upstream did not return reset credit details.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return ( return (
<div className='flex flex-col gap-3 p-3'> <div className='flex flex-col gap-3 p-3'>
...@@ -796,35 +875,7 @@ function ResetCreditsPanel(props: { ...@@ -796,35 +875,7 @@ function ResetCreditsPanel(props: {
</Alert> </Alert>
) : null} ) : null}
{props.errorMessage ? ( {creditsContent}
<div className='border-destructive/40 bg-destructive/10 text-destructive rounded-lg border px-3 py-2 text-sm'>
{props.errorMessage}
</div>
) : props.isLoading ? (
<div className='flex flex-col gap-2'>
<Skeleton className='h-24 w-full' />
<Skeleton className='h-24 w-full' />
</div>
) : credits.length > 0 ? (
<div className='flex flex-col gap-2'>
{credits.map((credit, index) => (
<ResetCreditItem
key={`${credit.id ?? credit.expires_at ?? 'credit'}-${index}`}
credit={credit}
index={index}
/>
))}
</div>
) : (
<Empty className='min-h-32 border'>
<EmptyHeader>
<EmptyTitle>{t('No reset credits')}</EmptyTitle>
<EmptyDescription>
{t('Upstream did not return reset credit details.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div> </div>
) )
} }
...@@ -853,13 +904,17 @@ export function CodexUsageDialog({ ...@@ -853,13 +904,17 @@ export function CodexUsageDialog({
const payload: CodexUsagePayload | null = useMemo(() => { const payload: CodexUsagePayload | null = useMemo(() => {
const raw = response?.data const raw = response?.data
if (!raw || typeof raw !== 'object') return null if (!raw || typeof raw !== 'object') {
return null
}
return raw as CodexUsagePayload return raw as CodexUsagePayload
}, [response?.data]) }, [response?.data])
const resetCreditsPayload: CodexResetCreditsPayload | null = useMemo(() => { const resetCreditsPayload: CodexResetCreditsPayload | null = useMemo(() => {
const raw = resetCreditsResponse?.data const raw = resetCreditsResponse?.data
if (!raw || typeof raw !== 'object') return null if (!raw || typeof raw !== 'object') {
return null
}
return raw as CodexResetCreditsPayload return raw as CodexResetCreditsPayload
}, [resetCreditsResponse?.data]) }, [resetCreditsResponse?.data])
...@@ -892,7 +947,9 @@ export function CodexUsageDialog({ ...@@ -892,7 +947,9 @@ export function CodexUsageDialog({
setResetCreditsError(t('Channel ID is required')) setResetCreditsError(t('Channel ID is required'))
return return
} }
if (isLoadingResetCredits || (!force && resetCreditsResponse)) return if (isLoadingResetCredits || (!force && resetCreditsResponse)) {
return
}
setIsLoadingResetCredits(true) setIsLoadingResetCredits(true)
setResetCreditsError('') setResetCreditsError('')
...@@ -940,7 +997,9 @@ export function CodexUsageDialog({ ...@@ -940,7 +997,9 @@ export function CodexUsageDialog({
} }
const handleConfirmReset = async () => { const handleConfirmReset = async () => {
if (!channelId || isResetting || !canResetCodexUsage) return if (!channelId || isResetting || !canResetCodexUsage) {
return
}
setIsResetting(true) setIsResetting(true)
setResetActionError('') setResetActionError('')
...@@ -975,7 +1034,9 @@ export function CodexUsageDialog({ ...@@ -975,7 +1034,9 @@ export function CodexUsageDialog({
} }
const rawJsonText = useMemo(() => { const rawJsonText = useMemo(() => {
if (!response) return '' if (!response) {
return ''
}
try { try {
return JSON.stringify( return JSON.stringify(
{ {
...@@ -1002,7 +1063,6 @@ export function CodexUsageDialog({ ...@@ -1002,7 +1063,6 @@ export function CodexUsageDialog({
contentHeight='auto' contentHeight='auto'
bodyClassName='flex flex-col gap-4' bodyClassName='flex flex-col gap-4'
footer={ footer={
<>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
...@@ -1010,7 +1070,6 @@ export function CodexUsageDialog({ ...@@ -1010,7 +1070,6 @@ export function CodexUsageDialog({
> >
{t('Close')} {t('Close')}
</Button> </Button>
</>
} }
> >
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
...@@ -1074,11 +1133,7 @@ export function CodexUsageDialog({ ...@@ -1074,11 +1133,7 @@ export function CodexUsageDialog({
) : null} ) : null}
</div> </div>
<div className='mt-4 grid grid-cols-1 gap-3 md:grid-cols-2'> <div className='mt-4 grid grid-cols-1 gap-3 md:grid-cols-2'>
<InfoField <InfoField label={t('Email')} value={payload?.email} copyable />
label={t('Email')}
value={payload?.email}
copyable={true}
/>
<InfoField <InfoField
label={t('Channel')} label={t('Channel')}
value={channelLabel} value={channelLabel}
...@@ -1171,14 +1226,14 @@ export function CodexUsageDialog({ ...@@ -1171,14 +1226,14 @@ export function CodexUsageDialog({
)} )}
/> />
<div className='flex flex-col gap-3'> <div className='flex flex-col gap-3'>
{additionalRateLimits.map((item, index) => { {additionalRateLimits.map((item) => {
const limitName = const limitName =
item.limit_name || item.limit_name ||
item.metered_feature || item.metered_feature ||
`${t('Additional Limit')} ${index + 1}` t('Additional Limit')
return ( return (
<RateLimitGroupSection <RateLimitGroupSection
key={`${limitName}-${item.metered_feature ?? ''}-${index}`} key={`${limitName}-${item.metered_feature ?? ''}-${item.plan_type ?? ''}`}
title={limitName} title={limitName}
description={t('Additional metered capability')} description={t('Additional metered capability')}
source={item} source={item}
......
...@@ -80,8 +80,8 @@ export const CHANNEL_TYPES = { ...@@ -80,8 +80,8 @@ export const CHANNEL_TYPES = {
} as const } as const
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23, 1, 14, 33, 24, 43, 3, 41, 48, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46,
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36,
50, 51, 52, 53, 54, 55, 56, 50, 51, 52, 53, 54, 55, 56,
] ]
...@@ -322,7 +322,7 @@ export const RESPONSE_TIME_THRESHOLDS = { ...@@ -322,7 +322,7 @@ export const RESPONSE_TIME_THRESHOLDS = {
export const RESPONSE_TIME_CONFIG = { export const RESPONSE_TIME_CONFIG = {
EXCELLENT: { variant: 'success' as const, label: 'Excellent' }, EXCELLENT: { variant: 'success' as const, label: 'Excellent' },
GOOD: { variant: 'info' as const, label: 'Good' }, GOOD: { variant: 'success' as const, label: 'Good' },
FAIR: { variant: 'warning' as const, label: 'Fair' }, FAIR: { variant: 'warning' as const, label: 'Fair' },
POOR: { variant: 'danger' as const, label: 'Poor' }, POOR: { variant: 'danger' as const, label: 'Poor' },
UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' }, UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' },
......
...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency' import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { import {
CHANNEL_STATUS_CONFIG, CHANNEL_STATUS_CONFIG,
CHANNEL_TYPES, CHANNEL_TYPES,
...@@ -170,7 +171,9 @@ export function formatChannelKey( ...@@ -170,7 +171,9 @@ export function formatChannelKey(
key: string, key: string,
isMultiKey: boolean = false isMultiKey: boolean = false
): string { ): string {
if (!key) return '' if (!key) {
return ''
}
if (isMultiKey) { if (isMultiKey) {
const keys = key.split('\n').filter((k) => k.trim()) const keys = key.split('\n').filter((k) => k.trim())
...@@ -190,8 +193,12 @@ export function formatChannelKey( ...@@ -190,8 +193,12 @@ export function formatChannelKey(
* Format key preview for multi-key display * Format key preview for multi-key display
*/ */
export function formatKeyPreview(key: string, maxLength: number = 10): string { export function formatKeyPreview(key: string, maxLength: number = 10): string {
if (!key) return '' if (!key) {
if (key.length <= maxLength) return key return ''
}
if (key.length <= maxLength) {
return key
}
return `${key.slice(0, maxLength)}...` return `${key.slice(0, maxLength)}...`
} }
...@@ -199,7 +206,9 @@ export function formatKeyPreview(key: string, maxLength: number = 10): string { ...@@ -199,7 +206,9 @@ export function formatKeyPreview(key: string, maxLength: number = 10): string {
* Count keys in multi-key string * Count keys in multi-key string
*/ */
export function countKeys(key: string): number { export function countKeys(key: string): number {
if (!key) return 0 if (!key) {
return 0
}
return key.split('\n').filter((k) => k.trim()).length return key.split('\n').filter((k) => k.trim()).length
} }
...@@ -211,7 +220,9 @@ export function countKeys(key: string): number { ...@@ -211,7 +220,9 @@ export function countKeys(key: string): number {
* Parse comma-separated models list * Parse comma-separated models list
*/ */
export function parseModelsList(models: string): string[] { export function parseModelsList(models: string): string[] {
if (!models) return [] if (!models) {
return []
}
return models return models
.split(',') .split(',')
.map((m) => m.trim()) .map((m) => m.trim())
...@@ -223,14 +234,20 @@ export function parseModelsList(models: string): string[] { ...@@ -223,14 +234,20 @@ export function parseModelsList(models: string): string[] {
* Sorts with 'default' group first, then locale-sorted alphabetically. * Sorts with 'default' group first, then locale-sorted alphabetically.
*/ */
export function parseGroupsList(groups: string): string[] { export function parseGroupsList(groups: string): string[] {
if (!groups) return [] if (!groups) {
return []
}
const list = groups const list = groups
.split(',') .split(',')
.map((g) => g.trim()) .map((g) => g.trim())
.filter((g) => g.length > 0) .filter((g) => g.length > 0)
return list.sort((a, b) => { return list.sort((a, b) => {
if (a === 'default') return -1 if (a === 'default') {
if (b === 'default') return 1 return -1
}
if (b === 'default') {
return 1
}
return a.localeCompare(b) return a.localeCompare(b)
}) })
} }
...@@ -259,7 +276,9 @@ export function formatGroupsString(groups: string[]): string { ...@@ -259,7 +276,9 @@ export function formatGroupsString(groups: string[]): string {
export function parseChannelSettings( export function parseChannelSettings(
settingStr: string | null | undefined settingStr: string | null | undefined
): ChannelSettings { ): ChannelSettings {
if (!settingStr) return {} if (!settingStr) {
return {}
}
try { try {
return JSON.parse(settingStr) as ChannelSettings return JSON.parse(settingStr) as ChannelSettings
} catch { } catch {
...@@ -273,7 +292,9 @@ export function parseChannelSettings( ...@@ -273,7 +292,9 @@ export function parseChannelSettings(
export function parseChannelOtherSettings( export function parseChannelOtherSettings(
settingsStr: string | null | undefined settingsStr: string | null | undefined
): ChannelOtherSettings { ): ChannelOtherSettings {
if (!settingsStr || settingsStr === '{}') return {} if (!settingsStr || settingsStr === '{}') {
return {}
}
try { try {
return JSON.parse(settingsStr) as ChannelOtherSettings return JSON.parse(settingsStr) as ChannelOtherSettings
} catch { } catch {
...@@ -285,7 +306,9 @@ export function parseChannelOtherSettings( ...@@ -285,7 +306,9 @@ export function parseChannelOtherSettings(
* Validate JSON string * Validate JSON string
*/ */
export function validateChannelSettings(settings: string): boolean { export function validateChannelSettings(settings: string): boolean {
if (!settings || settings.trim() === '') return true if (!settings || settings.trim() === '') {
return true
}
try { try {
JSON.parse(settings) JSON.parse(settings)
return true return true
...@@ -302,7 +325,9 @@ export function validateChannelSettings(settings: string): boolean { ...@@ -302,7 +325,9 @@ export function validateChannelSettings(settings: string): boolean {
* Format balance with currency symbol * Format balance with currency symbol
*/ */
export function formatBalance(balance: number | null | undefined): string { export function formatBalance(balance: number | null | undefined): string {
if (balance == null || Number.isNaN(balance)) return '-' if (balance == null || Number.isNaN(balance)) {
return '-'
}
return formatCurrencyFromUSD(balance, { return formatCurrencyFromUSD(balance, {
digitsLarge: 2, digitsLarge: 2,
digitsSmall: 4, digitsSmall: 4,
...@@ -316,9 +341,15 @@ export function formatBalance(balance: number | null | undefined): string { ...@@ -316,9 +341,15 @@ export function formatBalance(balance: number | null | undefined): string {
export function getBalanceVariant( export function getBalanceVariant(
balance: number balance: number
): 'success' | 'warning' | 'danger' | 'neutral' { ): 'success' | 'warning' | 'danger' | 'neutral' {
if (balance === 0) return 'neutral' if (balance === 0) {
if (balance < 1) return 'danger' return 'neutral'
if (balance < 10) return 'warning' }
if (balance < 1) {
return 'danger'
}
if (balance < 10) {
return 'warning'
}
return 'success' return 'success'
} }
...@@ -334,9 +365,12 @@ type TFunction = (key: string, options?: { value?: number | string }) => string ...@@ -334,9 +365,12 @@ type TFunction = (key: string, options?: { value?: number | string }) => string
* Pass `t` from useTranslation() for i18n (e.g. "Not tested", "{{value}}ms", "{{value}}s"). * Pass `t` from useTranslation() for i18n (e.g. "Not tested", "{{value}}ms", "{{value}}s").
*/ */
export function formatResponseTime(timeMs: number, t?: TFunction): string { export function formatResponseTime(timeMs: number, t?: TFunction): string {
if (timeMs === 0) return t ? t('Not tested') : 'Not tested' if (timeMs === 0) {
if (timeMs < 1000) return t ? t('Not tested') : 'Not tested'
}
if (timeMs < 1000) {
return t ? t('{{value}}ms', { value: timeMs }) : `${timeMs}ms` return t ? t('{{value}}ms', { value: timeMs }) : `${timeMs}ms`
}
return t return t
? t('{{value}}s', { value: (timeMs / 1000).toFixed(2) }) ? t('{{value}}s', { value: (timeMs / 1000).toFixed(2) })
: `${(timeMs / 1000).toFixed(2)}s` : `${(timeMs / 1000).toFixed(2)}s`
...@@ -346,12 +380,21 @@ export function formatResponseTime(timeMs: number, t?: TFunction): string { ...@@ -346,12 +380,21 @@ export function formatResponseTime(timeMs: number, t?: TFunction): string {
* Get response time performance rating * Get response time performance rating
*/ */
export function getResponseTimeConfig(timeMs: number) { export function getResponseTimeConfig(timeMs: number) {
if (timeMs === 0) return RESPONSE_TIME_CONFIG.UNKNOWN if (timeMs === 0) {
if (timeMs <= RESPONSE_TIME_THRESHOLDS.EXCELLENT) return RESPONSE_TIME_CONFIG.UNKNOWN
}
if (timeMs <= RESPONSE_TIME_THRESHOLDS.EXCELLENT) {
return RESPONSE_TIME_CONFIG.EXCELLENT return RESPONSE_TIME_CONFIG.EXCELLENT
if (timeMs <= RESPONSE_TIME_THRESHOLDS.GOOD) return RESPONSE_TIME_CONFIG.GOOD }
if (timeMs <= RESPONSE_TIME_THRESHOLDS.FAIR) return RESPONSE_TIME_CONFIG.FAIR if (timeMs <= RESPONSE_TIME_THRESHOLDS.GOOD) {
if (timeMs <= RESPONSE_TIME_THRESHOLDS.POOR) return RESPONSE_TIME_CONFIG.POOR return RESPONSE_TIME_CONFIG.GOOD
}
if (timeMs <= RESPONSE_TIME_THRESHOLDS.FAIR) {
return RESPONSE_TIME_CONFIG.FAIR
}
if (timeMs <= RESPONSE_TIME_THRESHOLDS.POOR) {
return RESPONSE_TIME_CONFIG.POOR
}
return RESPONSE_TIME_CONFIG.POOR return RESPONSE_TIME_CONFIG.POOR
} }
...@@ -362,14 +405,16 @@ export function getResponseTimeConfig(timeMs: number) { ...@@ -362,14 +405,16 @@ export function getResponseTimeConfig(timeMs: number) {
/** /**
* Format a Unix timestamp (seconds) as a compact, locale-aware relative time. * Format a Unix timestamp (seconds) as a compact, locale-aware relative time.
* Uses `Intl.RelativeTimeFormat` with the `narrow` style so the label stays * Uses `Intl.RelativeTimeFormat` with the `narrow` style so the label stays
* short inside table cells, e.g. "4h ago" / "42m ago" (en) or "4小时前" (zh), * short inside table cells, e.g. "4h ago" / "42m ago" (en) or "4 小时前" (zh),
* instead of the verbose "4 hours ago". * instead of the verbose "4 hours ago".
*/ */
export function formatRelativeTime( export function formatRelativeTime(
timestamp: number, timestamp: number,
locale?: Intl.LocalesArgument locale?: Intl.LocalesArgument
): string { ): string {
if (!timestamp || timestamp === 0) return 'Never' if (!timestamp || timestamp === 0) {
return 'Never'
}
try { try {
const diffSec = timestamp - Date.now() / 1000 const diffSec = timestamp - Date.now() / 1000
...@@ -385,12 +430,35 @@ export function formatRelativeTime( ...@@ -385,12 +430,35 @@ export function formatRelativeTime(
const MONTH = 30 * DAY const MONTH = 30 * DAY
const YEAR = 365 * DAY const YEAR = 365 * DAY
if (absSec < MINUTE) return rtf.format(Math.round(diffSec), 'second') let value: number
if (absSec < HOUR) return rtf.format(Math.round(diffSec / MINUTE), 'minute') let unit: Intl.RelativeTimeFormatUnit
if (absSec < DAY) return rtf.format(Math.round(diffSec / HOUR), 'hour') if (absSec < MINUTE) {
if (absSec < MONTH) return rtf.format(Math.round(diffSec / DAY), 'day') value = Math.round(diffSec)
if (absSec < YEAR) return rtf.format(Math.round(diffSec / MONTH), 'month') unit = 'second'
return rtf.format(Math.round(diffSec / YEAR), 'year') } else if (absSec < HOUR) {
value = Math.round(diffSec / MINUTE)
unit = 'minute'
} else if (absSec < DAY) {
value = Math.round(diffSec / HOUR)
unit = 'hour'
} else if (absSec < MONTH) {
value = Math.round(diffSec / DAY)
unit = 'day'
} else if (absSec < YEAR) {
value = Math.round(diffSec / MONTH)
unit = 'month'
} else {
value = Math.round(diffSec / YEAR)
unit = 'year'
}
const formatted = rtf.format(value, unit)
const primaryLocale = Array.isArray(locale) ? locale[0] : locale
const language = primaryLocale?.toString()
if (language?.startsWith('zh')) {
return formatted.replaceAll(/(\d)([\u4e00-\u9fff])/g, '$1 $2')
}
return formatted
} catch { } catch {
return 'Unknown' return 'Unknown'
} }
...@@ -400,7 +468,9 @@ export function formatRelativeTime( ...@@ -400,7 +468,9 @@ export function formatRelativeTime(
* Format Unix timestamp to date string * Format Unix timestamp to date string
*/ */
export function formatTimestamp(timestamp: number): string { export function formatTimestamp(timestamp: number): string {
if (!timestamp || timestamp === 0) return 'N/A' if (!timestamp || timestamp === 0) {
return 'N/A'
}
try { try {
return formatTimestampToDate(timestamp) return formatTimestampToDate(timestamp)
...@@ -432,7 +502,9 @@ export function formatQuota(quota: number): string { ...@@ -432,7 +502,9 @@ export function formatQuota(quota: number): string {
export function getPriorityDisplay( export function getPriorityDisplay(
priority: number | null | undefined priority: number | null | undefined
): string { ): string {
if (priority === null || priority === undefined) return '0' if (priority === null || priority === undefined) {
return '0'
}
return String(priority) return String(priority)
} }
...@@ -440,7 +512,9 @@ export function getPriorityDisplay( ...@@ -440,7 +512,9 @@ export function getPriorityDisplay(
* Get weight display value * Get weight display value
*/ */
export function getWeightDisplay(weight: number | null | undefined): string { export function getWeightDisplay(weight: number | null | undefined): string {
if (weight === null || weight === undefined) return '0' if (weight === null || weight === undefined) {
return '0'
}
return String(weight) return String(weight)
} }
...@@ -481,10 +555,14 @@ export function validateGroups(groups: string): boolean { ...@@ -481,10 +555,14 @@ export function validateGroups(groups: string): boolean {
*/ */
export function channelNeedsAttention(channel: Channel): boolean { export function channelNeedsAttention(channel: Channel): boolean {
// Auto-disabled // Auto-disabled
if (channel.status === 3) return true if (channel.status === 3) {
return true
}
// Low balance (less than $1) // Low balance (less than $1)
if (channel.balance > 0 && channel.balance < 1) return true if (channel.balance > 0 && channel.balance < 1) {
return true
}
// Multi-key channel with all keys disabled // Multi-key channel with all keys disabled
if ( if (
...@@ -503,8 +581,12 @@ export function channelNeedsAttention(channel: Channel): boolean { ...@@ -503,8 +581,12 @@ export function channelNeedsAttention(channel: Channel): boolean {
* Get attention reason for channel * Get attention reason for channel
*/ */
export function getAttentionReason(channel: Channel): string | null { export function getAttentionReason(channel: Channel): string | null {
if (channel.status === 3) return 'Auto-disabled' if (channel.status === 3) {
if (channel.balance > 0 && channel.balance < 1) return 'Low balance' return 'Auto-disabled'
}
if (channel.balance > 0 && channel.balance < 1) {
return 'Low balance'
}
if ( if (
channel.channel_info?.is_multi_key && channel.channel_info?.is_multi_key &&
channel.channel_info.multi_key_status_list && channel.channel_info.multi_key_status_list &&
...@@ -553,7 +635,7 @@ export function aggregateChannelsByTag( ...@@ -553,7 +635,7 @@ export function aggregateChannelsByTag(
...channel, ...channel,
key: tag, key: tag,
id: tag as unknown as number, id: tag as unknown as number,
tag: tag, tag,
name: tag, name: tag,
type: 0, type: 0,
status: undefined as unknown as number, status: undefined as unknown as number,
...@@ -573,7 +655,10 @@ export function aggregateChannelsByTag( ...@@ -573,7 +655,10 @@ export function aggregateChannelsByTag(
result.push(tagRow) result.push(tagRow)
} }
const tagRow = tagMap.get(tag)! const tagRow = tagMap.get(tag)
if (!tagRow) {
continue
}
// Add to children // Add to children
tagRow.children.push(channel) tagRow.children.push(channel)
...@@ -609,7 +694,7 @@ export function aggregateChannelsByTag( ...@@ -609,7 +694,7 @@ export function aggregateChannelsByTag(
const newGroups = channel.group.split(',').filter(Boolean) const newGroups = channel.group.split(',').filter(Boolean)
newGroups.forEach((g) => { newGroups.forEach((g) => {
if (!existingGroups.has(g)) { if (!existingGroups.has(g)) {
tagRow.group += ',' + g tagRow.group += `,${g}`
} }
}) })
} }
......
import { useQuery } from '@tanstack/react-query'
import { Copy, ExternalLink, Loader2, RefreshCcw } from 'lucide-react'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -17,10 +19,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,10 +19,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useMemo } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Copy, ExternalLink, Loader2, RefreshCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Collapsible, Collapsible,
...@@ -28,7 +30,7 @@ import { ...@@ -28,7 +30,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { getDeployment, listDeploymentContainers } from '../../api' import { getDeployment, listDeploymentContainers } from '../../api'
export function ViewDetailsDialog({ export function ViewDetailsDialog({
...@@ -73,10 +75,14 @@ export function ViewDetailsDialog({ ...@@ -73,10 +75,14 @@ export function ViewDetailsDialog({
const locations = useMemo(() => { const locations = useMemo(() => {
const items = details?.locations const items = details?.locations
if (!Array.isArray(items)) return [] if (!Array.isArray(items)) {
return []
}
return items return items
.map((x) => { .map((x) => {
if (!x || typeof x !== 'object') return null if (!x || typeof x !== 'object') {
return null
}
const name = (x as Record<string, unknown>)?.name const name = (x as Record<string, unknown>)?.name
const iso2 = (x as Record<string, unknown>)?.iso2 const iso2 = (x as Record<string, unknown>)?.iso2
const id = (x as Record<string, unknown>)?.id const id = (x as Record<string, unknown>)?.id
...@@ -86,7 +92,9 @@ export function ViewDetailsDialog({ ...@@ -86,7 +92,9 @@ export function ViewDetailsDialog({
}, [details]) }, [details])
const handleCopyId = async () => { const handleCopyId = async () => {
if (deploymentId === null || deploymentId === undefined) return if (deploymentId === null || deploymentId === undefined) {
return
}
try { try {
await navigator.clipboard.writeText(String(deploymentId)) await navigator.clipboard.writeText(String(deploymentId))
toast.success(t('Copied')) toast.success(t('Copied'))
...@@ -108,6 +116,9 @@ export function ViewDetailsDialog({ ...@@ -108,6 +116,9 @@ export function ViewDetailsDialog({
return '' return ''
} }
}, [details]) }, [details])
const isDetailsLoading = isLoadingDetails || isLoadingContainers
const showDetailsError = !isDetailsLoading && !detailsRes?.success
const showDetailsContent = !isDetailsLoading && detailsRes?.success
return ( return (
<Dialog <Dialog
...@@ -118,7 +129,6 @@ export function ViewDetailsDialog({ ...@@ -118,7 +129,6 @@ export function ViewDetailsDialog({
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
footer={ footer={
<>
<Button <Button
variant='outline' variant='outline'
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
...@@ -126,7 +136,6 @@ export function ViewDetailsDialog({ ...@@ -126,7 +136,6 @@ export function ViewDetailsDialog({
> >
{t('Close')} {t('Close')}
</Button> </Button>
</>
} }
> >
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'> <div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
...@@ -158,15 +167,17 @@ export function ViewDetailsDialog({ ...@@ -158,15 +167,17 @@ export function ViewDetailsDialog({
<Separator /> <Separator />
{isLoadingDetails || isLoadingContainers ? ( {isDetailsLoading ? (
<div className='flex items-center justify-center py-10'> <div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' /> <Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div> </div>
) : !detailsRes?.success ? ( ) : null}
{showDetailsError ? (
<div className='text-muted-foreground py-10 text-center text-sm'> <div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')} {detailsRes?.message || t('Failed to fetch deployment details')}
</div> </div>
) : ( ) : null}
{showDetailsContent ? (
<> <>
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'> <div className='rounded-lg border p-3'>
...@@ -225,7 +236,9 @@ export function ViewDetailsDialog({ ...@@ -225,7 +236,9 @@ export function ViewDetailsDialog({
<div className='space-y-2'> <div className='space-y-2'>
{containers.map((c) => { {containers.map((c) => {
const id = c?.container_id const id = c?.container_id
if (typeof id !== 'string' || !id) return null if (typeof id !== 'string' || !id) {
return null
}
const status = const status =
typeof c?.status === 'string' ? c.status : undefined typeof c?.status === 'string' ? c.status : undefined
const url = const url =
...@@ -263,13 +276,13 @@ export function ViewDetailsDialog({ ...@@ -263,13 +276,13 @@ export function ViewDetailsDialog({
{t('Raw JSON')} {t('Raw JSON')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'> <pre className='bg-muted text-foreground mt-3 max-h-[360px] overflow-auto rounded-md p-3 text-xs'>
{payloadJson || '-'} {payloadJson || '-'}
</pre> </pre>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</> </>
)} ) : null}
</div> </div>
</Dialog> </Dialog>
) )
......
import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
/* /*
Copyright (C) 2023-2026 QuantumNous Copyright (C) 2023-2026 QuantumNous
...@@ -16,10 +18,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,10 +18,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo, useRef, useState } from 'react' import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Select, Select,
...@@ -30,7 +32,7 @@ import { ...@@ -30,7 +32,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Dialog } from '@/components/dialog'
import { getDeploymentLogs, listDeploymentContainers } from '../../api' import { getDeploymentLogs, listDeploymentContainers } from '../../api'
interface ViewLogsDialogProps { interface ViewLogsDialogProps {
...@@ -114,9 +116,17 @@ export function ViewLogsDialog({ ...@@ -114,9 +116,17 @@ export function ViewLogsDialog({
}, [logsData?.data]) }, [logsData?.data])
const logLines = useMemo(() => { const logLines = useMemo(() => {
const normalized = logsText.replace(/\r\n?/g, '\n') const normalized = logsText.replaceAll(/\r\n?/g, '\n')
return normalized ? normalized.split('\n') : [] return normalized ? normalized.split('\n') : []
}, [logsText]) }, [logsText])
const keyedLogLines = useMemo(() => {
const seen = new Map<string, number>()
return logLines.map((line) => {
const count = seen.get(line) ?? 0
seen.set(line, count + 1)
return { key: `${line}-${count}`, line }
})
}, [logLines])
// Auto-scroll to bottom // Auto-scroll to bottom
useEffect(() => { useEffect(() => {
...@@ -135,6 +145,44 @@ export function ViewLogsDialog({ ...@@ -135,6 +145,44 @@ export function ViewLogsDialog({
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
let containerPlaceholder = t('Select')
if (isLoadingContainers) {
containerPlaceholder = t('Loading...')
} else if (containers.length === 0) {
containerPlaceholder = t('No containers')
}
let logsContent: ReactNode
if (isLoadingContainers || isLoadingLogs) {
logsContent = (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
)
} else if (containers.length === 0) {
logsContent = (
<div className='py-8 text-center text-gray-400'>{t('No containers')}</div>
)
} else if (!containerId) {
logsContent = (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
)
} else if (!logsText.trim()) {
logsContent = (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
)
} else {
logsContent = (
<div className='font-mono text-sm'>
{keyedLogLines.map(({ key, line }) => (
<div key={key} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)
}
return ( return (
<Dialog <Dialog
...@@ -191,8 +239,7 @@ export function ViewLogsDialog({ ...@@ -191,8 +239,7 @@ export function ViewLogsDialog({
<div className='space-y-1'> <div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Container')}</div> <div className='text-muted-foreground text-xs'>{t('Container')}</div>
<Select <Select
items={[ items={containers.flatMap((c) => {
...containers.flatMap((c) => {
const id = c?.container_id const id = c?.container_id
if (typeof id !== 'string' || !id) return [] if (typeof id !== 'string' || !id) return []
const status = const status =
...@@ -210,28 +257,21 @@ export function ViewLogsDialog({ ...@@ -210,28 +257,21 @@ export function ViewLogsDialog({
), ),
}, },
] ]
}), })}
]}
value={containerId} value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)} onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0} disabled={isLoadingContainers || containers.length === 0}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue placeholder={containerPlaceholder} />
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent alignItemWithTrigger={false}> <SelectContent alignItemWithTrigger={false}>
<SelectGroup> <SelectGroup>
{containers.map((c) => { {containers.map((c) => {
const id = c?.container_id const id = c?.container_id
if (typeof id !== 'string' || !id) return null if (typeof id !== 'string' || !id) {
return null
}
const status = const status =
typeof c?.status === 'string' && c.status typeof c?.status === 'string' && c.status
? ` (${c.status})` ? ` (${c.status})`
...@@ -279,7 +319,7 @@ export function ViewLogsDialog({ ...@@ -279,7 +319,7 @@ export function ViewLogsDialog({
</div> </div>
<div <div
ref={scrollRef} ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4' className='bg-muted flex-1 overflow-auto rounded-md border p-3 sm:p-4'
onScroll={(e) => { onScroll={(e) => {
const target = e.target as HTMLDivElement const target = e.target as HTMLDivElement
const isAtBottom = const isAtBottom =
...@@ -287,29 +327,7 @@ export function ViewLogsDialog({ ...@@ -287,29 +327,7 @@ export function ViewLogsDialog({
setAutoScroll(isAtBottom) setAutoScroll(isAtBottom)
}} }}
> >
{isLoadingContainers || isLoadingLogs ? ( {logsContent}
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
</div> </div>
</Dialog> </Dialog>
) )
......
...@@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { Route } from 'lucide-react' import { Route } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils' import { StatusBadge } from '@/components/status-badge'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { StatusBadge } from '@/components/status-badge' import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
interface ModelBadgeProps { interface ModelBadgeProps {
modelName: string modelName: string
...@@ -109,11 +110,11 @@ function ModelBadgeContent(props: ModelBadgeProps) { ...@@ -109,11 +110,11 @@ function ModelBadgeContent(props: ModelBadgeProps) {
<span className='flex max-w-none items-center gap-1.5'> <span className='flex max-w-none items-center gap-1.5'>
{provider && ( {provider && (
<span <span
className='flex size-3.5 shrink-0 items-center justify-center' className='flex h-[18px] w-[18px] shrink-0 items-center justify-center'
title={provider.label} title={provider.label}
aria-label={provider.label} aria-label={provider.label}
> >
{getLobeIcon(provider.icon, 14)} {getLobeIcon(provider.icon, 18)}
</span> </span>
)} )}
<span className='whitespace-nowrap'>{props.modelName}</span> <span className='whitespace-nowrap'>{props.modelName}</span>
......
...@@ -25,8 +25,8 @@ export function showSubmittedData( ...@@ -25,8 +25,8 @@ export function showSubmittedData(
toast.message(title, { toast.message(title, {
description: ( description: (
// w-[340px] // w-[340px]
<pre className='mt-2 w-full overflow-x-auto rounded-md bg-slate-950 p-4'> <pre className='bg-muted text-foreground mt-2 w-full overflow-x-auto rounded-md p-4'>
<code className='text-white'>{JSON.stringify(data, null, 2)}</code> <code>{JSON.stringify(data, null, 2)}</code>
</pre> </pre>
), ),
}) })
......
...@@ -353,32 +353,32 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -353,32 +353,32 @@ For commercial licensing, please contact support@quantumnous.com
--spacing: 0.3rem; --spacing: 0.3rem;
} }
.dark [data-theme-preset='simple-large'] { .dark [data-theme-preset='simple-large'] {
--background: oklch(0.12 0 0); --background: oklch(0.215 0 0);
--foreground: oklch(0.98 0 0); --foreground: oklch(0.97 0 0);
--card: oklch(0.18 0 0); --card: oklch(0.27 0 0);
--card-foreground: oklch(0.98 0 0); --card-foreground: oklch(0.97 0 0);
--popover: oklch(0.2 0 0); --popover: oklch(0.29 0 0);
--popover-foreground: oklch(0.98 0 0); --popover-foreground: oklch(0.97 0 0);
--primary: oklch(0.94 0 0); --primary: oklch(0.94 0 0);
--primary-foreground: oklch(0.12 0 0); --primary-foreground: oklch(0.145 0 0);
--secondary: oklch(0.24 0 0); --secondary: oklch(0.315 0 0);
--secondary-foreground: oklch(0.98 0 0); --secondary-foreground: oklch(0.97 0 0);
--muted: oklch(0.24 0 0); --muted: oklch(0.315 0 0);
--muted-foreground: oklch(0.82 0 0); --muted-foreground: oklch(0.82 0 0);
--accent: oklch(0.3 0 0); --accent: oklch(0.36 0 0);
--accent-foreground: oklch(0.98 0 0); --accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.68 0.19 25); --destructive: oklch(0.68 0.19 25);
--destructive-foreground: oklch(0.98 0 0); --destructive-foreground: oklch(0.98 0 0);
--success: oklch(0.72 0.13 145); --success: oklch(0.72 0.13 145);
--success-foreground: oklch(0.12 0 0); --success-foreground: oklch(0.145 0 0);
--warning: oklch(0.82 0.13 75); --warning: oklch(0.82 0.13 75);
--warning-foreground: oklch(0.12 0 0); --warning-foreground: oklch(0.145 0 0);
--info: oklch(0.72 0.12 250); --info: oklch(0.72 0.12 250);
--info-foreground: oklch(0.12 0 0); --info-foreground: oklch(0.145 0 0);
--neutral: oklch(0.82 0 0); --neutral: oklch(0.82 0 0);
--neutral-foreground: oklch(0.12 0 0); --neutral-foreground: oklch(0.145 0 0);
--border: oklch(1 0 0 / 18%); --border: oklch(1 0 0 / 18%);
--input: oklch(1 0 0 / 24%); --input: oklch(1 0 0 / 24%);
...@@ -390,17 +390,17 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -390,17 +390,17 @@ For commercial licensing, please contact support@quantumnous.com
--chart-4: oklch(0.82 0.13 75); --chart-4: oklch(0.82 0.13 75);
--chart-5: oklch(0.68 0.19 25); --chart-5: oklch(0.68 0.19 25);
--sidebar: oklch(0.16 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.98 0 0); --sidebar-foreground: oklch(0.97 0 0);
--sidebar-primary: oklch(0.94 0 0); --sidebar-primary: oklch(0.94 0 0);
--sidebar-primary-foreground: oklch(0.12 0 0); --sidebar-primary-foreground: oklch(0.145 0 0);
--sidebar-accent: oklch(0.28 0 0); --sidebar-accent: oklch(0.34 0 0);
--sidebar-accent-foreground: oklch(0.98 0 0); --sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(1 0 0 / 18%); --sidebar-border: oklch(1 0 0 / 18%);
--sidebar-ring: oklch(0.94 0 0); --sidebar-ring: oklch(0.94 0 0);
--skeleton-base: oklch(0.24 0 0); --skeleton-base: oklch(0.315 0 0);
--skeleton-highlight: oklch(0.34 0 0); --skeleton-highlight: oklch(0.415 0 0);
} }
/* ── Semantic surface bridge ──────────────────────────────────────────── */ /* ── Semantic surface bridge ──────────────────────────────────────────── */
...@@ -552,21 +552,21 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -552,21 +552,21 @@ For commercial licensing, please contact support@quantumnous.com
.dark [data-theme-preset='anthropic'] { .dark [data-theme-preset='anthropic'] {
/* Warm near-black product surfaces, not pure black — keeps the editorial /* Warm near-black product surfaces, not pure black — keeps the editorial
* personality even when inverted. Coral lifts slightly for legibility. */ * personality even when inverted. Coral lifts slightly for legibility. */
--background: oklch(0.205 0.004 60); /* ≈ #181715 */ --background: oklch(0.215 0.004 60);
--foreground: oklch(0.965 0.005 92); /* ≈ #faf9f5 */ --foreground: oklch(0.965 0.005 92);
--card: oklch(0.245 0.004 60); --card: oklch(0.255 0.004 60);
--card-foreground: oklch(0.965 0.005 92); --card-foreground: oklch(0.965 0.005 92);
--popover: oklch(0.265 0.004 60); --popover: oklch(0.275 0.004 60);
--popover-foreground: oklch(0.965 0.005 92); --popover-foreground: oklch(0.965 0.005 92);
--primary: oklch(0.72 0.135 40); --primary: oklch(0.72 0.135 40);
--primary-foreground: oklch(0.18 0.005 60); --primary-foreground: oklch(0.18 0.005 60);
--secondary: oklch(0.295 0.004 60); --secondary: oklch(0.305 0.004 60);
--secondary-foreground: oklch(0.945 0.005 92); --secondary-foreground: oklch(0.945 0.005 92);
--muted: oklch(0.275 0.004 60); --muted: oklch(0.285 0.004 60);
--muted-foreground: oklch(0.76 0.006 75); --muted-foreground: oklch(0.76 0.006 75);
--accent: oklch(0.32 0.006 60); --accent: oklch(0.33 0.006 60);
--accent-foreground: oklch(0.985 0.005 92); --accent-foreground: oklch(0.985 0.005 92);
--destructive: oklch(0.7 0.19 22); --destructive: oklch(0.7 0.19 22);
...@@ -590,17 +590,17 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -590,17 +590,17 @@ For commercial licensing, please contact support@quantumnous.com
--chart-4: oklch(0.78 0.13 0); --chart-4: oklch(0.78 0.13 0);
--chart-5: oklch(0.83 0.027 175); --chart-5: oklch(0.83 0.027 175);
--sidebar: oklch(0.175 0.004 60); --sidebar: oklch(0.205 0.004 60);
--sidebar-foreground: oklch(0.95 0.005 92); --sidebar-foreground: oklch(0.95 0.005 92);
--sidebar-primary: oklch(0.72 0.135 40); --sidebar-primary: oklch(0.72 0.135 40);
--sidebar-primary-foreground: oklch(0.18 0.005 60); --sidebar-primary-foreground: oklch(0.18 0.005 60);
--sidebar-accent: oklch(0.31 0.006 60); --sidebar-accent: oklch(0.32 0.006 60);
--sidebar-accent-foreground: oklch(0.985 0.005 92); --sidebar-accent-foreground: oklch(0.985 0.005 92);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.72 0.135 40); --sidebar-ring: oklch(0.72 0.135 40);
--skeleton-base: oklch(0.295 0.004 60); --skeleton-base: oklch(0.305 0.004 60);
--skeleton-highlight: oklch(0.4 0.004 60); --skeleton-highlight: oklch(0.41 0.004 60);
} }
/* ── Font axis ──────────────────────────────────────────────────────────── /* ── Font axis ────────────────────────────────────────────────────────────
......
...@@ -142,22 +142,48 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -142,22 +142,48 @@ For commercial licensing, please contact support@quantumnous.com
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--skeleton-base: oklch(0.97 0 0); --skeleton-base: oklch(0.97 0 0);
--skeleton-highlight: oklch(0.985 0 0); --skeleton-highlight: oklch(0.985 0 0);
--table-row: var(--background);
--table-header: color-mix(
in oklch,
var(--foreground) 1.5%,
var(--background)
);
--table-header-hover: color-mix(
in oklch,
var(--foreground) 3%,
var(--background)
);
--table-disabled: color-mix(
in oklch,
var(--foreground) 5.5%,
var(--background)
);
--table-disabled-hover: color-mix(
in oklch,
var(--foreground) 7%,
var(--background)
);
--table-disabled-border: color-mix(
in oklch,
var(--foreground) 24%,
var(--background)
);
} }
.dark { .dark {
/* OpenAI-like dark mode with a softer charcoal canvas. */ /* OpenAI-like dark mode with a crisp charcoal canvas. */
--background: oklch(0.225 0 0); --background: oklch(0.235 0 0);
--foreground: oklch(0.965 0 0); --foreground: oklch(0.965 0 0);
--card: oklch(0.275 0 0); --card: oklch(0.285 0 0);
--card-foreground: oklch(0.965 0 0); --card-foreground: oklch(0.965 0 0);
--popover: oklch(0.295 0 0); --popover: oklch(0.305 0 0);
--popover-foreground: oklch(0.965 0 0); --popover-foreground: oklch(0.965 0 0);
--primary: oklch(0.965 0 0); --primary: oklch(0.965 0 0);
--primary-foreground: oklch(0.145 0 0); --primary-foreground: oklch(0.155 0 0);
--secondary: oklch(0.325 0 0); --secondary: oklch(0.335 0 0);
--secondary-foreground: oklch(0.965 0 0); --secondary-foreground: oklch(0.965 0 0);
--muted: oklch(0.295 0 0); --muted: oklch(0.305 0 0);
--muted-foreground: oklch(0.76 0 0); --muted-foreground: oklch(0.78 0 0);
--accent: oklch(0.365 0 0); --accent: oklch(0.365 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
...@@ -169,23 +195,49 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -169,23 +195,49 @@ For commercial licensing, please contact support@quantumnous.com
--info: oklch(0.68 0.17 237.323); --info: oklch(0.68 0.17 237.323);
--info-foreground: oklch(0.145 0 0); --info-foreground: oklch(0.145 0 0);
--neutral: oklch(0.76 0 0); --neutral: oklch(0.76 0 0);
--neutral-foreground: oklch(0.145 0 0); --neutral-foreground: oklch(0.155 0 0);
--border: oklch(1 0 0 / 9%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 16%); --input: oklch(1 0 0 / 17%);
--ring: oklch(0.68 0 0); --ring: oklch(0.68 0 0);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.195 0 0); --sidebar: oklch(0.225 0 0);
--sidebar-foreground: oklch(0.95 0 0); --sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.965 0 0); --sidebar-primary: oklch(0.965 0 0);
--sidebar-primary-foreground: oklch(0.145 0 0); --sidebar-primary-foreground: oklch(0.155 0 0);
--sidebar-accent: oklch(0.35 0 0); --sidebar-accent: oklch(0.355 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.68 0 0); --sidebar-ring: oklch(0.68 0 0);
--skeleton-base: oklch(0.325 0 0); --skeleton-base: oklch(0.335 0 0);
--skeleton-highlight: oklch(0.43 0 0); --skeleton-highlight: oklch(0.44 0 0);
--table-row: var(--background);
--table-header: color-mix(
in oklch,
var(--foreground) 6%,
var(--background)
);
--table-header-hover: color-mix(
in oklch,
var(--foreground) 9%,
var(--background)
);
--table-disabled: color-mix(
in oklch,
var(--foreground) 10%,
var(--background)
);
--table-disabled-hover: color-mix(
in oklch,
var(--foreground) 13%,
var(--background)
);
--table-disabled-border: color-mix(
in oklch,
var(--foreground) 34%,
var(--background)
);
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment