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