feat(clients): bind host rules to individual clients in subscriptions (#6620)

Host rules applied per inbound only, so every client of an inbound got the same
subscription endpoints (#6618). A client can now be bound to one or more host
rules, and its raw, JSON and Clash subscriptions render only those hosts, in
either subscription order; an unbound client is unchanged. A binding whose hosts
are deleted, disabled or excluded from the format fails closed: the client gets
no links there but stays in traffic accounting. Host edits keep row ids, so
bindings survive them; nil leaves a stored binding alone and an empty list
clears it.
This commit is contained in:
Nethmina Yasas
2026-10-24 16:03:02 +05:30
committed by GitHub
parent d103c0c012
commit bc1b750e00
47 changed files with 1940 additions and 168 deletions
@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { z } from 'zod';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
// One row of the raw hosts table (GET /panel/api/hosts/raw), carrying the
// numeric id that per-client host rule bindings reference.
export const RawHostSchema = z
.object({
id: z.number(),
inboundId: z.number().optional(),
remark: z.string().optional(),
address: z.string().optional(),
port: z.number().optional(),
isDisabled: z.boolean().optional(),
})
.loose();
export type RawHost = z.infer<typeof RawHostSchema>;
const RawHostListSchema = z
.array(RawHostSchema)
.nullable()
.transform((v) => v ?? []);
async function fetchRawHosts(): Promise<RawHost[]> {
const msg = await HttpUtil.get('/panel/api/hosts/raw', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
const validated = parseMsg(msg, RawHostListSchema, 'hosts/raw');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useRawHostsQuery() {
const query = useQuery({
queryKey: keys.hosts.raw(),
queryFn: fetchRawHosts,
});
const hosts = useMemo(() => query.data ?? [], [query.data]);
return {
hosts,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refetch: query.refetch,
};
}