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:
mrchatam
2026-09-13 23:33:50 +03:30
committed by GitHub
parent 4760ccaba0
commit 939c470698
7 changed files with 141 additions and 11 deletions
@@ -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,