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
12 changed files with 350 additions and 226 deletions

View File

@@ -1,21 +1,12 @@
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 type { NodeRecord } from '@/api/queries/useNodesQuery';
import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
export interface ProbeResult {
status: string;
latencyMs?: number;
xrayVersion?: string;
error?: string;
}
export type { ProbeResult };
export function useNodeMutations() {
const queryClient = useQueryClient();
@@ -23,31 +14,33 @@ export function useNodeMutations() {
const createMut = useMutation({
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(); },
});
const updateMut = useMutation({
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(); },
});
const removeMut = useMutation({
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(); },
});
const setEnableMut = useMutation({
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(); },
});
const probeMut = useMutation({
mutationFn: (id: number) =>
HttpUtil.post(`/panel/api/nodes/probe/${id}`) as Promise<ApiMsg<ProbeResult>>,
mutationFn: async (id: number): Promise<Msg<ProbeResult>> => {
const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
},
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
@@ -57,7 +50,9 @@ export function useNodeMutations() {
remove: (id: number) => removeMut.mutateAsync(id),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
probe: (id: number) => probeMut.mutateAsync(id),
testConnection: (payload: Partial<NodeRecord>) =>
HttpUtil.post('/panel/api/nodes/test', payload) as Promise<ApiMsg<ProbeResult>>,
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<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 { 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 {
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;
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 {
enable: boolean;
subURI: string;
@@ -68,24 +43,6 @@ export interface ClientQueryParams {
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_SUMMARY: ClientsSummary = {
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
@@ -106,21 +63,25 @@ function buildQS(p: ClientQueryParams): string {
async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
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');
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[]> {
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');
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>> {
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');
return msg.obj || {};
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj || {};
}
export function useClients() {
@@ -168,9 +129,10 @@ export function useClients() {
const onlinesQuery = useQuery({
queryKey: keys.clients.onlines(),
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');
return Array.isArray(msg.obj) ? msg.obj : [];
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
},
staleTime: Infinity,
});
@@ -208,22 +170,23 @@ export function useClients() {
await 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;
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;
return msg.obj;
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
return validated.obj;
}, []);
const createMut = useMutation({
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(); },
});
const updateMut = useMutation({
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(); },
});
@@ -232,7 +195,7 @@ export function useClients() {
const url = keepTraffic
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
return HttpUtil.post(url) as Promise<ApiMsg>;
return HttpUtil.post(url);
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
@@ -242,7 +205,7 @@ export function useClients() {
const suffix = keepTraffic ? '?keepTraffic=1' : '';
const results = await Promise.all(emails.map((email) => {
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;
},
@@ -250,54 +213,55 @@ export function useClients() {
});
const bulkAdjustMut = useMutation({
mutationFn: (payload: { emails: string[]; addDays: number; addBytes: number }) =>
HttpUtil.post(
'/panel/api/clients/bulkAdjust',
payload,
JSON_HEADERS,
) as Promise<ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>>,
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const attachMut = useMutation({
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(); },
});
const detachMut = useMutation({
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(); },
});
const resetTrafficMut = useMutation({
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(); },
});
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(); },
});
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(); },
});
const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
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 });
}, [updateMut]);
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 });
}, [removeMut]);
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 });
}, [removeManyMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
@@ -305,15 +269,15 @@ export function useClients() {
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
}, [bulkAdjustMut]);
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 });
}, [attachMut]);
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 });
}, [detachMut]);
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);
}, [resetTrafficMut]);
const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);

View File

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

View File

@@ -1,30 +1,25 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 {
OutboundTrafficListSchema,
OutboundTestResultSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow,
} from '@/schemas/xray';
const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
export interface OutboundTrafficRow {
tag: string;
up: number;
down: number;
}
export type { OutboundTrafficRow, OutboundTestResult };
export interface OutboundTestResult {
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 type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export interface OutboundTestState {
testing?: boolean;
@@ -32,23 +27,6 @@ export interface OutboundTestState {
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 = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void;
@@ -84,35 +62,32 @@ export interface UseXraySettingResult {
restartXray: () => Promise<void>;
}
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
msg?: string;
}
interface XrayConfigPayload {
xraySetting: XraySettingsValue;
inboundTags?: string[];
clientReverseTags?: string[];
outboundTestUrl?: string;
}
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
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 (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
let parsed: unknown;
try {
return JSON.parse(msg.obj) as XrayConfigPayload;
parsed = JSON.parse(msg.obj);
} catch (e) {
const err = e as Error;
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[]> {
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');
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 {
@@ -219,7 +194,7 @@ export function useXraySetting(): UseXraySettingResult {
HttpUtil.post('/panel/xray/update', {
xraySetting: xraySettingRef.current,
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
}) as Promise<ApiMsg>,
}),
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
@@ -227,7 +202,7 @@ export function useXraySetting(): UseXraySettingResult {
const resetTrafficMut = useMutation({
mutationFn: (tag: string) =>
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise<ApiMsg>,
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
},
@@ -235,17 +210,21 @@ export function useXraySetting(): UseXraySettingResult {
const restartMut = useMutation({
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;
await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg<string>;
if (r?.success) setRestartResult(r.obj || '');
const r = await HttpUtil.get('/panel/xray/getXrayResult');
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
if (validated?.success) setRestartResult(validated.obj || '');
return msg;
},
});
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) => {
if (msg?.success && msg.obj) {
const cloned = JSON.parse(JSON.stringify(msg.obj));
@@ -269,15 +248,16 @@ export function useXraySetting(): UseXraySettingResult {
[index]: { testing: true, result: null, mode },
}));
try {
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
const raw = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode,
}) as ApiMsg<OutboundTestResult>;
});
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) {
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: false, result: msg.obj as OutboundTestResult },
[index]: { testing: false, result: 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 { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DBInbound } from '@/models/dbinbound';
import { Protocols } from '@/models/inbound';
import { setDatepicker } from '@/hooks/useDatepicker';
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 {
enable: boolean;
@@ -27,27 +31,6 @@ interface ClientRollup {
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 = [
Protocols.VMESS,
Protocols.VLESS,
@@ -57,27 +40,31 @@ const TRACKED_PROTOCOLS = [
];
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');
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[]> {
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');
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>> {
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');
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> {
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');
return (msg.obj as DefaultsPayload) || {};
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj ?? {};
}
export function useInbounds() {
@@ -272,8 +259,9 @@ export function useInbounds() {
const hydrateInbound = useCallback(async (id: number) => {
const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
if (!msg?.success || !msg.obj) return null;
const full = msg.obj as { id: number; protocol: string };
const dbInbound = new DBInbound(full) as DBInboundInstance;
const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
if (!validated.obj) return null;
const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
setDbInbounds((prev) => {
const next = prev.map((row) => (
(row as unknown as { id: number }).id === id ? dbInbound : row

View File

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

View File

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