mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
feat(inbounds): show linked host remarks in inbound list (#6468)
* feat(inbounds): show linked host remarks in inbound list Join Host Group remarks from the existing hosts list onto each inbound row client-side so multiple endpoints (IPv4/IPv6/CDN) are visible without opening the inbound. Truncate long lists with a tooltip for the full set. Fixes #6026 * fix(inbounds): skip disabled host groups in inbound list remarks buildHostRemarksByInboundId joined every host group from /hosts/list onto its inbounds, so a group toggled off on the Hosts page still read as a live endpoint in the inbound remark cell and matched the search box. A disabled group serves nothing: internal/sub/host_sub.go filters it out of subscription output and withMtprotoHostEndpoints skips it for MTProto share links. Skip it here the same way, and drop the unread `truncated` field from formatHostRemarksLabel. --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -803,6 +803,7 @@ export default function InboundsPage() {
|
||||
subEnable={subSettings.enable}
|
||||
nodesById={nodesById}
|
||||
hasActiveNode={showNodeInfo}
|
||||
hosts={hosts}
|
||||
onAddInbound={onAddInbound}
|
||||
onGeneralAction={onGeneralAction}
|
||||
onRowAction={({ key, dbInbound }) =>
|
||||
|
||||
@@ -180,3 +180,34 @@
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.inbound-remark-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inbound-remark {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inbound-host-remarks {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
opacity: 0.65;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tag-name .inbound-host-remarks {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,21 @@ import { activateOnKey } from '@/utils/a11y';
|
||||
|
||||
import { buildRowActionsMenu } from './RowActions';
|
||||
import { useInboundColumns } from './useInboundColumns';
|
||||
import { buildHostRemarksByInboundId, formatHostRemarksLabel } from './helpers';
|
||||
import InboundStatsModal from './InboundStatsModal';
|
||||
import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction } from './types';
|
||||
import './InboundList.css';
|
||||
|
||||
function HostRemarksSuffix({ remarks }: { remarks: string[] }) {
|
||||
if (remarks.length === 0) return null;
|
||||
const { display, full } = formatHostRemarksLabel(remarks);
|
||||
return (
|
||||
<Tooltip title={full}>
|
||||
<span className="inbound-host-remarks"> ({display})</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InboundList({
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
@@ -48,6 +59,7 @@ export default function InboundList({
|
||||
subEnable,
|
||||
nodesById,
|
||||
hasActiveNode,
|
||||
hosts,
|
||||
onAddInbound,
|
||||
onGeneralAction,
|
||||
onRowAction,
|
||||
@@ -86,19 +98,22 @@ export default function InboundList({
|
||||
[nodesById, t],
|
||||
);
|
||||
|
||||
const hostRemarksByInboundId = useMemo(() => buildHostRemarksByInboundId(hosts), [hosts]);
|
||||
|
||||
const visibleInbounds = useMemo(() => {
|
||||
let list = dbInbounds;
|
||||
if (nodeFilter === 0) list = list.filter((ib) => ib.nodeId == null);
|
||||
else if (nodeFilter !== 'all') list = list.filter((ib) => ib.nodeId === nodeFilter);
|
||||
const q = searchKey.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter(
|
||||
(ib) =>
|
||||
(ib.remark || '').toLowerCase().includes(q) ||
|
||||
String(ib.port).includes(q) ||
|
||||
(ib.protocol || '').toLowerCase().includes(q),
|
||||
);
|
||||
}, [dbInbounds, nodeFilter, searchKey]);
|
||||
return list.filter((ib) => {
|
||||
if ((ib.remark || '').toLowerCase().includes(q)) return true;
|
||||
if (String(ib.port).includes(q)) return true;
|
||||
if ((ib.protocol || '').toLowerCase().includes(q)) return true;
|
||||
const hostRemarks = hostRemarksByInboundId.get(ib.id) ?? [];
|
||||
return hostRemarks.some((remark) => remark.toLowerCase().includes(q));
|
||||
});
|
||||
}, [dbInbounds, nodeFilter, searchKey, hostRemarksByInboundId]);
|
||||
|
||||
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
|
||||
const previous = dbInbound.enable;
|
||||
@@ -114,8 +129,10 @@ export default function InboundList({
|
||||
}, []);
|
||||
|
||||
const hasAnyRemark = useMemo(
|
||||
() => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
|
||||
[dbInbounds],
|
||||
() =>
|
||||
dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== '') ||
|
||||
dbInbounds.some((i) => (hostRemarksByInboundId.get(i.id)?.length ?? 0) > 0),
|
||||
[dbInbounds, hostRemarksByInboundId],
|
||||
);
|
||||
|
||||
const hasAnySubSortIndex = useMemo(
|
||||
@@ -154,6 +171,7 @@ export default function InboundList({
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
hostRemarksByInboundId,
|
||||
clientCount,
|
||||
inboundSpeed,
|
||||
subEnable,
|
||||
@@ -293,7 +311,10 @@ export default function InboundList({
|
||||
onChange={(e) => toggleSelect(record.id, e.target.checked)}
|
||||
/>
|
||||
<span className="card-id">#{record.id}</span>
|
||||
<span className="tag-name">{record.remark}</span>
|
||||
<span className="tag-name">
|
||||
<span className="inbound-remark">{record.remark}</span>
|
||||
<HostRemarksSuffix remarks={hostRemarksByInboundId.get(record.id) ?? []} />
|
||||
</span>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('pages.inbounds.inboundInfo')}>
|
||||
<InfoCircleOutlined
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||
import type { HostRecord } from '@/schemas/api/host';
|
||||
|
||||
import type { DBInboundRecord, StreamHints } from './types';
|
||||
|
||||
@@ -105,3 +106,41 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Max host remarks shown inline before truncating with "+N". */
|
||||
export const HOST_REMARK_VISIBLE_LIMIT = 2;
|
||||
|
||||
/** Join Host Group remarks onto inbound ids using existing /hosts/list fields. */
|
||||
export function buildHostRemarksByInboundId(
|
||||
hosts: Pick<HostRecord, 'remark' | 'inboundIds' | 'hosts' | 'isDisabled'>[],
|
||||
): Map<number, string[]> {
|
||||
const map = new Map<number, string[]>();
|
||||
for (const host of hosts) {
|
||||
if (host.isDisabled) continue;
|
||||
const addressFallback = Array.isArray(host.hosts)
|
||||
? host.hosts.map((h) => (h || '').trim()).find(Boolean) || ''
|
||||
: '';
|
||||
const label = (host.remark || '').trim() || addressFallback;
|
||||
if (!label) continue;
|
||||
for (const inboundId of host.inboundIds || []) {
|
||||
if (!Number.isFinite(inboundId)) continue;
|
||||
const list = map.get(inboundId) ?? [];
|
||||
if (!list.includes(label)) list.push(label);
|
||||
map.set(inboundId, list);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function formatHostRemarksLabel(
|
||||
remarks: string[],
|
||||
visibleLimit = HOST_REMARK_VISIBLE_LIMIT,
|
||||
): { display: string; full: string } {
|
||||
const full = remarks.join(', ');
|
||||
if (remarks.length <= visibleLimit) {
|
||||
return { display: full, full };
|
||||
}
|
||||
const visible = remarks.slice(0, visibleLimit).join(', ');
|
||||
const more = remarks.length - visibleLimit;
|
||||
return { display: `${visible}, +${more}`, full };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { HostRecord } from '@/schemas/api/host';
|
||||
|
||||
export interface StreamHints {
|
||||
network: string;
|
||||
@@ -78,6 +79,7 @@ export interface InboundListProps {
|
||||
subEnable: boolean;
|
||||
nodesById: Map<number, NodeRecord>;
|
||||
hasActiveNode: boolean;
|
||||
hosts: HostRecord[];
|
||||
onAddInbound: () => void;
|
||||
onGeneralAction: (key: GeneralAction) => void;
|
||||
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
shadowsocksNetworkLabel,
|
||||
tunnelNetworkLabel,
|
||||
mixedNetworkLabel,
|
||||
formatHostRemarksLabel,
|
||||
} from './helpers';
|
||||
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
|
||||
|
||||
@@ -31,6 +32,7 @@ interface UseInboundColumnsParams {
|
||||
hasAnySubSortIndex: boolean;
|
||||
hasActiveNode: boolean;
|
||||
nodesById: Map<number, NodeRecord>;
|
||||
hostRemarksByInboundId: Map<number, string[]>;
|
||||
clientCount: Record<number, ClientCountEntry>;
|
||||
inboundSpeed: Record<number, InboundSpeedEntry>;
|
||||
subEnable: boolean;
|
||||
@@ -45,6 +47,7 @@ export function useInboundColumns({
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
hostRemarksByInboundId,
|
||||
clientCount,
|
||||
inboundSpeed,
|
||||
subEnable,
|
||||
@@ -138,8 +141,23 @@ export function useInboundColumns({
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
width: 140,
|
||||
sorter: (a, b) => compareText(a.remark, b.remark),
|
||||
render: (_, record) => {
|
||||
const hostRemarks = hostRemarksByInboundId.get(record.id) ?? [];
|
||||
if (hostRemarks.length === 0) {
|
||||
return record.remark || null;
|
||||
}
|
||||
const { display, full } = formatHostRemarksLabel(hostRemarks);
|
||||
return (
|
||||
<div className="inbound-remark-cell">
|
||||
<div className="inbound-remark">{record.remark}</div>
|
||||
<Tooltip title={full}>
|
||||
<div className="inbound-host-remarks">({display})</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -453,6 +471,7 @@ export function useInboundColumns({
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
hostRemarksByInboundId,
|
||||
clientCount,
|
||||
inboundSpeed,
|
||||
subEnable,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildHostRemarksByInboundId } from '@/pages/inbounds/list/helpers';
|
||||
|
||||
describe('buildHostRemarksByInboundId', () => {
|
||||
it('joins only host groups a client can reach (#6026)', () => {
|
||||
const map = buildHostRemarksByInboundId([
|
||||
{ remark: 'USA IPv4', inboundIds: [1, 2], hosts: ['1.2.3.4:443'], isDisabled: false },
|
||||
{ remark: 'USA IPv6', inboundIds: [1], hosts: ['[2001:db8::1]:443'], isDisabled: true },
|
||||
{ remark: '', inboundIds: [2], hosts: ['', 'cdn.example.com:443'], isDisabled: false },
|
||||
{ remark: 'USA IPv4', inboundIds: [2], hosts: ['5.6.7.8'] },
|
||||
]);
|
||||
expect(map.get(1)).toEqual(['USA IPv4']);
|
||||
expect(map.get(2)).toEqual(['USA IPv4', 'cdn.example.com:443']);
|
||||
expect(map.has(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user