mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
feat(frontend): add Zod runtime validation at API boundary
Introduces Zod 4 schemas for response validation on the three highest-traffic endpoints (server/status, nodes/list, setting/all) and a Zod->AntD form rule adapter, replacing the duplicated per-file ApiMsg<T> interfaces. Validation runs safeParse with console.warn + raw-payload fallback so backend drift never breaks the UI for users. Login form switches to schema-driven rules as the proof-of-life for the adapter. Class-based models stay untouched; remaining query/mutation hooks and form modals will migrate in follow-ups.
This commit is contained in:
parent
20edaee8ed
commit
6846fac1cc
13 changed files with 266 additions and 58 deletions
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -26,7 +26,8 @@
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6"
|
"swagger-ui-react": "^5.32.6",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
@ -6819,7 +6820,6 @@
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6"
|
"swagger-ui-react": "^5.32.6",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,17 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
|
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
interface ApiMsg<T = unknown> {
|
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||||
success?: boolean;
|
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
|
||||||
obj?: T;
|
|
||||||
msg?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllSetting(): Promise<unknown> {
|
|
||||||
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }) as ApiMsg;
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
||||||
return msg.obj;
|
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
|
||||||
|
return validated.obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAllSettings() {
|
export function useAllSettings() {
|
||||||
|
|
@ -45,8 +42,13 @@ export function useAllSettings() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async (next: AllSetting) =>
|
mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
|
||||||
HttpUtil.post('/panel/setting/update', next) as Promise<ApiMsg>,
|
const body = AllSettingSchema.partial().safeParse(next);
|
||||||
|
if (!body.success) {
|
||||||
|
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||||
|
}
|
||||||
|
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
|
||||||
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { NodeListSchema } from '@/schemas/node';
|
||||||
|
import type { NodeRecord } from '@/schemas/node';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
export interface NodeRecord {
|
export type { NodeRecord };
|
||||||
id: number;
|
|
||||||
name?: string;
|
|
||||||
remark?: string;
|
|
||||||
scheme?: string;
|
|
||||||
address?: string;
|
|
||||||
port?: number;
|
|
||||||
basePath?: string;
|
|
||||||
apiToken?: string;
|
|
||||||
enable?: boolean;
|
|
||||||
status?: 'online' | 'offline' | string;
|
|
||||||
latencyMs?: number;
|
|
||||||
cpuPct?: number;
|
|
||||||
memPct?: number;
|
|
||||||
xrayVersion?: string;
|
|
||||||
panelVersion?: string;
|
|
||||||
uptimeSecs?: number;
|
|
||||||
inboundCount?: number;
|
|
||||||
clientCount?: number;
|
|
||||||
onlineCount?: number;
|
|
||||||
depletedCount?: number;
|
|
||||||
lastHeartbeat?: number;
|
|
||||||
lastError?: string;
|
|
||||||
allowPrivateAddress?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeTotals {
|
export interface NodeTotals {
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -42,16 +20,11 @@ export interface NodeTotals {
|
||||||
depleted: number;
|
depleted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiMsg<T = unknown> {
|
|
||||||
success?: boolean;
|
|
||||||
msg?: string;
|
|
||||||
obj?: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNodes(): Promise<NodeRecord[]> {
|
async function fetchNodes(): Promise<NodeRecord[]> {
|
||||||
const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true }) as ApiMsg<NodeRecord[]>;
|
const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes');
|
||||||
return Array.isArray(msg.obj) ? msg.obj : [];
|
const validated = parseMsg(msg, NodeListSchema, 'nodes/list');
|
||||||
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNodesQuery() {
|
export function useNodesQuery() {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
import { Status } from '@/models/status';
|
import { Status } from '@/models/status';
|
||||||
|
import { StatusSchema } from '@/schemas/status';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 2000;
|
const POLL_INTERVAL_MS = 2000;
|
||||||
|
|
@ -10,7 +12,8 @@ const POLL_INTERVAL_MS = 2000;
|
||||||
async function fetchStatus(): Promise<Status> {
|
async function fetchStatus(): Promise<Status> {
|
||||||
const msg = await HttpUtil.get('/panel/api/server/status', undefined, { silent: true });
|
const msg = await HttpUtil.get('/panel/api/server/status', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch status');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch status');
|
||||||
return new Status(msg.obj);
|
const validated = parseMsg(msg, StatusSchema, 'server/status');
|
||||||
|
return new Status(validated.obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStatusQuery() {
|
export function useStatusQuery() {
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,15 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil, LanguageManager } from '@/utils';
|
import { HttpUtil, LanguageManager } from '@/utils';
|
||||||
|
import { antdRule } from '@/utils/zodForm';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
|
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
const HEADLINE_INTERVAL_MS = 2000;
|
const HEADLINE_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
interface LoginForm {
|
type LoginForm = LoginFormValues;
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
twoFactorCode?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const basePath = window.X_UI_BASE_PATH || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
|
||||||
|
|
@ -191,7 +189,7 @@ export default function LoginPage() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('username')}
|
label={t('username')}
|
||||||
name="username"
|
name="username"
|
||||||
rules={[{ required: true, message: t('username') }]}
|
rules={[antdRule(LoginFormSchema.shape.username, t)]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
prefix={<UserOutlined />}
|
prefix={<UserOutlined />}
|
||||||
|
|
@ -205,7 +203,7 @@ export default function LoginPage() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('password')}
|
label={t('password')}
|
||||||
name="password"
|
name="password"
|
||||||
rules={[{ required: true, message: t('password') }]}
|
rules={[antdRule(LoginFormSchema.shape.password, t)]}
|
||||||
>
|
>
|
||||||
<Input.Password
|
<Input.Password
|
||||||
prefix={<LockOutlined />}
|
prefix={<LockOutlined />}
|
||||||
|
|
@ -219,7 +217,7 @@ export default function LoginPage() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('twoFactorCode')}
|
label={t('twoFactorCode')}
|
||||||
name="twoFactorCode"
|
name="twoFactorCode"
|
||||||
rules={[{ required: true, message: t('twoFactorCode') }]}
|
rules={[antdRule(TwoFactorCodeSchema, t)]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
prefix={<KeyOutlined />}
|
prefix={<KeyOutlined />}
|
||||||
|
|
|
||||||
10
frontend/src/schemas/_envelope.ts
Normal file
10
frontend/src/schemas/_envelope.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const msgSchema = <T extends z.ZodTypeAny>(obj: T) =>
|
||||||
|
z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
msg: z.string().default(''),
|
||||||
|
obj: obj.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MsgOf<S extends z.ZodTypeAny> = z.infer<ReturnType<typeof msgSchema<S>>>;
|
||||||
11
frontend/src/schemas/login.ts
Normal file
11
frontend/src/schemas/login.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const LoginFormSchema = z.object({
|
||||||
|
username: z.string().min(1, 'username'),
|
||||||
|
password: z.string().min(1, 'password'),
|
||||||
|
twoFactorCode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TwoFactorCodeSchema = z.string().min(1, 'twoFactorCode');
|
||||||
|
|
||||||
|
export type LoginFormValues = z.infer<typeof LoginFormSchema>;
|
||||||
31
frontend/src/schemas/node.ts
Normal file
31
frontend/src/schemas/node.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const NodeRecordSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
remark: z.string().optional(),
|
||||||
|
scheme: z.string().optional(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
port: z.number().optional(),
|
||||||
|
basePath: z.string().optional(),
|
||||||
|
apiToken: z.string().optional(),
|
||||||
|
enable: z.boolean().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
latencyMs: z.number().optional(),
|
||||||
|
cpuPct: z.number().optional(),
|
||||||
|
memPct: z.number().optional(),
|
||||||
|
xrayVersion: z.string().optional(),
|
||||||
|
panelVersion: z.string().optional(),
|
||||||
|
uptimeSecs: z.number().optional(),
|
||||||
|
inboundCount: z.number().optional(),
|
||||||
|
clientCount: z.number().optional(),
|
||||||
|
onlineCount: z.number().optional(),
|
||||||
|
depletedCount: z.number().optional(),
|
||||||
|
lastHeartbeat: z.number().optional(),
|
||||||
|
lastError: z.string().optional(),
|
||||||
|
allowPrivateAddress: z.boolean().optional(),
|
||||||
|
}).loose();
|
||||||
|
|
||||||
|
export const NodeListSchema = z.array(NodeRecordSchema);
|
||||||
|
|
||||||
|
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||||
90
frontend/src/schemas/setting.ts
Normal file
90
frontend/src/schemas/setting.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const AllSettingSchema = z.object({
|
||||||
|
webListen: z.string().optional(),
|
||||||
|
webDomain: z.string().optional(),
|
||||||
|
webPort: z.number().optional(),
|
||||||
|
webCertFile: z.string().optional(),
|
||||||
|
webKeyFile: z.string().optional(),
|
||||||
|
webBasePath: z.string().optional(),
|
||||||
|
sessionMaxAge: z.number().optional(),
|
||||||
|
trustedProxyCIDRs: z.string().optional(),
|
||||||
|
pageSize: z.number().optional(),
|
||||||
|
expireDiff: z.number().optional(),
|
||||||
|
trafficDiff: z.number().optional(),
|
||||||
|
remarkModel: z.string().optional(),
|
||||||
|
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||||
|
tgBotEnable: z.boolean().optional(),
|
||||||
|
tgBotToken: z.string().optional(),
|
||||||
|
tgBotProxy: z.string().optional(),
|
||||||
|
tgBotAPIServer: z.string().optional(),
|
||||||
|
tgBotChatId: z.string().optional(),
|
||||||
|
tgRunTime: z.string().optional(),
|
||||||
|
tgBotBackup: z.boolean().optional(),
|
||||||
|
tgBotLoginNotify: z.boolean().optional(),
|
||||||
|
tgCpu: z.number().optional(),
|
||||||
|
tgLang: z.string().optional(),
|
||||||
|
twoFactorEnable: z.boolean().optional(),
|
||||||
|
twoFactorToken: z.string().optional(),
|
||||||
|
xrayTemplateConfig: z.string().optional(),
|
||||||
|
subEnable: z.boolean().optional(),
|
||||||
|
subJsonEnable: z.boolean().optional(),
|
||||||
|
subTitle: z.string().optional(),
|
||||||
|
subSupportUrl: z.string().optional(),
|
||||||
|
subProfileUrl: z.string().optional(),
|
||||||
|
subAnnounce: z.string().optional(),
|
||||||
|
subEnableRouting: z.boolean().optional(),
|
||||||
|
subRoutingRules: z.string().optional(),
|
||||||
|
subListen: z.string().optional(),
|
||||||
|
subPort: z.number().optional(),
|
||||||
|
subPath: z.string().optional(),
|
||||||
|
subJsonPath: z.string().optional(),
|
||||||
|
subClashEnable: z.boolean().optional(),
|
||||||
|
subClashPath: z.string().optional(),
|
||||||
|
subDomain: z.string().optional(),
|
||||||
|
externalTrafficInformEnable: z.boolean().optional(),
|
||||||
|
externalTrafficInformURI: z.string().optional(),
|
||||||
|
restartXrayOnClientDisable: z.boolean().optional(),
|
||||||
|
subCertFile: z.string().optional(),
|
||||||
|
subKeyFile: z.string().optional(),
|
||||||
|
subUpdates: z.number().optional(),
|
||||||
|
subEncrypt: z.boolean().optional(),
|
||||||
|
subShowInfo: z.boolean().optional(),
|
||||||
|
subEmailInRemark: z.boolean().optional(),
|
||||||
|
subURI: z.string().optional(),
|
||||||
|
subJsonURI: z.string().optional(),
|
||||||
|
subClashURI: z.string().optional(),
|
||||||
|
subJsonFragment: z.string().optional(),
|
||||||
|
subJsonNoises: z.string().optional(),
|
||||||
|
subJsonMux: z.string().optional(),
|
||||||
|
subJsonRules: z.string().optional(),
|
||||||
|
timeLocation: z.string().optional(),
|
||||||
|
ldapEnable: z.boolean().optional(),
|
||||||
|
ldapHost: z.string().optional(),
|
||||||
|
ldapPort: z.number().optional(),
|
||||||
|
ldapUseTLS: z.boolean().optional(),
|
||||||
|
ldapBindDN: z.string().optional(),
|
||||||
|
ldapPassword: z.string().optional(),
|
||||||
|
ldapBaseDN: z.string().optional(),
|
||||||
|
ldapUserFilter: z.string().optional(),
|
||||||
|
ldapUserAttr: z.string().optional(),
|
||||||
|
ldapVlessField: z.string().optional(),
|
||||||
|
ldapSyncCron: z.string().optional(),
|
||||||
|
ldapFlagField: z.string().optional(),
|
||||||
|
ldapTruthyValues: z.string().optional(),
|
||||||
|
ldapInvertFlag: z.boolean().optional(),
|
||||||
|
ldapInboundTags: z.string().optional(),
|
||||||
|
ldapAutoCreate: z.boolean().optional(),
|
||||||
|
ldapAutoDelete: z.boolean().optional(),
|
||||||
|
ldapDefaultTotalGB: z.number().optional(),
|
||||||
|
ldapDefaultExpiryDays: z.number().optional(),
|
||||||
|
ldapDefaultLimitIP: z.number().optional(),
|
||||||
|
hasTgBotToken: z.boolean().optional(),
|
||||||
|
hasTwoFactorToken: z.boolean().optional(),
|
||||||
|
hasLdapPassword: z.boolean().optional(),
|
||||||
|
hasApiToken: z.boolean().optional(),
|
||||||
|
hasWarpSecret: z.boolean().optional(),
|
||||||
|
hasNordSecret: z.boolean().optional(),
|
||||||
|
}).loose();
|
||||||
|
|
||||||
|
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
||||||
56
frontend/src/schemas/status.ts
Normal file
56
frontend/src/schemas/status.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const CurTotalInputSchema = z.object({
|
||||||
|
current: z.number().optional(),
|
||||||
|
total: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NetIOSchema = z.object({
|
||||||
|
up: z.number(),
|
||||||
|
down: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NetTrafficSchema = z.object({
|
||||||
|
sent: z.number(),
|
||||||
|
recv: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PublicIPSchema = z.object({
|
||||||
|
ipv4: z.union([z.string(), z.number()]),
|
||||||
|
ipv6: z.union([z.string(), z.number()]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AppStatsSchema = z.object({
|
||||||
|
threads: z.number(),
|
||||||
|
mem: z.number(),
|
||||||
|
uptime: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const XrayInfoSchema = z.object({
|
||||||
|
state: z.string(),
|
||||||
|
errorMsg: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
}).partial();
|
||||||
|
|
||||||
|
export const StatusSchema = z.object({
|
||||||
|
cpu: z.number().optional(),
|
||||||
|
cpuCores: z.number().optional(),
|
||||||
|
logicalPro: z.number().optional(),
|
||||||
|
cpuSpeedMhz: z.number().optional(),
|
||||||
|
disk: CurTotalInputSchema.optional(),
|
||||||
|
loads: z.array(z.number()).optional(),
|
||||||
|
mem: CurTotalInputSchema.optional(),
|
||||||
|
netIO: NetIOSchema.optional(),
|
||||||
|
netTraffic: NetTrafficSchema.optional(),
|
||||||
|
publicIP: PublicIPSchema.optional(),
|
||||||
|
swap: CurTotalInputSchema.optional(),
|
||||||
|
tcpCount: z.number().optional(),
|
||||||
|
udpCount: z.number().optional(),
|
||||||
|
uptime: z.number().optional(),
|
||||||
|
appUptime: z.number().optional(),
|
||||||
|
appStats: AppStatsSchema.optional(),
|
||||||
|
xray: XrayInfoSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StatusInput = z.infer<typeof StatusSchema>;
|
||||||
15
frontend/src/utils/zodForm.ts
Normal file
15
frontend/src/utils/zodForm.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { Rule } from 'antd/es/form';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
export function antdRule<T extends z.ZodTypeAny>(schema: T, t: TFunction): Rule {
|
||||||
|
return {
|
||||||
|
validator: async (_rule, value) => {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (result.success) return;
|
||||||
|
const issue = result.error.issues[0];
|
||||||
|
const key = issue?.message ?? 'validation.invalid';
|
||||||
|
throw new Error(t(key, { defaultValue: key }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
18
frontend/src/utils/zodValidate.ts
Normal file
18
frontend/src/utils/zodValidate.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import { Msg } from '@/utils';
|
||||||
|
|
||||||
|
export function parseMsg<T extends z.ZodTypeAny>(
|
||||||
|
msg: Msg<unknown>,
|
||||||
|
schema: T,
|
||||||
|
context: string,
|
||||||
|
): Msg<z.infer<T>> {
|
||||||
|
if (!msg.success || msg.obj == null) {
|
||||||
|
return msg as Msg<z.infer<T>>;
|
||||||
|
}
|
||||||
|
const result = schema.safeParse(msg.obj);
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`[zod] ${context} response failed validation`, result.error.issues);
|
||||||
|
return msg as Msg<z.infer<T>>;
|
||||||
|
}
|
||||||
|
return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue