B6 frontend: agent-only node form/list, drop mTLS modal and homepage config button (rider 5); regenerate contracts

This commit is contained in:
Muse
2026-10-23 19:08:26 +00:00
parent 4c04cdb1bc
commit ef89259a49
14 changed files with 303 additions and 1285 deletions
@@ -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 -24
View File
@@ -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": "",
+21 -102
View File
@@ -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 -19
View File
@@ -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;
+6 -21
View File
@@ -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'));
+3 -71
View File
@@ -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 />,
+40 -421
View File
@@ -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>
+186 -353
View File
@@ -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,
}} }}
/> />
)} )}
+8 -166
View File
@@ -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>
); );
+19 -74
View File
@@ -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'],
+4 -5
View File
@@ -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}