Commit f9e508bd by CaIon

perf(channels): optimize card view layout and reduce re-renders

- Show 3-column card grid from xl breakpoint instead of 2xl
- Cap inline priority/weight width to avoid huge values stretching cards
- Collapse right-column grid to content-sized columns, removing wasted space
- Memoize channel columns, context value, upstream-update result, and ChannelCard
  to avoid rebuilding/re-rendering all cards on unrelated state changes
parent cb841850
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { memo } from 'react'
import { flexRender, type Row } from '@tanstack/react-table' import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
...@@ -36,7 +37,7 @@ const SENSITIVE_MASK = '••••' ...@@ -36,7 +37,7 @@ const SENSITIVE_MASK = '••••'
* priority/weight spinners, balance refresh, response/test times, tag * priority/weight spinners, balance refresh, response/test times, tag
* expand-collapse, and the per-row (or per-tag) actions menu. * expand-collapse, and the per-row (or per-tag) actions menu.
*/ */
export function ChannelCard({ row }: { row: Row<Channel> }) { function ChannelCardComponent({ row }: { row: Row<Channel> }) {
const { t } = useTranslation() const { t } = useTranslation()
const { sensitiveVisible } = useChannels() const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(row.original) const isTagRow = isTagAggregateRow(row.original)
...@@ -118,19 +119,18 @@ export function ChannelCard({ row }: { row: Row<Channel> }) { ...@@ -118,19 +119,18 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div> </div>
</div> </div>
{/* Right column (sits on the right, content left-aligned) */} {/* Right column (sits on the right, content left-aligned). A single
<div className='flex flex-shrink-0 flex-col gap-3'> grid with content-sized columns keeps Priority/Weight and
<div className='grid grid-cols-2 items-center gap-x-3'> Response/Last Tested aligned without wasting horizontal space. */}
<div className='grid flex-shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span> <span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span> <span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div> <div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div> <div className='flex justify-start'>{weightCell}</div>
</div> <span className={cn('mt-2', labelClass)}>
<div className='grid grid-cols-2 gap-x-3'>
<div className={cn('mb-1', labelClass)}>
{fieldLabels.response_time} {fieldLabels.response_time}
</div> </span>
<div className={cn('mb-1', labelClass)}>{fieldLabels.test_time}</div> <span className={cn('mt-2', labelClass)}>{fieldLabels.test_time}</span>
<div className='overflow-hidden text-sm'> <div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>} {responseCell ?? <span className='text-muted-foreground'>-</span>}
</div> </div>
...@@ -139,7 +139,6 @@ export function ChannelCard({ row }: { row: Row<Channel> }) { ...@@ -139,7 +139,6 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Last row: groups span the full width, showing every group (no label) */} {/* Last row: groups span the full width, showing every group (no label) */}
<div className='min-w-0'> <div className='min-w-0'>
...@@ -161,3 +160,10 @@ export function ChannelCard({ row }: { row: Row<Channel> }) { ...@@ -161,3 +160,10 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div> </div>
) )
} }
/**
* Memoized so each card only re-renders when its own react-table row reference
* changes, instead of every card re-rendering whenever the parent table state
* (filters, pagination, sensitive toggle, etc.) updates.
*/
export const ChannelCard = memo(ChannelCardComponent)
...@@ -27,7 +27,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -27,7 +27,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
*/ */
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { useState } from 'react' import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
...@@ -384,9 +384,7 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -384,9 +384,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
await handleUpdateChannelBalance(channel.id, queryClient) await handleUpdateChannelBalance(channel.id, queryClient)
setIsUpdating(false) setIsUpdating(false)
} }
let remainingBadgeLabel = sensitiveVisible let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK
? remainingDisplay
: SENSITIVE_MASK
if (sensitiveVisible && isUpdating) { if (sensitiveVisible && isUpdating) {
remainingBadgeLabel = t('Updating...') remainingBadgeLabel = t('Updating...')
} else if (sensitiveVisible && channel.type === 57) { } else if (sensitiveVisible && channel.type === 57) {
...@@ -488,7 +486,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -488,7 +486,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { sensitiveVisible } = useChannels() const { sensitiveVisible } = useChannels()
const locale = i18n.resolvedLanguage || i18n.language const locale = i18n.resolvedLanguage || i18n.language
return [ // The column definitions only depend on the translation function, the active
// locale, and sensitive-data visibility. Memoizing keeps the array (and every
// cell renderer reference) stable across unrelated re-renders, so react-table
// does not invalidate the whole row model on each parent render.
return useMemo<ColumnDef<Channel>[]>(
() => [
// Checkbox column // Checkbox column
{ {
id: 'select', id: 'select',
...@@ -496,7 +499,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -496,7 +499,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected()} checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()} indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label='Select all' aria-label='Select all'
/> />
), ),
...@@ -696,7 +701,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -696,7 +701,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
> >
<MultiKeyModeIcon className='h-3 w-3' /> <MultiKeyModeIcon className='h-3 w-3' />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top'>{multiKeyTooltip}</TooltipContent> <TooltipContent side='top'>
{multiKeyTooltip}
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
...@@ -817,8 +824,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -817,8 +824,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Regular channel row // Regular channel row
const config = const config =
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] || CHANNEL_STATUS_CONFIG[
CHANNEL_STATUS_CONFIG[0] status as keyof typeof CHANNEL_STATUS_CONFIG
] || CHANNEL_STATUS_CONFIG[0]
const isMultiKey = isMultiKeyChannel(channel) const isMultiKey = isMultiKeyChannel(channel)
const keySize = channel.channel_info?.multi_key_size ?? 0 const keySize = channel.channel_info?.multi_key_size ?? 0
...@@ -1105,5 +1113,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] { ...@@ -1105,5 +1113,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
enableHiding: false, enableHiding: false,
meta: { pinned: 'right' as const }, meta: { pinned: 'right' as const },
}, },
] ],
[t, locale, sensitiveVisible]
)
} }
...@@ -17,7 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,13 @@ 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
*/ */
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useState, useCallback } from 'react' import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
} from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates' import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
import { channelsQueryKeys } from '../lib' import { channelsQueryKeys } from '../lib'
...@@ -88,9 +94,11 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) { ...@@ -88,9 +94,11 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
}, [queryClient]) }, [queryClient])
const upstream = useChannelUpstreamUpdates(refreshChannels) const upstream = useChannelUpstreamUpdates(refreshChannels)
return ( // useState setters are stable, so the context value only needs to change when
<ChannelsContext.Provider // an actual state value changes. Memoizing avoids handing every consumer
value={{ // (including all channel cards/cells) a brand-new object on each render.
const value = useMemo<ChannelsContextType>(
() => ({
open, open,
setOpen, setOpen,
currentRow, currentRow,
...@@ -104,8 +112,20 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) { ...@@ -104,8 +112,20 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
sensitiveVisible, sensitiveVisible,
setSensitiveVisible, setSensitiveVisible,
upstream, upstream,
}} }),
> [
open,
currentRow,
currentTag,
enableTagMode,
idSort,
sensitiveVisible,
upstream,
]
)
return (
<ChannelsContext.Provider value={value}>
{children} {children}
</ChannelsContext.Provider> </ChannelsContext.Provider>
) )
......
...@@ -371,7 +371,7 @@ export function ChannelsTable() { ...@@ -371,7 +371,7 @@ export function ChannelsTable() {
enableCardView enableCardView
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY} viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row) => <ChannelCard row={row} />} renderCard={(row) => <ChannelCard row={row} />}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3' cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
applyHeaderSize applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'), searchPlaceholder: t('Filter by name, ID, or key...'),
......
...@@ -164,8 +164,9 @@ export function NumericSpinnerInput({ ...@@ -164,8 +164,9 @@ export function NumericSpinnerInput({
type='button' type='button'
onClick={handleStartEdit} onClick={handleStartEdit}
disabled={disabled} disabled={disabled}
title={localValue}
className={cn( className={cn(
'h-7 min-w-8 cursor-text px-1 text-center font-mono text-sm tabular-nums', 'h-7 min-w-8 max-w-16 cursor-text truncate px-1 text-center font-mono text-sm tabular-nums',
disabled && 'cursor-default opacity-50' disabled && 'cursor-default opacity-50'
)} )}
> >
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useRef, useState, useCallback } from 'react' import { useRef, useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api, type ApiRequestConfig } from '@/lib/api' import { api, type ApiRequestConfig } from '@/lib/api'
...@@ -285,7 +285,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) { ...@@ -285,7 +285,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
} }
}, [refresh, t]) }, [refresh, t])
return { // Memoized so consumers (and the channels context value built from this) get
// a stable reference unless an actual field changes. Callbacks above are all
// useCallback-stable, so this only changes when relevant state changes.
return useMemo(
() => ({
showModal, showModal,
channel, channel,
addModels, addModels,
...@@ -300,5 +304,22 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) { ...@@ -300,5 +304,22 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
applyAllUpdates, applyAllUpdates,
detectChannelUpdates, detectChannelUpdates,
detectAllUpdates, detectAllUpdates,
} }),
[
showModal,
channel,
addModels,
removeModels,
preferredTab,
applyLoading,
detectAllLoading,
applyAllLoading,
openModal,
closeModal,
applyUpdates,
applyAllUpdates,
detectChannelUpdates,
detectAllUpdates,
]
)
} }
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