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;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteInboundOption {
|
|
||||||
tag: string;
|
|
||||||
remark?: string;
|
|
||||||
protocol?: string;
|
|
||||||
port?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNodeMutations() {
|
export function useNodeMutations() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
@@ -91,13 +84,5 @@ export function useNodeMutations() {
|
|||||||
probe: (id: number) => probeMut.mutateAsync(id),
|
probe: (id: number) => probeMut.mutateAsync(id),
|
||||||
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> =>
|
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> =>
|
||||||
updatePanelsMut.mutateAsync({ ids, dev }),
|
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,
|
"expireDiff": 0,
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
"restartXrayOnClientDisable": false,
|
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
@@ -55,7 +54,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"hasWarpSecret": false,
|
"hasWarpSecret": false,
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
"restartXrayOnClientDisable": false,
|
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
@@ -488,23 +486,14 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"NodeMutationRequest": {
|
"NodeMutationRequest": {
|
||||||
"address": "",
|
"address": "",
|
||||||
"allowPrivateAddress": false,
|
"allowPrivateAddress": false,
|
||||||
"apiToken": null,
|
|
||||||
"basePath": "",
|
"basePath": "",
|
||||||
"clearApiToken": false,
|
|
||||||
"enable": false,
|
"enable": false,
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"inboundSyncMode": "all",
|
|
||||||
"inboundTags": [
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"name": "",
|
"name": "",
|
||||||
"nodeType": "traditional",
|
|
||||||
"outboundTag": "",
|
|
||||||
"pinnedCertSha256": "",
|
|
||||||
"port": 0,
|
"port": 0,
|
||||||
|
"probeOnly": null,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"scheme": "http",
|
"scheme": "http"
|
||||||
"tlsVerifyMode": "verify"
|
|
||||||
},
|
},
|
||||||
"NodeView": {
|
"NodeView": {
|
||||||
"activeCount": 20,
|
"activeCount": 20,
|
||||||
@@ -532,13 +521,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"guid": "node-guid",
|
"guid": "node-guid",
|
||||||
"hasApiToken": true,
|
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"inboundCount": 3,
|
"inboundCount": 3,
|
||||||
"inboundSyncMode": "all",
|
|
||||||
"inboundTags": [
|
|
||||||
"in-443-tcp"
|
|
||||||
],
|
|
||||||
"ipv4": "203.0.113.7",
|
"ipv4": "203.0.113.7",
|
||||||
"ipv6": "2001:db8::7",
|
"ipv6": "2001:db8::7",
|
||||||
"lastError": "",
|
"lastError": "",
|
||||||
@@ -548,21 +532,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"name": "edge-1",
|
"name": "edge-1",
|
||||||
"netDown": 1048576,
|
"netDown": 1048576,
|
||||||
"netUp": 2097152,
|
"netUp": 2097152,
|
||||||
"nodeType": "traditional",
|
"nodeType": "agent",
|
||||||
"onlineCount": 5,
|
"onlineCount": 5,
|
||||||
"onlineEmails": [
|
"onlineEmails": [
|
||||||
"a@example.com"
|
"a@example.com"
|
||||||
],
|
],
|
||||||
"outboundTag": "direct",
|
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"parentGuid": "",
|
|
||||||
"pinnedCertSha256": "",
|
|
||||||
"port": 2053,
|
"port": 2053,
|
||||||
|
"probeOnly": false,
|
||||||
"remark": "Primary edge",
|
"remark": "Primary edge",
|
||||||
"scheme": "https",
|
"scheme": "https",
|
||||||
|
"singboxVersion": "1.13.21",
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
|
||||||
"transitive": false,
|
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"remarkTemplate": {
|
"remarkTemplate": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"restartXrayOnClientDisable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"sessionMaxAge": {
|
"sessionMaxAge": {
|
||||||
"maximum": 525600,
|
"maximum": 525600,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -160,7 +157,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"expireDiff",
|
"expireDiff",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
"restartXrayOnClientDisable",
|
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
@@ -232,9 +228,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"remarkTemplate": {
|
"remarkTemplate": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"restartXrayOnClientDisable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"sessionMaxAge": {
|
"sessionMaxAge": {
|
||||||
"maximum": 525600,
|
"maximum": 525600,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -379,7 +372,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"hasWarpSecret",
|
"hasWarpSecret",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
"restartXrayOnClientDisable",
|
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
@@ -2004,7 +1996,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"NodeMutationRequest": {
|
"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": {
|
"properties": {
|
||||||
"address": {
|
"address": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2012,57 +2004,28 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"allowPrivateAddress": {
|
"allowPrivateAddress": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"apiToken": {
|
|
||||||
"nullable": true,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"basePath": {
|
"basePath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"clearApiToken": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"enable": {
|
"enable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"inboundSyncMode": {
|
|
||||||
"enum": [
|
|
||||||
"all",
|
|
||||||
"selected"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"inboundTags": {
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"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": {
|
"port": {
|
||||||
"maximum": 65535,
|
"maximum": 65535,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"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": {
|
"remark": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2072,15 +2035,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"https"
|
"https"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"tlsVerifyMode": {
|
|
||||||
"enum": [
|
|
||||||
"verify",
|
|
||||||
"skip",
|
|
||||||
"pin",
|
|
||||||
"mtls"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -2089,20 +2043,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"basePath",
|
"basePath",
|
||||||
"enable",
|
"enable",
|
||||||
"id",
|
"id",
|
||||||
"inboundSyncMode",
|
|
||||||
"inboundTags",
|
|
||||||
"name",
|
"name",
|
||||||
"outboundTag",
|
|
||||||
"pinnedCertSha256",
|
|
||||||
"port",
|
"port",
|
||||||
"remark",
|
"remark",
|
||||||
"scheme",
|
"scheme"
|
||||||
"tlsVerifyMode"
|
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"NodeView": {
|
"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": {
|
"properties": {
|
||||||
"activeCount": {
|
"activeCount": {
|
||||||
"example": 20,
|
"example": 20,
|
||||||
@@ -2197,10 +2146,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": "node-guid",
|
"example": "node-guid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"hasApiToken": {
|
|
||||||
"example": true,
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"id": {
|
"id": {
|
||||||
"example": 1,
|
"example": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2209,19 +2154,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 3,
|
"example": 3,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"inboundSyncMode": {
|
|
||||||
"example": "all",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"inboundTags": {
|
|
||||||
"example": [
|
|
||||||
"in-443-tcp"
|
|
||||||
],
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"ipv4": {
|
"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.",
|
"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",
|
"example": "203.0.113.7",
|
||||||
@@ -2262,8 +2194,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"nodeType": {
|
"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.",
|
"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": "traditional",
|
"example": "agent",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"onlineCount": {
|
"onlineCount": {
|
||||||
@@ -2280,24 +2212,19 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"outboundTag": {
|
|
||||||
"example": "direct",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"panelVersion": {
|
"panelVersion": {
|
||||||
"example": "v3.x.x",
|
"example": "v3.x.x",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"parentGuid": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"pinnedCertSha256": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"port": {
|
"port": {
|
||||||
"example": 2053,
|
"example": 2053,
|
||||||
"type": "integer"
|
"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": {
|
"remark": {
|
||||||
"example": "Primary edge",
|
"example": "Primary edge",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2306,18 +2233,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": "https",
|
"example": "https",
|
||||||
"type": "string"
|
"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": {
|
"status": {
|
||||||
"example": "online",
|
"example": "online",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"tlsVerifyMode": {
|
|
||||||
"example": "verify",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"transitive": {
|
|
||||||
"example": false,
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"example": 1700003600,
|
"example": 1700003600,
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -2354,11 +2278,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"disabledCount",
|
"disabledCount",
|
||||||
"enable",
|
"enable",
|
||||||
"guid",
|
"guid",
|
||||||
"hasApiToken",
|
|
||||||
"id",
|
"id",
|
||||||
"inboundCount",
|
"inboundCount",
|
||||||
"inboundSyncMode",
|
|
||||||
"inboundTags",
|
|
||||||
"lastError",
|
"lastError",
|
||||||
"lastHeartbeat",
|
"lastHeartbeat",
|
||||||
"latencyMs",
|
"latencyMs",
|
||||||
@@ -2367,14 +2288,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"netDown",
|
"netDown",
|
||||||
"netUp",
|
"netUp",
|
||||||
"onlineCount",
|
"onlineCount",
|
||||||
"outboundTag",
|
|
||||||
"panelVersion",
|
"panelVersion",
|
||||||
"pinnedCertSha256",
|
|
||||||
"port",
|
"port",
|
||||||
"remark",
|
"remark",
|
||||||
"scheme",
|
"scheme",
|
||||||
|
"singboxVersion",
|
||||||
"status",
|
"status",
|
||||||
"tlsVerifyMode",
|
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"uptimeSecs",
|
"uptimeSecs",
|
||||||
"xrayError",
|
"xrayError",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ export type OnlineAPISupport = number;
|
|||||||
export type ProcessState = string;
|
export type ProcessState = string;
|
||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
export type SubLinkProvider = unknown;
|
export type SubLinkProvider = unknown;
|
||||||
|
export type geoipFetchResult = number;
|
||||||
export type rawBytes = number[];
|
export type rawBytes = number[];
|
||||||
export type staticEgressResolver = string;
|
|
||||||
export type trafficLocalApplyAction = number;
|
export type trafficLocalApplyAction = number;
|
||||||
export type transportBits = number;
|
export type transportBits = number;
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ export interface AllSetting {
|
|||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
|
||||||
sessionMaxAge: number;
|
sessionMaxAge: number;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
@@ -65,7 +64,6 @@ export interface AllSettingView {
|
|||||||
hasWarpSecret: boolean;
|
hasWarpSecret: boolean;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
|
||||||
sessionMaxAge: number;
|
sessionMaxAge: number;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
@@ -457,21 +455,14 @@ export interface Node {
|
|||||||
export interface NodeMutationRequest {
|
export interface NodeMutationRequest {
|
||||||
address: string;
|
address: string;
|
||||||
allowPrivateAddress: boolean;
|
allowPrivateAddress: boolean;
|
||||||
apiToken?: string | null;
|
|
||||||
basePath: string;
|
basePath: string;
|
||||||
clearApiToken?: boolean;
|
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
inboundSyncMode: string;
|
|
||||||
inboundTags: string[];
|
|
||||||
name: string;
|
name: string;
|
||||||
nodeType?: string;
|
|
||||||
outboundTag: string;
|
|
||||||
pinnedCertSha256: string;
|
|
||||||
port: number;
|
port: number;
|
||||||
|
probeOnly?: boolean | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
scheme: string;
|
scheme: string;
|
||||||
tlsVerifyMode: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeView {
|
export interface NodeView {
|
||||||
@@ -494,11 +485,8 @@ export interface NodeView {
|
|||||||
disabledEmails?: string[];
|
disabledEmails?: string[];
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
guid: string;
|
guid: string;
|
||||||
hasApiToken: boolean;
|
|
||||||
id: number;
|
id: number;
|
||||||
inboundCount: number;
|
inboundCount: number;
|
||||||
inboundSyncMode: string;
|
|
||||||
inboundTags: string[];
|
|
||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
@@ -511,16 +499,13 @@ export interface NodeView {
|
|||||||
nodeType?: string;
|
nodeType?: string;
|
||||||
onlineCount: number;
|
onlineCount: number;
|
||||||
onlineEmails?: string[];
|
onlineEmails?: string[];
|
||||||
outboundTag: string;
|
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
parentGuid?: string;
|
|
||||||
pinnedCertSha256: string;
|
|
||||||
port: number;
|
port: number;
|
||||||
|
probeOnly?: boolean;
|
||||||
remark: string;
|
remark: string;
|
||||||
scheme: string;
|
scheme: string;
|
||||||
|
singboxVersion: string;
|
||||||
status: string;
|
status: string;
|
||||||
tlsVerifyMode: string;
|
|
||||||
transitive?: boolean;
|
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
uptimeSecs: number;
|
uptimeSecs: number;
|
||||||
xrayError: string;
|
xrayError: string;
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
|||||||
export const SubLinkProviderSchema = z.unknown();
|
export const SubLinkProviderSchema = z.unknown();
|
||||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
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 const rawBytesSchema = z.array(z.number().int());
|
||||||
export type rawBytes = z.infer<typeof rawBytesSchema>;
|
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 const trafficLocalApplyActionSchema = z.number().int();
|
||||||
export type trafficLocalApplyAction = z.infer<typeof trafficLocalApplyActionSchema>;
|
export type trafficLocalApplyAction = z.infer<typeof trafficLocalApplyActionSchema>;
|
||||||
|
|
||||||
@@ -32,7 +32,6 @@ export const AllSettingSchema = z.object({
|
|||||||
expireDiff: z.number().int().min(0),
|
expireDiff: z.number().int().min(0),
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
@@ -84,7 +83,6 @@ export const AllSettingViewSchema = z.object({
|
|||||||
hasWarpSecret: z.boolean(),
|
hasWarpSecret: z.boolean(),
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
@@ -499,21 +497,14 @@ export type Node = z.infer<typeof NodeSchema>;
|
|||||||
export const NodeMutationRequestSchema = z.object({
|
export const NodeMutationRequestSchema = z.object({
|
||||||
address: z.string(),
|
address: z.string(),
|
||||||
allowPrivateAddress: z.boolean(),
|
allowPrivateAddress: z.boolean(),
|
||||||
apiToken: z.string().nullable().optional(),
|
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
clearApiToken: z.boolean().optional(),
|
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
inboundSyncMode: z.enum(['all', 'selected']),
|
|
||||||
inboundTags: z.array(z.string()),
|
|
||||||
name: 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),
|
port: z.number().int().min(0).max(65535),
|
||||||
|
probeOnly: z.boolean().nullable().optional(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
scheme: z.enum(['http', 'https']),
|
scheme: z.enum(['http', 'https']),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
|
||||||
});
|
});
|
||||||
export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
||||||
|
|
||||||
@@ -537,11 +528,8 @@ export const NodeViewSchema = z.object({
|
|||||||
disabledEmails: z.array(z.string()).optional(),
|
disabledEmails: z.array(z.string()).optional(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
guid: z.string(),
|
guid: z.string(),
|
||||||
hasApiToken: z.boolean(),
|
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
inboundCount: z.number().int(),
|
inboundCount: z.number().int(),
|
||||||
inboundSyncMode: z.string(),
|
|
||||||
inboundTags: z.array(z.string()),
|
|
||||||
ipv4: z.string().optional(),
|
ipv4: z.string().optional(),
|
||||||
ipv6: z.string().optional(),
|
ipv6: z.string().optional(),
|
||||||
lastError: z.string(),
|
lastError: z.string(),
|
||||||
@@ -554,16 +542,13 @@ export const NodeViewSchema = z.object({
|
|||||||
nodeType: z.string().optional(),
|
nodeType: z.string().optional(),
|
||||||
onlineCount: z.number().int(),
|
onlineCount: z.number().int(),
|
||||||
onlineEmails: z.array(z.string()).optional(),
|
onlineEmails: z.array(z.string()).optional(),
|
||||||
outboundTag: z.string(),
|
|
||||||
panelVersion: z.string(),
|
panelVersion: z.string(),
|
||||||
parentGuid: z.string().optional(),
|
|
||||||
pinnedCertSha256: z.string(),
|
|
||||||
port: z.number().int(),
|
port: z.number().int(),
|
||||||
|
probeOnly: z.boolean().optional(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
scheme: z.string(),
|
scheme: z.string(),
|
||||||
|
singboxVersion: z.string(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
tlsVerifyMode: z.string(),
|
|
||||||
transitive: z.boolean().optional(),
|
|
||||||
updatedAt: z.number().int(),
|
updatedAt: z.number().int(),
|
||||||
uptimeSecs: z.number().int(),
|
uptimeSecs: z.number().int(),
|
||||||
xrayError: z.string(),
|
xrayError: z.string(),
|
||||||
|
|||||||
@@ -290,12 +290,15 @@ export function useSecurityActions({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
/*
|
/*
|
||||||
* Node-assigned inbounds run on the node, so their cert files must be the
|
* Only a local inbound can borrow this panel's own cert paths.
|
||||||
* node's own paths (fetched through the central panel), not this panel's.
|
* 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 =
|
const msg =
|
||||||
typeof nodeId === 'number'
|
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 });
|
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
if (!msg?.success) {
|
if (!msg?.success) {
|
||||||
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
|
import { Button, ConfigProvider, Layout, Result, Spin, message } from 'antd';
|
||||||
import {
|
import { DashboardOutlined, DatabaseOutlined, HddOutlined, SwapOutlined } from '@ant-design/icons';
|
||||||
CopyOutlined,
|
|
||||||
CloudDownloadOutlined,
|
|
||||||
DashboardOutlined,
|
|
||||||
DatabaseOutlined,
|
|
||||||
HddOutlined,
|
|
||||||
SwapOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
|
import { HttpUtil, CPUFormatter, SizeFormatter } from '@/utils';
|
||||||
import {
|
import {
|
||||||
USAGE_CRIT_COLOR,
|
USAGE_CRIT_COLOR,
|
||||||
USAGE_CRIT_PERCENT,
|
USAGE_CRIT_PERCENT,
|
||||||
@@ -30,7 +23,6 @@ import ConnectionsCard from './ConnectionsCard';
|
|||||||
import SystemStrip from './SystemStrip';
|
import SystemStrip from './SystemStrip';
|
||||||
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
|
||||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||||
const BackupModal = lazy(() => import('./BackupModal'));
|
const BackupModal = lazy(() => import('./BackupModal'));
|
||||||
import './IndexPage.css';
|
import './IndexPage.css';
|
||||||
@@ -59,8 +51,6 @@ export default function IndexPage() {
|
|||||||
const [showIp, setShowIp] = useState(false);
|
const [showIp, setShowIp] = useState(false);
|
||||||
const [backupOpen, setBackupOpen] = useState(false);
|
const [backupOpen, setBackupOpen] = useState(false);
|
||||||
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
|
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
|
||||||
const [configTextOpen, setConfigTextOpen] = useState(false);
|
|
||||||
const [configText, setConfigText] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
@@ -95,27 +85,6 @@ export default function IndexPage() {
|
|||||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
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 pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
||||||
const totalDisk = status.disk.total;
|
const totalDisk = status.disk.total;
|
||||||
const freeDisk = Math.max(0, totalDisk - status.disk.current);
|
const freeDisk = Math.max(0, totalDisk - status.disk.current);
|
||||||
@@ -183,7 +152,6 @@ export default function IndexPage() {
|
|||||||
: (panelUpdateInfo.displayUpdateAvailable ??
|
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||||
panelUpdateInfo.updateAvailable)
|
panelUpdateInfo.updateAvailable)
|
||||||
}
|
}
|
||||||
onOpenConfig={openConfig}
|
|
||||||
onOpenBackup={() => setBackupOpen(true)}
|
onOpenBackup={() => setBackupOpen(true)}
|
||||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -290,42 +258,6 @@ export default function IndexPage() {
|
|||||||
onBusy={setBusy}
|
onBusy={setBusy}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</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>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Tag, Tooltip } from 'antd';
|
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';
|
import { formatPanelVersion } from '@/lib/panel-version';
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ interface OverviewActionBarProps {
|
|||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
onOpenConfig: () => void;
|
|
||||||
onOpenBackup: () => void;
|
onOpenBackup: () => void;
|
||||||
onOpenPanelUpdate: () => void;
|
onOpenPanelUpdate: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,7 +27,6 @@ export default function OverviewActionBar({
|
|||||||
panelVersion,
|
panelVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
onOpenConfig,
|
|
||||||
onOpenBackup,
|
onOpenBackup,
|
||||||
onOpenPanelUpdate,
|
onOpenPanelUpdate,
|
||||||
}: OverviewActionBarProps) {
|
}: OverviewActionBarProps) {
|
||||||
@@ -36,12 +34,6 @@ export default function OverviewActionBar({
|
|||||||
const size = isMobile ? ('small' as const) : ('middle' as const);
|
const size = isMobile ? ('small' as const) : ('middle' as const);
|
||||||
|
|
||||||
const actions: BarAction[] = [
|
const actions: BarAction[] = [
|
||||||
{
|
|
||||||
key: 'config',
|
|
||||||
icon: <ControlOutlined />,
|
|
||||||
text: t('pages.index.config'),
|
|
||||||
onClick: onOpenConfig,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'backup',
|
key: 'backup',
|
||||||
icon: <CloudServerOutlined />,
|
icon: <CloudServerOutlined />,
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import { Alert, Button, Col, Form, Input, Modal, Row, Switch, message } from 'antd';
|
||||||
Alert,
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
Button,
|
|
||||||
Col,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Modal,
|
|
||||||
Row,
|
|
||||||
Select,
|
|
||||||
Switch,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
|
||||||
import { HttpUtil, type Msg } from '@/utils';
|
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 { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
|
||||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||||
|
|
||||||
import './NodeFormModal.css';
|
import './NodeFormModal.css';
|
||||||
@@ -31,9 +17,6 @@ interface NodeFormModalProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
node: NodeRecord | null;
|
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>>;
|
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -43,21 +26,13 @@ function defaultValues(): NodeFormValues {
|
|||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
nodeType: 'traditional',
|
|
||||||
scheme: 'https',
|
scheme: 'https',
|
||||||
address: '',
|
address: '',
|
||||||
port: 2053,
|
port: 2053,
|
||||||
basePath: '/',
|
basePath: '/',
|
||||||
apiToken: '',
|
|
||||||
hasStoredToken: false,
|
|
||||||
probeOnly: false,
|
probeOnly: false,
|
||||||
enable: true,
|
enable: true,
|
||||||
allowPrivateAddress: false,
|
allowPrivateAddress: false,
|
||||||
tlsVerifyMode: 'verify',
|
|
||||||
pinnedCertSha256: '',
|
|
||||||
inboundSyncMode: 'all',
|
|
||||||
inboundTags: [],
|
|
||||||
outboundTag: '',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +40,6 @@ export default function NodeFormModal({
|
|||||||
open,
|
open,
|
||||||
mode,
|
mode,
|
||||||
node,
|
node,
|
||||||
testConnection,
|
|
||||||
fetchFingerprint,
|
|
||||||
fetchInbounds,
|
|
||||||
save,
|
save,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NodeFormModalProps) {
|
}: NodeFormModalProps) {
|
||||||
@@ -77,11 +49,6 @@ export default function NodeFormModal({
|
|||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 [agentToken, setAgentToken] = useState<string | null>(null);
|
||||||
const [agentNodeName, setAgentNodeName] = useState('');
|
const [agentNodeName, setAgentNodeName] = useState('');
|
||||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||||
@@ -96,34 +63,6 @@ export default function NodeFormModal({
|
|||||||
hasToken: boolean;
|
hasToken: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [editMasterUrl, setEditMasterUrl] = useState('');
|
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.
|
// 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);
|
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>),
|
...(node as unknown as Partial<NodeFormValues>),
|
||||||
id: node.id,
|
id: node.id,
|
||||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
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;
|
: base;
|
||||||
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
|
||||||
methods.reset(next);
|
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
|
// full install command can be re-displayed (item 19). Nodes created
|
||||||
// before tokens were stored answer hasToken=false until the agent's
|
// before tokens were stored answer hasToken=false until the agent's
|
||||||
// next authenticated request backfills the token.
|
// next authenticated request backfills the token.
|
||||||
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
|
const editNodeId = mode === 'edit' ? (node?.id ?? 0) : 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !editNodeId) return;
|
if (!open || !editNodeId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -195,84 +127,22 @@ export default function NodeFormModal({
|
|||||||
[mode, t],
|
[mode, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken);
|
|
||||||
|
|
||||||
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||||
const token = values.apiToken.trim();
|
// Agent nodes have no panel address; the backend ignores these
|
||||||
const agentMode = values.nodeType === 'agent';
|
// (and sanitizes the stored row), so the payload keeps the
|
||||||
const payload: Partial<NodeRecord> = {
|
// historical placeholder pair.
|
||||||
|
return {
|
||||||
id: values.id || 0,
|
id: values.id || 0,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
remark: values.remark?.trim() || '',
|
remark: values.remark?.trim() || '',
|
||||||
nodeType: values.nodeType,
|
|
||||||
scheme: values.scheme,
|
scheme: values.scheme,
|
||||||
// Agent nodes have no panel address; the backend ignores these.
|
address: 'agent',
|
||||||
address: agentMode ? 'agent' : values.address.trim(),
|
port: 2053,
|
||||||
port: agentMode ? 2053 : values.port,
|
basePath: values.basePath?.trim() || '/',
|
||||||
basePath: values.basePath.trim() || '/',
|
|
||||||
enable: values.enable,
|
enable: values.enable,
|
||||||
allowPrivateAddress: values.allowPrivateAddress,
|
allowPrivateAddress: values.allowPrivateAddress,
|
||||||
tlsVerifyMode: values.tlsVerifyMode,
|
probeOnly: values.probeOnly ?? false,
|
||||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
|
||||||
inboundSyncMode: values.inboundSyncMode,
|
|
||||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
|
||||||
outboundTag: values.outboundTag || '',
|
|
||||||
};
|
};
|
||||||
// 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) {
|
async function onFinish(values: NodeFormValues) {
|
||||||
@@ -284,26 +154,13 @@ export default function NodeFormModal({
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload(result.data);
|
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);
|
const msg = await save(payload);
|
||||||
if (msg?.success) {
|
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
|
const obj = msg.obj as unknown as
|
||||||
| { agentToken?: string; node?: { id?: number } }
|
| { agentToken?: string; node?: { id?: number } }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
if (mode === 'add' && obj?.agentToken) {
|
||||||
setAgentToken(obj.agentToken);
|
setAgentToken(obj.agentToken);
|
||||||
setAgentNodeName(result.data.name.trim());
|
setAgentNodeName(result.data.name.trim());
|
||||||
// Default the master URL to the address this panel is being
|
// Default the master URL to the address this panel is being
|
||||||
@@ -347,27 +204,12 @@ export default function NodeFormModal({
|
|||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Row gutter={16}>
|
<Alert
|
||||||
<Col xs={24}>
|
type="info"
|
||||||
<FormField label={t('pages.nodes.nodeType')} name="nodeType">
|
showIcon
|
||||||
<Select
|
style={{ marginBottom: 16 }}
|
||||||
disabled={mode === 'edit'}
|
title={t('pages.nodes.agentHint')}
|
||||||
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')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -385,80 +227,25 @@ export default function NodeFormModal({
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{!isAgent && (
|
<Row gutter={16}>
|
||||||
<Row gutter={16}>
|
<Col xs={24} md={12}>
|
||||||
<Col xs={24} md={6}>
|
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||||
<FormField
|
<Switch />
|
||||||
label={t('pages.nodes.scheme')}
|
</FormField>
|
||||||
name="scheme"
|
</Col>
|
||||||
onAfterChange={(value) => {
|
<Col xs={24} md={12}>
|
||||||
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
<FormField
|
||||||
}}
|
label={t('pages.nodes.probeOnly')}
|
||||||
>
|
name="probeOnly"
|
||||||
<Select
|
valueProp="checked"
|
||||||
options={[
|
tooltip={t('pages.nodes.probeOnlyHint')}
|
||||||
{ value: 'https', label: 'https' },
|
>
|
||||||
{ value: 'http', label: 'http' },
|
<Switch />
|
||||||
]}
|
</FormField>
|
||||||
/>
|
</Col>
|
||||||
</FormField>
|
</Row>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAgent && (
|
{mode === 'edit' && shownInstall && (
|
||||||
<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 && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
||||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||||
@@ -495,7 +282,7 @@ export default function NodeFormModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'edit' && isAgent && node?.id ? (
|
{mode === 'edit' && node?.id ? (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||||
{t('pages.nodes.probe.title')}
|
{t('pages.nodes.probe.title')}
|
||||||
@@ -506,174 +293,6 @@ export default function NodeFormModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</Form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import type { BadgeProps } from 'antd';
|
import type { BadgeProps } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
ApartmentOutlined,
|
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CloudDownloadOutlined,
|
CloudDownloadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
@@ -29,14 +28,12 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
RadarChartOutlined,
|
RadarChartOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
SafetyCertificateOutlined,
|
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import NodeHistoryPanel from './NodeHistoryPanel';
|
import NodeHistoryPanel from './NodeHistoryPanel';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
|
||||||
import { activateOnKey } from '@/utils/a11y';
|
import { activateOnKey } from '@/utils/a11y';
|
||||||
import './NodeList.css';
|
import './NodeList.css';
|
||||||
|
|
||||||
@@ -44,11 +41,9 @@ interface NodeListProps {
|
|||||||
nodes: NodeRecord[];
|
nodes: NodeRecord[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
latestVersion?: string;
|
|
||||||
selectedIds: number[];
|
selectedIds: number[];
|
||||||
onSelectionChange: (ids: number[]) => void;
|
onSelectionChange: (ids: number[]) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onMtls: () => void;
|
|
||||||
onProbeTasks: () => void;
|
onProbeTasks: () => void;
|
||||||
onEdit: (node: NodeRecord) => void;
|
onEdit: (node: NodeRecord) => void;
|
||||||
onDelete: (node: NodeRecord) => void;
|
onDelete: (node: NodeRecord) => void;
|
||||||
@@ -102,10 +97,16 @@ function CategoryChip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface NodeRow extends NodeRecord {
|
interface NodeRow extends NodeRecord {
|
||||||
url: string;
|
|
||||||
key: string | number;
|
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'] {
|
function badgeStatus(status?: string): BadgeProps['status'] {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'online':
|
case 'online':
|
||||||
@@ -215,11 +216,9 @@ export default function NodeList({
|
|||||||
nodes,
|
nodes,
|
||||||
loading = false,
|
loading = false,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
latestVersion = '',
|
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
onMtls,
|
|
||||||
onProbeTasks,
|
onProbeTasks,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -235,49 +234,7 @@ export default function NodeList({
|
|||||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Map a node GUID to its display name so a transitive sub-node can show which
|
const dataSource = useMemo<NodeRow[]>(() => nodes.map((n) => ({ ...n, key: n.id })), [nodes]);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
function toggleExpanded(id: number) {
|
function toggleExpanded(id: number) {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
@@ -294,106 +251,74 @@ export default function NodeList({
|
|||||||
title: t('pages.nodes.actions'),
|
title: t('pages.nodes.actions'),
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (_value, record) =>
|
render: (_value, record) => (
|
||||||
record.transitive ? (
|
<Space>
|
||||||
<Tooltip
|
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||||
title={t('pages.nodes.subNodeTip', {
|
<Button
|
||||||
parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-',
|
type="text"
|
||||||
})}
|
size="small"
|
||||||
>
|
style={{ fontSize: 16 }}
|
||||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
icon={<ThunderboltOutlined />}
|
||||||
{t('pages.nodes.subNode')}
|
aria-label={t('pages.nodes.testConnection')}
|
||||||
</Tag>
|
onClick={() => onProbe(record)}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
{isUpdateEligible(record) && (
|
||||||
<Space>
|
<Tooltip title={t('pages.nodes.updateAgent')}>
|
||||||
<Tooltip title={t('pages.nodes.testConnection')}>
|
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ fontSize: 16 }}
|
style={{ fontSize: 16 }}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<CloudDownloadOutlined />}
|
||||||
aria-label={t('pages.nodes.testConnection')}
|
aria-label={t('pages.nodes.updateAgent')}
|
||||||
onClick={() => onProbe(record)}
|
onClick={() => onUpdateNode(record)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isUpdateEligible(record) && (
|
)}
|
||||||
<Tooltip
|
<Tooltip title={t('edit')}>
|
||||||
title={t(
|
<Button
|
||||||
record.nodeType === 'agent'
|
type="text"
|
||||||
? 'pages.nodes.updateAgent'
|
size="small"
|
||||||
: 'pages.nodes.updatePanel',
|
style={{ fontSize: 16 }}
|
||||||
)}
|
icon={<EditOutlined />}
|
||||||
>
|
aria-label={t('edit')}
|
||||||
<Button
|
onClick={() => onEdit(record)}
|
||||||
type="text"
|
/>
|
||||||
size="small"
|
</Tooltip>
|
||||||
style={{ fontSize: 16 }}
|
<Tooltip title={t('delete')}>
|
||||||
icon={<CloudDownloadOutlined />}
|
<Button
|
||||||
aria-label={t(
|
type="text"
|
||||||
record.nodeType === 'agent'
|
size="small"
|
||||||
? 'pages.nodes.updateAgent'
|
danger
|
||||||
: 'pages.nodes.updatePanel',
|
style={{ fontSize: 16 }}
|
||||||
)}
|
icon={<DeleteOutlined />}
|
||||||
onClick={() => onUpdateNode(record)}
|
aria-label={t('delete')}
|
||||||
/>
|
onClick={() => onDelete(record)}
|
||||||
</Tooltip>
|
/>
|
||||||
)}
|
</Tooltip>
|
||||||
<Tooltip title={t('edit')}>
|
</Space>
|
||||||
<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'),
|
title: t('pages.nodes.enable'),
|
||||||
dataIndex: 'enable',
|
dataIndex: 'enable',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 64,
|
width: 64,
|
||||||
render: (_value, record) =>
|
render: (_value, record) => (
|
||||||
record.transitive ? (
|
<Switch
|
||||||
<span style={{ opacity: 0.4 }}>—</span>
|
checked={!!record.enable}
|
||||||
) : (
|
size="small"
|
||||||
<Switch
|
onChange={(v) => onToggleEnable(record, v)}
|
||||||
checked={!!record.enable}
|
/>
|
||||||
size="small"
|
),
|
||||||
onChange={(v) => onToggleEnable(record, v)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.nodes.name'),
|
title: t('pages.nodes.name'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => (
|
||||||
<div
|
<div className="name-cell">
|
||||||
className="name-cell"
|
<span className="name">{record.name}</span>
|
||||||
style={record.transitive ? { paddingInlineStart: 20 } : undefined}
|
|
||||||
>
|
|
||||||
<span className="name">
|
|
||||||
{record.transitive && (
|
|
||||||
<ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />
|
|
||||||
)}
|
|
||||||
{record.name}
|
|
||||||
</span>
|
|
||||||
{record.remark && <span className="remark">{record.remark}</span>}
|
{record.remark && <span className="remark">{record.remark}</span>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -425,37 +350,22 @@ export default function NodeList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
dataIndex: 'url',
|
dataIndex: 'address',
|
||||||
width: 330,
|
width: 330,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (_value, record) => {
|
render: (_value, record) => {
|
||||||
if (record.nodeType === 'agent') {
|
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<a
|
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||||
href={record.url}
|
{ips.length > 0
|
||||||
target="_blank"
|
? ips.map((ip, i) => (
|
||||||
rel="noopener noreferrer"
|
<span key={ip}>
|
||||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||||
>
|
{ip}
|
||||||
{record.url}
|
</span>
|
||||||
</a>
|
))
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -493,58 +403,29 @@ export default function NodeList({
|
|||||||
render: (_value, record) => formatPct(record.memPct),
|
render: (_value, record) => formatPct(record.memPct),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Core version (V2.0.1 ②): agent rows run sing-box and show
|
// Core version (V2.0.1 ②): rows run sing-box and show its
|
||||||
// its version; traditional rows keep showing the xray one.
|
// version. Probe-only rows (V2.1.0) run no core by design: the
|
||||||
// Probe-only agent rows (V2.1.0) run no core by design: the
|
|
||||||
// cell says so instead of a bare '-', which reads as a fault.
|
// cell says so instead of a bare '-', which reads as a fault.
|
||||||
title: t('pages.nodes.coreVersion'),
|
title: t('pages.nodes.coreVersion'),
|
||||||
dataIndex: 'xrayVersion',
|
dataIndex: 'xrayVersion',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_value, record) =>
|
render: (_value, record) =>
|
||||||
record.nodeType === 'agent'
|
record.probeOnly ? (
|
||||||
? record.probeOnly
|
<Tag color="cyan" style={{ margin: 0 }}>
|
||||||
? (
|
{t('pages.nodes.probeOnly')}
|
||||||
<Tag color="cyan" style={{ margin: 0 }}>
|
</Tag>
|
||||||
{t('pages.nodes.probeOnly')}
|
) : (
|
||||||
</Tag>
|
record.singboxVersion || record.xrayVersion || '-'
|
||||||
)
|
),
|
||||||
: record.singboxVersion || record.xrayVersion || '-'
|
|
||||||
: 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',
|
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||||
dataIndex: 'panelVersion',
|
dataIndex: 'panelVersion',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_value, record) => {
|
render: (_value, record) => <span>{record.panelVersion || '-'}</span>,
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.nodes.uptime'),
|
title: t('pages.nodes.uptime'),
|
||||||
@@ -611,18 +492,7 @@ export default function NodeList({
|
|||||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode],
|
||||||
t,
|
|
||||||
showAddress,
|
|
||||||
relativeTime,
|
|
||||||
latestVersion,
|
|
||||||
onToggleEnable,
|
|
||||||
onProbe,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onUpdateNode,
|
|
||||||
nameByGuid,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -631,9 +501,6 @@ export default function NodeList({
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||||
{t('pages.nodes.addNode')}
|
{t('pages.nodes.addNode')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
|
||||||
{t('pages.nodes.mtls.title')}
|
|
||||||
</Button>
|
|
||||||
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
|
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
|
||||||
{t('pages.nodes.probeTasks.title')}
|
{t('pages.nodes.probeTasks.title')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -653,133 +520,109 @@ export default function NodeList({
|
|||||||
<div>{t('noData')}</div>
|
<div>{t('noData')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dataSource.map((record) =>
|
dataSource.map((record) => (
|
||||||
record.transitive ? (
|
<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
|
<div
|
||||||
key={String(record.key)}
|
className="card-head"
|
||||||
className="node-card"
|
onClick={(e) => {
|
||||||
style={{ paddingInlineStart: 16, opacity: 0.85 }}
|
if (!(e.target as HTMLElement).closest('.card-actions'))
|
||||||
|
toggleExpanded(record.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="card-head">
|
<RightOutlined
|
||||||
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
role="button"
|
||||||
<span className="node-name">{record.name}</span>
|
tabIndex={0}
|
||||||
<div className="card-actions">
|
aria-expanded={expandedIds.has(record.id)}
|
||||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
aria-label={record.name}
|
||||||
{t('pages.nodes.subNode')}
|
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||||
</Tag>
|
/>
|
||||||
</div>
|
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||||
</div>
|
<span className="node-name">{record.name}</span>
|
||||||
</div>
|
<div className="card-actions">
|
||||||
) : (
|
<Tooltip title={t('info')}>
|
||||||
<div key={record.id} className="node-card">
|
<InfoCircleOutlined
|
||||||
{/* 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 */}
|
className="row-action-trigger"
|
||||||
<div
|
role="button"
|
||||||
className="card-head"
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
aria-label={t('info')}
|
||||||
if (!(e.target as HTMLElement).closest('.card-actions'))
|
onClick={() => setStatsNode(record)}
|
||||||
toggleExpanded(record.id);
|
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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)}
|
|
||||||
/>
|
/>
|
||||||
<Dropdown
|
</Tooltip>
|
||||||
trigger={['click']}
|
<Switch
|
||||||
placement="bottomRight"
|
checked={!!record.enable}
|
||||||
menu={{
|
size="small"
|
||||||
items: [
|
onChange={(v) => onToggleEnable(record, v)}
|
||||||
{
|
/>
|
||||||
key: 'probe',
|
<Dropdown
|
||||||
label: (
|
trigger={['click']}
|
||||||
<>
|
placement="bottomRight"
|
||||||
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
menu={{
|
||||||
</>
|
items: [
|
||||||
),
|
{
|
||||||
onClick: () => onProbe(record),
|
key: 'probe',
|
||||||
},
|
label: (
|
||||||
...(isUpdateEligible(record)
|
<>
|
||||||
? [
|
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||||
{
|
</>
|
||||||
key: 'update',
|
),
|
||||||
label: (
|
onClick: () => onProbe(record),
|
||||||
<>
|
},
|
||||||
<CloudDownloadOutlined />{' '}
|
...(isUpdateEligible(record)
|
||||||
{t(
|
? [
|
||||||
record.nodeType === 'agent'
|
{
|
||||||
? 'pages.nodes.updateAgent'
|
key: 'update',
|
||||||
: 'pages.nodes.updatePanel',
|
label: (
|
||||||
)}
|
<>
|
||||||
</>
|
<CloudDownloadOutlined /> {t('pages.nodes.updateAgent')}
|
||||||
),
|
</>
|
||||||
onClick: () => onUpdateNode(record),
|
),
|
||||||
},
|
onClick: () => onUpdateNode(record),
|
||||||
]
|
},
|
||||||
: []),
|
]
|
||||||
{
|
: []),
|
||||||
key: 'edit',
|
{
|
||||||
label: (
|
key: 'edit',
|
||||||
<>
|
label: (
|
||||||
<EditOutlined /> {t('edit')}
|
<>
|
||||||
</>
|
<EditOutlined /> {t('edit')}
|
||||||
),
|
</>
|
||||||
onClick: () => onEdit(record),
|
),
|
||||||
},
|
onClick: () => onEdit(record),
|
||||||
{
|
},
|
||||||
key: 'delete',
|
{
|
||||||
danger: true,
|
key: 'delete',
|
||||||
label: (
|
danger: true,
|
||||||
<>
|
label: (
|
||||||
<DeleteOutlined /> {t('delete')}
|
<>
|
||||||
</>
|
<DeleteOutlined /> {t('delete')}
|
||||||
),
|
</>
|
||||||
onClick: () => onDelete(record),
|
),
|
||||||
},
|
onClick: () => onDelete(record),
|
||||||
],
|
},
|
||||||
}}
|
],
|
||||||
>
|
}}
|
||||||
<Button
|
>
|
||||||
type="text"
|
<Button
|
||||||
size="small"
|
type="text"
|
||||||
className="row-action-trigger"
|
size="small"
|
||||||
icon={<MoreOutlined />}
|
className="row-action-trigger"
|
||||||
aria-label={t('more')}
|
icon={<MoreOutlined />}
|
||||||
/>
|
aria-label={t('more')}
|
||||||
</Dropdown>
|
/>
|
||||||
</div>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedIds.has(record.id) && (
|
|
||||||
<div className="card-history">
|
|
||||||
<NodeHistoryPanel node={record} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
|
||||||
)
|
{expandedIds.has(record.id) && (
|
||||||
|
<div className="card-history">
|
||||||
|
<NodeHistoryPanel node={record} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -801,14 +644,9 @@ export default function NodeList({
|
|||||||
)}
|
)}
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.nodes.address')}</span>
|
<span className="stat-label">{t('pages.nodes.address')}</span>
|
||||||
<a
|
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||||
href={statsNode.url}
|
{agentIpText(statsNode)}
|
||||||
target="_blank"
|
</span>
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
|
||||||
>
|
|
||||||
{statsNode.url}
|
|
||||||
</a>
|
|
||||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||||
{showAddress ? (
|
{showAddress ? (
|
||||||
<EyeOutlined
|
<EyeOutlined
|
||||||
@@ -854,11 +692,7 @@ export default function NodeList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
|
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
|
||||||
<Tag>
|
<Tag>{statsNode.singboxVersion || statsNode.xrayVersion || '-'}</Tag>
|
||||||
{statsNode.nodeType === 'agent'
|
|
||||||
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
|
|
||||||
: statsNode.xrayVersion || '-'}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">
|
<span className="stat-label">
|
||||||
@@ -928,7 +762,7 @@ export default function NodeList({
|
|||||||
onChange: (keys) =>
|
onChange: (keys) =>
|
||||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||||
getCheckboxProps: (record) => ({
|
getCheckboxProps: (record) => ({
|
||||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
disabled: !isUpdateEligible(record),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -943,7 +777,6 @@ export default function NodeList({
|
|||||||
}}
|
}}
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Checkbox,
|
|
||||||
Col,
|
Col,
|
||||||
ConfigProvider,
|
ConfigProvider,
|
||||||
Input,
|
|
||||||
Layout,
|
Layout,
|
||||||
Modal,
|
Modal,
|
||||||
Result,
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -35,38 +30,6 @@ import AppSidebar from '@/layouts/AppSidebar';
|
|||||||
import NodeList from './NodeList';
|
import NodeList from './NodeList';
|
||||||
import NodeFormModal from './NodeFormModal';
|
import NodeFormModal from './NodeFormModal';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
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() {
|
export default function NodesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -80,70 +43,12 @@ export default function NodesPage() {
|
|||||||
}, [messageApi]);
|
}, [messageApi]);
|
||||||
|
|
||||||
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
||||||
const {
|
const { create, update, remove, setEnable, probe, updatePanels } = useNodeMutations();
|
||||||
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 [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
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(() => {
|
const onAdd = useCallback(() => {
|
||||||
setFormMode('add');
|
setFormMode('add');
|
||||||
@@ -208,8 +113,6 @@ export default function NodesPage() {
|
|||||||
[setEnable],
|
[setEnable],
|
||||||
);
|
);
|
||||||
|
|
||||||
const devRef = useRef(false);
|
|
||||||
|
|
||||||
const runUpdate = useCallback(
|
const runUpdate = useCallback(
|
||||||
async (ids: number[], dev: boolean) => {
|
async (ids: number[], dev: boolean) => {
|
||||||
const msg = await updatePanels(ids, dev);
|
const msg = await updatePanels(ids, dev);
|
||||||
@@ -232,33 +135,16 @@ export default function NodesPage() {
|
|||||||
[updatePanels, messageApi, t],
|
[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(
|
const onUpdateNode = useCallback(
|
||||||
(node: NodeRecord) => {
|
(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({
|
modal.confirm({
|
||||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||||
content: (
|
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||||
<UpdateChannelChoice
|
|
||||||
onChange={(v) => {
|
|
||||||
devRef.current = v;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
okText: t('update'),
|
okText: t('update'),
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: () => runUpdate([node.id], devRef.current),
|
onOk: () => runUpdate([node.id], false),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[modal, t, runUpdate],
|
[modal, t, runUpdate],
|
||||||
@@ -272,19 +158,12 @@ export default function NodesPage() {
|
|||||||
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
|
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
devRef.current = false;
|
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
|
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
|
||||||
content: (
|
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||||
<UpdateChannelChoice
|
|
||||||
onChange={(v) => {
|
|
||||||
devRef.current = v;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
okText: t('update'),
|
okText: t('update'),
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: () => runUpdate(eligible, devRef.current),
|
onOk: () => runUpdate(eligible, false),
|
||||||
});
|
});
|
||||||
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
|
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
|
||||||
|
|
||||||
@@ -364,11 +243,9 @@ export default function NodesPage() {
|
|||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
latestVersion={latestVersion}
|
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
onAdd={onAdd}
|
onAdd={onAdd}
|
||||||
onMtls={() => setMtlsOpen(true)}
|
|
||||||
onProbeTasks={() => navigate('/settings#probe')}
|
onProbeTasks={() => navigate('/settings#probe')}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -388,44 +265,9 @@ export default function NodesPage() {
|
|||||||
open={formOpen}
|
open={formOpen}
|
||||||
mode={formMode}
|
mode={formMode}
|
||||||
node={formNode}
|
node={formNode}
|
||||||
testConnection={testConnection}
|
|
||||||
fetchFingerprint={fetchFingerprint}
|
|
||||||
fetchInbounds={fetchInbounds}
|
|
||||||
save={onSave}
|
save={onSave}
|
||||||
onOpenChange={setFormOpen}
|
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>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export const NodeRecordSchema = z
|
|||||||
address: z.string().optional(),
|
address: z.string().optional(),
|
||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
basePath: z.string().optional(),
|
basePath: z.string().optional(),
|
||||||
apiToken: z.string().optional(),
|
|
||||||
hasApiToken: z.boolean().optional(),
|
|
||||||
enable: z.boolean().optional(),
|
enable: z.boolean().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
latencyMs: z.number().optional(),
|
latencyMs: z.number().optional(),
|
||||||
@@ -42,20 +40,8 @@ export const NodeRecordSchema = z
|
|||||||
xrayState: z.string().optional(),
|
xrayState: z.string().optional(),
|
||||||
xrayError: z.string().optional(),
|
xrayError: z.string().optional(),
|
||||||
allowPrivateAddress: z.boolean().optional(),
|
allowPrivateAddress: z.boolean().optional(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(),
|
// A node's stable GUID (online attribution is keyed on it).
|
||||||
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(),
|
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
|
// V2.1.0: probe-only agent nodes run no core, only the probe
|
||||||
// channels. Annotated by the backend from the bound agent row.
|
// channels. Annotated by the backend from the bound agent row.
|
||||||
probeOnly: z.boolean().optional(),
|
probeOnly: z.boolean().optional(),
|
||||||
@@ -84,65 +70,24 @@ export const ProbeResultSchema = z
|
|||||||
})
|
})
|
||||||
.loose();
|
.loose();
|
||||||
|
|
||||||
export const NodeFormSchema = z
|
export const NodeFormSchema = z.object({
|
||||||
.object({
|
id: z.number().optional(),
|
||||||
id: z.number().optional(),
|
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
remark: z.string().optional(),
|
||||||
remark: z.string().optional(),
|
// Every node is an agent node now (B6): the panel never dials it,
|
||||||
// Node type: traditional panel-to-panel, or lightweight agent.
|
// so the connection fields are not user-editable. They stay in the
|
||||||
// Agent nodes skip the panel probe and API token; an agent token is
|
// form values so the submit payload carries the stored row's values
|
||||||
// issued on creation instead.
|
// on edit (or the historical defaults on add); the backend
|
||||||
nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'),
|
// sanitizes the endpoint for agent rows regardless.
|
||||||
scheme: z.enum(['http', 'https']),
|
scheme: z.enum(['http', 'https']).optional().default('https'),
|
||||||
// For agent nodes the panel never dials the node (agent dials out),
|
address: z.string().optional().default(''),
|
||||||
// so address/port are not needed. Required for traditional nodes.
|
port: z.number().optional(),
|
||||||
address: z.string().trim().optional().default(''),
|
basePath: z.string().optional().default('/'),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
allowPrivateAddress: z.boolean().optional().default(false),
|
||||||
basePath: z.string(),
|
// V2.1.0: probe-only toggle (see NodeRecordSchema).
|
||||||
// mTLS nodes authenticate via the client certificate, so the token is optional
|
probeOnly: z.boolean().optional().default(false),
|
||||||
// there; every other verify mode still requires one (matches remote.do()).
|
enable: z.boolean(),
|
||||||
// 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 type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||||
export type ProbeResult = z.infer<typeof ProbeResultSchema>;
|
export type ProbeResult = z.infer<typeof ProbeResultSchema>;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ function renderNodes(nodes: NodeRecord[]) {
|
|||||||
selectedIds={[]}
|
selectedIds={[]}
|
||||||
onSelectionChange={noop}
|
onSelectionChange={noop}
|
||||||
onAdd={noop}
|
onAdd={noop}
|
||||||
onMtls={noop}
|
|
||||||
onProbeTasks={noop}
|
onProbeTasks={noop}
|
||||||
onEdit={noop}
|
onEdit={noop}
|
||||||
onDelete={noop}
|
onDelete={noop}
|
||||||
@@ -49,7 +48,6 @@ describe('NodeList clients-cell category chips', () => {
|
|||||||
name: 'agent-node',
|
name: 'agent-node',
|
||||||
enable: true,
|
enable: true,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
nodeType: 'agent',
|
|
||||||
clientCount: 9,
|
clientCount: 9,
|
||||||
activeCount: 2,
|
activeCount: 2,
|
||||||
activeEmails: ['active-a@x', 'active-b@x'],
|
activeEmails: ['active-a@x', 'active-b@x'],
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ const noop = () => {};
|
|||||||
|
|
||||||
function sampleNodes(): NodeRecord[] {
|
function sampleNodes(): NodeRecord[] {
|
||||||
return [
|
return [
|
||||||
{ id: 1, name: 'parent', guid: 'p1', transitive: false, enable: true, status: 'online' },
|
{ id: 1, name: 'node-a', guid: 'p1', enable: true, status: 'online' },
|
||||||
{ id: 0, name: 'child-a', guid: 'ca', parentGuid: 'p1', transitive: true },
|
{ id: 2, name: 'node-b', guid: 'ca', enable: true, status: 'online' },
|
||||||
{ id: 0, name: 'child-b', guid: 'cb', parentGuid: 'p1', transitive: true },
|
{ id: 3, name: 'node-c', guid: 'cb', enable: true, status: 'online' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('NodeList desktop table row keys', () => {
|
describe('NodeList desktop table row keys', () => {
|
||||||
afterEach(() => vi.restoreAllMocks());
|
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(() => {});
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
@@ -28,7 +28,6 @@ describe('NodeList desktop table row keys', () => {
|
|||||||
selectedIds={[]}
|
selectedIds={[]}
|
||||||
onSelectionChange={noop}
|
onSelectionChange={noop}
|
||||||
onAdd={noop}
|
onAdd={noop}
|
||||||
onMtls={noop}
|
|
||||||
onProbeTasks={noop}
|
onProbeTasks={noop}
|
||||||
onEdit={noop}
|
onEdit={noop}
|
||||||
onDelete={noop}
|
onDelete={noop}
|
||||||
|
|||||||
Reference in New Issue
Block a user