mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-18 11:55:02 +03:00
f52c3c4837
The clients page was slow on panels with many clients for two independent reasons: the server rebuilt the whole picture on every request, and the browser rebuilt the whole table on every poll. Server side, ListPaged loaded every client row, every client_inbounds link and every client_traffics row into Go memory, then filtered, sorted and paginated in a loop -- on a request the page repeats every five seconds. Every predicate now runs in SQL and only the requested page's ids are hydrated, so the cost tracks the page size rather than the client count. Measured on SQLite with a realistic status mix: the default view at 100k clients goes from 1,072ms to 64ms. Behaviour is preserved deliberately in the subtle places -- the cross-panel global-traffic overlay is folded into the same used-bytes expression the predicates and sort use, LIKE wildcards are escaped so a search for "a_b" stays literal, and the two different tiebreak rules the in-memory comparator had are reproduced per sort key. The summary's per-bucket email lists are capped at 200 with exact counters beside them. They only back hover popovers, but shipping every match made the response grow with the panel: at 100k clients it carried ~42k emails, and the page revalidated all of them through a strict Zod parse every five seconds. The popover now shows a "+N" chip for the remainder. Browser side, the page fired three sequential list requests per load and threw the first two away: the query went out before the persisted sort was applied, and again before the configured page size was known -- 0 meaning "one long page" is indistinguishable from "not loaded yet". The page size is now derived rather than mirrored through an effect, and the previous visit's value is remembered so the single request goes out at mount instead of queueing behind /setting/defaultSettings. Then the per-poll work. Reading isFetching made it a tracked property, so the refetch interval notified twice per cycle and re-rendered the page even when structural sharing left the data identical. Xray reports a traffic row per client whether or not it moved bytes, so the speed map was mostly zeros and was replaced wholesale every push; zero rows are now dropped and an unchanged result returns the previous object, which lets React bail out instead of re-rendering. The five Tooltip-wrapped buttons and the inbound chips per row do not depend on traffic at all and are now memoised, keyed on the email because a push replaces the row object of every client whose counters moved. antd's hashed:false drops 3,311 :where(.css-<hash>) wrappers and 29% of the generated stylesheet, and a pinned cssVar key stops each of the eleven page-level ConfigProviders minting its own token scope. Two callers that only need the mutations, GroupsPage and ClientBulkAddModal, no longer start the list query -- the groups page had been polling the full paged list every five seconds for data it never renders.
155 lines
4.4 KiB
TypeScript
155 lines
4.4 KiB
TypeScript
import { memo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, Popover, Space, Tag, Tooltip } from 'antd';
|
|
import {
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
InfoCircleOutlined,
|
|
QrcodeOutlined,
|
|
RetweetOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
|
import type { InboundOption } from '@/hooks/useClients';
|
|
|
|
const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
|
|
|
|
interface ClientRowActionsProps {
|
|
email: string;
|
|
onShowQr: (email: string) => void;
|
|
onShowInfo: (email: string) => void;
|
|
onResetTraffic: (email: string) => void;
|
|
onEdit: (email: string) => void;
|
|
onDelete: (email: string) => void;
|
|
}
|
|
|
|
// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
|
|
// inline they re-ran rc-tooltip's alignment machinery for every visible row on
|
|
// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
|
|
// Keyed on the email rather than the row object, because a push replaces the row
|
|
// object of every client whose counters moved; the page resolves the live row.
|
|
export const ClientRowActions = memo(function ClientRowActions({
|
|
email,
|
|
onShowQr,
|
|
onShowInfo,
|
|
onResetTraffic,
|
|
onEdit,
|
|
onDelete,
|
|
}: ClientRowActionsProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Space size={4}>
|
|
<Tooltip title={t('pages.clients.qrCode')}>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
style={ICON_BUTTON_STYLE}
|
|
icon={<QrcodeOutlined />}
|
|
aria-label={t('pages.clients.qrCode')}
|
|
onClick={() => onShowQr(email)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('pages.clients.clientInfo')}>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
style={ICON_BUTTON_STYLE}
|
|
icon={<InfoCircleOutlined />}
|
|
aria-label={t('pages.clients.clientInfo')}
|
|
onClick={() => onShowInfo(email)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
style={ICON_BUTTON_STYLE}
|
|
icon={<RetweetOutlined />}
|
|
aria-label={t('pages.inbounds.resetTraffic')}
|
|
onClick={() => onResetTraffic(email)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('edit')}>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
style={ICON_BUTTON_STYLE}
|
|
icon={<EditOutlined />}
|
|
aria-label={t('edit')}
|
|
onClick={() => onEdit(email)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('delete')}>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
danger
|
|
style={ICON_BUTTON_STYLE}
|
|
icon={<DeleteOutlined />}
|
|
aria-label={t('delete')}
|
|
onClick={() => onDelete(email)}
|
|
/>
|
|
</Tooltip>
|
|
</Space>
|
|
);
|
|
});
|
|
|
|
const CHIP_STYLE = { margin: 2 } as const;
|
|
const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
|
|
const OVERFLOW_LIST_STYLE = {
|
|
display: 'flex',
|
|
flexDirection: 'column' as const,
|
|
gap: 4,
|
|
maxWidth: 280,
|
|
maxHeight: 280,
|
|
overflowY: 'auto' as const,
|
|
};
|
|
|
|
interface ClientInboundChipsProps {
|
|
ids: number[];
|
|
inboundsById: Record<number, InboundOption>;
|
|
protocolColors: Record<string, string>;
|
|
chipLimit: number;
|
|
}
|
|
|
|
// Attachments never change on a traffic push either, so the same memoisation
|
|
// applies: one Tooltip per visible chip plus a Popover for the overflow.
|
|
export const ClientInboundChips = memo(function ClientInboundChips({
|
|
ids,
|
|
inboundsById,
|
|
protocolColors,
|
|
chipLimit,
|
|
}: ClientInboundChipsProps) {
|
|
if (ids.length === 0) return <span className="cell-empty">—</span>;
|
|
|
|
const label = (id: number) => {
|
|
const ib = inboundsById[id];
|
|
return formatInboundLabel(ib?.tag, ib?.remark);
|
|
};
|
|
const chip = (id: number) => {
|
|
const proto = (inboundsById[id]?.protocol || '').toLowerCase();
|
|
return (
|
|
<Tooltip key={id} title={label(id)}>
|
|
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const visible = ids.slice(0, chipLimit);
|
|
const overflow = ids.slice(chipLimit);
|
|
return (
|
|
<>
|
|
{visible.map(chip)}
|
|
{overflow.length > 0 && (
|
|
<Popover
|
|
trigger="click"
|
|
placement="bottomRight"
|
|
content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
|
|
>
|
|
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
|
|
</Popover>
|
|
)}
|
|
</>
|
|
);
|
|
});
|