mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
feat(clients): toolbar sort selector + preserve updated_at on unchanged rows
Frontend
- New Sort dropdown in the clients toolbar covering oldest/newest,
recently updated, recently online, email A↔Z, most traffic, highest
remaining, expiring soonest. Default is Oldest first.
- Strip per-column sorter arrows from the Table — all sorting now flows
through the single dropdown, so the column headers stop competing
with it.
- Empty state: TeamOutlined icon, t('noData'), text-secondary color
(matching the inbound/node polish).
Backend
- sortClients: add createdAt, updatedAt and lastOnline cases (with id
tie-break for stable ordering when timestamps collide).
- Fix Recently updated: SyncInbound was calling tx.Save on every client
in the inbound, and GORM's autoUpdateTime tag stamped updated_at to
time.Now() each time — so editing one client bumped ALL of them.
After the Save, restore each row's preserved updated_at via
UpdateColumn (skips hooks). The actually-edited client gets its
fresh stamp from the explicit UpdateColumn at the end of Update().
- Fix periodic updated_at churn: adjustTraffics unconditionally set
c["updated_at"] = now() for every client in any inbound that had a
delayed-start expiry, every traffic-stats pass. Turn that into a
backfill (only when the key is missing), matching the created_at
treatment one line above.
This commit is contained in:
parent
6286bb8676
commit
7680e27d1d
5 changed files with 214 additions and 155 deletions
|
|
@ -178,7 +178,7 @@
|
||||||
.card-empty {
|
.card-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 16px;
|
padding: 40px 16px;
|
||||||
opacity: 0.55;
|
color: var(--ant-color-text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -188,5 +188,5 @@
|
||||||
.clients-empty {
|
.clients-empty {
|
||||||
padding: 32px 0;
|
padding: 32px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.55;
|
color: var(--ant-color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Pagination,
|
Pagination,
|
||||||
Popover,
|
Popover,
|
||||||
Row,
|
Row,
|
||||||
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
|
@ -36,8 +37,8 @@ import {
|
||||||
RestOutlined,
|
RestOutlined,
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
|
SortAscendingOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined,
|
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
|
@ -108,6 +109,25 @@ function gbToBytes(gb: number | undefined): number {
|
||||||
return Math.round(gb * 1024 * 1024 * 1024);
|
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() {
|
export default function ClientsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
|
|
@ -152,8 +172,8 @@ export default function ClientsPage() {
|
||||||
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
|
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
|
||||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
|
|
||||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
const [sortColumn, setSortColumn] = useState<string | null>(DEFAULT_SORT.column);
|
||||||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
|
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [tablePageSize, setTablePageSize] = useState(25);
|
const [tablePageSize, setTablePageSize] = useState(25);
|
||||||
// debouncedSearch lags behind the input so we don't spam the server on every
|
// debouncedSearch lags behind the input so we don't spam the server on every
|
||||||
|
|
@ -475,151 +495,139 @@ export default function ClientsPage() {
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
}, [isDark, isUltra]);
|
}, [isDark, isUltra]);
|
||||||
|
|
||||||
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag, _filters, sorter) => {
|
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
|
||||||
if (pag?.current) setCurrentPage(pag.current);
|
if (pag?.current) setCurrentPage(pag.current);
|
||||||
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
||||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
|
||||||
setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
|
|
||||||
setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<ClientRecord>>(() => {
|
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
|
||||||
function sortableCol<T extends ColumnsType<ClientRecord>[number]>(col: T, key: string): T {
|
{
|
||||||
return {
|
title: t('pages.clients.actions'),
|
||||||
...col,
|
key: 'actions',
|
||||||
sorter: true,
|
width: 200,
|
||||||
showSorterTooltip: false,
|
render: (_v, record) => (
|
||||||
sortOrder: sortColumn === key ? sortOrder : null,
|
<Space size={4}>
|
||||||
sortDirections: ['ascend', 'descend'],
|
<Tooltip title={t('pages.clients.qrCode')}>
|
||||||
};
|
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: t('pages.clients.actions'),
|
|
||||||
key: 'actions',
|
|
||||||
width: 200,
|
|
||||||
render: (_v, record) => (
|
|
||||||
<Space size={4}>
|
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
|
||||||
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('pages.clients.moreInformation')}>
|
|
||||||
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
|
||||||
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('edit')}>
|
|
||||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('delete')}>
|
|
||||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
sortableCol({
|
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}, 'enable'),
|
|
||||||
{
|
|
||||||
title: t('pages.clients.online'),
|
|
||||||
key: 'online',
|
|
||||||
width: 90,
|
|
||||||
render: (_v, record) => {
|
|
||||||
const bucket = clientBucket(record);
|
|
||||||
if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
|
|
||||||
if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
|
|
||||||
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
|
|
||||||
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
|
|
||||||
return <Tag>{t('pages.clients.offline')}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sortableCol({
|
|
||||||
title: t('pages.clients.client'),
|
|
||||||
key: 'email',
|
|
||||||
render: (_v, record) => (
|
|
||||||
<div className="email-cell">
|
|
||||||
<span className="email">{record.email}</span>
|
|
||||||
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
|
|
||||||
{record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}, 'email'),
|
|
||||||
sortableCol({
|
|
||||||
title: t('pages.clients.attachedInbounds'),
|
|
||||||
key: 'inboundIds',
|
|
||||||
width: 170,
|
|
||||||
render: (_v, record) => {
|
|
||||||
const ids = record.inboundIds || [];
|
|
||||||
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
|
||||||
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
|
||||||
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
|
||||||
const chip = (id: number, compact: boolean) => {
|
|
||||||
const ib = inboundsById[id];
|
|
||||||
const proto = (ib?.protocol || '').toLowerCase();
|
|
||||||
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
|
||||||
const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
|
|
||||||
return (
|
|
||||||
<Tooltip key={id} title={inboundLabel(id)}>
|
|
||||||
<Tag color={color} style={{ margin: 2 }}>
|
|
||||||
{compact ? compactLabel : inboundLabel(id)}
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{visible.map((id) => chip(id, true))}
|
|
||||||
{overflow.length > 0 && (
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="bottomRight"
|
|
||||||
content={
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
|
|
||||||
{overflow.map((id) => chip(id, false))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
|
|
||||||
+{overflow.length}
|
|
||||||
</Tag>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}, 'inboundIds'),
|
|
||||||
sortableCol({
|
|
||||||
title: t('pages.clients.traffic'),
|
|
||||||
key: 'traffic',
|
|
||||||
render: (_v, record) => trafficLabel(record),
|
|
||||||
}, 'traffic'),
|
|
||||||
sortableCol({
|
|
||||||
title: t('pages.clients.remaining'),
|
|
||||||
key: 'remaining',
|
|
||||||
width: 130,
|
|
||||||
render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
|
|
||||||
}, 'remaining'),
|
|
||||||
sortableCol({
|
|
||||||
title: t('pages.clients.duration'),
|
|
||||||
key: 'expiryTime',
|
|
||||||
render: (_v, record) => (
|
|
||||||
<Tooltip title={expiryLabel(record)}>
|
|
||||||
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
<Tooltip title={t('pages.clients.moreInformation')}>
|
||||||
}, 'expiryTime'),
|
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
||||||
];
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||||
|
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('edit')}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('delete')}>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
|
||||||
|
if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
|
||||||
|
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
|
||||||
|
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
|
||||||
|
return <Tag>{t('pages.clients.offline')}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.clients.client'),
|
||||||
|
key: 'email',
|
||||||
|
render: (_v, record) => (
|
||||||
|
<div className="email-cell">
|
||||||
|
<span className="email">{record.email}</span>
|
||||||
|
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
|
||||||
|
{record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.clients.attachedInbounds'),
|
||||||
|
key: 'inboundIds',
|
||||||
|
width: 170,
|
||||||
|
render: (_v, record) => {
|
||||||
|
const ids = record.inboundIds || [];
|
||||||
|
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
||||||
|
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
||||||
|
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
||||||
|
const chip = (id: number, compact: boolean) => {
|
||||||
|
const ib = inboundsById[id];
|
||||||
|
const proto = (ib?.protocol || '').toLowerCase();
|
||||||
|
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
||||||
|
const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
|
||||||
|
return (
|
||||||
|
<Tooltip key={id} title={inboundLabel(id)}>
|
||||||
|
<Tag color={color} style={{ margin: 2 }}>
|
||||||
|
{compact ? compactLabel : inboundLabel(id)}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.map((id) => chip(id, true))}
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
content={
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
|
||||||
|
{overflow.map((id) => chip(id, false))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
|
||||||
|
+{overflow.length}
|
||||||
|
</Tag>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.clients.traffic'),
|
||||||
|
key: 'traffic',
|
||||||
|
render: (_v, record) => trafficLabel(record),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline, inboundsById]);
|
], [t, togglingEmail, clientBucket, isOnline, inboundsById]);
|
||||||
|
|
||||||
const tablePagination = {
|
const tablePagination = {
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
|
|
@ -777,6 +785,18 @@ export default function ClientsPage() {
|
||||||
{!isMobile && t('filter')}
|
{!isMobile && t('filter')}
|
||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Select
|
||||||
|
value={sortValueFor(sortColumn, sortOrder)}
|
||||||
|
size={isMobile ? 'small' : 'middle'}
|
||||||
|
suffixIcon={<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 && (
|
{activeCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size={isMobile ? 'small' : 'middle'}
|
size={isMobile ? 'small' : 'middle'}
|
||||||
|
|
@ -862,8 +882,8 @@ export default function ClientsPage() {
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
<div className="clients-empty">
|
<div className="clients-empty">
|
||||||
<UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||||
<div>{t('pages.clients.empty')}</div>
|
<div>{t('noData')}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|
@ -887,8 +907,8 @@ export default function ClientsPage() {
|
||||||
)}
|
)}
|
||||||
{filteredClients.length === 0 && (
|
{filteredClients.length === 0 && (
|
||||||
<div className="card-empty">
|
<div className="card-empty">
|
||||||
<UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
<TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
||||||
<div>{t('pages.clients.empty')}</div>
|
<div>{t('noData')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filteredClients.length > 0 && (
|
{filteredClients.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -242,12 +242,19 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||||
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
|
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
|
||||||
row.CreatedAt = incoming.CreatedAt
|
row.CreatedAt = incoming.CreatedAt
|
||||||
}
|
}
|
||||||
if incoming.UpdatedAt > row.UpdatedAt {
|
preservedUpdatedAt := row.UpdatedAt
|
||||||
row.UpdatedAt = incoming.UpdatedAt
|
if incoming.UpdatedAt > preservedUpdatedAt {
|
||||||
|
preservedUpdatedAt = incoming.UpdatedAt
|
||||||
}
|
}
|
||||||
|
row.UpdatedAt = preservedUpdatedAt
|
||||||
if err := tx.Save(row).Error; err != nil {
|
if err := tx.Save(row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := tx.Model(&model.ClientRecord{}).
|
||||||
|
Where("id = ?", row.Id).
|
||||||
|
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
link := model.ClientInbound{
|
link := model.ClientInbound{
|
||||||
|
|
@ -648,7 +655,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||||
|
|
||||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Update("updated_at", updated.UpdatedAt).Error; err != nil {
|
UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
|
||||||
return needRestart, err
|
return needRestart, err
|
||||||
}
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
|
|
@ -1343,6 +1350,29 @@ func sortClients(rows []ClientWithAttachments, sortKey, order string) {
|
||||||
eb = b.ExpiryTime
|
eb = b.ExpiryTime
|
||||||
}
|
}
|
||||||
return ea < eb
|
return ea < eb
|
||||||
|
case "createdAt":
|
||||||
|
if a.CreatedAt == b.CreatedAt {
|
||||||
|
return a.Id < b.Id
|
||||||
|
}
|
||||||
|
return a.CreatedAt < b.CreatedAt
|
||||||
|
case "updatedAt":
|
||||||
|
if a.UpdatedAt == b.UpdatedAt {
|
||||||
|
return a.Id < b.Id
|
||||||
|
}
|
||||||
|
return a.UpdatedAt < b.UpdatedAt
|
||||||
|
case "lastOnline":
|
||||||
|
la := int64(0)
|
||||||
|
if a.Traffic != nil {
|
||||||
|
la = a.Traffic.LastOnline
|
||||||
|
}
|
||||||
|
lb := int64(0)
|
||||||
|
if b.Traffic != nil {
|
||||||
|
lb = b.Traffic.LastOnline
|
||||||
|
}
|
||||||
|
if la == lb {
|
||||||
|
return a.Id < b.Id
|
||||||
|
}
|
||||||
|
return la < lb
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1745,11 +1745,12 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Backfill created_at and updated_at
|
|
||||||
if _, ok := c["created_at"]; !ok {
|
if _, ok := c["created_at"]; !ok {
|
||||||
c["created_at"] = time.Now().Unix() * 1000
|
c["created_at"] = time.Now().Unix() * 1000
|
||||||
}
|
}
|
||||||
c["updated_at"] = time.Now().Unix() * 1000
|
if _, ok := c["updated_at"]; !ok {
|
||||||
|
c["updated_at"] = time.Now().Unix() * 1000
|
||||||
|
}
|
||||||
newClients = append(newClients, any(c))
|
newClients = append(newClients, any(c))
|
||||||
}
|
}
|
||||||
settings["clients"] = newClients
|
settings["clients"] = newClients
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"noData": "No data.",
|
"noData": "Nothing here yet",
|
||||||
"copySuccess": "Copied successfully",
|
"copySuccess": "Copied successfully",
|
||||||
"sure": "Sure",
|
"sure": "Sure",
|
||||||
"encryption": "Encryption",
|
"encryption": "Encryption",
|
||||||
|
|
@ -461,6 +461,15 @@
|
||||||
"searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
|
"searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
|
||||||
"filterTitle": "Filter clients",
|
"filterTitle": "Filter clients",
|
||||||
"clearAllFilters": "Clear all",
|
"clearAllFilters": "Clear all",
|
||||||
|
"sortOldest": "Oldest first",
|
||||||
|
"sortNewest": "Newest first",
|
||||||
|
"sortRecentlyUpdated": "Recently updated",
|
||||||
|
"sortRecentlyOnline": "Recently online",
|
||||||
|
"sortEmailAZ": "Email A→Z",
|
||||||
|
"sortEmailZA": "Email Z→A",
|
||||||
|
"sortMostTraffic": "Most traffic",
|
||||||
|
"sortHighestRemaining": "Highest remaining",
|
||||||
|
"sortExpiringSoonest": "Expiring soonest",
|
||||||
"has": "Has",
|
"has": "Has",
|
||||||
"hasNot": "Doesn't have",
|
"hasNot": "Doesn't have",
|
||||||
"title": "Clients",
|
"title": "Clients",
|
||||||
|
|
@ -496,7 +505,6 @@
|
||||||
"resetAllTraffics": "Reset all client traffic",
|
"resetAllTraffics": "Reset all client traffic",
|
||||||
"resetAllTrafficsTitle": "Reset all client traffic?",
|
"resetAllTrafficsTitle": "Reset all client traffic?",
|
||||||
"resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.",
|
"resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.",
|
||||||
"empty": "No clients yet — add one to get started.",
|
|
||||||
"deleteConfirmTitle": "Delete client {email}?",
|
"deleteConfirmTitle": "Delete client {email}?",
|
||||||
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
|
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
|
||||||
"deleteSelected": "Delete ({count})",
|
"deleteSelected": "Delete ({count})",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue