B6 frontend: agent-only node form/list, drop mTLS modal and homepage config button (rider 5); regenerate contracts
This commit is contained in:
@@ -15,13 +15,6 @@ export interface NodeUpdateResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RemoteInboundOption {
|
||||
tag: string;
|
||||
remark?: string;
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export function useNodeMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
@@ -91,13 +84,5 @@ export function useNodeMutations() {
|
||||
probe: (id: number) => probeMut.mutateAsync(id),
|
||||
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> =>
|
||||
updatePanelsMut.mutateAsync({ ids, dev }),
|
||||
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||
},
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
|
||||
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
|
||||
fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
|
||||
HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"expireDiff": 0,
|
||||
"pageSize": 0,
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
@@ -55,7 +54,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"hasWarpSecret": false,
|
||||
"pageSize": 0,
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
@@ -488,23 +486,14 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"NodeMutationRequest": {
|
||||
"address": "",
|
||||
"allowPrivateAddress": false,
|
||||
"apiToken": null,
|
||||
"basePath": "",
|
||||
"clearApiToken": false,
|
||||
"enable": false,
|
||||
"id": 0,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
""
|
||||
],
|
||||
"name": "",
|
||||
"nodeType": "traditional",
|
||||
"outboundTag": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 0,
|
||||
"probeOnly": null,
|
||||
"remark": "",
|
||||
"scheme": "http",
|
||||
"tlsVerifyMode": "verify"
|
||||
"scheme": "http"
|
||||
},
|
||||
"NodeView": {
|
||||
"activeCount": 20,
|
||||
@@ -532,13 +521,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
],
|
||||
"enable": true,
|
||||
"guid": "node-guid",
|
||||
"hasApiToken": true,
|
||||
"id": 1,
|
||||
"inboundCount": 3,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"ipv4": "203.0.113.7",
|
||||
"ipv6": "2001:db8::7",
|
||||
"lastError": "",
|
||||
@@ -548,21 +532,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"nodeType": "traditional",
|
||||
"nodeType": "agent",
|
||||
"onlineCount": 5,
|
||||
"onlineEmails": [
|
||||
"a@example.com"
|
||||
],
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 2053,
|
||||
"probeOnly": false,
|
||||
"remark": "Primary edge",
|
||||
"scheme": "https",
|
||||
"singboxVersion": "1.13.21",
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
|
||||
@@ -17,9 +17,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sessionMaxAge": {
|
||||
"maximum": 525600,
|
||||
"minimum": 1,
|
||||
@@ -160,7 +157,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"expireDiff",
|
||||
"pageSize",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
@@ -232,9 +228,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sessionMaxAge": {
|
||||
"maximum": 525600,
|
||||
"minimum": 1,
|
||||
@@ -379,7 +372,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"hasWarpSecret",
|
||||
"pageSize",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
@@ -2004,7 +1996,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "object"
|
||||
},
|
||||
"NodeMutationRequest": {
|
||||
"description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
|
||||
"description": "NodeMutationRequest is the node write contract. Every node is\nagent-managed: it dials out to the master and authenticates with the\nagent token issued on creation, so the request carries no panel\nendpoint credentials.",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
@@ -2012,57 +2004,28 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"allowPrivateAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiToken": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"basePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"clearApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"enum": [
|
||||
"all",
|
||||
"selected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType distinguishes traditional panel-to-panel nodes from lightweight\nagent nodes. Agent nodes skip the panel reachability probe and the API\ntoken requirement; an agent token is issued on creation instead.",
|
||||
"enum": [
|
||||
"traditional",
|
||||
"agent"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"probeOnly": {
|
||||
"description": "ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A\npointer so updates can tell \"leave the flag alone\" (nil) from an\nexplicit on/off; applied to the bound agent rows. On create it\npresets the flag on the freshly issued agent.",
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"remark": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2072,15 +2035,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"enum": [
|
||||
"verify",
|
||||
"skip",
|
||||
"pin",
|
||||
"mtls"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2089,20 +2043,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"basePath",
|
||||
"enable",
|
||||
"id",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"name",
|
||||
"outboundTag",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"tlsVerifyMode"
|
||||
"scheme"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NodeView": {
|
||||
"description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
|
||||
"description": "NodeView is the browser/API read contract for nodes.",
|
||||
"properties": {
|
||||
"activeCount": {
|
||||
"example": 20,
|
||||
@@ -2197,10 +2146,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "node-guid",
|
||||
"type": "string"
|
||||
},
|
||||
"hasApiToken": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
@@ -2209,19 +2154,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 3,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"example": "all",
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"example": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"ipv4": {
|
||||
"description": "Ipv4/Ipv6 carry the resolved externally usable host addresses for\nagent nodes (P13 telemetry, public-address resolution on top).\nThe nodes page renders them as plain text on agent rows instead\nof the panel-URL link used for traditional nodes. Empty for\ntraditional nodes and for agents with no known address yet.",
|
||||
"example": "203.0.113.7",
|
||||
@@ -2262,8 +2194,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType is \"agent\" for lightweight agent-managed nodes (an agent is\nbound to the node) and \"traditional\" otherwise. It is derived from the\nagent binding, not stored on the node row; read paths that serve the\npanel UI annotate it via annotateNodeTypes. Empty on views built\noutside those paths.",
|
||||
"example": "traditional",
|
||||
"description": "NodeType is \"agent\" for agent-managed nodes (an agent is bound to\nthe node) and \"traditional\" for a legacy row with no binding. It\nis derived from the agent binding, not stored on the node row;\nread paths that serve the panel UI annotate it via\nannotateNodeTypes. Empty on views built outside those paths.",
|
||||
"example": "agent",
|
||||
"type": "string"
|
||||
},
|
||||
"onlineCount": {
|
||||
@@ -2280,24 +2212,19 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"outboundTag": {
|
||||
"example": "direct",
|
||||
"type": "string"
|
||||
},
|
||||
"panelVersion": {
|
||||
"example": "v3.x.x",
|
||||
"type": "string"
|
||||
},
|
||||
"parentGuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 2053,
|
||||
"type": "integer"
|
||||
},
|
||||
"probeOnly": {
|
||||
"description": "ProbeOnly marks an agent node as probe-only (V2.1.0): no core\nruns on it, only the probe channels. Overlaid from the bound\nagent row like the update bookkeeping below; false (and absent)\nfor traditional nodes and unflagged agents.",
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"remark": {
|
||||
"example": "Primary edge",
|
||||
"type": "string"
|
||||
@@ -2306,18 +2233,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "https",
|
||||
"type": "string"
|
||||
},
|
||||
"singboxVersion": {
|
||||
"description": "SingboxVersion is the bound agent's sing-box core version\n(V2.0.1 ②), overlaid from agent telemetry like XrayVersion;\nempty for traditional nodes and agents that never reported\none. The nodes page prefers it over XrayVersion when set.",
|
||||
"example": "1.13.21",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"example": "online",
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"example": "verify",
|
||||
"type": "string"
|
||||
},
|
||||
"transitive": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700003600,
|
||||
"format": "int64",
|
||||
@@ -2354,11 +2278,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"disabledCount",
|
||||
"enable",
|
||||
"guid",
|
||||
"hasApiToken",
|
||||
"id",
|
||||
"inboundCount",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"lastError",
|
||||
"lastHeartbeat",
|
||||
"latencyMs",
|
||||
@@ -2367,14 +2288,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"netDown",
|
||||
"netUp",
|
||||
"onlineCount",
|
||||
"outboundTag",
|
||||
"panelVersion",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"singboxVersion",
|
||||
"status",
|
||||
"tlsVerifyMode",
|
||||
"updatedAt",
|
||||
"uptimeSecs",
|
||||
"xrayError",
|
||||
|
||||
@@ -4,8 +4,8 @@ export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type geoipFetchResult = number;
|
||||
export type rawBytes = number[];
|
||||
export type staticEgressResolver = string;
|
||||
export type trafficLocalApplyAction = number;
|
||||
export type transportBits = number;
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface AllSetting {
|
||||
expireDiff: number;
|
||||
pageSize: number;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
@@ -65,7 +64,6 @@ export interface AllSettingView {
|
||||
hasWarpSecret: boolean;
|
||||
pageSize: number;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
@@ -457,21 +455,14 @@ export interface Node {
|
||||
export interface NodeMutationRequest {
|
||||
address: string;
|
||||
allowPrivateAddress: boolean;
|
||||
apiToken?: string | null;
|
||||
basePath: string;
|
||||
clearApiToken?: boolean;
|
||||
enable: boolean;
|
||||
id: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
name: string;
|
||||
nodeType?: string;
|
||||
outboundTag: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
probeOnly?: boolean | null;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
tlsVerifyMode: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
@@ -494,11 +485,8 @@ export interface NodeView {
|
||||
disabledEmails?: string[];
|
||||
enable: boolean;
|
||||
guid: string;
|
||||
hasApiToken: boolean;
|
||||
id: number;
|
||||
inboundCount: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
lastError: string;
|
||||
@@ -511,16 +499,13 @@ export interface NodeView {
|
||||
nodeType?: string;
|
||||
onlineCount: number;
|
||||
onlineEmails?: string[];
|
||||
outboundTag: string;
|
||||
panelVersion: string;
|
||||
parentGuid?: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
probeOnly?: boolean;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
singboxVersion: string;
|
||||
status: string;
|
||||
tlsVerifyMode: string;
|
||||
transitive?: boolean;
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayError: string;
|
||||
|
||||
@@ -15,12 +15,12 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
export const SubLinkProviderSchema = z.unknown();
|
||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
|
||||
export const geoipFetchResultSchema = z.number().int();
|
||||
export type geoipFetchResult = z.infer<typeof geoipFetchResultSchema>;
|
||||
|
||||
export const rawBytesSchema = z.array(z.number().int());
|
||||
export type rawBytes = z.infer<typeof rawBytesSchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
export const trafficLocalApplyActionSchema = z.number().int();
|
||||
export type trafficLocalApplyAction = z.infer<typeof trafficLocalApplyActionSchema>;
|
||||
|
||||
@@ -32,7 +32,6 @@ export const AllSettingSchema = z.object({
|
||||
expireDiff: z.number().int().min(0),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
@@ -84,7 +83,6 @@ export const AllSettingViewSchema = z.object({
|
||||
hasWarpSecret: z.boolean(),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
@@ -499,21 +497,14 @@ export type Node = z.infer<typeof NodeSchema>;
|
||||
export const NodeMutationRequestSchema = z.object({
|
||||
address: z.string(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
apiToken: z.string().nullable().optional(),
|
||||
basePath: z.string(),
|
||||
clearApiToken: z.boolean().optional(),
|
||||
enable: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundSyncMode: z.enum(['all', 'selected']),
|
||||
inboundTags: z.array(z.string()),
|
||||
name: z.string(),
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
outboundTag: z.string(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
probeOnly: z.boolean().nullable().optional(),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
});
|
||||
export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
||||
|
||||
@@ -537,11 +528,8 @@ export const NodeViewSchema = z.object({
|
||||
disabledEmails: z.array(z.string()).optional(),
|
||||
enable: z.boolean(),
|
||||
guid: z.string(),
|
||||
hasApiToken: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundCount: z.number().int(),
|
||||
inboundSyncMode: z.string(),
|
||||
inboundTags: z.array(z.string()),
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
lastError: z.string(),
|
||||
@@ -554,16 +542,13 @@ export const NodeViewSchema = z.object({
|
||||
nodeType: z.string().optional(),
|
||||
onlineCount: z.number().int(),
|
||||
onlineEmails: z.array(z.string()).optional(),
|
||||
outboundTag: z.string(),
|
||||
panelVersion: z.string(),
|
||||
parentGuid: z.string().optional(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int(),
|
||||
probeOnly: z.boolean().optional(),
|
||||
remark: z.string(),
|
||||
scheme: z.string(),
|
||||
singboxVersion: z.string(),
|
||||
status: z.string(),
|
||||
tlsVerifyMode: z.string(),
|
||||
transitive: z.boolean().optional(),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayError: z.string(),
|
||||
|
||||
@@ -290,12 +290,15 @@ export function useSecurityActions({
|
||||
setSaving(true);
|
||||
try {
|
||||
/*
|
||||
* Node-assigned inbounds run on the node, so their cert files must be the
|
||||
* node's own paths (fetched through the central panel), not this panel's.
|
||||
* Only a local inbound can borrow this panel's own cert paths.
|
||||
* Node-assigned inbounds run on agent machines, which have no
|
||||
* panel cert to borrow (the old /nodes/webCert proxy went away
|
||||
* with traditional nodes, B6), so they fall through to the
|
||||
* empty-cert warning below.
|
||||
*/
|
||||
const msg =
|
||||
typeof nodeId === 'number'
|
||||
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
||||
? null
|
||||
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||
if (!msg?.success) {
|
||||
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
|
||||
import {
|
||||
CopyOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
HddOutlined,
|
||||
SwapOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, ConfigProvider, Layout, Result, Spin, message } from 'antd';
|
||||
import { DashboardOutlined, DatabaseOutlined, HddOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||
import { HttpUtil, CPUFormatter, SizeFormatter } from '@/utils';
|
||||
import {
|
||||
USAGE_CRIT_COLOR,
|
||||
USAGE_CRIT_PERCENT,
|
||||
@@ -30,7 +23,6 @@ import ConnectionsCard from './ConnectionsCard';
|
||||
import SystemStrip from './SystemStrip';
|
||||
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||
const BackupModal = lazy(() => import('./BackupModal'));
|
||||
import './IndexPage.css';
|
||||
@@ -59,8 +51,6 @@ export default function IndexPage() {
|
||||
const [showIp, setShowIp] = useState(false);
|
||||
const [backupOpen, setBackupOpen] = useState(false);
|
||||
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
|
||||
const [configTextOpen, setConfigTextOpen] = useState(false);
|
||||
const [configText, setConfigText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||
|
||||
@@ -95,27 +85,6 @@ export default function IndexPage() {
|
||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||
}
|
||||
|
||||
async function openConfig() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
||||
if (!msg?.success) return;
|
||||
setConfigText(JSON.stringify(msg.obj, null, 2));
|
||||
setConfigTextOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
const ok = await ClipboardManager.copyText(configText || '');
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
FileManager.downloadTextFile(configText, 'config.json');
|
||||
}
|
||||
|
||||
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
||||
const totalDisk = status.disk.total;
|
||||
const freeDisk = Math.max(0, totalDisk - status.disk.current);
|
||||
@@ -183,7 +152,6 @@ export default function IndexPage() {
|
||||
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||
panelUpdateInfo.updateAvailable)
|
||||
}
|
||||
onOpenConfig={openConfig}
|
||||
onOpenBackup={() => setBackupOpen(true)}
|
||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||
/>
|
||||
@@ -290,42 +258,6 @@ export default function IndexPage() {
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={configTextOpen}>
|
||||
<Modal
|
||||
open={configTextOpen}
|
||||
title={t('pages.index.config')}
|
||||
width={isMobile ? '100%' : 900}
|
||||
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : { top: 20 }}
|
||||
onCancel={() => setConfigTextOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="download"
|
||||
onClick={downloadConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
{isMobile ? 'Download' : 'config.json'}
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
onClick={copyConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CopyOutlined />}
|
||||
>
|
||||
Copy
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<JsonEditor
|
||||
value={configText}
|
||||
onChange={setConfigText}
|
||||
minHeight={isMobile ? '300px' : 'calc(100vh - 220px)'}
|
||||
maxHeight={isMobile ? '70vh' : 'calc(100vh - 220px)'}
|
||||
readOnly
|
||||
/>
|
||||
</Modal>
|
||||
</LazyMount>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tag, Tooltip } from 'antd';
|
||||
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
|
||||
import { CloudDownloadOutlined, CloudServerOutlined } from '@ant-design/icons';
|
||||
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
|
||||
@@ -10,7 +10,6 @@ interface OverviewActionBarProps {
|
||||
panelVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
onOpenConfig: () => void;
|
||||
onOpenBackup: () => void;
|
||||
onOpenPanelUpdate: () => void;
|
||||
}
|
||||
@@ -28,7 +27,6 @@ export default function OverviewActionBar({
|
||||
panelVersion,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
onOpenConfig,
|
||||
onOpenBackup,
|
||||
onOpenPanelUpdate,
|
||||
}: OverviewActionBarProps) {
|
||||
@@ -36,12 +34,6 @@ export default function OverviewActionBar({
|
||||
const size = isMobile ? ('small' as const) : ('middle' as const);
|
||||
|
||||
const actions: BarAction[] = [
|
||||
{
|
||||
key: 'config',
|
||||
icon: <ControlOutlined />,
|
||||
text: t('pages.index.config'),
|
||||
onClick: onOpenConfig,
|
||||
},
|
||||
{
|
||||
key: 'backup',
|
||||
icon: <CloudServerOutlined />,
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { Alert, Button, Col, Form, Input, Modal, Row, Switch, message } from 'antd';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import { HttpUtil, type Msg } from '@/utils';
|
||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||
import { NodeFormSchema, type NodeFormValues } from '@/schemas/node';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||
|
||||
import './NodeFormModal.css';
|
||||
@@ -31,9 +17,6 @@ interface NodeFormModalProps {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
node: NodeRecord | null;
|
||||
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
|
||||
fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
|
||||
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
@@ -43,21 +26,13 @@ function defaultValues(): NodeFormValues {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
nodeType: 'traditional',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
hasStoredToken: false,
|
||||
probeOnly: false,
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
tlsVerifyMode: 'verify',
|
||||
pinnedCertSha256: '',
|
||||
inboundSyncMode: 'all',
|
||||
inboundTags: [],
|
||||
outboundTag: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,9 +40,6 @@ export default function NodeFormModal({
|
||||
open,
|
||||
mode,
|
||||
node,
|
||||
testConnection,
|
||||
fetchFingerprint,
|
||||
fetchInbounds,
|
||||
save,
|
||||
onOpenChange,
|
||||
}: NodeFormModalProps) {
|
||||
@@ -77,11 +49,6 @@ export default function NodeFormModal({
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [fetchingPin, setFetchingPin] = useState(false);
|
||||
const [fetchingInbounds, setFetchingInbounds] = useState(false);
|
||||
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
|
||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
@@ -96,34 +63,6 @@ export default function NodeFormModal({
|
||||
hasToken: boolean;
|
||||
} | null>(null);
|
||||
const [editMasterUrl, setEditMasterUrl] = useState('');
|
||||
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
|
||||
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
||||
const isAgent = nodeType === 'agent';
|
||||
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||
|
||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||
// when balancers exist they get a labeled group so it's clear the selection
|
||||
// routes through a balancer. Empty falls back to the placeholder ("Direct
|
||||
// connection") rather than a synthetic option, so it can't read as a second
|
||||
// "direct" next to a real freedom outbound.
|
||||
const outboundOptions = useMemo<
|
||||
(
|
||||
| { label: string; value: string }
|
||||
| { label: string; options: { label: string; value: string }[] }
|
||||
)[]
|
||||
>(() => {
|
||||
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
|
||||
if (!outboundGroups?.balancers.length) return outOpts;
|
||||
return [
|
||||
{ label: t('pages.xray.Outbounds'), options: outOpts },
|
||||
{
|
||||
label: t('pages.xray.Balancers'),
|
||||
options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })),
|
||||
},
|
||||
];
|
||||
}, [outboundGroups, t]);
|
||||
|
||||
// Reset during render, not in an effect, so the first frame is already clean.
|
||||
const [synced, setSynced] = useState<{ mode: string; node: NodeRecord | null } | null>(null);
|
||||
@@ -139,23 +78,16 @@ export default function NodeFormModal({
|
||||
...(node as unknown as Partial<NodeFormValues>),
|
||||
id: node.id,
|
||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
|
||||
inboundTags: node.inboundTags ?? [],
|
||||
apiToken: '',
|
||||
hasStoredToken: node.hasApiToken ?? false,
|
||||
}
|
||||
: base;
|
||||
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
||||
methods.reset(next);
|
||||
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
// Load the stored install material when editing an agent node so the
|
||||
// Load the stored install material when editing a node so the
|
||||
// full install command can be re-displayed (item 19). Nodes created
|
||||
// before tokens were stored answer hasToken=false until the agent's
|
||||
// next authenticated request backfills the token.
|
||||
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
|
||||
const editNodeId = mode === 'edit' ? (node?.id ?? 0) : 0;
|
||||
useEffect(() => {
|
||||
if (!open || !editNodeId) return;
|
||||
let cancelled = false;
|
||||
@@ -195,84 +127,22 @@ export default function NodeFormModal({
|
||||
[mode, t],
|
||||
);
|
||||
|
||||
const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken);
|
||||
|
||||
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||
const token = values.apiToken.trim();
|
||||
const agentMode = values.nodeType === 'agent';
|
||||
const payload: Partial<NodeRecord> = {
|
||||
// Agent nodes have no panel address; the backend ignores these
|
||||
// (and sanitizes the stored row), so the payload keeps the
|
||||
// historical placeholder pair.
|
||||
return {
|
||||
id: values.id || 0,
|
||||
name: values.name.trim(),
|
||||
remark: values.remark?.trim() || '',
|
||||
nodeType: values.nodeType,
|
||||
scheme: values.scheme,
|
||||
// Agent nodes have no panel address; the backend ignores these.
|
||||
address: agentMode ? 'agent' : values.address.trim(),
|
||||
port: agentMode ? 2053 : values.port,
|
||||
basePath: values.basePath.trim() || '/',
|
||||
address: 'agent',
|
||||
port: 2053,
|
||||
basePath: values.basePath?.trim() || '/',
|
||||
enable: values.enable,
|
||||
allowPrivateAddress: values.allowPrivateAddress,
|
||||
tlsVerifyMode: values.tlsVerifyMode,
|
||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
||||
inboundSyncMode: values.inboundSyncMode,
|
||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||
outboundTag: values.outboundTag || '',
|
||||
probeOnly: values.probeOnly ?? false,
|
||||
};
|
||||
// Probe-only (V2.1.0) is an agent-node concept; traditional
|
||||
// payloads stay byte-identical to before.
|
||||
if (agentMode) payload.probeOnly = values.probeOnly ?? false;
|
||||
if (token) payload.apiToken = token;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const payload = buildPayload(methods.getValues());
|
||||
const msg = await testConnection(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
setTestResult(msg.obj);
|
||||
} else {
|
||||
setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' });
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFetchPin() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setFetchingPin(true);
|
||||
try {
|
||||
const payload = buildPayload(methods.getValues());
|
||||
const msg = await fetchFingerprint(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
methods.setValue('pinnedCertSha256', msg.obj);
|
||||
messageApi.success(t('pages.nodes.pinFetched'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
|
||||
}
|
||||
} finally {
|
||||
setFetchingPin(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFetchInbounds() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return;
|
||||
setFetchingInbounds(true);
|
||||
try {
|
||||
const msg = await fetchInbounds(buildPayload(methods.getValues()));
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
setInboundOptions(msg.obj);
|
||||
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
|
||||
}
|
||||
} finally {
|
||||
setFetchingInbounds(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFinish(values: NodeFormValues) {
|
||||
@@ -284,26 +154,13 @@ export default function NodeFormModal({
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = buildPayload(result.data);
|
||||
const agentMode = result.data.nodeType === 'agent';
|
||||
// Agent nodes have no panel to probe; skip the connection test.
|
||||
if (!agentMode) {
|
||||
const test = await testConnection(payload);
|
||||
const probe = test?.success ? test.obj : null;
|
||||
if (!probe || probe.status !== 'online') {
|
||||
setTestResult(
|
||||
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTestResult(probe);
|
||||
}
|
||||
const msg = await save(payload);
|
||||
if (msg?.success) {
|
||||
// Backend returns { node, agentToken, nodeType } for agent nodes.
|
||||
// Backend returns { node, agentToken } for a created node.
|
||||
const obj = msg.obj as unknown as
|
||||
| { agentToken?: string; node?: { id?: number } }
|
||||
| undefined;
|
||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
||||
if (mode === 'add' && obj?.agentToken) {
|
||||
setAgentToken(obj.agentToken);
|
||||
setAgentNodeName(result.data.name.trim());
|
||||
// Default the master URL to the address this panel is being
|
||||
@@ -347,27 +204,12 @@ export default function NodeFormModal({
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
<FormField label={t('pages.nodes.nodeType')} name="nodeType">
|
||||
<Select
|
||||
disabled={mode === 'edit'}
|
||||
options={[
|
||||
{ value: 'traditional', label: t('pages.nodes.nodeTypeTraditional') },
|
||||
{ value: 'agent', label: t('pages.nodes.nodeTypeAgent') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
{isAgent && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentHint')}
|
||||
/>
|
||||
)}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentHint')}
|
||||
/>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
@@ -385,80 +227,25 @@ export default function NodeFormModal({
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.scheme')}
|
||||
name="scheme"
|
||||
onAfterChange={(value) => {
|
||||
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'https', label: 'https' },
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.address')}
|
||||
name="address"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
|
||||
>
|
||||
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.port')}
|
||||
name="port"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.probeOnly')}
|
||||
name="probeOnly"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.probeOnlyHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.basePath')} name="basePath">
|
||||
<Input placeholder="/" />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.probeOnly')}
|
||||
name="probeOnly"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.probeOnlyHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && shownInstall && (
|
||||
{mode === 'edit' && shownInstall && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||
@@ -495,7 +282,7 @@ export default function NodeFormModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && node?.id ? (
|
||||
{mode === 'edit' && node?.id ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.nodes.probe.title')}
|
||||
@@ -506,174 +293,6 @@ export default function NodeFormModal({
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.allowPrivateAddress')}
|
||||
name="allowPrivateAddress"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.allowPrivateAddressHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.tlsVerifyMode')}
|
||||
name="tlsVerifyMode"
|
||||
tooltip={t('pages.nodes.tlsVerifyModeHint')}
|
||||
>
|
||||
<Select
|
||||
disabled={scheme === 'http'}
|
||||
options={[
|
||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'skip' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.tlsSkipWarning')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'mtls' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.mtlsFormHint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'pin' && (
|
||||
<FormField
|
||||
label={t('pages.nodes.pinnedCert')}
|
||||
name="pinnedCertSha256"
|
||||
tooltip={t('pages.nodes.pinnedCertHint')}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
|
||||
enterButton={t('pages.nodes.fetchPin')}
|
||||
loading={fetchingPin}
|
||||
onSearch={onFetchPin}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.apiToken')}
|
||||
name="apiToken"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||
tooltip={t('pages.nodes.apiTokenHint')}
|
||||
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={
|
||||
editingWithToken
|
||||
? t('pages.nodes.apiTokenKeepHint')
|
||||
: t('pages.nodes.apiTokenPlaceholder')
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.outboundTag')}
|
||||
name="outboundTag"
|
||||
tooltip={t('pages.nodes.outboundTagHint')}
|
||||
transform={{ input: (v) => (v as string) || undefined }}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('pages.nodes.outboundTagPlaceholder')}
|
||||
options={outboundOptions}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.inboundSyncMode')}
|
||||
name="inboundSyncMode"
|
||||
tooltip={t('pages.nodes.inboundSyncModeHint')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all', label: t('pages.nodes.allInbounds') },
|
||||
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{inboundSyncMode === 'selected' && (
|
||||
<FormField
|
||||
label={t('pages.nodes.inboundTags')}
|
||||
name="inboundTags"
|
||||
tooltip={t('pages.nodes.inboundTagsHint')}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
loading={fetchingInbounds}
|
||||
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
loading={fetchingInbounds}
|
||||
onClick={onFetchInbounds}
|
||||
>
|
||||
{t('pages.nodes.loadInbounds')}
|
||||
</Button>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
options={inboundOptions.map((inbound) => ({
|
||||
value: inbound.tag,
|
||||
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<div className="test-row">
|
||||
<Button type="default" loading={testing} onClick={onTest}>
|
||||
{t('pages.nodes.testConnection')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.status === 'online' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||
description={
|
||||
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionFailed')}
|
||||
description={testResult.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { BadgeProps } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ClusterOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -29,14 +28,12 @@ import {
|
||||
PlusOutlined,
|
||||
RadarChartOutlined,
|
||||
RightOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import NodeHistoryPanel from './NodeHistoryPanel';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import './NodeList.css';
|
||||
|
||||
@@ -44,11 +41,9 @@ interface NodeListProps {
|
||||
nodes: NodeRecord[];
|
||||
loading?: boolean;
|
||||
isMobile?: boolean;
|
||||
latestVersion?: string;
|
||||
selectedIds: number[];
|
||||
onSelectionChange: (ids: number[]) => void;
|
||||
onAdd: () => void;
|
||||
onMtls: () => void;
|
||||
onProbeTasks: () => void;
|
||||
onEdit: (node: NodeRecord) => void;
|
||||
onDelete: (node: NodeRecord) => void;
|
||||
@@ -102,10 +97,16 @@ function CategoryChip({
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
// Agent nodes have no panel to link to: the address cell shows the
|
||||
// host IPs the agent reports with its heartbeat as plain text (P13).
|
||||
function agentIpText(record: Pick<NodeRecord, 'ipv4' | 'ipv6'>): string {
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return ips.length > 0 ? ips.join(' · ') : '-';
|
||||
}
|
||||
|
||||
function badgeStatus(status?: string): BadgeProps['status'] {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
@@ -215,11 +216,9 @@ export default function NodeList({
|
||||
nodes,
|
||||
loading = false,
|
||||
isMobile = false,
|
||||
latestVersion = '',
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
onAdd,
|
||||
onMtls,
|
||||
onProbeTasks,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -235,49 +234,7 @@ export default function NodeList({
|
||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Map a node GUID to its display name so a transitive sub-node can show which
|
||||
// parent it is reached through (#4983).
|
||||
const nameByGuid = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
||||
return m;
|
||||
}, [nodes]);
|
||||
|
||||
// Order direct nodes first, each immediately followed by its transitive
|
||||
// sub-nodes, so the table reads as a parent -> child tree without colliding
|
||||
// with the per-row history expander (transitive nodes carry id 0).
|
||||
const dataSource = useMemo<NodeRow[]>(() => {
|
||||
const toRow = (n: NodeRecord): NodeRow => ({
|
||||
...n,
|
||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||
key: n.transitive ? `t-${n.guid || ''}` : n.id,
|
||||
});
|
||||
const childrenByParent = new Map<string, NodeRecord[]>();
|
||||
for (const n of nodes) {
|
||||
if (n.transitive && n.parentGuid) {
|
||||
const arr = childrenByParent.get(n.parentGuid) || [];
|
||||
arr.push(n);
|
||||
childrenByParent.set(n.parentGuid, arr);
|
||||
}
|
||||
}
|
||||
const ordered: NodeRow[] = [];
|
||||
const added = new Set<string>();
|
||||
const push = (n: NodeRecord) => {
|
||||
const row = toRow(n);
|
||||
ordered.push(row);
|
||||
added.add(String(row.key));
|
||||
};
|
||||
for (const n of nodes) {
|
||||
if (n.transitive) continue;
|
||||
push(n);
|
||||
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
|
||||
}
|
||||
// Transitive nodes whose parent isn't in the list still get shown.
|
||||
for (const n of nodes) {
|
||||
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
|
||||
}
|
||||
return ordered;
|
||||
}, [nodes]);
|
||||
const dataSource = useMemo<NodeRow[]>(() => nodes.map((n) => ({ ...n, key: n.id })), [nodes]);
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
setExpandedIds((prev) => {
|
||||
@@ -294,106 +251,74 @@ export default function NodeList({
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 150,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<Tooltip
|
||||
title={t('pages.nodes.subNodeTip', {
|
||||
parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-',
|
||||
})}
|
||||
>
|
||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
||||
{t('pages.nodes.subNode')}
|
||||
</Tag>
|
||||
render: (_value, record) => (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.testConnection')}
|
||||
onClick={() => onProbe(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip title={t('pages.nodes.updateAgent')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.testConnection')}
|
||||
onClick={() => onProbe(record)}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t('pages.nodes.updateAgent')}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip
|
||||
title={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
)}
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.enable'),
|
||||
dataIndex: 'enable',
|
||||
align: 'center',
|
||||
width: 64,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<span style={{ opacity: 0.4 }}>—</span>
|
||||
) : (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
render: (_value, record) => (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<div
|
||||
className="name-cell"
|
||||
style={record.transitive ? { paddingInlineStart: 20 } : undefined}
|
||||
>
|
||||
<span className="name">
|
||||
{record.transitive && (
|
||||
<ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />
|
||||
)}
|
||||
{record.name}
|
||||
</span>
|
||||
<div className="name-cell">
|
||||
<span className="name">{record.name}</span>
|
||||
{record.remark && <span className="remark">{record.remark}</span>}
|
||||
</div>
|
||||
),
|
||||
@@ -425,37 +350,22 @@ export default function NodeList({
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
dataIndex: 'address',
|
||||
width: 330,
|
||||
ellipsis: true,
|
||||
render: (_value, record) => {
|
||||
if (record.nodeType === 'agent') {
|
||||
// Agent nodes have no panel to link to: show the host IPs the
|
||||
// agent reports with its heartbeat as plain text on one line
|
||||
// (P13, layout per item 21).
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -493,58 +403,29 @@ export default function NodeList({
|
||||
render: (_value, record) => formatPct(record.memPct),
|
||||
},
|
||||
{
|
||||
// Core version (V2.0.1 ②): agent rows run sing-box and show
|
||||
// its version; traditional rows keep showing the xray one.
|
||||
// Probe-only agent rows (V2.1.0) run no core by design: the
|
||||
// Core version (V2.0.1 ②): rows run sing-box and show its
|
||||
// version. Probe-only rows (V2.1.0) run no core by design: the
|
||||
// cell says so instead of a bare '-', which reads as a fault.
|
||||
title: t('pages.nodes.coreVersion'),
|
||||
dataIndex: 'xrayVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) =>
|
||||
record.nodeType === 'agent'
|
||||
? record.probeOnly
|
||||
? (
|
||||
<Tag color="cyan" style={{ margin: 0 }}>
|
||||
{t('pages.nodes.probeOnly')}
|
||||
</Tag>
|
||||
)
|
||||
: record.singboxVersion || record.xrayVersion || '-'
|
||||
: record.xrayVersion || '-',
|
||||
record.probeOnly ? (
|
||||
<Tag color="cyan" style={{ margin: 0 }}>
|
||||
{t('pages.nodes.probeOnly')}
|
||||
</Tag>
|
||||
) : (
|
||||
record.singboxVersion || record.xrayVersion || '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
// Rows carry the A-series agent display version ("A2.3.0"),
|
||||
// not an upstream panel version — no update badge here (the
|
||||
// one-click update action lives in the actions column).
|
||||
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||
dataIndex: 'panelVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => {
|
||||
// Agent rows carry the A-series display version ("A1.0.0"), not
|
||||
// an upstream panel version: the update badge must never light
|
||||
// up for them (the version comparator falls back to string
|
||||
// inequality for unparseable versions, which would always
|
||||
// differ) (P13, item 18).
|
||||
const canUpdate =
|
||||
record.nodeType !== 'agent' &&
|
||||
isUpdateEligible(record) &&
|
||||
isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span>{record.panelVersion || '-'}</span>
|
||||
{canUpdate && (
|
||||
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
|
||||
<Tag
|
||||
color="orange"
|
||||
style={{ margin: 0, cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
onKeyDown={activateOnKey(() => onUpdateNode(record))}
|
||||
>
|
||||
{t('pages.nodes.updateAvailable')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
render: (_value, record) => <span>{record.panelVersion || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.uptime'),
|
||||
@@ -611,18 +492,7 @@ export default function NodeList({
|
||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
showAddress,
|
||||
relativeTime,
|
||||
latestVersion,
|
||||
onToggleEnable,
|
||||
onProbe,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateNode,
|
||||
nameByGuid,
|
||||
],
|
||||
[t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -631,9 +501,6 @@ export default function NodeList({
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{t('pages.nodes.addNode')}
|
||||
</Button>
|
||||
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
||||
{t('pages.nodes.mtls.title')}
|
||||
</Button>
|
||||
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
|
||||
{t('pages.nodes.probeTasks.title')}
|
||||
</Button>
|
||||
@@ -653,133 +520,109 @@ export default function NodeList({
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
) : (
|
||||
dataSource.map((record) =>
|
||||
record.transitive ? (
|
||||
dataSource.map((record) => (
|
||||
<div key={record.id} className="node-card">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
||||
<div
|
||||
key={String(record.key)}
|
||||
className="node-card"
|
||||
style={{ paddingInlineStart: 16, opacity: 0.85 }}
|
||||
className="card-head"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('.card-actions'))
|
||||
toggleExpanded(record.id);
|
||||
}}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
||||
{t('pages.nodes.subNode')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={record.id} className="node-card">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
||||
<div
|
||||
className="card-head"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('.card-actions'))
|
||||
toggleExpanded(record.id);
|
||||
}}
|
||||
>
|
||||
<RightOutlined
|
||||
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedIds.has(record.id)}
|
||||
aria-label={record.name}
|
||||
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||
/>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('info')}
|
||||
onClick={() => setStatsNode(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
<RightOutlined
|
||||
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedIds.has(record.id)}
|
||||
aria-label={record.name}
|
||||
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||
/>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('info')}
|
||||
onClick={() => setStatsNode(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
...(isUpdateEligible(record)
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined />{' '}
|
||||
{t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="row-action-trigger"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
...(isUpdateEligible(record)
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined /> {t('pages.nodes.updateAgent')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="row-action-trigger"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -801,14 +644,9 @@ export default function NodeList({
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.address')}</span>
|
||||
<a
|
||||
href={statsNode.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{statsNode.url}
|
||||
</a>
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{agentIpText(statsNode)}
|
||||
</span>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined
|
||||
@@ -854,11 +692,7 @@ export default function NodeList({
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
|
||||
<Tag>
|
||||
{statsNode.nodeType === 'agent'
|
||||
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
|
||||
: statsNode.xrayVersion || '-'}
|
||||
</Tag>
|
||||
<Tag>{statsNode.singboxVersion || statsNode.xrayVersion || '-'}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">
|
||||
@@ -928,7 +762,7 @@ export default function NodeList({
|
||||
onChange: (keys) =>
|
||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||
disabled: !isUpdateEligible(record),
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
@@ -943,7 +777,6 @@ export default function NodeList({
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
rowExpandable: (record) => !record.transitive,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Result,
|
||||
Row,
|
||||
Spin,
|
||||
Statistic,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -35,38 +30,6 @@ import AppSidebar from '@/layouts/AppSidebar';
|
||||
import NodeList from './NodeList';
|
||||
import NodeFormModal from './NodeFormModal';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
|
||||
|
||||
// Confirm-dialog body that lets the operator pick the stable or dev channel for
|
||||
// a node panel update. Reports changes via onChange so the imperative
|
||||
// modal.confirm onOk can read the latest choice through a ref.
|
||||
function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [dev, setDev] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<p>{t('pages.nodes.updateConfirmContent')}</p>
|
||||
<Checkbox
|
||||
checked={dev}
|
||||
onChange={(e) => {
|
||||
setDev(e.target.checked);
|
||||
onChange(e.target.checked);
|
||||
}}
|
||||
>
|
||||
{t('pages.nodes.updateDevChannel')}
|
||||
</Checkbox>
|
||||
{dev && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 8 }}
|
||||
title={t('pages.index.devChannelWarning')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -80,70 +43,12 @@ export default function NodesPage() {
|
||||
}, [messageApi]);
|
||||
|
||||
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
||||
const {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
fetchFingerprint,
|
||||
fetchInbounds,
|
||||
probe,
|
||||
updatePanels,
|
||||
} = useNodeMutations();
|
||||
|
||||
const { data: latestVersion = '' } = useQuery({
|
||||
queryKey: ['server', 'panelUpdateInfo'],
|
||||
queryFn: async () => {
|
||||
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
|
||||
return msg?.obj?.latestVersion || '';
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const { create, update, remove, setEnable, probe, updatePanels } = useNodeMutations();
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [mtlsOpen, setMtlsOpen] = useState(false);
|
||||
const [trustCa, setTrustCa] = useState('');
|
||||
const [copyingCa, setCopyingCa] = useState(false);
|
||||
const [savingTrustCa, setSavingTrustCa] = useState(false);
|
||||
|
||||
const onCopyNodeCa = useCallback(async () => {
|
||||
setCopyingCa(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
|
||||
const ca = msg?.obj?.caCert;
|
||||
if (msg?.success && ca) {
|
||||
await navigator.clipboard.writeText(ca);
|
||||
messageApi.success(t('pages.nodes.mtls.caCopied'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('pages.nodes.mtls.caFailed'));
|
||||
} finally {
|
||||
setCopyingCa(false);
|
||||
}
|
||||
}, [messageApi, t]);
|
||||
|
||||
const onSaveTrustCa = useCallback(async () => {
|
||||
setSavingTrustCa(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.nodes.mtls.saved'));
|
||||
setMtlsOpen(false);
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
setSavingTrustCa(false);
|
||||
}
|
||||
}, [trustCa, messageApi, t]);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
setFormMode('add');
|
||||
@@ -208,8 +113,6 @@ export default function NodesPage() {
|
||||
[setEnable],
|
||||
);
|
||||
|
||||
const devRef = useRef(false);
|
||||
|
||||
const runUpdate = useCallback(
|
||||
async (ids: number[], dev: boolean) => {
|
||||
const msg = await updatePanels(ids, dev);
|
||||
@@ -232,33 +135,16 @@ export default function NodesPage() {
|
||||
[updatePanels, messageApi, t],
|
||||
);
|
||||
|
||||
// Nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
const onUpdateNode = useCallback(
|
||||
(node: NodeRecord) => {
|
||||
// Agent nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
if (node.nodeType === 'agent') {
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: (
|
||||
<UpdateChannelChoice
|
||||
onChange={(v) => {
|
||||
devRef.current = v;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], devRef.current),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
},
|
||||
[modal, t, runUpdate],
|
||||
@@ -272,19 +158,12 @@ export default function NodesPage() {
|
||||
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
|
||||
content: (
|
||||
<UpdateChannelChoice
|
||||
onChange={(v) => {
|
||||
devRef.current = v;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate(eligible, devRef.current),
|
||||
onOk: () => runUpdate(eligible, false),
|
||||
});
|
||||
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
|
||||
|
||||
@@ -364,11 +243,9 @@ export default function NodesPage() {
|
||||
nodes={nodes}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
latestVersion={latestVersion}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onAdd={onAdd}
|
||||
onMtls={() => setMtlsOpen(true)}
|
||||
onProbeTasks={() => navigate('/settings#probe')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
@@ -388,44 +265,9 @@ export default function NodesPage() {
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
node={formNode}
|
||||
testConnection={testConnection}
|
||||
fetchFingerprint={fetchFingerprint}
|
||||
fetchInbounds={fetchInbounds}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={mtlsOpen}
|
||||
title={t('pages.nodes.mtls.title')}
|
||||
footer={null}
|
||||
onCancel={() => setMtlsOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
{t('pages.nodes.mtls.intro')}
|
||||
</Typography.Paragraph>
|
||||
<Button onClick={onCopyNodeCa} loading={copyingCa} style={{ marginBottom: 4 }}>
|
||||
{t('pages.nodes.mtls.copyCa')}
|
||||
</Button>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.nodes.mtls.copyCaHint')}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={trustCa}
|
||||
onChange={(e) => setTrustCa(e.target.value)}
|
||||
placeholder={t('pages.nodes.mtls.trustPlaceholder')}
|
||||
style={{ marginTop: 4, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||
{t('pages.nodes.mtls.trustHint')}
|
||||
</Typography.Paragraph>
|
||||
<Button type="primary" onClick={onSaveTrustCa} loading={savingTrustCa} block>
|
||||
{t('pages.nodes.mtls.save')}
|
||||
</Button>
|
||||
</Modal>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,6 @@ export const NodeRecordSchema = z
|
||||
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(),
|
||||
@@ -42,20 +40,8 @@ export const NodeRecordSchema = z
|
||||
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.
|
||||
// A node's stable GUID (online attribution is keyed on it).
|
||||
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(),
|
||||
// V2.1.0: probe-only agent nodes run no core, only the probe
|
||||
// channels. Annotated by the backend from the bound agent row.
|
||||
probeOnly: z.boolean().optional(),
|
||||
@@ -84,65 +70,24 @@ export const ProbeResultSchema = z
|
||||
})
|
||||
.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),
|
||||
// V2.1.0: probe-only toggle for agent nodes (see NodeRecordSchema).
|
||||
probeOnly: 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 const NodeFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
remark: z.string().optional(),
|
||||
// Every node is an agent node now (B6): the panel never dials it,
|
||||
// so the connection fields are not user-editable. They stay in the
|
||||
// form values so the submit payload carries the stored row's values
|
||||
// on edit (or the historical defaults on add); the backend
|
||||
// sanitizes the endpoint for agent rows regardless.
|
||||
scheme: z.enum(['http', 'https']).optional().default('https'),
|
||||
address: z.string().optional().default(''),
|
||||
port: z.number().optional(),
|
||||
basePath: z.string().optional().default('/'),
|
||||
allowPrivateAddress: z.boolean().optional().default(false),
|
||||
// V2.1.0: probe-only toggle (see NodeRecordSchema).
|
||||
probeOnly: z.boolean().optional().default(false),
|
||||
enable: z.boolean(),
|
||||
});
|
||||
|
||||
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||
export type ProbeResult = z.infer<typeof ProbeResultSchema>;
|
||||
|
||||
@@ -16,7 +16,6 @@ function renderNodes(nodes: NodeRecord[]) {
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onProbeTasks={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
@@ -49,7 +48,6 @@ describe('NodeList clients-cell category chips', () => {
|
||||
name: 'agent-node',
|
||||
enable: true,
|
||||
status: 'online',
|
||||
nodeType: 'agent',
|
||||
clientCount: 9,
|
||||
activeCount: 2,
|
||||
activeEmails: ['active-a@x', 'active-b@x'],
|
||||
|
||||
@@ -9,16 +9,16 @@ const noop = () => {};
|
||||
|
||||
function sampleNodes(): NodeRecord[] {
|
||||
return [
|
||||
{ id: 1, name: 'parent', guid: 'p1', transitive: false, enable: true, status: 'online' },
|
||||
{ id: 0, name: 'child-a', guid: 'ca', parentGuid: 'p1', transitive: true },
|
||||
{ id: 0, name: 'child-b', guid: 'cb', parentGuid: 'p1', transitive: true },
|
||||
{ id: 1, name: 'node-a', guid: 'p1', enable: true, status: 'online' },
|
||||
{ id: 2, name: 'node-b', guid: 'ca', enable: true, status: 'online' },
|
||||
{ id: 3, name: 'node-c', guid: 'cb', enable: true, status: 'online' },
|
||||
];
|
||||
}
|
||||
|
||||
describe('NodeList desktop table row keys', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('gives transitive sub-node rows distinct keys instead of colliding on id 0', () => {
|
||||
it('keys rows by node id without duplicate-key warnings', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderWithProviders(
|
||||
@@ -28,7 +28,6 @@ describe('NodeList desktop table row keys', () => {
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onProbeTasks={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
|
||||
Reference in New Issue
Block a user