Files
3x-ui/frontend/src/pages/clients/ClientsPage.tsx
T
Sanaei 92fb94d856 Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint

TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.

Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.

Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.

oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.

The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.

Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.

Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
  cast, which hid the optional chain from ESLint and would throw on a
  null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
  accessible but oxlint cannot evaluate it, so it gets a scoped disable.

* chore(docs): replace Prettier with oxfmt

oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.

The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)

The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.

.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.

oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.

* style(frontend): adopt oxfmt and format src

frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.

Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.

Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.

Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
  leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
  line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.

* ci: enforce formatting in CI and make verify

Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.

Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.

Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.

No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.

* ci: trigger CI on Makefile changes

The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.

* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components

`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.

Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.

Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.

Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.

* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard

Addresses the review on #6262.

The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.

The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.

The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.

Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
  hand-written lint logic in the repo is no longer the least covered
  file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
  CI gate in this PR while the hook only ran the linter, so a commit
  could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
  and make msw-worker-check byte-compare stay safe even if oxfmt is
  invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
  carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
  both accept JSONC, so relocating them was unnecessary.

Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
2026-08-19 15:36:27 +02:00

2004 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Checkbox,
Col,
ConfigProvider,
Dropdown,
Input,
Layout,
Modal,
Pagination,
Popover,
Result,
Row,
Select,
Spin,
Statistic,
Switch,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import {
CheckCircleOutlined,
ClockCircleOutlined,
DeleteOutlined,
DisconnectOutlined,
DownloadOutlined,
EditOutlined,
FilterOutlined,
InfoCircleOutlined,
LinkOutlined,
MoreOutlined,
PlusOutlined,
QrcodeOutlined,
RestOutlined,
RetweetOutlined,
SearchOutlined,
SortAscendingOutlined,
StopOutlined,
TagsOutlined,
TeamOutlined,
UploadOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { useTheme } from '@/hooks/useTheme';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useDatepicker } from '@/hooks/useDatepicker';
import type {
ClientRecord,
InboundOption,
ExternalLink,
ExternalLinkInput,
} from '@/hooks/useClients';
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
import ClientSpeedTag, { isActiveSpeed } from '@/components/clients/ClientSpeedTag';
import ClientCardComment from '@/components/clients/ClientCardComment';
import AppSidebar from '@/layouts/AppSidebar';
import { IntlUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { LazyMount } from '@/components/utility';
import {
SPEED_COLUMN_WIDTH,
SPEED_TAG_CLASS_NAME,
SPEED_TAG_STYLE,
} from '@/components/utility/speedTagStyle';
const ClientFormModal = lazy(() => import('./ClientFormModal'));
const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
const ClientQrModal = lazy(() => import('./ClientQrModal'));
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer'));
const SubLinksModal = lazy(() => import('./SubLinksModal'));
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
import { ClientInboundChips, ClientRowActions } from './RowCells';
import { emptyFilters, activeFilterCount } from './filters';
import type { ClientFilters } from './filters';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
const DEFAULT_TABLE_PAGE_SIZE = 25;
function UngroupIcon() {
return (
<span
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1em',
height: '1em',
}}
>
<TagsOutlined />
<span
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<span
style={{
display: 'block',
width: '125%',
height: '1.5px',
background: 'currentColor',
transform: 'rotate(-45deg)',
borderRadius: '1px',
}}
/>
</span>
</span>
);
}
// The server sends exact counters but caps the email arrays behind them, so a
// panel with thousands of depleted clients neither ships nor renders them all.
// The trailing chip reports what the popover left out.
function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
const hidden = total - emails.length;
return (
<div className="client-email-list">
{emails.map((e) => (
<div key={e}>{e}</div>
))}
{hidden > 0 && <div className="client-email-more">+{hidden}</div>}
</div>
);
}
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface PersistedFilterState {
searchKey: string;
filters: ClientFilters;
sort: string;
// The page size resolved on the previous visit. Without it the first list
// request has to wait for /setting/defaultSettings just to learn how many rows
// to ask for, which serialises two round trips on every load.
pageSize: number | null;
}
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
vless: 'blue',
vmess: 'geekblue',
trojan: 'volcano',
shadowsocks: 'magenta',
hysteria: 'cyan',
hysteria2: 'green',
wireguard: 'gold',
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
};
const INBOUND_CHIP_LIMIT = 1;
// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
// every unattached client on every render.
const EMPTY_INBOUND_IDS: number[] = [];
function readFilterState(): PersistedFilterState {
try {
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
return {
searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
filters: {
...emptyFilters(),
...fromRaw,
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
nodeIds: Array.isArray(fromRaw.nodeIds) ? fromRaw.nodeIds : [],
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
},
sort: typeof raw.sort === 'string' ? raw.sort : '',
pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
};
} catch {
return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
}
}
function gbToBytes(gb: number | undefined): number {
if (!gb || gb <= 0) return 0;
return Math.round(gb * 1024 * 1024 * 1024);
}
const SORT_OPTIONS: {
value: string;
column: string;
order: 'ascend' | 'descend';
labelKey: string;
}[] = [
{
value: 'createdAt:ascend',
column: 'createdAt',
order: 'ascend',
labelKey: 'pages.clients.sortOldest',
},
{
value: 'createdAt:descend',
column: 'createdAt',
order: 'descend',
labelKey: 'pages.clients.sortNewest',
},
{
value: 'updatedAt:descend',
column: 'updatedAt',
order: 'descend',
labelKey: 'pages.clients.sortRecentlyUpdated',
},
{
value: 'lastOnline:descend',
column: 'lastOnline',
order: 'descend',
labelKey: 'pages.clients.sortRecentlyOnline',
},
{
value: 'email:ascend',
column: 'email',
order: 'ascend',
labelKey: 'pages.clients.sortEmailAZ',
},
{
value: 'email:descend',
column: 'email',
order: 'descend',
labelKey: 'pages.clients.sortEmailZA',
},
{
value: 'traffic:descend',
column: 'traffic',
order: 'descend',
labelKey: 'pages.clients.sortMostTraffic',
},
{
value: 'remaining:descend',
column: 'remaining',
order: 'descend',
labelKey: 'pages.clients.sortHighestRemaining',
},
{
value: 'expiryTime:ascend',
column: 'expiryTime',
order: 'ascend',
labelKey: 'pages.clients.sortExpiringSoonest',
},
];
const DEFAULT_SORT = SORT_OPTIONS[0];
function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string {
if (!column || !order) return DEFAULT_SORT.value;
return `${column}:${order}`;
}
export default function ClientsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const {
clients,
total,
filtered,
summary,
allGroups,
setQuery,
inbounds,
onlines,
transitioning,
fetched,
fetchError,
subSettings,
tgBotEnable,
expireDiff,
trafficDiff,
pageSize,
settingsReady,
create,
update,
remove,
bulkDelete,
bulkAdjust,
bulkEnable,
bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
detach,
bulkDetach,
resetTraffic,
resetAllTraffics,
delDepleted,
delOrphans,
exportClients,
importClients,
setEnable,
clientSpeed,
applyTrafficEvent,
applyClientStatsEvent,
refresh,
hydrate,
} = useClients();
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
});
// Node list for the Nodes filter; the section only renders when the panel
// actually manages nodes (#4997).
const { nodes } = useNodesQuery();
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
const [infoOpen, setInfoOpen] = useState(false);
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
const [qrOpen, setQrOpen] = useState(false);
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
const [bulkAddOpen, setBulkAddOpen] = useState(false);
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
const [subLinksOpen, setSubLinksOpen] = useState(false);
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [textOpen, setTextOpen] = useState(false);
const [textTitle, setTextTitle] = useState('');
const [textContent, setTextContent] = useState('');
const [textFileName, setTextFileName] = useState('');
const [promptOpen, setPromptOpen] = useState(false);
const [promptTitle, setPromptTitle] = useState('');
const [promptOkText, setPromptOkText] = useState('');
const [promptInitial, setPromptInitial] = useState('');
const [promptLoading, setPromptLoading] = useState(false);
const [promptHandler, setPromptHandler] = useState<
((value: string) => Promise<boolean | void> | boolean | void) | null
>(null);
const initial = readFilterState();
const [searchKey, setSearchKey] = useState(initial.searchKey);
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const initialSort = SORT_OPTIONS.find((o) => o.value === initial.sort) ?? DEFAULT_SORT;
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
const [currentPage, setCurrentPage] = useState(1);
// Derived, not mirrored into state by an effect: an effect lags one render
// behind the settings arriving, and that lag is what made the page fetch the
// list once with the placeholder size and again with the real one.
const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
// Last visit's resolved size stands in until the settings land, so the list
// request goes out with the page mount instead of queueing behind them. If the
// admin has since changed the setting the authoritative value replaces it and
// costs one refetch — only on the load that follows the change. Null means
// nothing is known yet, which is the one case worth waiting for.
const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
// debouncedSearch lags behind the input so we don't spam the server on every
// keystroke; the search box still feels instant locally.
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
useEffect(() => {
localStorage.setItem(
FILTER_STATE_KEY,
JSON.stringify({
searchKey,
filters,
sort: sortValueFor(sortColumn, sortOrder),
// Only ever persist a size we actually resolved, never the render fallback.
pageSize: resolvedPageSize,
}),
);
}, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
return () => window.clearTimeout(handle);
}, [searchKey]);
useEffect(() => {
// Reset to page 1 whenever a filter or sort changes — otherwise an empty
// result set on a high page number leaves the user staring at "no clients".
setCurrentPage(1);
}, [debouncedSearch, filters, sortColumn, sortOrder]);
// The node filter maps onto inbound ids client-side (#4997): the paging API
// already accepts an inbound CSV, so nodes never have to reach the backend.
// Sentinel 0 = "local panel" (inbounds without a nodeId).
const effectiveInboundCsv = useMemo(() => {
if (!filters.nodeIds.length) return filters.inboundIds.join(',');
const nodeSet = new Set(filters.nodeIds);
const nodeInboundIds = inbounds.filter((ib) => nodeSet.has(ib.nodeId ?? 0)).map((ib) => ib.id);
const pool = filters.inboundIds.length
? nodeInboundIds.filter((id) => filters.inboundIds.includes(id))
: nodeInboundIds;
// Nothing matches the selected nodes: send an impossible id so the filter
// yields an honest empty result instead of being silently ignored.
return pool.length ? pool.join(',') : '-1';
}, [filters.nodeIds, filters.inboundIds, inbounds]);
useEffect(() => {
// With no remembered size and no settings yet, any query we build would be a
// guess, and issuing it costs a full server round trip that is thrown away as
// soon as the real size arrives.
if (resolvedPageSize === null) return;
setQuery({
page: currentPage,
pageSize: tablePageSize,
search: debouncedSearch,
filter: filters.buckets.join(','),
protocol: filters.protocols.join(','),
inbound: effectiveInboundCsv,
expiryFrom: filters.expiryFrom,
expiryTo: filters.expiryTo,
usageFrom: gbToBytes(filters.usageFromGB),
usageTo: gbToBytes(filters.usageToGB),
autoRenew: filters.autoRenew || undefined,
hasTgId: filters.hasTgId || undefined,
hasComment: filters.hasComment || undefined,
group: filters.groups.join(',') || undefined,
sort: sortColumn || undefined,
order: sortOrder || undefined,
});
}, [
setQuery,
resolvedPageSize,
currentPage,
tablePageSize,
debouncedSearch,
filters,
effectiveInboundCsv,
sortColumn,
sortOrder,
]);
const activeCount = activeFilterCount(filters);
// Row handlers take an email and look the row up here at call time. Keying
// them on the record object instead would defeat the memoised cells: every
// traffic push replaces the row object of every client whose counters moved,
// so the memo would miss on exactly the rows that are busy. Reading through
// the ref also means a modal opened mid-poll shows current usage.
const rowsByEmail = useRef(new Map<string, ClientRecord>());
rowsByEmail.current = useMemo(() => {
const map = new Map<string, ClientRecord>();
for (const c of clients) map.set(c.email, c);
return map;
}, [clients]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
const inboundsById = useMemo(() => {
const out: Record<number, InboundOption> = {};
for (const ib of inbounds) out[ib.id] = ib;
return out;
}, [inbounds]);
const protocolOptions = useMemo(() => {
const values = new Set<string>(
(inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x),
);
return [...values].sort();
}, [inbounds]);
const groupOptions = useMemo(() => {
const values = new Set<string>(allGroups);
for (const g of filters.groups) values.add(g);
return [...values].sort((a, b) => a.localeCompare(b));
}, [allGroups, filters.groups]);
const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
function inboundLabel(id: number) {
const ib = inboundsById[id];
return formatInboundLabel(ib?.tag, ib?.remark);
}
const clientBucket = useCallback(
(row: ClientRecord | null | undefined): Bucket | null => {
if (!row) return null;
const traffic = row.traffic || {};
const used = (traffic.up || 0) + (traffic.down || 0);
const total = row.totalGB || 0;
const now = Date.now();
const expired = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) <= now;
const exhausted = total > 0 && used >= total;
if (expired || exhausted) return 'depleted';
if (!row.enable) return 'deactive';
const nearExpiry =
(row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) - now < (expireDiff || 0);
const nearLimit = total > 0 && total - used < (trafficDiff || 0);
if (nearExpiry || nearLimit) return 'expiring';
return 'active';
},
[expireDiff, trafficDiff],
);
function bucketBadgeStatus(bucket: Bucket | null): 'success' | 'warning' | 'error' | 'default' {
switch (bucket) {
case 'depleted':
return 'error';
case 'expiring':
return 'warning';
case 'active':
return 'success';
default:
return 'default';
}
}
// The list page renders rows the server already sorted, filtered, and
// paginated. Local filtering is gone — keep the variable name so the rest
// of the file (table dataSource, mobile cards, select-all) doesn't need
// a rename.
const filteredClients = clients;
// Sort is server-side now; the page already arrives in the requested
// order, so we just hand it through.
const sortedClients = filteredClients;
function remainingLabel(row: ClientRecord) {
const total = row.totalGB || 0;
if (total <= 0) return '∞';
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
const r = total - used;
return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
}
function remainingColor(row: ClientRecord): string {
const total = row.totalGB || 0;
if (total <= 0) return 'purple';
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
const ratio = used / total;
if (ratio >= 1) return 'red';
if (ratio >= 0.85) return 'orange';
return 'green';
}
function expiryLabel(row: ClientRecord) {
if (!row.expiryTime) return '∞';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${t('pages.clients.delayedStart')}: ${days}d`;
}
return IntlUtil.formatDate(row.expiryTime, datepicker);
}
function expiryRelative(row: ClientRecord) {
if (!row.expiryTime) return '';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${days}d`;
}
return IntlUtil.formatRelativeTime(row.expiryTime);
}
function expiryColor(row: ClientRecord): string {
if (!row.expiryTime) return 'purple';
if (row.expiryTime < 0) return 'blue';
const now = Date.now();
if (row.expiryTime <= now) return 'red';
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
return 'green';
}
async function onToggleEnable(row: ClientRecord, next: boolean) {
setTogglingEmail(row.email);
try {
const msg = await setEnable(row, next);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
}
} finally {
setTogglingEmail(null);
}
}
function onAdd() {
setFormMode('add');
setEditingClient(null);
setEditingAttachedIds([]);
setEditingExternalLinks([]);
setFormOpen(true);
}
const onEdit = useCallback(
async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
setFormMode('edit');
// Paged list omits per-client secrets to keep the row payload tiny;
// edit needs them, so fetch the full record first.
const full = await hydrate(row.email);
const merged: ClientRecord = full ? { ...row, ...full.client } : { ...row };
setEditingClient(merged);
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
setEditingAttachedIds([...ids]);
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
setFormOpen(true);
},
[hydrate],
);
const onDelete = useCallback(
(email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
content: t('pages.clients.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(row.email);
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
},
});
},
[modal, t, remove, messageApi],
);
const onResetTraffic = useCallback(
(email: string) => {
const row = rowsByEmail.current.get(email);
if (!row?.email) {
messageApi.warning(t('pages.clients.resetNotPossible'));
return;
}
modal.confirm({
title: `${t('pages.inbounds.resetTraffic')}${row.email}`,
content: t('pages.inbounds.resetTrafficContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetTraffic(row);
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
},
});
},
[modal, t, resetTraffic, messageApi],
);
const onShowInfo = useCallback(
async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
const full = await hydrate(row.email);
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setInfoOpen(true);
},
[hydrate],
);
const onShowQr = useCallback(
async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
const full = await hydrate(row.email);
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setQrOpen(true);
},
[hydrate],
);
const [refreshing, setRefreshing] = useState(false);
const onRefreshClick = useCallback(async () => {
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
}, [refresh]);
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
setTextTitle(opts.title);
setTextContent(opts.content);
setTextFileName(opts.fileName || '');
setTextOpen(true);
}, []);
const openPrompt = useCallback(
(opts: {
title: string;
okText?: string;
value?: string;
confirm: (value: string) => Promise<boolean | void> | boolean | void;
}) => {
setPromptTitle(opts.title);
setPromptOkText(opts.okText || t('confirm'));
setPromptInitial(opts.value || '');
setPromptHandler(() => opts.confirm);
setPromptOpen(true);
},
[t],
);
const onPromptConfirm = useCallback(
async (value: string) => {
if (!promptHandler) {
setPromptOpen(false);
return;
}
setPromptLoading(true);
try {
const ok = await promptHandler(value);
if (ok !== false) setPromptOpen(false);
} finally {
setPromptLoading(false);
}
},
[promptHandler],
);
function onResetAllTraffics() {
modal.confirm({
title: t('pages.clients.resetAllTrafficsTitle'),
content: t('pages.clients.resetAllTrafficsContent'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetAllTraffics();
if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
},
});
}
function onDelDepleted() {
modal.confirm({
title: t('pages.clients.delDepletedConfirmTitle'),
content: t('pages.clients.delDepletedConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await delDepleted();
if (msg?.success) {
const deleted = msg.obj?.deleted ?? 0;
messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
}
},
});
}
function onDeleteOrphans() {
modal.confirm({
title: t('pages.clients.delOrphansConfirmTitle'),
content: t('pages.clients.delOrphansConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await delOrphans();
if (msg?.success) {
const deleted = msg.obj?.deleted ?? 0;
messageApi.success(t('pages.clients.toasts.delOrphans', { count: deleted }));
}
},
});
}
async function onExportClients() {
const items = await exportClients();
if (!items) return;
openText({
title: t('pages.clients.exportClients'),
content: JSON.stringify(items, null, 2),
fileName: 'clients-export.json',
});
}
function onImportClients() {
openPrompt({
title: t('pages.clients.importClients'),
okText: t('pages.clients.import'),
value: '',
confirm: async (value) => {
const msg = await importClients(value);
if (!msg?.success) return false;
const created = msg.obj?.created ?? 0;
const skipped = msg.obj?.skipped ?? [];
if (skipped.length === 0) {
messageApi.success(t('pages.clients.toasts.imported', { count: created }));
} else {
const firstError = skipped[0]?.reason ?? '';
messageApi.warning(
firstError
? `${t('pages.clients.toasts.importedMixed', { ok: created, failed: skipped.length })} — ${firstError}`
: t('pages.clients.toasts.importedMixed', { ok: created, failed: skipped.length }),
);
}
return true;
},
});
}
function onBulkUngroup() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
content: t('pages.clients.ungroupConfirmContent'),
okText: t('confirm'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
setSelectedRowKeys([]);
const affected =
(msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
}
},
});
}
function onBulkSetEnable(enable: boolean) {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t(
enable ? 'pages.clients.bulkEnableConfirmTitle' : 'pages.clients.bulkDisableConfirmTitle',
{ count: emails.length },
),
content: t(
enable
? 'pages.clients.bulkEnableConfirmContent'
: 'pages.clients.bulkDisableConfirmContent',
),
okText: t('confirm'),
okType: enable ? 'primary' : 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = enable ? await bulkEnable(emails) : await bulkDisable(emails);
setSelectedRowKeys([]);
const changed = msg?.obj?.changed ?? 0;
const skipped = msg?.obj?.skipped ?? [];
const failed = skipped.length;
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
const okKey = enable
? 'pages.clients.toasts.bulkEnabled'
: 'pages.clients.toasts.bulkDisabled';
const mixedKey = enable
? 'pages.clients.toasts.bulkEnabledMixed'
: 'pages.clients.toasts.bulkDisabledMixed';
if (failed === 0 && msg?.success) {
messageApi.success(t(okKey, { count: changed }));
} else {
messageApi.warning(
firstError
? `${t(mixedKey, { ok: changed, failed })} — ${firstError}`
: t(mixedKey, { ok: changed, failed }),
);
}
},
});
}
function onBulkDelete() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
content: t('pages.clients.bulkDeleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkDelete(emails);
setSelectedRowKeys([]);
const ok = msg?.obj?.deleted ?? 0;
const skipped = msg?.obj?.skipped ?? [];
const failed = skipped.length;
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
if (failed === 0 && msg?.success) {
messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else {
messageApi.warning(
firstError
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }),
);
}
},
});
}
const onSave = useCallback(
async (
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
meta:
| { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
| {
isEdit: true;
email: string;
attach: number[];
detach: number[];
externalLinks: ExternalLinkInput[];
},
) => {
if (!meta.isEdit) {
const createMsg = await create(payload);
if (!createMsg?.success) return createMsg;
if (meta.email && meta.externalLinks.length > 0) {
const r = await setExternalLinks(meta.email, meta.externalLinks);
if (!r?.success) return r;
}
return createMsg;
}
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
const rawEmail = (payload as { email?: unknown }).email;
const emailKey =
typeof rawEmail === 'string' && rawEmail.trim() ? rawEmail.trim() : meta.email;
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
const r = await attach(emailKey, meta.attach);
if (!r?.success) return r;
}
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
const r = await detach(emailKey, meta.detach);
if (!r?.success) return r;
}
// Always replace the client's external links (an empty set clears them).
const r = await setExternalLinks(emailKey, meta.externalLinks);
if (!r?.success) return r;
return updateMsg;
},
[create, update, attach, detach, setExternalLinks],
);
const pageClass = useMemo(() => {
const classes = ['clients-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
if (pag?.current) setCurrentPage(pag.current);
if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
};
const columns = useMemo<ColumnsType<ClientRecord>>(
() => [
{
title: t('pages.clients.actions'),
key: 'actions',
width: 200,
render: (_v, record) => (
<ClientRowActions
email={record.email}
onShowQr={onShowQr}
onShowInfo={onShowInfo}
onResetTraffic={onResetTraffic}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
{
title: t('pages.clients.enabled'),
key: 'enable',
width: 80,
render: (_v, record) => (
<Switch
checked={!!record.enable}
size="small"
loading={togglingEmail === record.email}
onChange={(next) => onToggleEnable(record, next)}
/>
),
},
{
title: t('pages.clients.online'),
key: 'online',
width: 90,
render: (_v, record) => {
const bucket = clientBucket(record);
const lastOnline = record.traffic?.lastOnline ?? 0;
const lastSubFetch = record.traffic?.lastSubFetch ?? 0;
const lastOnlineTitle = `${t('lastOnline')}: ${lastOnline > 0 ? IntlUtil.formatDate(lastOnline, datepicker) : '-'}\n${t('lastSubFetch')}: ${lastSubFetch > 0 ? IntlUtil.formatDate(lastSubFetch, datepicker) : '-'}`;
if (bucket === 'depleted')
return (
<Tooltip title={lastOnlineTitle}>
<Tag color="red">{t('depleted')}</Tag>
</Tooltip>
);
if (record.enable && isOnline(record.email))
return (
<Tag color="green" className="dot-tag">
<span className="online-dot" />
{t('pages.clients.online')}
</Tag>
);
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
return (
<Tooltip title={lastOnlineTitle}>
<Tag>{t('pages.clients.offline')}</Tag>
</Tooltip>
);
},
},
{
title: t('pages.clients.client'),
key: 'email',
width: 220,
render: (_v, record) => (
<div className="email-cell">
<span className="email">{record.email}</span>
{record.subId && (
<span className="sub" title={record.subId}>
{record.subId}
</span>
)}
<ClientCardComment comment={record.comment} className="sub" />
</div>
),
},
{
title: t('pages.clients.group'),
key: 'group',
width: 130,
hidden: allGroups.length === 0,
render: (_v, record) => {
if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}></span>;
const isActive = filters.groups.includes(record.group);
return (
<Tag
color="geekblue"
style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }}
onClick={(e) => {
e.stopPropagation();
if (!isActive) {
setFilters({ ...filters, groups: [...filters.groups, record.group!] });
}
}}
>
{record.group}
</Tag>
);
},
},
{
title: t('pages.clients.attachedInbounds'),
key: 'inboundIds',
width: 170,
render: (_v, record) => {
return (
<ClientInboundChips
ids={record.inboundIds || EMPTY_INBOUND_IDS}
inboundsById={inboundsById}
protocolColors={INBOUND_PROTOCOL_COLORS}
chipLimit={INBOUND_CHIP_LIMIT}
/>
);
},
},
{
title: t('pages.clients.traffic'),
key: 'traffic',
width: 300,
render: (_v, record) => (
<ClientTrafficCell
up={record.traffic?.up}
down={record.traffic?.down}
total={record.totalGB}
enabled={record.enable}
trafficDiff={trafficDiff}
/>
),
},
{
title: t('pages.clients.speed'),
key: 'speed',
width: SPEED_COLUMN_WIDTH,
align: 'center',
render: (_v, record) => {
const speed = clientSpeed[record.email];
if (!isActiveSpeed(speed)) {
return (
<Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>
</Tag>
);
}
return <ClientSpeedTag speed={speed} tableCell />;
},
},
{
title: t('pages.clients.remaining'),
key: 'remaining',
width: 130,
render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
},
{
title: t('pages.clients.duration'),
key: 'expiryTime',
width: 130,
render: (_v, record) => (
<Tooltip title={expiryLabel(record)}>
<Tag color={expiryColor(record)}>
{record.expiryTime ? expiryRelative(record) : '∞'}
</Tag>
</Tooltip>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
togglingEmail,
clientBucket,
isOnline,
inboundsById,
filters,
allGroups,
datepicker,
trafficDiff,
clientSpeed,
],
);
const tablePagination = {
current: currentPage,
pageSize: tablePageSize,
total: filtered,
showSizeChanger: filtered > 10,
pageSizeOptions: ['10', '25', '50', '100', '200'],
hideOnSinglePage: filtered <= tablePageSize,
showTotal: (n: number) => `${n}`,
};
const rowSelection = {
selectedRowKeys,
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
};
function toggleSelect(email: string, checked: boolean) {
setSelectedRowKeys((prev) => {
const next = new Set(prev);
if (checked) next.add(email);
else next.delete(email);
return Array.from(next);
});
}
function selectAll(checked: boolean) {
setSelectedRowKeys(checked ? filteredClients.map((c) => c.email) : []);
}
const allSelected =
filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
const someSelected =
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
if (key === 'expiryFrom' || key === 'expiryTo') {
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
return;
}
if (key === 'usageFromGB' || key === 'usageToGB') {
setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined });
return;
}
setFilters({ ...filters, [key]: emptyFilters()[key] });
}
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={
<Button type="primary" loading={refreshing} onClick={onRefreshClick}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, 12]}>
<Col xs={12} sm={8} md={4}>
<Statistic
title={t('clients')}
value={String(summary.total)}
prefix={<TeamOutlined />}
/>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('online')}
open={summary.onlineCount ? undefined : false}
content={
<ClientEmailList
emails={summary.online}
total={summary.onlineCount}
/>
}
>
<Statistic
title={t('online')}
value={String(summary.onlineCount)}
prefix={<span className="dot dot-blue" />}
/>
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depleted')}
open={summary.depletedCount ? undefined : false}
content={
<ClientEmailList
emails={summary.depleted}
total={summary.depletedCount}
/>
}
>
<Statistic
title={t('depleted')}
value={String(summary.depletedCount)}
prefix={<span className="dot dot-red" />}
/>
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depletingSoon')}
open={summary.expiringCount ? undefined : false}
content={
<ClientEmailList
emails={summary.expiring}
total={summary.expiringCount}
/>
}
>
<Statistic
title={t('depletingSoon')}
value={String(summary.expiringCount)}
prefix={<span className="dot dot-orange" />}
/>
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('disabled')}
open={summary.deactiveCount ? undefined : false}
content={
<ClientEmailList
emails={summary.deactive}
total={summary.deactiveCount}
/>
}
>
<Statistic
title={t('disabled')}
value={String(summary.deactiveCount)}
prefix={<span className="dot dot-gray" />}
/>
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Statistic
title={t('subscription.active')}
value={String(summary.active)}
prefix={<span className="dot dot-green" />}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
size="small"
hoverable
title={
<div className="card-toolbar">
{selectedRowKeys.length === 0 ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onAdd}
aria-label={t('pages.clients.addClients')}
>
{!isMobile && t('pages.clients.addClients')}
</Button>
) : (
<Tag
color="blue"
closable
onClose={() => setSelectedRowKeys([])}
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
>
{t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
</Tag>
)}
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items:
selectedRowKeys.length > 0
? [
{
key: 'attach',
icon: <UsergroupAddOutlined />,
label: t('pages.clients.attach'),
onClick: () => setBulkAttachOpen(true),
},
{
key: 'detach',
icon: <UsergroupDeleteOutlined />,
label: t('pages.clients.detach'),
danger: true,
onClick: () => setBulkDetachOpen(true),
},
{
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'ungroup',
icon: <UngroupIcon />,
label: t('pages.clients.ungroup'),
danger: true,
onClick: onBulkUngroup,
},
{ type: 'divider' as const },
{
key: 'enable',
icon: <CheckCircleOutlined />,
label: t('pages.clients.enable'),
onClick: () => onBulkSetEnable(true),
},
{
key: 'disable',
icon: <StopOutlined />,
label: t('pages.clients.disable'),
danger: true,
onClick: () => onBulkSetEnable(false),
},
{
key: 'adjust',
icon: <ClockCircleOutlined />,
label: t('pages.clients.adjust'),
onClick: () => setBulkAdjustOpen(true),
},
{
key: 'subLinks',
icon: <LinkOutlined />,
label: t('pages.clients.subLinks'),
onClick: () => setSubLinksOpen(true),
},
]
: [
{
key: 'bulk',
icon: <UsergroupAddOutlined />,
label: t('pages.clients.bulk'),
onClick: () => setBulkAddOpen(true),
},
{
key: 'export',
icon: <DownloadOutlined />,
label: t('pages.clients.exportClients'),
onClick: onExportClients,
},
{
key: 'import',
icon: <UploadOutlined />,
label: t('pages.clients.importClients'),
onClick: onImportClients,
},
{
key: 'resetAll',
icon: <RetweetOutlined />,
label: t('pages.clients.resetAllTraffics'),
onClick: onResetAllTraffics,
},
{ type: 'divider' as const },
{
key: 'delDepleted',
icon: <RestOutlined />,
label: t('pages.clients.delDepleted'),
danger: true,
onClick: onDelDepleted,
},
{
key: 'delOrphans',
icon: <DisconnectOutlined />,
label: t('pages.clients.delOrphans'),
danger: true,
onClick: onDeleteOrphans,
},
],
}}
>
<Button icon={<MoreOutlined />} aria-label={t('more')}>
{!isMobile && t('more')}
</Button>
</Dropdown>
{selectedRowKeys.length > 0 && (
<Button
danger
icon={<DeleteOutlined />}
onClick={onBulkDelete}
style={{ marginInlineStart: 'auto' }}
aria-label={t('delete')}
>
{!isMobile && t('delete')}
</Button>
)}
</div>
}
>
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
<Input
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('pages.clients.searchPlaceholder')}
allowClear
prefix={<SearchOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }}
aria-label={t('search')}
/>
<Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button
icon={<FilterOutlined />}
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'}
aria-label={t('filter')}
>
{!isMobile && t('filter')}
</Button>
</Badge>
<Select
value={sortValueFor(sortColumn, sortOrder)}
aria-label={t('sort')}
size={isMobile ? 'small' : 'middle'}
suffix={<SortAscendingOutlined />}
style={{ minWidth: isMobile ? 130 : 200 }}
onChange={(value) => {
const opt = SORT_OPTIONS.find((o) => o.value === value);
setSortColumn(opt?.column ?? null);
setSortOrder(opt?.order ?? null);
}}
options={SORT_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
{activeCount > 0 && (
<Button
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilters(emptyFilters())}
>
{t('pages.clients.clearAllFilters')}
</Button>
)}
{(activeCount > 0 || debouncedSearch.trim().length > 0) && (
<span className="filter-count">
{t('pages.clients.showingCount', { shown: filtered, total })}
</span>
)}
</div>
{activeCount > 0 && (
<div className="filter-chips">
{filters.buckets.map((b) => (
<Tag
key={`b-${b}`}
closable
onClose={() =>
setFilters({
...filters,
buckets: filters.buckets.filter((x) => x !== b),
})
}
>
{bucketChipLabel(b, t)}
</Tag>
))}
{filters.protocols.map((p) => (
<Tag
key={`p-${p}`}
closable
color="blue"
onClose={() =>
setFilters({
...filters,
protocols: filters.protocols.filter((x) => x !== p),
})
}
>
{p}
</Tag>
))}
{filters.inboundIds.map((id) => (
<Tag
key={`i-${id}`}
closable
color="cyan"
onClose={() =>
setFilters({
...filters,
inboundIds: filters.inboundIds.filter((x) => x !== id),
})
}
>
{inboundLabel(id)}
</Tag>
))}
{filters.groups.map((g) => (
<Tag
key={`g-${g}`}
closable
color="geekblue"
onClose={() =>
setFilters({
...filters,
groups: filters.groups.filter((x) => x !== g),
})
}
>
{t('pages.clients.group')}: {g}
</Tag>
))}
{(filters.expiryFrom || filters.expiryTo) && (
<Tag
closable
color="purple"
onClose={() => clearOneFilter('expiryFrom')}
>
{t('pages.clients.expiryTime')}:{' '}
{filters.expiryFrom
? IntlUtil.formatDate(filters.expiryFrom, datepicker)
: '…'}
{' → '}
{filters.expiryTo
? IntlUtil.formatDate(filters.expiryTo, datepicker)
: '…'}
</Tag>
)}
{(filters.usageFromGB || filters.usageToGB) && (
<Tag
closable
color="orange"
onClose={() => clearOneFilter('usageFromGB')}
>
{t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}
{filters.usageToGB ? `${filters.usageToGB}` : '+'} GB
</Tag>
)}
{filters.autoRenew && (
<Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
{t('pages.clients.renew')}:{' '}
{filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
</Tag>
)}
{filters.hasTgId && (
<Tag closable onClose={() => clearOneFilter('hasTgId')}>
{t('pages.clients.telegramId')}:{' '}
{filters.hasTgId === 'yes'
? t('pages.clients.has')
: t('pages.clients.hasNot')}
</Tag>
)}
{filters.hasComment && (
<Tag closable onClose={() => clearOneFilter('hasComment')}>
{t('pages.clients.comment')}:{' '}
{filters.hasComment === 'yes'
? t('pages.clients.has')
: t('pages.clients.hasNot')}
</Tag>
)}
</div>
)}
{!isMobile ? (
<Table<ClientRecord>
columns={columns}
dataSource={sortedClients}
loading={transitioning}
rowKey="email"
rowSelection={rowSelection}
pagination={tablePagination}
size="small"
scroll={{ x: 1200 }}
onChange={onTableChange}
locale={{
emptyText: (
<div className="clients-empty">
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
) : (
<Spin spinning={transitioning}>
<div className="client-cards">
{filteredClients.length > 0 && (
<div className="card-bulk-bar">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={(e) => selectAll(e.target.checked)}
>
{t('pages.clients.selectAll')}
</Checkbox>
{selectedRowKeys.length > 0 && (
<span className="bulk-count">{selectedRowKeys.length}</span>
)}
</div>
)}
{filteredClients.length === 0 && (
<div className="card-empty">
<TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div>
</div>
)}
{filteredClients.length > 0 && (
<div className="card-pagination">
<Pagination
current={currentPage}
pageSize={tablePageSize}
total={filtered}
showSizeChanger={filtered > 10}
pageSizeOptions={['10', '25', '50', '100', '200']}
hideOnSinglePage={filtered <= tablePageSize}
size="small"
showTotal={(n) => `${n}`}
onChange={(p, s) => {
setCurrentPage(p);
if (s && s !== tablePageSize) setPageSizeChoice(s);
}}
/>
</div>
)}
{filteredClients.map((row) => {
const bucket = clientBucket(row);
return (
<div
key={row.email}
className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}
>
<div className="card-head">
<Checkbox
checked={selectedRowKeys.includes(row.email)}
onChange={(e) => toggleSelect(row.email, e.target.checked)}
/>
{row.enable && bucket !== 'depleted' && isOnline(row.email) ? (
<span className="online-dot" style={{ marginInlineEnd: 0 }} />
) : (
<Badge status={bucketBadgeStatus(bucket)} />
)}
<span className="tag-name">{row.email}</span>
{bucket === 'depleted' && (
<Tag color="red" className="status-tag">
{t('depleted')}
</Tag>
)}
{bucket === 'expiring' && (
<Tag color="orange" className="status-tag">
{t('depletingSoon')}
</Tag>
)}
<div className="card-actions">
<Tooltip title={t('pages.clients.clientInfo')}>
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(row.email)}
onKeyDown={activateOnKey(() => onShowInfo(row.email))}
/>
</Tooltip>
<Switch
checked={!!row.enable}
size="small"
loading={togglingEmail === row.email}
onChange={(next) => onToggleEnable(row, next)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: [
{
key: 'qr',
label: (
<>
<QrcodeOutlined /> {t('pages.clients.qrCode')}
</>
),
onClick: () => onShowQr(row.email),
},
{
key: 'reset',
label: (
<>
<RetweetOutlined />{' '}
{t('pages.inbounds.resetTraffic')}
</>
),
onClick: () => onResetTraffic(row.email),
},
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => onEdit(row.email),
},
{
key: 'delete',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => onDelete(row.email),
},
],
}}
>
<Button
type="text"
size="small"
className="row-action-trigger"
icon={<MoreOutlined />}
aria-label={t('more')}
/>
</Dropdown>
</div>
</div>
<ClientCardComment comment={row.comment} />
<ClientTrafficCell
compact
up={row.traffic?.up}
down={row.traffic?.down}
total={row.totalGB}
enabled={row.enable}
trafficDiff={trafficDiff}
/>
{(() => {
const speed = clientSpeed[row.email];
if (!isActiveSpeed(speed)) return null;
return (
<div className="client-card-speed">
<ClientSpeedTag speed={speed} />
</div>
);
})()}
</div>
);
})}
</div>
</Spin>
)}
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<LazyMount when={formOpen}>
<ClientFormModal
open={formOpen}
mode={formMode}
client={editingClient}
attachedIds={editingAttachedIds}
attachedExternalLinks={editingExternalLinks}
inbounds={inbounds}
tgBotEnable={tgBotEnable}
groups={allGroups}
save={onSave}
resetTraffic={resetTraffic}
onOpenChange={setFormOpen}
/>
</LazyMount>
<LazyMount when={infoOpen}>
<ClientInfoModal
open={infoOpen}
client={infoClient}
inboundsById={inboundsById}
isOnline={infoClient ? isOnline(infoClient.email) : false}
subSettings={subSettings}
onOpenChange={setInfoOpen}
/>
</LazyMount>
<LazyMount when={qrOpen}>
<ClientQrModal
open={qrOpen}
client={qrClient}
inboundsById={inboundsById}
subSettings={subSettings}
onOpenChange={setQrOpen}
/>
</LazyMount>
<LazyMount when={bulkAddOpen}>
<ClientBulkAddModal
open={bulkAddOpen}
inbounds={inbounds}
groups={allGroups}
onOpenChange={setBulkAddOpen}
onSaved={() => setBulkAddOpen(false)}
/>
</LazyMount>
<LazyMount when={bulkAdjustOpen}>
<ClientBulkAdjustModal
open={bulkAdjustOpen}
count={selectedRowKeys.length}
onOpenChange={setBulkAdjustOpen}
onSubmit={async (addDays, addBytes, flow) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
if (msg?.success) {
setSelectedRowKeys([]);
return msg.obj ?? { adjusted: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={subLinksOpen}>
<SubLinksModal
open={subLinksOpen}
emails={selectedRowKeys}
clients={clients}
subSettings={subSettings}
onOpenChange={setSubLinksOpen}
/>
</LazyMount>
<LazyMount when={bulkGroupOpen}>
<BulkAddToGroupModal
open={bulkGroupOpen}
count={selectedRowKeys.length}
groups={allGroups}
onOpenChange={setBulkGroupOpen}
onSubmit={async (group) => {
const msg = await bulkAddToGroup([...selectedRowKeys], group);
if (msg?.success) {
setSelectedRowKeys([]);
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={bulkAttachOpen}>
<BulkAttachInboundsModal
open={bulkAttachOpen}
count={selectedRowKeys.length}
inbounds={inbounds}
onOpenChange={setBulkAttachOpen}
onSubmit={async (inboundIds) => {
const msg = await bulkAttach([...selectedRowKeys], inboundIds);
if (msg?.success) {
setSelectedRowKeys([]);
return msg.obj ?? { attached: [], skipped: [], errors: [] };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={bulkDetachOpen}>
<BulkDetachInboundsModal
open={bulkDetachOpen}
count={selectedRowKeys.length}
inbounds={inbounds}
onOpenChange={setBulkDetachOpen}
onSubmit={async (inboundIds) => {
const msg = await bulkDetach([...selectedRowKeys], inboundIds);
if (msg?.success) {
setSelectedRowKeys([]);
return msg.obj ?? { detached: [], skipped: [], errors: [] };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={filterDrawerOpen}>
<FilterDrawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
filters={filters}
onChange={setFilters}
inbounds={inbounds}
protocols={protocolOptions}
groups={groupOptions}
nodes={nodes}
/>
</LazyMount>
<LazyMount when={textOpen}>
<TextModal
open={textOpen}
onClose={() => setTextOpen(false)}
title={textTitle}
content={textContent}
fileName={textFileName}
json
/>
</LazyMount>
<LazyMount when={promptOpen}>
<PromptModal
open={promptOpen}
onClose={() => setPromptOpen(false)}
title={promptTitle}
okText={promptOkText}
initialValue={promptInitial}
loading={promptLoading}
json
onConfirm={onPromptConfirm}
/>
</LazyMount>
</Layout>
</ConfigProvider>
);
}
function bucketChipLabel(b: string, t: (k: string) => string): string {
switch (b) {
case 'active':
return t('subscription.active');
case 'expiring':
return t('depletingSoon');
case 'depleted':
return t('depleted');
case 'deactive':
return t('disabled');
case 'online':
return t('online');
default:
return b;
}
}