diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index af87d17..125f19b 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -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> => updatePanelsMut.mutateAsync({ ids, dev }), - testConnection: async (payload: Partial): Promise> => { - const raw = await HttpUtil.post('/panel/api/nodes/test', payload); - return parseMsg(raw, ProbeResultSchema, 'nodes/test'); - }, - fetchFingerprint: (payload: Partial): Promise> => - HttpUtil.post('/panel/api/nodes/certFingerprint', payload), - fetchInbounds: (payload: Partial): Promise> => - HttpUtil.post('/panel/api/nodes/inbounds', payload), }; } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 2813a86..fb9f468 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -5,7 +5,6 @@ export const EXAMPLES: Record = { "expireDiff": 0, "pageSize": 0, "remarkTemplate": "", - "restartXrayOnClientDisable": false, "sessionMaxAge": 1, "subAnnounce": "", "subCertFile": "", @@ -55,7 +54,6 @@ export const EXAMPLES: Record = { "hasWarpSecret": false, "pageSize": 0, "remarkTemplate": "", - "restartXrayOnClientDisable": false, "sessionMaxAge": 1, "subAnnounce": "", "subCertFile": "", @@ -488,23 +486,14 @@ export const EXAMPLES: Record = { "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 = { ], "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 = { "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": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index e9c5dc9..668a95e 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -17,9 +17,6 @@ export const SCHEMAS: Record = { "remarkTemplate": { "type": "string" }, - "restartXrayOnClientDisable": { - "type": "boolean" - }, "sessionMaxAge": { "maximum": 525600, "minimum": 1, @@ -160,7 +157,6 @@ export const SCHEMAS: Record = { "expireDiff", "pageSize", "remarkTemplate", - "restartXrayOnClientDisable", "sessionMaxAge", "subAnnounce", "subCertFile", @@ -232,9 +228,6 @@ export const SCHEMAS: Record = { "remarkTemplate": { "type": "string" }, - "restartXrayOnClientDisable": { - "type": "boolean" - }, "sessionMaxAge": { "maximum": 525600, "minimum": 1, @@ -379,7 +372,6 @@ export const SCHEMAS: Record = { "hasWarpSecret", "pageSize", "remarkTemplate", - "restartXrayOnClientDisable", "sessionMaxAge", "subAnnounce", "subCertFile", @@ -2004,7 +1996,7 @@ export const SCHEMAS: Record = { "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 = { "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 = { "https" ], "type": "string" - }, - "tlsVerifyMode": { - "enum": [ - "verify", - "skip", - "pin", - "mtls" - ], - "type": "string" } }, "required": [ @@ -2089,20 +2043,15 @@ export const SCHEMAS: Record = { "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 = { "example": "node-guid", "type": "string" }, - "hasApiToken": { - "example": true, - "type": "boolean" - }, "id": { "example": 1, "type": "integer" @@ -2209,19 +2154,6 @@ export const SCHEMAS: Record = { "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 = { "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 = { }, "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 = { "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 = { "disabledCount", "enable", "guid", - "hasApiToken", "id", "inboundCount", - "inboundSyncMode", - "inboundTags", "lastError", "lastHeartbeat", "latencyMs", @@ -2367,14 +2288,12 @@ export const SCHEMAS: Record = { "netDown", "netUp", "onlineCount", - "outboundTag", "panelVersion", - "pinnedCertSha256", "port", "remark", "scheme", + "singboxVersion", "status", - "tlsVerifyMode", "updatedAt", "uptimeSecs", "xrayError", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 2ed9403..425d4a0 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 07fef6d..d35a1e0 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -15,12 +15,12 @@ export type Protocol = z.infer; export const SubLinkProviderSchema = z.unknown(); export type SubLinkProvider = z.infer; +export const geoipFetchResultSchema = z.number().int(); +export type geoipFetchResult = z.infer; + export const rawBytesSchema = z.array(z.number().int()); export type rawBytes = z.infer; -export const staticEgressResolverSchema = z.string(); -export type staticEgressResolver = z.infer; - export const trafficLocalApplyActionSchema = z.number().int(); export type trafficLocalApplyAction = z.infer; @@ -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; 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; @@ -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(), diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index d8f32be..33b3ab6 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -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')); diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index f6d9e9c..23860bc 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -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} /> - - setConfigTextOpen(false)} - footer={[ - , - , - ]} - > - - - ); diff --git a/frontend/src/pages/index/OverviewActionBar.tsx b/frontend/src/pages/index/OverviewActionBar.tsx index 4aa87fd..cca2ce8 100644 --- a/frontend/src/pages/index/OverviewActionBar.tsx +++ b/frontend/src/pages/index/OverviewActionBar.tsx @@ -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: , - text: t('pages.index.config'), - onClick: onOpenConfig, - }, { key: 'backup', icon: , diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 9bbb132..532bc08 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -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) => Promise>; - fetchFingerprint: (payload: Partial) => Promise>; - fetchInbounds: (payload: Partial) => Promise>; save: (payload: Partial) => Promise>; 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([]); - const [testResult, setTestResult] = useState(null); const [agentToken, setAgentToken] = useState(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), 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 { - const token = values.apiToken.trim(); - const agentMode = values.nodeType === 'agent'; - const payload: Partial = { + // 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({ >
- - - - - - - - - - - - - - - - - - )} + + + + + + + + + + + + - {!isAgent && ( - - - - - - - - - - - - - )} - {isAgent && ( - - - - - - - - - - - - - )} - - {mode === 'edit' && isAgent && shownInstall && ( + {mode === 'edit' && shownInstall && (
{t('pages.nodes.agentInstallCommand')}

{t('pages.nodes.agentMasterUrlHint')}

@@ -495,7 +282,7 @@ export default function NodeFormModal({
)} - {mode === 'edit' && isAgent && node?.id ? ( + {mode === 'edit' && node?.id ? (
{t('pages.nodes.probe.title')} @@ -506,174 +293,6 @@ export default function NodeFormModal({
) : null} - - {!isAgent && ( - - - - )} - - {!isAgent && ( - - - - - - ( - <> - - {menu} - - )} - options={inboundOptions.map((inbound) => ({ - value: inbound.tag, - label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`, - }))} - /> - - )} - - {!isAgent && ( -
- - {testResult && ( -
- {testResult.status === 'online' ? ( - - ) : ( - - )} -
- )} -
- )} diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index df31669..b342b0d 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -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): 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(null); const [expandedIds, setExpandedIds] = useState>(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(); - 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(() => { - 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(); - 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(); - 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(() => nodes.map((n) => ({ ...n, key: n.id })), [nodes]); function toggleExpanded(id: number) { setExpandedIds((prev) => { @@ -294,106 +251,74 @@ export default function NodeList({ title: t('pages.nodes.actions'), align: 'center', width: 150, - render: (_value, record) => - record.transitive ? ( - - } style={{ margin: 0 }}> - {t('pages.nodes.subNode')} - + render: (_value, record) => ( + + + - @@ -653,133 +520,109 @@ export default function NodeList({
{t('noData')}
) : ( - dataSource.map((record) => - record.transitive ? ( + dataSource.map((record) => ( +
+ {/* 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 */}
{ + if (!(e.target as HTMLElement).closest('.card-actions')) + toggleExpanded(record.id); + }} > -
- - - {record.name} -
- } style={{ margin: 0 }}> - {t('pages.nodes.subNode')} - -
-
-
- ) : ( -
- {/* 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 */} -
{ - if (!(e.target as HTMLElement).closest('.card-actions')) - toggleExpanded(record.id); - }} - > - toggleExpanded(record.id))} - /> - - {record.name} -
- - setStatsNode(record)} - onKeyDown={activateOnKey(() => setStatsNode(record))} - /> - - onToggleEnable(record, v)} + toggleExpanded(record.id))} + /> + + {record.name} +
+ + setStatsNode(record)} + onKeyDown={activateOnKey(() => setStatsNode(record))} /> - - {t('pages.nodes.testConnection')} - - ), - onClick: () => onProbe(record), - }, - ...(isUpdateEligible(record) - ? [ - { - key: 'update', - label: ( - <> - {' '} - {t( - record.nodeType === 'agent' - ? 'pages.nodes.updateAgent' - : 'pages.nodes.updatePanel', - )} - - ), - onClick: () => onUpdateNode(record), - }, - ] - : []), - { - key: 'edit', - label: ( - <> - {t('edit')} - - ), - onClick: () => onEdit(record), - }, - { - key: 'delete', - danger: true, - label: ( - <> - {t('delete')} - - ), - onClick: () => onDelete(record), - }, - ], - }} - > -
+ + onToggleEnable(record, v)} + /> + + {t('pages.nodes.testConnection')} + + ), + onClick: () => onProbe(record), + }, + ...(isUpdateEligible(record) + ? [ + { + key: 'update', + label: ( + <> + {t('pages.nodes.updateAgent')} + + ), + onClick: () => onUpdateNode(record), + }, + ] + : []), + { + key: 'edit', + label: ( + <> + {t('edit')} + + ), + onClick: () => onEdit(record), + }, + { + key: 'delete', + danger: true, + label: ( + <> + {t('delete')} + + ), + onClick: () => onDelete(record), + }, + ], + }} + > +
- - {expandedIds.has(record.id) && ( -
- -
- )}
- ), - ) + + {expandedIds.has(record.id) && ( +
+ +
+ )} +
+ )) )}
@@ -801,14 +644,9 @@ export default function NodeList({ )}
{t('pages.nodes.address')} - - {statsNode.url} - + + {agentIpText(statsNode)} + {showAddress ? (
{t('pages.nodes.coreVersion')} - - {statsNode.nodeType === 'agent' - ? statsNode.singboxVersion || statsNode.xrayVersion || '-' - : statsNode.xrayVersion || '-'} - + {statsNode.singboxVersion || statsNode.xrayVersion || '-'}
@@ -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) => , - rowExpandable: (record) => !record.transitive, }} /> )} diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index 3b42d03..7a047a1 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -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 ( -
-

{t('pages.nodes.updateConfirmContent')}

- { - setDev(e.target.checked); - onChange(e.target.checked); - }} - > - {t('pages.nodes.updateDevChannel')} - - {dev && ( - - )} -
- ); -} 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('/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(null); const [selectedIds, setSelectedIds] = useState([]); - const [mtlsOpen, setMtlsOpen] = useState(false); - const [trustCa, setTrustCa] = useState(''); - const [copyingCa, setCopyingCa] = useState(false); - const [savingTrustCa, setSavingTrustCa] = useState(false); - - const onCopyNodeCa = useCallback(async () => { - setCopyingCa(true); - try { - const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca'); - const ca = msg?.obj?.caCert; - if (msg?.success && ca) { - await navigator.clipboard.writeText(ca); - messageApi.success(t('pages.nodes.mtls.caCopied')); - } else { - messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed')); - } - } catch { - messageApi.error(t('pages.nodes.mtls.caFailed')); - } finally { - setCopyingCa(false); - } - }, [messageApi, t]); - - const onSaveTrustCa = useCallback(async () => { - setSavingTrustCa(true); - try { - const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa }); - if (msg?.success) { - messageApi.success(t('pages.nodes.mtls.saved')); - setMtlsOpen(false); - } else { - messageApi.error(msg?.msg || t('somethingWentWrong')); - } - } catch { - messageApi.error(t('somethingWentWrong')); - } finally { - setSavingTrustCa(false); - } - }, [trustCa, messageApi, t]); const onAdd = useCallback(() => { setFormMode('add'); @@ -208,8 +113,6 @@ export default function NodesPage() { [setEnable], ); - const devRef = useRef(false); - const runUpdate = useCallback( async (ids: number[], dev: boolean) => { const msg = await updatePanels(ids, dev); @@ -232,33 +135,16 @@ export default function NodesPage() { [updatePanels, messageApi, t], ); + // Nodes update through the master-recorded intent (item 20): + // there is no channel to pick, so the confirm is a plain message. const onUpdateNode = useCallback( (node: NodeRecord) => { - // Agent nodes update through the master-recorded intent (item 20): - // there is no channel to pick, so the confirm is a plain message. - if (node.nodeType === 'agent') { - modal.confirm({ - title: t('pages.nodes.updateConfirmTitle', { count: 1 }), - content: t('pages.nodes.updateAgentConfirmContent'), - okText: t('update'), - cancelText: t('cancel'), - onOk: () => runUpdate([node.id], false), - }); - return; - } - devRef.current = false; modal.confirm({ title: t('pages.nodes.updateConfirmTitle', { count: 1 }), - content: ( - { - devRef.current = v; - }} - /> - ), + content: t('pages.nodes.updateAgentConfirmContent'), okText: t('update'), cancelText: t('cancel'), - onOk: () => runUpdate([node.id], devRef.current), + onOk: () => runUpdate([node.id], false), }); }, [modal, t, runUpdate], @@ -272,19 +158,12 @@ export default function NodesPage() { messageApi.warning(t('pages.nodes.toasts.updateNoneEligible')); return; } - devRef.current = false; modal.confirm({ title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }), - content: ( - { - 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} /> - - setMtlsOpen(false)} - destroyOnHidden - > - - {t('pages.nodes.mtls.intro')} - - - - {t('pages.nodes.mtls.copyCaHint')} - - {t('pages.nodes.mtls.trustLabel')} - setTrustCa(e.target.value)} - placeholder={t('pages.nodes.mtls.trustPlaceholder')} - style={{ marginTop: 4, fontFamily: 'monospace' }} - /> - - {t('pages.nodes.mtls.trustHint')} - - - ); diff --git a/frontend/src/schemas/node.ts b/frontend/src/schemas/node.ts index 6e2d2e2..a756b07 100644 --- a/frontend/src/schemas/node.ts +++ b/frontend/src/schemas/node.ts @@ -9,8 +9,6 @@ export const NodeRecordSchema = z address: z.string().optional(), port: z.number().optional(), basePath: z.string().optional(), - apiToken: z.string().optional(), - hasApiToken: z.boolean().optional(), enable: z.boolean().optional(), status: z.string().optional(), latencyMs: z.number().optional(), @@ -42,20 +40,8 @@ export const NodeRecordSchema = z xrayState: z.string().optional(), xrayError: z.string().optional(), allowPrivateAddress: z.boolean().optional(), - tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(), - pinnedCertSha256: z.string().optional(), - inboundSyncMode: z.enum(['all', 'selected']).optional(), - // Backend serializes a nil []string as null for nodes saved before #5178. - inboundTags: z.array(z.string()).nullish(), - outboundTag: z.string().optional(), - // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and - // whether it's a read-only transitive sub-node surfaced from a downstream node. + // A node's stable GUID (online attribution is keyed on it). guid: z.string().optional(), - parentGuid: z.string().optional(), - transitive: z.boolean().optional(), - // P7: node kind, annotated by the backend for nodes registered by an - // agent. Absent/traditional for classic panel-to-panel nodes. - nodeType: z.enum(['traditional', 'agent']).optional(), // V2.1.0: probe-only agent nodes run no core, only the probe // channels. Annotated by the backend from the bound agent row. probeOnly: z.boolean().optional(), @@ -84,65 +70,24 @@ export const ProbeResultSchema = z }) .loose(); -export const NodeFormSchema = z - .object({ - id: z.number().optional(), - name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), - remark: z.string().optional(), - // Node type: traditional panel-to-panel, or lightweight agent. - // Agent nodes skip the panel probe and API token; an agent token is - // issued on creation instead. - nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'), - scheme: z.enum(['http', 'https']), - // For agent nodes the panel never dials the node (agent dials out), - // so address/port are not needed. Required for traditional nodes. - address: z.string().trim().optional().default(''), - port: z.number().int().min(1).max(65535).optional(), - basePath: z.string(), - // mTLS nodes authenticate via the client certificate, so the token is optional - // there; every other verify mode still requires one (matches remote.do()). - // Agent nodes use an agent token instead of a panel API token. - apiToken: z.string().trim(), - hasStoredToken: z.boolean().optional().default(false), - // V2.1.0: probe-only toggle for agent nodes (see NodeRecordSchema). - probeOnly: z.boolean().optional().default(false), - enable: z.boolean(), - allowPrivateAddress: z.boolean(), - tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']), - pinnedCertSha256: z.string().optional().default(''), - inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'), - // Unmounted when sync mode is "all" (absent from antd onFinish values) and - // serialized as null by the backend for a nil slice — tolerate both. - inboundTags: z - .array(z.string()) - .nullish() - .transform((tags) => tags ?? []), - outboundTag: z.string().optional(), - }) - .superRefine((val, ctx) => { - if (val.nodeType === 'agent') return; // no panel credentials needed - if (!val.address || val.address.trim().length === 0) { - ctx.addIssue({ - code: 'custom', - path: ['address'], - message: 'pages.nodes.toasts.fillRequired', - }); - } - if (val.port === undefined) { - ctx.addIssue({ - code: 'custom', - path: ['port'], - message: 'pages.nodes.toasts.fillRequired', - }); - } - if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) { - ctx.addIssue({ - code: 'custom', - path: ['apiToken'], - message: 'pages.nodes.toasts.fillRequired', - }); - } - }); +export const NodeFormSchema = z.object({ + id: z.number().optional(), + name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), + remark: z.string().optional(), + // Every node is an agent node now (B6): the panel never dials it, + // so the connection fields are not user-editable. They stay in the + // form values so the submit payload carries the stored row's values + // on edit (or the historical defaults on add); the backend + // sanitizes the endpoint for agent rows regardless. + scheme: z.enum(['http', 'https']).optional().default('https'), + address: z.string().optional().default(''), + port: z.number().optional(), + basePath: z.string().optional().default('/'), + allowPrivateAddress: z.boolean().optional().default(false), + // V2.1.0: probe-only toggle (see NodeRecordSchema). + probeOnly: z.boolean().optional().default(false), + enable: z.boolean(), +}); export type NodeRecord = z.infer; export type ProbeResult = z.infer; diff --git a/frontend/src/test/node-list-category-chips.test.tsx b/frontend/src/test/node-list-category-chips.test.tsx index 5ee90b1..65c4cf5 100644 --- a/frontend/src/test/node-list-category-chips.test.tsx +++ b/frontend/src/test/node-list-category-chips.test.tsx @@ -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'], diff --git a/frontend/src/test/node-list-rowkey.test.tsx b/frontend/src/test/node-list-rowkey.test.tsx index 6c402a1..f49b63d 100644 --- a/frontend/src/test/node-list-rowkey.test.tsx +++ b/frontend/src/test/node-list-rowkey.test.tsx @@ -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}