feat(frontend): extend Zod validation to remaining query/mutation hooks

Adds Zod schemas for client/inbound/xray/node-probe endpoints and wires
useNodeMutations, useClients, useInbounds, useXraySetting, useDatepicker
through parseMsg. Drops the duplicated per-file ApiMsg<T> interfaces and
the local ClientRecord / OutboundTrafficRow / XraySettingsValue / DefaultsPayload
declarations in favour of schema-inferred types re-exported from the
new src/schemas/ modules.

API boundary now validates: clients list/paged, clients onlines,
clients lastOnline, clients get/hydrate, inbounds slim, inbounds get,
inbounds options, defaultSettings, xray config, xray outbounds traffic,
xray testOutbound, xray getXrayResult, getDefaultJsonConfig, nodes probe,
nodes test. Mutation responses that consume obj (bulkAdjust, delDepleted,
nodes probe / test) get response validation; pass-through mutations stay
agnostic. NodeFormModal type-aligned to Msg<ProbeResult>.
This commit is contained in:
MHSanaei 2026-05-25 16:14:00 +02:00
parent 6846fac1cc
commit d00ddc3f58
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
12 changed files with 350 additions and 226 deletions

View file

@ -1,21 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils'; import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
interface ApiMsg<T = unknown> { export type { ProbeResult };
success?: boolean;
msg?: string;
obj?: T;
}
export interface ProbeResult {
status: string;
latencyMs?: number;
xrayVersion?: string;
error?: string;
}
export function useNodeMutations() { export function useNodeMutations() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -23,31 +14,33 @@ export function useNodeMutations() {
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (payload: Partial<NodeRecord>) => mutationFn: (payload: Partial<NodeRecord>) =>
HttpUtil.post('/panel/api/nodes/add', payload) as Promise<ApiMsg>, HttpUtil.post('/panel/api/nodes/add', payload),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) => mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as Promise<ApiMsg>, HttpUtil.post(`/panel/api/nodes/update/${id}`, payload),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const removeMut = useMutation({ const removeMut = useMutation({
mutationFn: (id: number) => mutationFn: (id: number) =>
HttpUtil.post(`/panel/api/nodes/del/${id}`) as Promise<ApiMsg>, HttpUtil.post(`/panel/api/nodes/del/${id}`),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const setEnableMut = useMutation({ const setEnableMut = useMutation({
mutationFn: ({ id, enable }: { id: number; enable: boolean }) => mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as Promise<ApiMsg>, HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const probeMut = useMutation({ const probeMut = useMutation({
mutationFn: (id: number) => mutationFn: async (id: number): Promise<Msg<ProbeResult>> => {
HttpUtil.post(`/panel/api/nodes/probe/${id}`) as Promise<ApiMsg<ProbeResult>>, const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
},
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
@ -57,7 +50,9 @@ export function useNodeMutations() {
remove: (id: number) => removeMut.mutateAsync(id), remove: (id: number) => removeMut.mutateAsync(id),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }), setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
probe: (id: number) => probeMut.mutateAsync(id), probe: (id: number) => probeMut.mutateAsync(id),
testConnection: (payload: Partial<NodeRecord>) => testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
HttpUtil.post('/panel/api/nodes/test', payload) as Promise<ApiMsg<ProbeResult>>, const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
},
}; };
} }

View file

@ -1,55 +1,30 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils'; import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import {
ClientHydrateSchema,
ClientPageResponseSchema,
InboundOptionsSchema,
OnlinesSchema,
BulkAdjustResultSchema,
DelDepletedResultSchema,
type ClientHydrate,
type ClientRecord,
type ClientTraffic,
type ClientsSummary,
type ClientPageResponse,
type InboundOption,
type BulkAdjustResult,
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const; const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
export interface ClientTraffic {
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
lastOnline?: number;
}
export interface ClientRecord {
email: string;
subId?: string;
uuid?: string;
password?: string;
auth?: string;
flow?: string;
totalGB?: number;
expiryTime?: number;
limitIp?: number;
tgId?: number | string;
comment?: string;
enable?: boolean;
inboundIds?: number[];
traffic?: ClientTraffic;
reverse?: { tag?: string };
createdAt?: number;
updatedAt?: number;
[key: string]: unknown;
}
export interface InboundOption {
id: number;
remark?: string;
protocol?: string;
port?: number;
tlsFlowCapable?: boolean;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
interface SubSettings { interface SubSettings {
enable: boolean; enable: boolean;
subURI: string; subURI: string;
@ -68,24 +43,6 @@ export interface ClientQueryParams {
order?: 'ascend' | 'descend'; order?: 'ascend' | 'descend';
} }
export interface ClientsSummary {
total: number;
active: number;
online: string[];
depleted: string[];
expiring: string[];
deactive: string[];
}
interface ClientPageResponse {
items: ClientRecord[];
total: number;
filtered: number;
page: number;
pageSize: number;
summary?: ClientsSummary;
}
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
const DEFAULT_SUMMARY: ClientsSummary = { const DEFAULT_SUMMARY: ClientsSummary = {
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [], total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
@ -106,21 +63,25 @@ function buildQS(p: ClientQueryParams): string {
async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> { async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
const qs = buildQS(params); const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }) as ApiMsg<ClientPageResponse>; const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients'); if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
return msg.obj; const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
if (!validated.obj) throw new Error('Empty clients response');
return validated.obj;
} }
async function fetchInboundOptions(): Promise<InboundOption[]> { async function fetchInboundOptions(): Promise<InboundOption[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }) as ApiMsg<InboundOption[]>; const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
return Array.isArray(msg.obj) ? msg.obj : []; const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
return Array.isArray(validated.obj) ? validated.obj : [];
} }
async function fetchDefaults(): Promise<Record<string, unknown>> { async function fetchDefaults(): Promise<Record<string, unknown>> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<Record<string, unknown>>; const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
return msg.obj || {}; const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj || {};
} }
export function useClients() { export function useClients() {
@ -168,9 +129,10 @@ export function useClients() {
const onlinesQuery = useQuery({ const onlinesQuery = useQuery({
queryKey: keys.clients.onlines(), queryKey: keys.clients.onlines(),
queryFn: async () => { queryFn: async () => {
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>; const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
return Array.isArray(msg.obj) ? msg.obj : []; const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
}, },
staleTime: Infinity, staleTime: Infinity,
}); });
@ -208,22 +170,23 @@ export function useClients() {
await invalidateAll(); await invalidateAll();
}, [invalidateAll]); }, [invalidateAll]);
const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => { const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
if (!email) return null; if (!email) return null;
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>; const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
if (!msg?.success || !msg.obj) return null; if (!msg?.success || !msg.obj) return null;
return msg.obj; const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
return validated.obj;
}, []); }, []);
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (payload: unknown) => mutationFn: (payload: unknown) =>
HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as Promise<ApiMsg>, HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) => mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS) as Promise<ApiMsg>, HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
@ -232,7 +195,7 @@ export function useClients() {
const url = keepTraffic const url = keepTraffic
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1` ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
: `/panel/api/clients/del/${encodeURIComponent(email)}`; : `/panel/api/clients/del/${encodeURIComponent(email)}`;
return HttpUtil.post(url) as Promise<ApiMsg>; return HttpUtil.post(url);
}, },
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
@ -242,7 +205,7 @@ export function useClients() {
const suffix = keepTraffic ? '?keepTraffic=1' : ''; const suffix = keepTraffic ? '?keepTraffic=1' : '';
const results = await Promise.all(emails.map((email) => { const results = await Promise.all(emails.map((email) => {
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`; const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>; return HttpUtil.post(url, undefined, { silent: true });
})); }));
return results; return results;
}, },
@ -250,54 +213,55 @@ export function useClients() {
}); });
const bulkAdjustMut = useMutation({ const bulkAdjustMut = useMutation({
mutationFn: (payload: { emails: string[]; addDays: number; addBytes: number }) => mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
HttpUtil.post( const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
'/panel/api/clients/bulkAdjust', return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
payload, },
JSON_HEADERS,
) as Promise<ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>>,
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
const attachMut = useMutation({ const attachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) => mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>, HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
const detachMut = useMutation({ const detachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) => mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>, HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
const resetTrafficMut = useMutation({ const resetTrafficMut = useMutation({
mutationFn: (email: string) => mutationFn: (email: string) =>
HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`) as Promise<ApiMsg>, HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
const resetAllTrafficsMut = useMutation({ const resetAllTrafficsMut = useMutation({
mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics') as Promise<ApiMsg>, mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
const delDepletedMut = useMutation({ const delDepletedMut = useMutation({
mutationFn: () => HttpUtil.post('/panel/api/clients/delDepleted') as Promise<ApiMsg<{ deleted?: number }>>, mutationFn: async () => {
const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
}); });
const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]); const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
const update = useCallback((email: string, client: unknown) => { const update = useCallback((email: string, client: unknown) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg); if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return updateMut.mutateAsync({ email, client }); return updateMut.mutateAsync({ email, client });
}, [updateMut]); }, [updateMut]);
const remove = useCallback((email: string, keepTraffic = false) => { const remove = useCallback((email: string, keepTraffic = false) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg); if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return removeMut.mutateAsync({ email, keepTraffic }); return removeMut.mutateAsync({ email, keepTraffic });
}, [removeMut]); }, [removeMut]);
const removeMany = useCallback((emails: string[], keepTraffic = false) => { const removeMany = useCallback((emails: string[], keepTraffic = false) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as ApiMsg[]); if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as Msg<unknown>[]);
return removeManyMut.mutateAsync({ emails, keepTraffic }); return removeManyMut.mutateAsync({ emails, keepTraffic });
}, [removeManyMut]); }, [removeManyMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => { const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
@ -305,15 +269,15 @@ export function useClients() {
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes }); return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
}, [bulkAdjustMut]); }, [bulkAdjustMut]);
const attach = useCallback((email: string, inboundIds: number[]) => { const attach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg); if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return attachMut.mutateAsync({ email, inboundIds }); return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]); }, [attachMut]);
const detach = useCallback((email: string, inboundIds: number[]) => { const detach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as ApiMsg); if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return detachMut.mutateAsync({ email, inboundIds }); return detachMut.mutateAsync({ email, inboundIds });
}, [detachMut]); }, [detachMut]);
const resetTraffic = useCallback((client: ClientRecord) => { const resetTraffic = useCallback((client: ClientRecord) => {
if (!client?.email) return Promise.resolve(null as unknown as ApiMsg); if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
return resetTrafficMut.mutateAsync(client.email); return resetTrafficMut.mutateAsync(client.email);
}, [resetTrafficMut]); }, [resetTrafficMut]);
const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]); const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);

View file

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
type Calendar = 'gregorian' | 'jalalian'; type Calendar = 'gregorian' | 'jalalian';
@ -20,12 +22,10 @@ async function loadOnce(): Promise<void> {
} }
pending = (async () => { pending = (async () => {
try { try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as { const msg = await HttpUtil.post('/panel/setting/defaultSettings');
success?: boolean;
obj?: { datepicker?: Calendar };
};
if (msg?.success) { if (msg?.success) {
cachedValue = msg.obj?.datepicker || 'gregorian'; const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
cachedValue = validated.obj?.datepicker || 'gregorian';
notify(cachedValue); notify(cachedValue);
} }
} finally { } finally {

View file

@ -1,30 +1,25 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { HttpUtil, PromiseUtil } from '@/utils'; import { HttpUtil, Msg, PromiseUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import {
OutboundTrafficListSchema,
OutboundTestResultSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow,
} from '@/schemas/xray';
const DIRTY_POLL_MS = 1000; const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
export interface OutboundTrafficRow { export type { OutboundTrafficRow, OutboundTestResult };
tag: string;
up: number;
down: number;
}
export interface OutboundTestResult { export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
success: boolean;
delay?: number;
error?: string;
mode?: string;
ttfbMs?: number;
tlsMs?: number;
connectMs?: number;
dnsMs?: number;
statusCode?: number;
endpoints?: { address: string; delay?: number; success: boolean; error?: string }[];
}
export interface OutboundTestState { export interface OutboundTestState {
testing?: boolean; testing?: boolean;
@ -32,23 +27,6 @@ export interface OutboundTestState {
mode?: string; mode?: string;
} }
export interface XraySettingsValue {
inbounds?: unknown[];
outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[];
routing?: {
rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[];
balancers?: unknown[];
domainStrategy?: string;
};
dns?: { tag?: string; servers?: unknown[] };
log?: Record<string, unknown>;
policy?: { system?: Record<string, boolean> };
observatory?: unknown;
burstObservatory?: unknown;
fakedns?: unknown;
[key: string]: unknown;
}
export type SetTemplate = ( export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null), next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void; ) => void;
@ -84,35 +62,32 @@ export interface UseXraySettingResult {
restartXray: () => Promise<void>; restartXray: () => Promise<void>;
} }
interface ApiMsg<T = unknown> { type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
success?: boolean;
obj?: T;
msg?: string;
}
interface XrayConfigPayload {
xraySetting: XraySettingsValue;
inboundTags?: string[];
clientReverseTags?: string[];
outboundTestUrl?: string;
}
async function fetchXrayConfig(): Promise<XrayConfigPayload> { async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg<string>; const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string'); if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
let parsed: unknown;
try { try {
return JSON.parse(msg.obj) as XrayConfigPayload; parsed = JSON.parse(msg.obj);
} catch (e) { } catch (e) {
const err = e as Error; const err = e as Error;
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e }); throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
} }
const result = XrayConfigPayloadSchema.safeParse(parsed);
if (!result.success) {
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
return parsed as XrayConfigPayload;
}
return result.data;
} }
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> { async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg<OutboundTrafficRow[]>; const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
return Array.isArray(msg.obj) ? msg.obj : []; const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
return Array.isArray(validated.obj) ? validated.obj : [];
} }
export function useXraySetting(): UseXraySettingResult { export function useXraySetting(): UseXraySettingResult {
@ -219,7 +194,7 @@ export function useXraySetting(): UseXraySettingResult {
HttpUtil.post('/panel/xray/update', { HttpUtil.post('/panel/xray/update', {
xraySetting: xraySettingRef.current, xraySetting: xraySettingRef.current,
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL, outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
}) as Promise<ApiMsg>, }),
onSuccess: (msg) => { onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() }); if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, },
@ -227,7 +202,7 @@ export function useXraySetting(): UseXraySettingResult {
const resetTrafficMut = useMutation({ const resetTrafficMut = useMutation({
mutationFn: (tag: string) => mutationFn: (tag: string) =>
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise<ApiMsg>, HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
onSuccess: (msg) => { onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() }); if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
}, },
@ -235,17 +210,21 @@ export function useXraySetting(): UseXraySettingResult {
const restartMut = useMutation({ const restartMut = useMutation({
mutationFn: async () => { mutationFn: async () => {
const msg = await HttpUtil.post('/panel/api/server/restartXrayService') as ApiMsg; const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
if (!msg?.success) return msg; if (!msg?.success) return msg;
await PromiseUtil.sleep(500); await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg<string>; const r = await HttpUtil.get('/panel/xray/getXrayResult');
if (r?.success) setRestartResult(r.obj || ''); const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
if (validated?.success) setRestartResult(validated.obj || '');
return msg; return msg;
}, },
}); });
const resetDefaultMut = useMutation({ const resetDefaultMut = useMutation({
mutationFn: async () => HttpUtil.get('/panel/setting/getDefaultJsonConfig') as Promise<ApiMsg<XraySettingsValue>>, mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
},
onSuccess: (msg) => { onSuccess: (msg) => {
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
const cloned = JSON.parse(JSON.stringify(msg.obj)); const cloned = JSON.parse(JSON.stringify(msg.obj));
@ -269,15 +248,16 @@ export function useXraySetting(): UseXraySettingResult {
[index]: { testing: true, result: null, mode }, [index]: { testing: true, result: null, mode },
})); }));
try { try {
const msg = await HttpUtil.post('/panel/xray/testOutbound', { const raw = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound), outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode, mode,
}) as ApiMsg<OutboundTestResult>; });
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setOutboundTestStates((prev) => ({ setOutboundTestStates((prev) => ({
...prev, ...prev,
[index]: { testing: false, result: msg.obj as OutboundTestResult }, [index]: { testing: false, result: msg.obj },
})); }));
return msg.obj; return msg.obj;
} }

View file

@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DBInbound } from '@/models/dbinbound'; import { DBInbound } from '@/models/dbinbound';
import { Protocols } from '@/models/inbound'; import { Protocols } from '@/models/inbound';
import { setDatepicker } from '@/hooks/useDatepicker'; import { setDatepicker } from '@/hooks/useDatepicker';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
import { OnlinesSchema } from '@/schemas/client';
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
export interface SubSettings { export interface SubSettings {
enable: boolean; enable: boolean;
@ -27,27 +31,6 @@ interface ClientRollup {
comments: Map<string, string>; comments: Map<string, string>;
} }
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
msg?: string;
}
interface DefaultsPayload {
expireDiff?: number;
trafficDiff?: number;
tgBotEnable?: boolean;
subEnable?: boolean;
subTitle?: string;
subURI?: string;
subJsonURI?: string;
subJsonEnable?: boolean;
pageSize?: number;
remarkModel?: string;
datepicker?: string;
ipLimitEnable?: boolean;
}
const TRACKED_PROTOCOLS = [ const TRACKED_PROTOCOLS = [
Protocols.VMESS, Protocols.VMESS,
Protocols.VLESS, Protocols.VLESS,
@ -57,27 +40,31 @@ const TRACKED_PROTOCOLS = [
]; ];
async function fetchSlimInbounds(): Promise<unknown[]> { async function fetchSlimInbounds(): Promise<unknown[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>; const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
return Array.isArray(msg.obj) ? msg.obj : []; const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim');
return Array.isArray(validated.obj) ? validated.obj : [];
} }
async function fetchOnlineClients(): Promise<string[]> { async function fetchOnlineClients(): Promise<string[]> {
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>; const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
return Array.isArray(msg.obj) ? msg.obj : []; const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
} }
async function fetchLastOnlineMap(): Promise<Record<string, number>> { async function fetchLastOnlineMap(): Promise<Record<string, number>> {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>; const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {}; const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {};
} }
async function fetchDefaultSettings(): Promise<DefaultsPayload> { async function fetchDefaultSettings(): Promise<DefaultsPayload> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>; const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
return (msg.obj as DefaultsPayload) || {}; const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj ?? {};
} }
export function useInbounds() { export function useInbounds() {
@ -272,8 +259,9 @@ export function useInbounds() {
const hydrateInbound = useCallback(async (id: number) => { const hydrateInbound = useCallback(async (id: number) => {
const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`); const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
if (!msg?.success || !msg.obj) return null; if (!msg?.success || !msg.obj) return null;
const full = msg.obj as { id: number; protocol: string }; const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
const dbInbound = new DBInbound(full) as DBInboundInstance; if (!validated.obj) return null;
const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
setDbInbounds((prev) => { setDbInbounds((prev) => {
const next = prev.map((row) => ( const next = prev.map((row) => (
(row as unknown as { id: number }).id === id ? dbInbound : row (row as unknown as { id: number }).id === id ? dbInbound : row

View file

@ -14,27 +14,18 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { Msg } from '@/utils';
import type { ProbeResult } from '@/schemas/node';
import './NodeFormModal.css'; import './NodeFormModal.css';
type Mode = 'add' | 'edit'; type Mode = 'add' | 'edit';
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
interface NodeFormModalProps { interface NodeFormModalProps {
open: boolean; open: boolean;
mode: Mode; mode: Mode;
node: NodeRecord | null; node: NodeRecord | null;
testConnection: (payload: Partial<NodeRecord>) => Promise<ApiMsg<{ testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
status: string; save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
latencyMs?: number;
xrayVersion?: string;
error?: string;
}>>;
save: (payload: Partial<NodeRecord>) => Promise<ApiMsg>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }

View file

@ -130,7 +130,7 @@ export default function OutboundsTab({
const [existingTags, setExistingTags] = useState<string[]>([]); const [existingTags, setExistingTags] = useState<string[]>([]);
const outbounds = useMemo( const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as OutboundRow[], () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
[templateSettings?.outbounds], [templateSettings?.outbounds],
); );

View file

@ -0,0 +1,84 @@
import { z } from 'zod';
export const ClientTrafficSchema = z.object({
up: z.number().optional(),
down: z.number().optional(),
total: z.number().optional(),
expiryTime: z.number().optional(),
enable: z.boolean().optional(),
lastOnline: z.number().optional(),
});
export const ClientRecordSchema = z.object({
email: z.string(),
subId: z.string().optional(),
uuid: z.string().optional(),
password: z.string().optional(),
auth: z.string().optional(),
flow: z.string().optional(),
totalGB: z.number().optional(),
expiryTime: z.number().optional(),
limitIp: z.number().optional(),
tgId: z.union([z.number(), z.string()]).optional(),
comment: z.string().optional(),
enable: z.boolean().optional(),
inboundIds: z.array(z.number()).optional(),
traffic: ClientTrafficSchema.optional(),
reverse: z.object({ tag: z.string().optional() }).loose().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
}).loose();
export const InboundOptionSchema = z.object({
id: z.number(),
remark: z.string().optional(),
protocol: z.string().optional(),
port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(),
}).loose();
export const InboundOptionsSchema = z.array(InboundOptionSchema);
export const ClientsSummarySchema = z.object({
total: z.number(),
active: z.number(),
online: z.array(z.string()),
depleted: z.array(z.string()),
expiring: z.array(z.string()),
deactive: z.array(z.string()),
});
export const ClientPageResponseSchema = z.object({
items: z.array(ClientRecordSchema),
total: z.number(),
filtered: z.number(),
page: z.number(),
pageSize: z.number(),
summary: ClientsSummarySchema.optional(),
});
export const ClientHydrateSchema = z.object({
client: ClientRecordSchema,
inboundIds: z.array(z.number()),
});
export const BulkAdjustResultSchema = z.object({
adjusted: z.number(),
skipped: z
.array(z.object({ email: z.string(), reason: z.string() }))
.optional(),
});
export const DelDepletedResultSchema = z.object({
deleted: z.number().optional(),
});
export const OnlinesSchema = z.array(z.string());
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
export type InboundOption = z.infer<typeof InboundOptionSchema>;
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;

View file

@ -0,0 +1,18 @@
import { z } from 'zod';
export const DefaultsPayloadSchema = z.object({
expireDiff: z.number().optional(),
trafficDiff: z.number().optional(),
tgBotEnable: z.boolean().optional(),
subEnable: z.boolean().optional(),
subTitle: z.string().optional(),
subURI: z.string().optional(),
subJsonURI: z.string().optional(),
subJsonEnable: z.boolean().optional(),
pageSize: z.number().optional(),
remarkModel: z.string().optional(),
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
ipLimitEnable: z.boolean().optional(),
}).loose();
export type DefaultsPayload = z.infer<typeof DefaultsPayloadSchema>;

View file

@ -0,0 +1,19 @@
import { z } from 'zod';
export const SlimInboundSchema = z.object({
id: z.number(),
protocol: z.string(),
}).loose();
export const SlimInboundListSchema = z.array(SlimInboundSchema);
export const InboundDetailSchema = z.object({
id: z.number(),
protocol: z.string(),
}).loose();
export const LastOnlineMapSchema = z.record(z.string(), z.number());
export type SlimInbound = z.infer<typeof SlimInboundSchema>;
export type InboundDetail = z.infer<typeof InboundDetailSchema>;
export type LastOnlineMap = z.infer<typeof LastOnlineMapSchema>;

View file

@ -28,4 +28,12 @@ export const NodeRecordSchema = z.object({
export const NodeListSchema = z.array(NodeRecordSchema); export const NodeListSchema = z.array(NodeRecordSchema);
export const ProbeResultSchema = z.object({
status: z.string(),
latencyMs: z.number().optional(),
xrayVersion: z.string().optional(),
error: z.string().optional(),
}).loose();
export type NodeRecord = z.infer<typeof NodeRecordSchema>; export type NodeRecord = z.infer<typeof NodeRecordSchema>;
export type ProbeResult = z.infer<typeof ProbeResultSchema>;

View file

@ -0,0 +1,77 @@
import { z } from 'zod';
export const XraySettingsValueSchema = z.object({
inbounds: z.array(z.unknown()).optional(),
outbounds: z
.array(
z.object({
tag: z.string().optional(),
protocol: z.string().optional(),
settings: z.unknown().optional(),
streamSettings: z.unknown().optional(),
}).loose(),
)
.optional(),
routing: z.object({
rules: z.array(z.object({
type: z.string().optional(),
outboundTag: z.string().optional(),
balancerTag: z.string().optional(),
}).loose()).optional(),
balancers: z.array(z.unknown()).optional(),
domainStrategy: z.string().optional(),
}).loose().optional(),
dns: z.object({
tag: z.string().optional(),
servers: z.array(z.unknown()).optional(),
}).loose().optional(),
log: z.record(z.string(), z.unknown()).optional(),
policy: z.object({
system: z.record(z.string(), z.boolean()).optional(),
}).loose().optional(),
observatory: z.unknown().optional(),
burstObservatory: z.unknown().optional(),
fakedns: z.unknown().optional(),
}).loose();
export const XrayConfigPayloadSchema = z.object({
xraySetting: XraySettingsValueSchema,
inboundTags: z.array(z.string()).optional(),
clientReverseTags: z.array(z.string()).optional(),
outboundTestUrl: z.string().optional(),
}).loose();
export const OutboundTrafficRowSchema = z.object({
tag: z.string(),
up: z.number(),
down: z.number(),
});
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
export const OutboundTestResultSchema = z.object({
success: z.boolean(),
delay: z.number().optional(),
error: z.string().optional(),
mode: z.string().optional(),
ttfbMs: z.number().optional(),
tlsMs: z.number().optional(),
connectMs: z.number().optional(),
dnsMs: z.number().optional(),
statusCode: z.number().optional(),
endpoints: z
.array(
z.object({
address: z.string(),
delay: z.number().optional(),
success: z.boolean(),
error: z.string().optional(),
}).loose(),
)
.optional(),
}).loose();
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
export type OutboundTestResult = z.infer<typeof OutboundTestResultSchema>;