mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-28 07:59:35 +00:00
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:
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
84
frontend/src/schemas/client.ts
Normal file
84
frontend/src/schemas/client.ts
Normal 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>;
|
||||
18
frontend/src/schemas/defaults.ts
Normal file
18
frontend/src/schemas/defaults.ts
Normal 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>;
|
||||
19
frontend/src/schemas/inbound.ts
Normal file
19
frontend/src/schemas/inbound.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
|
||||
77
frontend/src/schemas/xray.ts
Normal file
77
frontend/src/schemas/xray.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user