Files
3x-ui-agent/frontend/src/schemas/node.ts
T
Muse c91fb454a8 fix(nodes): per-category client email lists behind the node count chips
NodeView carried only the category counts, so the nodes page chips
could hover to nothing but the category title. GetAll/recountByGuid
now emit online/active/disabled/depleted email lists from the same
bucketing pass as the counts (statuses gain the client email;
the online list is the counted set), and the chips pop the list
over with the inbounds page's popover pattern.
2026-10-22 15:33:46 +00:00

142 lines
5.8 KiB
TypeScript

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(),
hasApiToken: z.boolean().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(),
activeCount: z.number().optional(),
disabledCount: z.number().optional(),
depletedCount: z.number().optional(),
// Per-category client email lists behind the counts above; the nodes
// page category chips pop these over on hover.
onlineEmails: z.array(z.string()).optional(),
activeEmails: z.array(z.string()).optional(),
disabledEmails: z.array(z.string()).optional(),
depletedEmails: z.array(z.string()).optional(),
lastHeartbeat: z.number().optional(),
lastError: z.string().optional(),
// Xray state captured from the remote node's own /panel/api/server/status.
// Lets the nodes list show a distinct indicator when the panel API is reachable
// (status=online) but the Xray core on that node has failed.
xrayState: z.string().optional(),
xrayError: z.string().optional(),
allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(),
pinnedCertSha256: z.string().optional(),
inboundSyncMode: z.enum(['all', 'selected']).optional(),
// Backend serializes a nil []string as null for nodes saved before #5178.
inboundTags: z.array(z.string()).nullish(),
outboundTag: z.string().optional(),
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
// whether it's a read-only transitive sub-node surfaced from a downstream node.
guid: z.string().optional(),
parentGuid: z.string().optional(),
transitive: z.boolean().optional(),
// P7: node kind, annotated by the backend for nodes registered by an
// agent. Absent/traditional for classic panel-to-panel nodes.
nodeType: z.enum(['traditional', 'agent']).optional(),
// P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
// Rendered as plain text in the address column for agent rows.
ipv4: z.string().optional(),
ipv6: z.string().optional(),
// Item 20: last one-click agent update outcome, agent rows only.
agentUpdateState: z.string().optional(),
agentUpdateError: z.string().optional(),
agentUpdateTarget: z.string().optional(),
})
.loose();
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(),
// Present on successful probe; used to surface "connected to panel, but xray failed on node".
xrayState: z.string().optional(),
xrayError: z.string().optional(),
})
.loose();
export const NodeFormSchema = z
.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
remark: z.string().optional(),
// Node type: traditional panel-to-panel, or lightweight agent.
// Agent nodes skip the panel probe and API token; an agent token is
// issued on creation instead.
nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'),
scheme: z.enum(['http', 'https']),
// For agent nodes the panel never dials the node (agent dials out),
// so address/port are not needed. Required for traditional nodes.
address: z.string().trim().optional().default(''),
port: z.number().int().min(1).max(65535).optional(),
basePath: z.string(),
// mTLS nodes authenticate via the client certificate, so the token is optional
// there; every other verify mode still requires one (matches remote.do()).
// Agent nodes use an agent token instead of a panel API token.
apiToken: z.string().trim(),
hasStoredToken: z.boolean().optional().default(false),
enable: z.boolean(),
allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
pinnedCertSha256: z.string().optional().default(''),
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
// serialized as null by the backend for a nil slice — tolerate both.
inboundTags: z
.array(z.string())
.nullish()
.transform((tags) => tags ?? []),
outboundTag: z.string().optional(),
})
.superRefine((val, ctx) => {
if (val.nodeType === 'agent') return; // no panel credentials needed
if (!val.address || val.address.trim().length === 0) {
ctx.addIssue({
code: 'custom',
path: ['address'],
message: 'pages.nodes.toasts.fillRequired',
});
}
if (val.port === undefined) {
ctx.addIssue({
code: 'custom',
path: ['port'],
message: 'pages.nodes.toasts.fillRequired',
});
}
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
ctx.addIssue({
code: 'custom',
path: ['apiToken'],
message: 'pages.nodes.toasts.fillRequired',
});
}
});
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
export type ProbeResult = z.infer<typeof ProbeResultSchema>;
export type NodeFormValues = z.infer<typeof NodeFormSchema>;