From 6ddd2da2e8bf021e3c2d316f2e768997739e99ab Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 1 Jun 2026 14:39:33 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yaml | 2 +- .../nodes/components/node-detail-page.tsx | 533 ++----- .../features/nodes/components/node-shared.tsx | 177 +++ .../nodes/components/relay-detail-page.tsx | 1381 +++++++++++++++++ .../nodes/components/tunnel-detail-page.tsx | 1307 ++++++++++++++++ openflare_server/web/features/nodes/utils.ts | 26 + 6 files changed, 3019 insertions(+), 407 deletions(-) create mode 100644 openflare_server/web/features/nodes/components/node-shared.tsx create mode 100644 openflare_server/web/features/nodes/components/relay-detail-page.tsx create mode 100644 openflare_server/web/features/nodes/components/tunnel-detail-page.tsx diff --git a/docker-compose.yaml b/docker-compose.yaml index cf9b87ff..111dccfa 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -48,5 +48,5 @@ services: - ./openflared/data/:/app/data environment: OPENFLARE_SERVER_URL: "http://host.docker.internal:3000" - OPENFLARE_TUNNEL_TOKEN: 4888482fd0e1e3331111e51a8db1333b + OPENFLARE_TUNNEL_TOKEN: deb0783ac1e264a9d86440169aca0f09 diff --git a/openflare_server/web/features/nodes/components/node-detail-page.tsx b/openflare_server/web/features/nodes/components/node-detail-page.tsx index 56633ceb..2ee728e5 100644 --- a/openflare_server/web/features/nodes/components/node-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/node-detail-page.tsx @@ -32,8 +32,8 @@ import { } from '@/features/nodes/api/nodes'; import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; import type { + NodeItem, NodeAgentReleaseInfo, - NodeObservability, } from '@/features/nodes/types'; import { CodeBlock, @@ -53,8 +53,6 @@ import { import { buildNodeInstallCommand, buildNodeDockerInstallCommand, - buildRelayInstallCommand, - buildRelayDockerInstallCommand, getApplyLabel, getApplyVariant, getNodeStatusLabel, @@ -66,183 +64,76 @@ import { isMeaningfulTime, } from '@/features/nodes/utils'; +import { + copyToClipboard, + formatUsageRatio, + formatUptime, + formatTrendHour, + aggregateTrafficBreakdown, + getErrorMessage, + getHealthEventLabel, + getHealthEventVariant, + FeedbackState, + HealthEventFilter, + NodeDetailTab, + MetricBar, + SummaryStat, +} from './node-shared'; +import { RelayDetailPage } from './relay-detail-page'; +import { TunnelDetailPage } from './tunnel-detail-page'; + const nodesQueryKey = ['nodes']; -type FeedbackState = { - tone: 'info' | 'success' | 'danger'; - message: string; -}; - -type HealthEventFilter = 'all' | 'active' | 'resolved'; -type NodeDetailTab = 'dashboard' | 'info'; - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '请求失败,请稍后重试。'; -} - -async function copyToClipboard(value: string) { - await navigator.clipboard.writeText(value); -} - -function formatUsageRatio(used?: number | null, total?: number | null) { - if (!used || !total || total <= 0) { - return null; - } - return Math.max(0, Math.min(100, (used / total) * 100)); -} - -function formatUptime(seconds?: number | null) { - if (!seconds || seconds <= 0) { - return '—'; - } - - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - if (days > 0) { - return `${days} 天 ${hours} 小时`; - } - if (hours > 0) { - return `${hours} 小时 ${minutes} 分钟`; - } - return `${minutes} 分钟`; -} - -function formatTrendHour(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return '—'; - } - return `${date.getHours().toString().padStart(2, '0')}:00`; -} - -function parseTrafficMap(value?: string | null) { - if (!value) { - return {} as Record; - } - try { - const parsed = JSON.parse(value) as Record; - return Object.entries(parsed).reduce>( - (result, [key, count]) => { - if (typeof count === 'number' && Number.isFinite(count)) { - result[key] = count; - } - return result; - }, - {}, - ); - } catch { - return {} as Record; - } -} - -function aggregateTrafficBreakdown( - reports: NodeObservability['traffic_reports'], - field: 'status_codes_json' | 'top_domains_json', -) { - const summary = new Map(); - for (const report of reports) { - const parsed = parseTrafficMap(report[field]); - for (const [key, value] of Object.entries(parsed)) { - summary.set(key, (summary.get(key) ?? 0) + value); - } - } - return Array.from(summary.entries()) - .sort((left, right) => { - if (right[1] === left[1]) { - return left[0].localeCompare(right[0]); - } - return right[1] - left[1]; - }) - .slice(0, 6) - .map(([label, value]) => ({ label, value })); -} - -function getHealthEventVariant( - event: NodeObservability['health_events'][number], -): 'success' | 'warning' | 'danger' | 'info' { - if (event.status === 'resolved') { - return 'success'; - } - if (event.severity === 'critical') { - return 'danger'; - } - if (event.severity === 'warning') { - return 'warning'; - } - return 'info'; -} - -function getHealthEventLabel( - event: NodeObservability['health_events'][number], -) { - return event.event_type.replaceAll('_', ' '); -} - -function MetricBar({ - label, - value, - progress, - hint, -}: { - label: string; - value: string; - progress?: number | null; - hint?: string; -}) { - return ( -
-
-
-

- {label} -

- {hint ? ( -

- {hint} -

- ) : null} -
-

- {value} -

-
-
-
-
-
- ); -} - -function SummaryStat({ - label, - value, - hint, -}: { - label: string; - value: string; - hint: string; -}) { - return ( -
-

- {label} -

-

- {value} -

-

{hint}

-
- ); -} - export function NodeDetailPage({ nodeId }: { nodeId: string }) { + const nodesQuery = useQuery({ + queryKey: nodesQueryKey, + queryFn: getNodes, + refetchInterval: 5000, + }); + + const node = useMemo(() => { + return ( + (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null + ); + }, [nodeId, nodesQuery.data]); + + if (nodesQuery.isLoading) { + return ; + } + + if (nodesQuery.isError) { + return ( + + ); + } + + if (!node) { + return ( + + ); + } + + if (node.node_type === 'tunnel_relay') { + return ; + } + + if (node.node_type === 'tunnel_client') { + return ; + } + + return ; +} + +function EdgeNodeDetailPage({ node }: { node: NodeItem }) { const router = useRouter(); const queryClient = useQueryClient(); + const nodeId = String(node.id); const [feedback, setFeedback] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); @@ -258,12 +149,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] = useState(false); - const nodesQuery = useQuery({ - queryKey: nodesQueryKey, - queryFn: getNodes, - refetchInterval: 5000, - }); - const stableAgentReleaseQuery = useQuery({ queryKey: ['node-agent-release', nodeId, 'stable'], queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'), @@ -276,21 +161,14 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { enabled: false, }); - const node = useMemo(() => { - return ( - (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null - ); - }, [nodeId, nodesQuery.data]); - const applyLogsQuery = useQuery({ - queryKey: ['apply-logs', node?.node_id ?? '', 1, 10], + queryKey: ['apply-logs', node.node_id, 1, 10], queryFn: () => getApplyLogs({ - node_id: node?.node_id, + node_id: node.node_id, pageNo: 1, pageSize: 10, }), - enabled: Boolean(node?.node_id), refetchInterval: 5000, }); @@ -304,7 +182,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { queryKey: ['node-observability', nodeId], queryFn: () => getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }), - enabled: Boolean(nodeId), refetchInterval: 10000, }); @@ -418,10 +295,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { }); const handleDelete = () => { - if (!node) { - return; - } - if ( !window.confirm( `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, @@ -435,10 +308,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { }; const handleRestartOpenresty = () => { - if (!node) { - return; - } - if ( !window.confirm( `确认向节点“${node.name}”下发 OpenResty 重启指令吗?该指令会在下一次心跳后执行。`, @@ -534,40 +403,14 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { [], ); - if (nodesQuery.isLoading) { - return ; - } - - if (nodesQuery.isError) { - return ( - - ); - } - - if (!node) { - return ( - - ); - } - const normalizedServerUrl = getServerUrl(serverUrl); const nodeInstallCommand = normalizedServerUrl && node.agent_token - ? node.node_type === 'tunnel_relay' - ? buildRelayInstallCommand(normalizedServerUrl, node.agent_token) - : buildNodeInstallCommand(normalizedServerUrl, node.agent_token) + ? buildNodeInstallCommand(normalizedServerUrl, node.agent_token) : ''; const nodeDockerInstallCommand = normalizedServerUrl && node.agent_token - ? node.node_type === 'tunnel_relay' - ? buildRelayDockerInstallCommand(normalizedServerUrl, node.agent_token) - : buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token) + ? buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token) : ''; const updateMode = getUpdateMode(node); const selectedAgentRelease = @@ -626,9 +469,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { }; const isRefreshing = - nodesQuery.isFetching || applyLogsQuery.isFetching || - observabilityQuery.isFetching; + observabilityQuery.isFetching || + configVersionsQuery.isFetching; const handleRefresh = async () => { setFeedback(null); @@ -651,7 +494,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
- {node.node_type === 'tunnel_relay' ? ( - <> - - - - - ) : ( - <> - - - - - )} + + +
@@ -886,9 +691,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { 总内存

- {formatBytes( - observability.profile.total_memory_bytes, - )} + {formatBytes(observability.profile.total_memory_bytes)}

@@ -932,9 +735,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
- {node.node_type === 'edge_node' ? ( - + {observabilityQuery.isLoading ? ( ) : observabilityQuery.isError ? ( @@ -996,9 +794,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { />
@@ -1058,9 +852,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {

{trafficSummary - ? trafficSummary.request_count.toLocaleString( - 'zh-CN', - ) + ? trafficSummary.request_count.toLocaleString('zh-CN') : '—'}

@@ -1093,12 +885,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { /> )} - ) : null}

- {node.node_type === 'edge_node' ? ( - <> -
+
- -
- - ) : null} - - {node.node_type === 'tunnel_relay' ? ( -
- - {observability?.relay_dashboard?.proxies?.length ? ( -
- - - - - - - - - - - - {observability.relay_dashboard.proxies.map((proxy) => ( - - - - - - - - ))} - -
代理名称类型状态客户端地址最近连接
- {proxy.name} - {proxy.type} - - {proxy.client_addr} - {isMeaningfulTime(proxy.last_start_time) - ? formatRelativeTime(proxy.last_start_time) - : '—'} -
-
- ) : ( - - )} -
-
- ) : null} + +
- {node.node_type === 'edge_node' ? ( -
@@ -1315,7 +1052,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {

{dominantStatusCode ? `${dominantStatusCode.value} 次` - : '暂无状态码聚合'} + : '暂无状态码配置'}

@@ -1328,7 +1065,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {

{dominantDomain ? `${dominantDomain.value} 次` - : '暂无域名聚合'} + : '暂无域名配置'}

@@ -1367,11 +1104,10 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
- ) : null}

@@ -1507,17 +1239,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {

- {node.node_type === 'tunnel_relay' ? ( - <> -

Relay:{node.relay_version || 'unknown'}

-

frps:{node.relay_frp_version || 'unknown'}

- - ) : ( - <> -

Agent:{node.agent_version || 'unknown'}

-

Nginx:{node.nginx_version || 'unknown'}

- - )} +

Agent:{node.agent_version || 'unknown'}

+

Nginx:{node.nginx_version || 'unknown'}

当前配置:{node.current_version || '未应用'}

@@ -1623,9 +1346,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { )} - {node.node_type === 'edge_node' ? ( - @@ -1674,7 +1396,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { - ) : null}
0) { + return `${days} 天 ${hours} 小时`; + } + if (hours > 0) { + return `${hours} 小时 ${minutes} 分钟`; + } + return `${minutes} 分钟`; +} + +export function formatTrendHour(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '—'; + } + return `${date.getHours().toString().padStart(2, '0')}:00`; +} + +export function parseTrafficMap(value?: string | null) { + if (!value) { + return {} as Record; + } + try { + const parsed = JSON.parse(value) as Record; + return Object.entries(parsed).reduce>( + (result, [key, count]) => { + if (typeof count === 'number' && Number.isFinite(count)) { + result[key] = count; + } + return result; + }, + {}, + ); + } catch { + return {} as Record; + } +} + +export function aggregateTrafficBreakdown( + reports: NodeObservability['traffic_reports'], + field: 'status_codes_json' | 'top_domains_json', +) { + const summary = new Map(); + for (const report of reports) { + const parsed = parseTrafficMap(report[field]); + for (const [key, value] of Object.entries(parsed)) { + summary.set(key, (summary.get(key) ?? 0) + value); + } + } + return Array.from(summary.entries()) + .sort((left, right) => { + if (right[1] === left[1]) { + return left[0].localeCompare(right[0]); + } + return right[1] - left[1]; + }) + .slice(0, 6) + .map(([label, value]) => ({ label, value })); +} + +export function getHealthEventVariant( + event: NodeObservability['health_events'][number], +): 'success' | 'warning' | 'danger' | 'info' { + if (event.status === 'resolved') { + return 'success'; + } + if (event.severity === 'critical') { + return 'danger'; + } + if (event.severity === 'warning') { + return 'warning'; + } + return 'info'; +} + +export function getHealthEventLabel( + event: NodeObservability['health_events'][number], +) { + return event.event_type.replaceAll('_', ' '); +} + +export function MetricBar({ + label, + value, + progress, + hint, +}: { + label: string; + value: string; + progress?: number | null; + hint?: string; +}) { + return ( +
+
+
+

+ {label} +

+ {hint ? ( +

+ {hint} +

+ ) : null} +
+

+ {value} +

+
+
+
+
+
+ ); +} + +export function SummaryStat({ + label, + value, + hint, +}: { + label: string; + value: string; + hint: string; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+

{hint}

+
+ ); +} diff --git a/openflare_server/web/features/nodes/components/relay-detail-page.tsx b/openflare_server/web/features/nodes/components/relay-detail-page.tsx new file mode 100644 index 00000000..b511dbfa --- /dev/null +++ b/openflare_server/web/features/nodes/components/relay-detail-page.tsx @@ -0,0 +1,1381 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppModal } from '@/components/ui/app-modal'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { getConfigVersions } from '@/features/config-versions/api/config-versions'; +import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal'; +import type { ConfigVersionSummary } from '@/features/config-versions/types'; +import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; +import { + cleanupNodeHealthEvents, + deleteNode, + getNodeAgentRelease, + getNodeObservability, + requestNodeForceSync, + requestNodeAgentUpdate, + updateNode, +} from '@/features/nodes/api/nodes'; +import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; +import type { + NodeItem, + NodeAgentReleaseInfo, +} from '@/features/nodes/types'; +import { + CodeBlock, + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import type { ReleaseChannel } from '@/features/update/types'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; +import { formatBytes, formatPercent } from '@/lib/utils/metrics'; +import { + buildRelayInstallCommand, + buildRelayDockerInstallCommand, + getApplyLabel, + getApplyVariant, + getNodeStatusLabel, + getNodeStatusVariant, + getServerUrl, + getUpdateMode, + isMeaningfulTime, +} from '@/features/nodes/utils'; +import { + copyToClipboard, + formatUsageRatio, + formatUptime, + getErrorMessage, + getHealthEventLabel, + getHealthEventVariant, + FeedbackState, + HealthEventFilter, + NodeDetailTab, + MetricBar, + SummaryStat, +} from './node-shared'; + +export function RelayDetailPage({ node }: { node: NodeItem }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const nodeId = String(node.id); + const [feedback, setFeedback] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); + const [isTargetSnapshotOpen, setIsTargetSnapshotOpen] = useState(false); + const [selectedReleaseChannel, setSelectedReleaseChannel] = + useState('stable'); + const [agentUpdateFeedback, setAgentUpdateFeedback] = + useState(null); + const [serverUrl, setServerUrl] = useState(''); + const [healthEventFilter, setHealthEventFilter] = + useState('all'); + const [activeTab, setActiveTab] = useState('dashboard'); + const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] = + useState(false); + + const stableAgentReleaseQuery = useQuery({ + queryKey: ['node-agent-release', nodeId, 'stable'], + queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'), + enabled: false, + }); + + const previewAgentReleaseQuery = useQuery({ + queryKey: ['node-agent-release', nodeId, 'preview'], + queryFn: () => getNodeAgentRelease(Number(nodeId), 'preview'), + enabled: false, + }); + + const applyLogsQuery = useQuery({ + queryKey: ['apply-logs', node.node_id, 1, 10], + queryFn: () => + getApplyLogs({ + node_id: node.node_id, + pageNo: 1, + pageSize: 10, + }), + refetchInterval: 5000, + }); + + const configVersionsQuery = useQuery({ + queryKey: ['config-versions'], + queryFn: getConfigVersions, + refetchInterval: 5000, + }); + + const observabilityQuery = useQuery({ + queryKey: ['node-observability', nodeId], + queryFn: () => + getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }), + refetchInterval: 10000, + }); + + useEffect(() => { + if (typeof window !== 'undefined' && !serverUrl) { + setServerUrl(window.location.origin); + } + }, [serverUrl]); + + const saveMutation = useMutation({ + mutationFn: async (payload: Parameters[1]) => + updateNode(Number(nodeId), payload), + onSuccess: async () => { + setFeedback({ tone: 'success', message: '中继节点已更新。' }); + setIsEditorOpen(false); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const updateAgentMutation = useMutation({ + mutationFn: (release: NodeAgentReleaseInfo | null) => + requestNodeAgentUpdate(Number(nodeId), { + channel: release?.channel ?? selectedReleaseChannel, + tag_name: + release?.channel === 'preview' + ? release.tag_name || undefined + : undefined, + }), + onSuccess: async (updatedNode) => { + setFeedback({ + tone: 'success', + message: `已向中继节点 ${updatedNode.name} 下发${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}更新指令。`, + }); + setAgentUpdateFeedback({ + tone: 'success', + message: `节点将在下一次心跳后执行${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}中继代理更新。`, + }); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + }, + onError: (error) => { + const message = getErrorMessage(error); + setFeedback({ tone: 'danger', message }); + setAgentUpdateFeedback({ tone: 'danger', message }); + }, + }); + + const forceSyncMutation = useMutation({ + mutationFn: () => requestNodeForceSync(Number(nodeId)), + onSuccess: async (updatedNode) => { + setFeedback({ + tone: 'success', + message: `已向中继节点 ${updatedNode.name} 下发强制同步指令,无视当前错误拦截。`, + }); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteNode(Number(nodeId)), + onSuccess: async () => { + setFeedback({ tone: 'success', message: '中继节点已删除。' }); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + router.push('/node'); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const cleanupHealthEventsMutation = useMutation({ + mutationFn: () => cleanupNodeHealthEvents(Number(nodeId)), + onSuccess: async (result) => { + setFeedback({ + tone: 'success', + message: + result.deleted_count > 0 + ? `已清理 ${result.deleted_count} 条中继健康事件日志。` + : '当前没有可清理的健康事件日志。', + }); + setHealthEventCleanupModalOpen(false); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['node-observability', nodeId], + }), + queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }), + ]); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const handleDelete = () => { + if (!window.confirm( + `确认删除中继节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, + )) { + return; + } + setFeedback(null); + deleteMutation.mutate(); + }; + + const handleCopy = async (value: string, message: string) => { + try { + await copyToClipboard(value); + setFeedback({ tone: 'success', message }); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + }; + + const activeConfigVersion = useMemo(() => { + return ( + (configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null + ); + }, [configVersionsQuery.data]); + + const observability = observabilityQuery.data ?? null; + const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null; + const activeHealthEvents = useMemo( + () => + observability?.health_events.filter( + (event) => event.status === 'active', + ) ?? [], + [observability?.health_events], + ); + const resolvedHealthEvents = useMemo( + () => + observability?.health_events.filter( + (event) => event.status === 'resolved', + ) ?? [], + [observability?.health_events], + ); + const filteredHealthEvents = useMemo(() => { + switch (healthEventFilter) { + case 'active': + return activeHealthEvents; + case 'resolved': + return resolvedHealthEvents; + default: + return observability?.health_events ?? []; + } + }, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]); + + const tabs = useMemo( + () => + [ + { + key: 'dashboard' as const, + label: '数据看板', + description: '隧道状态、资源快照与运行诊断。', + }, + { + key: 'info' as const, + label: '节点信息', + description: '更新模式、版本状态、部署信息与应用记录。', + }, + ] satisfies Array<{ + key: NodeDetailTab; + label: string; + description: string; + }>, + [], + ); + + const normalizedServerUrl = getServerUrl(serverUrl); + const relayInstallCommand = + normalizedServerUrl && node.agent_token + ? buildRelayInstallCommand(normalizedServerUrl, node.agent_token) + : ''; + const relayDockerInstallCommand = + normalizedServerUrl && node.agent_token + ? buildRelayDockerInstallCommand(normalizedServerUrl, node.agent_token) + : ''; + + const updateMode = getUpdateMode(node); + const selectedAgentRelease = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.data + : stableAgentReleaseQuery.data; + const selectedAgentReleaseError = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.error + : stableAgentReleaseQuery.error; + const isCheckingAgentRelease = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.isFetching + : stableAgentReleaseQuery.isFetching; + + const applyLogs = applyLogsQuery.data?.rows ?? []; + const latestHealthEvent = activeHealthEvents[0] ?? null; + const memoryUsageRatio = formatUsageRatio( + latestMetricSnapshot?.memory_used_bytes, + latestMetricSnapshot?.memory_total_bytes, + ); + const storageUsageRatio = formatUsageRatio( + latestMetricSnapshot?.storage_used_bytes, + latestMetricSnapshot?.storage_total_bytes, + ); + const isTargetVersionApplied = + activeConfigVersion !== null && + activeConfigVersion.version === node.current_version; + + const handleOpenAgentUpdateModal = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('stable'); + setIsAgentUpdateModalOpen(true); + void stableAgentReleaseQuery.refetch(); + }; + + const handleCheckStableAgentRelease = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('stable'); + void stableAgentReleaseQuery.refetch(); + }; + + const handleCheckPreviewAgentRelease = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('preview'); + void previewAgentReleaseQuery.refetch(); + }; + + const handleRequestAgentUpdate = () => { + updateAgentMutation.mutate(selectedAgentRelease ?? null); + }; + + const isRefreshing = + applyLogsQuery.isFetching || + observabilityQuery.isFetching || + configVersionsQuery.isFetching; + + const handleRefresh = async () => { + setFeedback(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['nodes'] }), + queryClient.invalidateQueries({ + queryKey: ['apply-logs', node.node_id], + }), + queryClient.invalidateQueries({ + queryKey: ['config-versions'], + }), + queryClient.invalidateQueries({ + queryKey: ['node-observability', nodeId], + }), + ]); + }; + + return ( + <> +
+ + + 返回 + + setIsEditorOpen(true)} + > + 编辑节点 + + void handleRefresh()} + disabled={isRefreshing} + > + {isRefreshing ? '刷新中...' : '刷新'} + + { + setFeedback(null); + forceSyncMutation.mutate(); + }} + disabled={forceSyncMutation.isPending} + > + {forceSyncMutation.isPending ? '同步中...' : '同步'} + + + {node.update_requested ? '查看升级' : '升级'} + + + 删除 + + + } + /> + + {feedback ? ( + setFeedback(null)} + /> + ) : null} + +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === 'dashboard' ? ( + <> +
+ + + + +
+ +
+ + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : observability?.profile ? ( +
+
+
+

+ 主机名 +

+

+ {observability.profile.hostname || node.name} +

+
+
+

+ 操作系统 +

+

+ {observability.profile.os_name || 'unknown'} + {observability.profile.os_version + ? ` ${observability.profile.os_version}` + : ''} +

+
+
+

+ 内核 / 架构 +

+

+ {observability.profile.kernel_version || 'unknown'} ·{' '} + {observability.profile.architecture || 'unknown'} +

+
+
+

+ 在线时长 +

+

+ {formatUptime(observability.profile.uptime_seconds)} +

+
+
+ +
+
+

+ CPU +

+

+ {observability.profile.cpu_model || 'unknown'} +

+

+ {observability.profile.cpu_cores || 0} 核 +

+
+
+

+ 总内存 +

+

+ {formatBytes(observability.profile.total_memory_bytes)} +

+
+
+

+ 总存储 +

+

+ {formatBytes(observability.profile.total_disk_bytes)} +

+
+
+

+ 上报时间 +

+

+ {isMeaningfulTime(observability.profile.reported_at) + ? formatDateTime(observability.profile.reported_at) + : '—'} +

+
+
+
+ ) : ( + + )} +
+ + + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : latestMetricSnapshot ? ( +
+
+ + + + +
+
+ ) : ( + + )} +
+ + setHealthEventCleanupModalOpen(true)} + > + {cleanupHealthEventsMutation.isPending + ? '清理中...' + : '清理日志'} + + } + > + {observability?.health_events.length ? ( +
+
+ + + +
+ + {filteredHealthEvents.slice(0, 8).map((event) => ( +
+
+ + +
+

+ {event.message || '暂无详细消息'} +

+
+

+ 首次触发: + {isMeaningfulTime(event.first_triggered_at) + ? ` ${formatDateTime(event.first_triggered_at)}` + : ' —'} +

+

+ 最近触发: + {isMeaningfulTime(event.last_triggered_at) + ? ` ${formatDateTime(event.last_triggered_at)}` + : ' —'} +

+

+ 恢复时间: + {isMeaningfulTime(event.resolved_at) + ? ` ${formatDateTime(event.resolved_at)}` + : ' —'} +

+
+
+ ))} + {filteredHealthEvents.length === 0 ? ( +
+ 当前筛选下没有健康事件。 +
+ ) : null} +
+ ) : ( + + )} +
+
+ +
+ + {observability?.relay_dashboard?.proxies?.length ? ( +
+ + + + + + + + + + + + {observability.relay_dashboard.proxies.map((proxy) => ( + + + + + + + + ))} + +
代理名称 (Proxy Name)代理类型在线状态客户端接入地址最近连接时间
+ {proxy.name} + {proxy.type} + + {proxy.client_addr} + {isMeaningfulTime(proxy.last_start_time) + ? formatRelativeTime(proxy.last_start_time) + : '—'} +
+
+ ) : ( + + )} +
+
+ + ) : null} + + {activeTab === 'info' ? ( + <> +
+ +
+ +

+ {node.update_requested + ? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。` + : node.auto_update_enabled + ? '节点已启用自动更新。' + : '当前仅支持手动触发更新。'} +

+
+
+ + +
+

Relay 中继版本:{node.relay_version || 'unknown'}

+

frps 核心版本:{node.relay_frp_version || 'unknown'}

+

中继网络接入:{node.relay_agent_access_addr || '—'}

+
+
+ + +
+ +

+ {isMeaningfulTime(node.latest_apply_at) + ? `${formatRelativeTime( + node.latest_apply_at, + )} · ${formatDateTime(node.latest_apply_at)}` + : '暂无应用记录'} +

+ {node.latest_apply_checksum ? ( +
+

同步文件:{node.latest_support_file_count} 个

+
+ ) : null} +
+
+
+ + setIsTargetSnapshotOpen(true)} + > + 查看目标快照 + + ) : null + } + > + {configVersionsQuery.isLoading ? ( + + ) : configVersionsQuery.isError ? ( + + ) : activeConfigVersion ? ( +
+
+

+ 追平状态 +

+
+ +
+

+ {isTargetVersionApplied + ? '当前节点已应用全局最新配置。' + : '当前中继代理版本落后于全局激活配置,请检查下面配置应用历史。'} +

+
+ +
+
+

+ 目标配置版本 +

+

+ {activeConfigVersion.version} +

+
+
+

+ 配置校验和 +

+

+ {activeConfigVersion.checksum} +

+
+
+

+ 全局激活时间 +

+

+ {formatDateTime(activeConfigVersion.created_at)} +

+
+
+
+ ) : ( + + )} +
+ +
+ + {relayInstallCommand ? ( + + void handleCopy( + relayInstallCommand, + '部署脚本命令已复制。', + ) + } + > + 复制脚本命令 + + ) : null} + {relayDockerInstallCommand ? ( + + void handleCopy( + relayDockerInstallCommand, + 'Docker 部署命令已复制。', + ) + } + > + 复制 Docker 命令 + + ) : null} +
+ } + > +
+
+
+

+ Node ID +

+

+ {node.node_id} +

+
+
+

+ Discovery Token (中继注册 Token) +

+

+ {node.agent_token || '暂无'} +

+
+
+ + + setServerUrl(event.target.value)} + /> + + + {relayInstallCommand ? ( +
+

+ 一键脚本部署 (Linux / macOS) +

+ + {relayInstallCommand} + +
+ ) : null} + + {relayDockerInstallCommand ? ( +
+

+ Docker 容器部署 +

+ + {relayDockerInstallCommand} + +
+ ) : null} +
+ + + +
+
+

+ 中继 IP 地址 +

+

+ {node.ip || '暂无上报'} + {node.ip_manual_override ? '(管理端锁定)' : ''} +

+
+
+

+ 绑定控制端口 +

+

{node.relay_bind_port || '—'}

+
+
+

+ 中继节点最近错误 +

+

+ {node.last_error || '无'} +

+
+
+

+ 创建时间 +

+

{formatDateTime(node.created_at)}

+
+
+

+ 更新时间 +

+

{formatDateTime(node.updated_at)}

+
+
+
+
+ + + 查看完整记录 + + } + > + {applyLogsQuery.isLoading ? ( + + ) : applyLogsQuery.isError ? ( + + ) : applyLogs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {applyLogs.map((log) => ( + + + + + + + + ))} + +
配置版本结果校验和应用时间消息
+ {log.version} + + + + {log.checksum + ? `${log.checksum.slice(0, 12)}...` + : '—'} + + {formatRelativeTime(log.created_at)} ·{' '} + {formatDateTime(log.created_at)} + +
+

{log.message || '—'}

+
+
+
+ )} +
+ + ) : null} +
+ + setIsTargetSnapshotOpen(false)} + /> + + setIsEditorOpen(false)} + title="编辑中继节点" + description="可以修改中继节点名、中继控制端口等信息。" + submitLabel="保存修改" + onSubmit={(payload) => { + setFeedback(null); + saveMutation.mutate(payload); + }} + /> + + setHealthEventCleanupModalOpen(false)} + title="清理运行诊断日志" + description={`确认清理中继节点“${node.name}”的健康诊断事件日志吗?`} + footer={ +
+ setHealthEventCleanupModalOpen(false)} + > + 取消 + + { + setFeedback(null); + cleanupHealthEventsMutation.mutate(); + }} + > + {cleanupHealthEventsMutation.isPending ? '清理中...' : '确认清理'} + +
+ } + > + {cleanupHealthEventsMutation.isError ? ( + + ) : ( +
+

+ 该操作会删除此中继节点在控制端记录的所有健康诊断事件历史。 +

+

+ 这不会影响节点在后续运行中继续捕捉并上报新的故障。 +

+
+ )} +
+ + setIsAgentUpdateModalOpen(false)} + title="中继代理升级" + description="管理当前中继软件 (relay/agent) 版本。" + footer={ +
+ + {isCheckingAgentRelease && selectedReleaseChannel === 'stable' + ? '检查中...' + : '检查正式版'} + + + {isCheckingAgentRelease && selectedReleaseChannel === 'preview' + ? '检查中...' + : '检查预览版'} + + + {updateAgentMutation.isPending + ? '下发中...' + : selectedReleaseChannel === 'preview' + ? '升级到预览版' + : '升级到正式版'} + +
+ } + > +
+ {agentUpdateFeedback ? ( + + ) : null} + +
+ +

+ {node.agent_version || 'unknown'} +

+
+ +
+

+ {selectedReleaseChannel === 'preview' ? '预览版' : '正式版'} +

+ +
+
+ + + +
+ + {isCheckingAgentRelease && !selectedAgentRelease ? ( + + ) : null} + {!isCheckingAgentRelease && selectedAgentReleaseError ? ( + + ) : null} + {!isCheckingAgentRelease && + !selectedAgentReleaseError && + !selectedAgentRelease ? ( + + ) : null} + + {selectedAgentRelease ? ( + +
+
+ + {selectedAgentRelease.prerelease ? ( + + ) : ( + + )} +
+ +
+
+

+ 当前中继软件版本 +

+

+ {selectedAgentRelease.current_version || 'unknown'} +

+
+
+

+ 最新发布版本 +

+

+ {selectedAgentRelease.tag_name || '未找到'} +

+
+
+ +
+ {selectedAgentRelease.body || '暂无更新说明'} +
+ + {selectedAgentRelease.html_url ? ( + + 查看发布详情 + + ) : null} +
+
+ ) : null} +
+
+ + ); +} diff --git a/openflare_server/web/features/nodes/components/tunnel-detail-page.tsx b/openflare_server/web/features/nodes/components/tunnel-detail-page.tsx new file mode 100644 index 00000000..4fec24d4 --- /dev/null +++ b/openflare_server/web/features/nodes/components/tunnel-detail-page.tsx @@ -0,0 +1,1307 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppModal } from '@/components/ui/app-modal'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { getConfigVersions } from '@/features/config-versions/api/config-versions'; +import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal'; +import type { ConfigVersionSummary } from '@/features/config-versions/types'; +import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; +import { + cleanupNodeHealthEvents, + deleteNode, + getNodeAgentRelease, + getNodeObservability, + requestNodeForceSync, + requestNodeAgentUpdate, + updateNode, +} from '@/features/nodes/api/nodes'; +import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; +import type { + NodeItem, + NodeAgentReleaseInfo, +} from '@/features/nodes/types'; +import { + CodeBlock, + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import type { ReleaseChannel } from '@/features/update/types'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; +import { formatBytes, formatPercent } from '@/lib/utils/metrics'; +import { + buildTunnelInstallCommand, + buildTunnelDockerInstallCommand, + getApplyLabel, + getApplyVariant, + getNodeStatusLabel, + getNodeStatusVariant, + getServerUrl, + getUpdateMode, + isMeaningfulTime, +} from '@/features/nodes/utils'; +import { + copyToClipboard, + formatUsageRatio, + formatUptime, + getErrorMessage, + getHealthEventLabel, + getHealthEventVariant, + FeedbackState, + HealthEventFilter, + NodeDetailTab, + MetricBar, + SummaryStat, +} from './node-shared'; + +export function TunnelDetailPage({ node }: { node: NodeItem }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const nodeId = String(node.id); + const [feedback, setFeedback] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); + const [isTargetSnapshotOpen, setIsTargetSnapshotOpen] = useState(false); + const [selectedReleaseChannel, setSelectedReleaseChannel] = + useState('stable'); + const [agentUpdateFeedback, setAgentUpdateFeedback] = + useState(null); + const [serverUrl, setServerUrl] = useState(''); + const [healthEventFilter, setHealthEventFilter] = + useState('all'); + const [activeTab, setActiveTab] = useState('dashboard'); + const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] = + useState(false); + + const stableAgentReleaseQuery = useQuery({ + queryKey: ['node-agent-release', nodeId, 'stable'], + queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'), + enabled: false, + }); + + const previewAgentReleaseQuery = useQuery({ + queryKey: ['node-agent-release', nodeId, 'preview'], + queryFn: () => getNodeAgentRelease(Number(nodeId), 'preview'), + enabled: false, + }); + + const applyLogsQuery = useQuery({ + queryKey: ['apply-logs', node.node_id, 1, 10], + queryFn: () => + getApplyLogs({ + node_id: node.node_id, + pageNo: 1, + pageSize: 10, + }), + refetchInterval: 5000, + }); + + const configVersionsQuery = useQuery({ + queryKey: ['config-versions'], + queryFn: getConfigVersions, + refetchInterval: 5000, + }); + + const observabilityQuery = useQuery({ + queryKey: ['node-observability', nodeId], + queryFn: () => + getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }), + refetchInterval: 10000, + }); + + useEffect(() => { + if (typeof window !== 'undefined' && !serverUrl) { + setServerUrl(window.location.origin); + } + }, [serverUrl]); + + const saveMutation = useMutation({ + mutationFn: async (payload: Parameters[1]) => + updateNode(Number(nodeId), payload), + onSuccess: async () => { + setFeedback({ tone: 'success', message: '节点已更新。' }); + setIsEditorOpen(false); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const updateAgentMutation = useMutation({ + mutationFn: (release: NodeAgentReleaseInfo | null) => + requestNodeAgentUpdate(Number(nodeId), { + channel: release?.channel ?? selectedReleaseChannel, + tag_name: + release?.channel === 'preview' + ? release.tag_name || undefined + : undefined, + }), + onSuccess: async (updatedNode) => { + setFeedback({ + tone: 'success', + message: `已向节点 ${updatedNode.name} 下发${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}更新指令。`, + }); + setAgentUpdateFeedback({ + tone: 'success', + message: `节点将在下一次心跳后执行${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'} Agent 更新。`, + }); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + }, + onError: (error) => { + const message = getErrorMessage(error); + setFeedback({ tone: 'danger', message }); + setAgentUpdateFeedback({ tone: 'danger', message }); + }, + }); + + const forceSyncMutation = useMutation({ + mutationFn: () => requestNodeForceSync(Number(nodeId)), + onSuccess: async (updatedNode) => { + setFeedback({ + tone: 'success', + message: `已向节点 ${updatedNode.name} 下发强制同步指令,无视当前错误拦截。`, + }); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteNode(Number(nodeId)), + onSuccess: async () => { + setFeedback({ tone: 'success', message: '节点已删除。' }); + await queryClient.invalidateQueries({ queryKey: ['nodes'] }); + router.push('/node'); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const cleanupHealthEventsMutation = useMutation({ + mutationFn: () => cleanupNodeHealthEvents(Number(nodeId)), + onSuccess: async (result) => { + setFeedback({ + tone: 'success', + message: + result.deleted_count > 0 + ? `已清理 ${result.deleted_count} 条健康事件日志。` + : '当前没有可清理的健康事件日志。', + }); + setHealthEventCleanupModalOpen(false); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['node-observability', nodeId], + }), + queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }), + ]); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const handleDelete = () => { + if (!window.confirm( + `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, + )) { + return; + } + setFeedback(null); + deleteMutation.mutate(); + }; + + const handleCopy = async (value: string, message: string) => { + try { + await copyToClipboard(value); + setFeedback({ tone: 'success', message }); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + }; + + const activeConfigVersion = useMemo(() => { + return ( + (configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null + ); + }, [configVersionsQuery.data]); + + const observability = observabilityQuery.data ?? null; + const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null; + const activeHealthEvents = useMemo( + () => + observability?.health_events.filter( + (event) => event.status === 'active', + ) ?? [], + [observability?.health_events], + ); + const resolvedHealthEvents = useMemo( + () => + observability?.health_events.filter( + (event) => event.status === 'resolved', + ) ?? [], + [observability?.health_events], + ); + const filteredHealthEvents = useMemo(() => { + switch (healthEventFilter) { + case 'active': + return activeHealthEvents; + case 'resolved': + return resolvedHealthEvents; + default: + return observability?.health_events ?? []; + } + }, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]); + + const tabs = useMemo( + () => + [ + { + key: 'dashboard' as const, + label: '数据看板', + description: '系统画像、资源快照与健康事件。', + }, + { + key: 'info' as const, + label: '节点信息', + description: '更新模式、版本状态、部署信息与应用记录。', + }, + ] satisfies Array<{ + key: NodeDetailTab; + label: string; + description: string; + }>, + [], + ); + + const normalizedServerUrl = getServerUrl(serverUrl); + const tunnelInstallCommand = + normalizedServerUrl && node.agent_token + ? buildTunnelInstallCommand(normalizedServerUrl, node.agent_token) + : ''; + const tunnelDockerInstallCommand = + normalizedServerUrl && node.agent_token + ? buildTunnelDockerInstallCommand(normalizedServerUrl, node.agent_token) + : ''; + + const updateMode = getUpdateMode(node); + const selectedAgentRelease = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.data + : stableAgentReleaseQuery.data; + const selectedAgentReleaseError = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.error + : stableAgentReleaseQuery.error; + const isCheckingAgentRelease = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.isFetching + : stableAgentReleaseQuery.isFetching; + + const applyLogs = applyLogsQuery.data?.rows ?? []; + const latestHealthEvent = activeHealthEvents[0] ?? null; + const memoryUsageRatio = formatUsageRatio( + latestMetricSnapshot?.memory_used_bytes, + latestMetricSnapshot?.memory_total_bytes, + ); + const storageUsageRatio = formatUsageRatio( + latestMetricSnapshot?.storage_used_bytes, + latestMetricSnapshot?.storage_total_bytes, + ); + const isTargetVersionApplied = + activeConfigVersion !== null && + activeConfigVersion.version === node.current_version; + + const handleOpenAgentUpdateModal = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('stable'); + setIsAgentUpdateModalOpen(true); + void stableAgentReleaseQuery.refetch(); + }; + + const handleCheckStableAgentRelease = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('stable'); + void stableAgentReleaseQuery.refetch(); + }; + + const handleCheckPreviewAgentRelease = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('preview'); + void previewAgentReleaseQuery.refetch(); + }; + + const handleRequestAgentUpdate = () => { + updateAgentMutation.mutate(selectedAgentRelease ?? null); + }; + + const isRefreshing = + applyLogsQuery.isFetching || + observabilityQuery.isFetching || + configVersionsQuery.isFetching; + + const handleRefresh = async () => { + setFeedback(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['nodes'] }), + queryClient.invalidateQueries({ + queryKey: ['apply-logs', node.node_id], + }), + queryClient.invalidateQueries({ + queryKey: ['config-versions'], + }), + queryClient.invalidateQueries({ + queryKey: ['node-observability', nodeId], + }), + ]); + }; + + return ( + <> +
+ + + 返回 + + setIsEditorOpen(true)} + > + 编辑节点 + + void handleRefresh()} + disabled={isRefreshing} + > + {isRefreshing ? '刷新中...' : '刷新'} + + { + setFeedback(null); + forceSyncMutation.mutate(); + }} + disabled={forceSyncMutation.isPending} + > + {forceSyncMutation.isPending ? '同步中...' : '同步'} + + + {node.update_requested ? '查看升级' : '升级'} + + + 删除 + + + } + /> + + {feedback ? ( + setFeedback(null)} + /> + ) : null} + +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === 'dashboard' ? ( + <> +
+ + + +
+ +
+ + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : observability?.profile ? ( +
+
+
+

+ 主机名 +

+

+ {observability.profile.hostname || node.name} +

+
+
+

+ 操作系统 +

+

+ {observability.profile.os_name || 'unknown'} + {observability.profile.os_version + ? ` ${observability.profile.os_version}` + : ''} +

+
+
+

+ 内核 / 架构 +

+

+ {observability.profile.kernel_version || 'unknown'} ·{' '} + {observability.profile.architecture || 'unknown'} +

+
+
+

+ 在线时长 +

+

+ {formatUptime(observability.profile.uptime_seconds)} +

+
+
+ +
+
+

+ CPU +

+

+ {observability.profile.cpu_model || 'unknown'} +

+

+ {observability.profile.cpu_cores || 0} 核 +

+
+
+

+ 总内存 +

+

+ {formatBytes(observability.profile.total_memory_bytes)} +

+
+
+

+ 总存储 +

+

+ {formatBytes(observability.profile.total_disk_bytes)} +

+
+
+

+ 上报时间 +

+

+ {isMeaningfulTime(observability.profile.reported_at) + ? formatDateTime(observability.profile.reported_at) + : '—'} +

+
+
+
+ ) : ( + + )} +
+ + + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : latestMetricSnapshot ? ( +
+
+ + + + +
+
+ ) : ( + + )} +
+ + setHealthEventCleanupModalOpen(true)} + > + {cleanupHealthEventsMutation.isPending + ? '清理中...' + : '清理日志'} + + } + > + {observability?.health_events.length ? ( +
+
+ + + +
+ + {filteredHealthEvents.slice(0, 8).map((event) => ( +
+
+ + +
+

+ {event.message || '暂无详细消息'} +

+
+

+ 首次触发: + {isMeaningfulTime(event.first_triggered_at) + ? ` ${formatDateTime(event.first_triggered_at)}` + : ' —'} +

+

+ 最近触发: + {isMeaningfulTime(event.last_triggered_at) + ? ` ${formatDateTime(event.last_triggered_at)}` + : ' —'} +

+

+ 恢复时间: + {isMeaningfulTime(event.resolved_at) + ? ` ${formatDateTime(event.resolved_at)}` + : ' —'} +

+
+
+ ))} + {filteredHealthEvents.length === 0 ? ( +
+ 当前筛选下没有健康事件。 +
+ ) : null} +
+ ) : ( + + )} +
+
+ + ) : null} + + {activeTab === 'info' ? ( + <> +
+ +
+ +

+ {node.update_requested + ? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。` + : node.auto_update_enabled + ? '节点已启用正式版自动更新。' + : '当前仅支持手动触发更新。'} +

+
+
+ + +
+

Client (openflared): {node.agent_version || 'unknown'}

+

当前配置版本:{node.current_version || '未应用'}

+

节点类型:隧道客户端 (tunnel_client)

+
+
+ + +
+ +

+ {isMeaningfulTime(node.latest_apply_at) + ? `${formatRelativeTime( + node.latest_apply_at, + )} · ${formatDateTime(node.latest_apply_at)}` + : '暂无应用记录'} +

+ {node.latest_apply_checksum ? ( +
+

同步文件:{node.latest_support_file_count} 个

+
+ ) : null} +
+
+
+ + setIsTargetSnapshotOpen(true)} + > + 查看目标快照 + + ) : null + } + > + {configVersionsQuery.isLoading ? ( + + ) : configVersionsQuery.isError ? ( + + ) : activeConfigVersion ? ( +
+
+

+ 追平状态 +

+
+ +
+

+ {isTargetVersionApplied + ? '当前节点已成功应用最新全局配置。' + : '当前节点版本落后于全局激活配置,请检查最新应用记录排查问题。'} +

+
+ +
+
+

+ 目标配置版本 +

+

+ {activeConfigVersion.version} +

+
+
+

+ 配置校验和 +

+

+ {activeConfigVersion.checksum} +

+
+
+

+ 全局激活时间 +

+

+ {formatDateTime(activeConfigVersion.created_at)} +

+
+
+
+ ) : ( + + )} +
+ +
+ + {tunnelInstallCommand ? ( + + void handleCopy( + tunnelInstallCommand, + '部署脚本命令已复制。', + ) + } + > + 复制脚本命令 + + ) : null} + {tunnelDockerInstallCommand ? ( + + void handleCopy( + tunnelDockerInstallCommand, + 'Docker 部署命令已复制。', + ) + } + > + 复制 Docker 命令 + + ) : null} +
+ } + > +
+
+
+

+ Node ID +

+

+ {node.node_id} +

+
+
+

+ Tunnel Token +

+

+ {node.agent_token || '暂无'} +

+
+
+ + + setServerUrl(event.target.value)} + /> + + + {tunnelInstallCommand ? ( +
+

+ 一键脚本部署 (Linux / macOS) +

+ + {tunnelInstallCommand} + +
+ ) : null} + + {tunnelDockerInstallCommand ? ( +
+

+ Docker 容器部署 +

+ + {tunnelDockerInstallCommand} + +
+ ) : null} +
+ + + +
+
+

+ 接入 IP 地址 +

+

+ {node.ip || '暂无上报'} + {node.ip_manual_override ? '(管理端锁定锁定)' : ''} +

+
+
+

+ 最近心跳错误 +

+

+ {node.last_error || '正常'} +

+
+
+

+ 创建时间 +

+

{formatDateTime(node.created_at)}

+
+
+

+ 更新时间 +

+

{formatDateTime(node.updated_at)}

+
+
+
+
+ + + 查看完整记录 + + } + > + {applyLogsQuery.isLoading ? ( + + ) : applyLogsQuery.isError ? ( + + ) : applyLogs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {applyLogs.map((log) => ( + + + + + + + + ))} + +
配置版本结果校验和应用时间日志消息
+ {log.version} + + + + {log.checksum + ? `${log.checksum.slice(0, 12)}...` + : '—'} + + {formatRelativeTime(log.created_at)} ·{' '} + {formatDateTime(log.created_at)} + +
+

{log.message || '—'}

+
+
+
+ )} +
+ + ) : null} +
+ + setIsTargetSnapshotOpen(false)} + /> + + setIsEditorOpen(false)} + title="编辑隧道客户端" + description="可以修改节点名称、IP等标识信息。" + submitLabel="保存修改" + onSubmit={(payload) => { + setFeedback(null); + saveMutation.mutate(payload); + }} + /> + + setHealthEventCleanupModalOpen(false)} + title="清理运行诊断日志" + description={`确认清理节点“${node.name}”的健康诊断事件吗?`} + footer={ +
+ setHealthEventCleanupModalOpen(false)} + > + 取消 + + { + setFeedback(null); + cleanupHealthEventsMutation.mutate(); + }} + > + {cleanupHealthEventsMutation.isPending ? '清理中...' : '确认清理'} + +
+ } + > + {cleanupHealthEventsMutation.isError ? ( + + ) : ( +
+

+ 该操作会物理删除此节点在控制端记录的所有健康诊断事件历史。 +

+

+ 这不会影响节点在后续运行中继续捕获并上报新的故障。 +

+
+ )} +
+ + setIsAgentUpdateModalOpen(false)} + title="客户端 Agent 升级" + description="管理当前客户端 (flared/agent) 软件版本。" + footer={ +
+ + {isCheckingAgentRelease && selectedReleaseChannel === 'stable' + ? '检查中...' + : '检查正式版'} + + + {isCheckingAgentRelease && selectedReleaseChannel === 'preview' + ? '检查中...' + : '检查预览版'} + + + {updateAgentMutation.isPending + ? '下发中...' + : selectedReleaseChannel === 'preview' + ? '升级到预览版' + : '升级到正式版'} + +
+ } + > +
+ {agentUpdateFeedback ? ( + + ) : null} + +
+ +

+ {node.agent_version || 'unknown'} +

+
+ +
+

+ {selectedReleaseChannel === 'preview' ? '预览版' : '正式版'} +

+ +
+
+ + + +
+ + {isCheckingAgentRelease && !selectedAgentRelease ? ( + + ) : null} + {!isCheckingAgentRelease && selectedAgentReleaseError ? ( + + ) : null} + {!isCheckingAgentRelease && + !selectedAgentReleaseError && + !selectedAgentRelease ? ( + + ) : null} + + {selectedAgentRelease ? ( + +
+
+ + {selectedAgentRelease.prerelease ? ( + + ) : ( + + )} +
+ +
+
+

+ 当前版本 +

+

+ {selectedAgentRelease.current_version || 'unknown'} +

+
+
+

+ 最新发布版本 +

+

+ {selectedAgentRelease.tag_name || '未找到'} +

+
+
+ +
+ {selectedAgentRelease.body || '暂无更新说明'} +
+ + {selectedAgentRelease.html_url ? ( + + 查看发布详情 + + ) : null} +
+
+ ) : null} +
+
+ + ); +} diff --git a/openflare_server/web/features/nodes/utils.ts b/openflare_server/web/features/nodes/utils.ts index b0672969..ca9e383f 100644 --- a/openflare_server/web/features/nodes/utils.ts +++ b/openflare_server/web/features/nodes/utils.ts @@ -243,3 +243,29 @@ export function buildRelayDockerInstallCommand( ` ${image}`, ].join('\n'); } + +export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string) { + const flaredInstallerScriptUrl = + 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh'; + return [ + `curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`, + ` --server-url ${serverUrl} \\`, + ` --tunnel-token ${tunnelToken}`, + ].join('\n'); +} + +export function buildTunnelDockerInstallCommand( + serverUrl: string, + tunnelToken: string, +) { + const image = 'ghcr.io/rain-kl/openflare-flared:latest'; + + return [ + `docker pull ${image}`, + `docker rm -f openflare-flared 2>/dev/null || true`, + `docker run -d --name openflare-flared --restart unless-stopped \\`, + ` -e OPENFLARE_SERVER_URL=${serverUrl} \\`, + ` -e OPENFLARE_TUNNEL_TOKEN=${tunnelToken} \\`, + ` ${image}`, + ].join('\n'); +}