Przeglądaj źródła

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 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
mrchatam 1 dzień temu
rodzic
commit
939c470698

+ 1 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -803,6 +803,7 @@ export default function InboundsPage() {
                       subEnable={subSettings.enable}
                       nodesById={nodesById}
                       hasActiveNode={showNodeInfo}
+                      hosts={hosts}
                       onAddInbound={onAddInbound}
                       onGeneralAction={onGeneralAction}
                       onRowAction={({ key, dbInbound }) =>

+ 31 - 0
frontend/src/pages/inbounds/list/InboundList.css

@@ -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;
+}

+ 31 - 10
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -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

+ 39 - 0
frontend/src/pages/inbounds/list/helpers.ts

@@ -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 };
+}

+ 2 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -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;

+ 20 - 1
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -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,

+ 17 - 0
frontend/src/test/inbound-host-remarks.test.ts

@@ -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);
+  });
+});