diff --git a/docker-compose.yaml b/docker-compose.yaml index 111dccfa..dd4e8137 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,5 @@ services: - openflare-agent: + agent: build: context: . dockerfile: openflare_agent/Dockerfile @@ -22,7 +22,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" - openflare-relay: + relay: build: context: . dockerfile: openflare_relay/Dockerfile @@ -37,7 +37,7 @@ services: - "host.docker.internal:host-gateway" - openflare-flared: + flared: build: context: . dockerfile: openflared/Dockerfile diff --git a/openflare_agent/internal/protocol/agent_api.go b/openflare_agent/internal/protocol/agent_api.go index 5a860701..94bb69d9 100644 --- a/openflare_agent/internal/protocol/agent_api.go +++ b/openflare_agent/internal/protocol/agent_api.go @@ -72,8 +72,8 @@ type NodePayload struct { NodeID string `json:"node_id"` Name string `json:"name"` IP string `json:"ip"` - Version string `json:"agent_version"` - ExtVersion string `json:"nginx_version"` + Version string `json:"version"` + ExtVersion string `json:"ext_version"` CurrentVersion string `json:"current_version"` LastError string `json:"last_error"` OpenrestyStatus string `json:"openresty_status"` diff --git a/openflare_relay/internal/relay/runner.go b/openflare_relay/internal/relay/runner.go index 26995a70..2e1df355 100644 --- a/openflare_relay/internal/relay/runner.go +++ b/openflare_relay/internal/relay/runner.go @@ -52,36 +52,63 @@ func (r *Runner) Run(ctx context.Context) error { } func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) { + // Send pings at 2× heartbeat interval to keep the server-side read deadline + // from expiring (server closes the WS if no data arrives within ~30 s). + pingInterval := r.Config.HeartbeatInterval.Duration() * 2 + pingTicker := time.NewTicker(pingInterval) + defer pingTicker.Stop() + + messages := make(chan service.WSMessage, 8) + readDone := make(chan error, 1) + go func() { + for { + msg, err := conn.Receive() + if err != nil { + readDone <- err + return + } + select { + case messages <- msg: + case <-ctx.Done(): + readDone <- ctx.Err() + return + } + } + }() + for { select { case <-ctx.Done(): return - default: - } - - msg, err := conn.Receive() - if err != nil { + case err := <-readDone: slog.Error("relay ws receive failed", "error", err) return - } - - switch msg.Type { - case "ping": - _ = conn.SendPong() - case "relay_config": - payloadBytes, ok := msg.Payload.(json.RawMessage) - if !ok { - slog.Error("invalid relay_config payload type") - continue + case <-pingTicker.C: + if err := conn.SendPing(); err != nil { + slog.Error("relay ws send ping failed", "error", err) + return } - var cfg service.RelayConfig - if err := json.Unmarshal(payloadBytes, &cfg); err != nil { - slog.Error("failed to unmarshal relay_config", "error", err) - continue + case msg := <-messages: + switch msg.Type { + case "ping": + _ = conn.SendPong() + case "pong": + slog.Debug("relay ws pong received") + case "relay_config": + payloadBytes, ok := msg.Payload.(json.RawMessage) + if !ok { + slog.Error("invalid relay_config payload type") + continue + } + var cfg service.RelayConfig + if err := json.Unmarshal(payloadBytes, &cfg); err != nil { + slog.Error("failed to unmarshal relay_config", "error", err) + continue + } + r.FrpsManager.UpdateConfig(&cfg) + default: + slog.Debug("ignored unknown ws message type", "type", msg.Type) } - r.FrpsManager.UpdateConfig(&cfg) - default: - slog.Debug("ignored unknown ws message type", "type", msg.Type) } } } diff --git a/openflare_relay/internal/wsclient/client.go b/openflare_relay/internal/wsclient/client.go index cd882508..5733c597 100644 --- a/openflare_relay/internal/wsclient/client.go +++ b/openflare_relay/internal/wsclient/client.go @@ -90,6 +90,16 @@ func buildWebsocketURL(baseURL string) (string, error) { return parsed.String(), nil } +func (conn *Connection) SendPing() error { + if conn == nil || conn.conn == nil { + return errors.New("relay ws connection is nil") + } + slog.Debug("relay ws sending ping") + return websocket.JSON.Send(conn.conn, service.WSMessage{ + Type: "ping", + }) +} + func (conn *Connection) SendPong() error { if conn == nil || conn.conn == nil { return errors.New("relay ws connection is nil") 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 eb1d5d63..f7911d45 100644 --- a/openflare_server/web/features/nodes/components/node-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/node-detail-page.tsx @@ -405,12 +405,12 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) { const normalizedServerUrl = getServerUrl(serverUrl); const nodeInstallCommand = - normalizedServerUrl && node.agent_token - ? buildNodeInstallCommand(normalizedServerUrl, node.agent_token) + normalizedServerUrl && node.access_token + ? buildNodeInstallCommand(normalizedServerUrl, node.access_token) : ''; const nodeDockerInstallCommand = - normalizedServerUrl && node.agent_token - ? buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token) + normalizedServerUrl && node.access_token + ? buildNodeDockerInstallCommand(normalizedServerUrl, node.access_token) : ''; const updateMode = getUpdateMode(node); const selectedAgentRelease = @@ -1446,7 +1446,7 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) { Agent Token

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

diff --git a/openflare_server/web/features/nodes/components/nodes-page.tsx b/openflare_server/web/features/nodes/components/nodes-page.tsx index 55b5d9d7..b5430358 100644 --- a/openflare_server/web/features/nodes/components/nodes-page.tsx +++ b/openflare_server/web/features/nodes/components/nodes-page.tsx @@ -1,441 +1,456 @@ 'use client'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; +import {useSearchParams} from 'next/navigation'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {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 { AppCard } from '@/components/ui/app-card'; -import { StatusBadge } from '@/components/ui/status-badge'; -import { getConfigVersions } from '@/features/config-versions/api/config-versions'; +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 {AppCard} from '@/components/ui/app-card'; +import {StatusBadge} from '@/components/ui/status-badge'; +import {getConfigVersions} from '@/features/config-versions/api/config-versions'; +import {createNode, deleteNode, getNodes, updateNode,} from '@/features/nodes/api/nodes'; +import {NodeEditorModal} from '@/features/nodes/components/node-editor-modal'; +import type {NodeItem, NodeMutationPayload} from '@/features/nodes/types'; +import {DangerButton, SecondaryButton,} from '@/features/shared/components/resource-primitives'; +import {formatDateTime, formatRelativeTime} from '@/lib/utils/date'; import { - createNode, - deleteNode, - getNodes, - updateNode, -} from '@/features/nodes/api/nodes'; -import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; -import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; -import { - DangerButton, - SecondaryButton, -} from '@/features/shared/components/resource-primitives'; -import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; -import { - getApplyLabel, - getApplyVariant, - getNodeStatusLabel, - getNodeStatusVariant, - getOpenrestyStatusLabel, - getOpenrestyStatusVariant, - getRelayStatusLabel, - getRelayStatusVariant, - isMeaningfulTime, - isWSConnectedLastSeen, + getApplyLabel, + getApplyVariant, + getNodeStatusLabel, + getNodeStatusVariant, + getOpenrestyStatusLabel, + getOpenrestyStatusVariant, + getRelayStatusLabel, + getRelayStatusVariant, + isMeaningfulTime, + isNodeAbnormal, + isWSConnectedLastSeen, } from '@/features/nodes/utils'; const nodesQueryKey = ['nodes']; -const supportedRiskFilters = [ - 'all', - 'offline', - 'unhealthy', - 'lagging', -] as const; +const supportedNodeFilters = ['all', 'edge', 'relay', 'tunnel', 'abnormal'] as const; -type NodeRiskFilter = (typeof supportedRiskFilters)[number]; +type NodeFilter = (typeof supportedNodeFilters)[number]; type FeedbackState = { - tone: 'info' | 'success' | 'danger'; - message: string; + tone: 'info' | 'success' | 'danger'; + message: string; }; function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '请求失败,请稍后重试。'; + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } export function NodesPage() { - const searchParams = useSearchParams(); - const queryClient = useQueryClient(); - const [feedback, setFeedback] = useState(null); - const [editingNode, setEditingNode] = useState(null); - const [isEditorOpen, setIsEditorOpen] = useState(false); + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState(null); + const [editingNode, setEditingNode] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); - const nodesQuery = useQuery({ - queryKey: nodesQueryKey, - queryFn: getNodes, - refetchInterval: 5000, - }); + const nodesQuery = useQuery({ + queryKey: nodesQueryKey, + queryFn: getNodes, + refetchInterval: 5000, + }); - const configVersionsQuery = useQuery({ - queryKey: ['config-versions'], - queryFn: getConfigVersions, - refetchInterval: 5000, - }); + const configVersionsQuery = useQuery({ + queryKey: ['config-versions'], + queryFn: getConfigVersions, + refetchInterval: 5000, + }); - const saveMutation = useMutation({ - mutationFn: async (payload: NodeMutationPayload) => { - return editingNode - ? updateNode(editingNode.id, payload) - : createNode(payload); - }, - onSuccess: async () => { - setFeedback({ - tone: 'success', - message: editingNode ? '节点已更新。' : '节点已创建。', - }); - setEditingNode(null); - setIsEditorOpen(false); - await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); + const saveMutation = useMutation({ + mutationFn: async (payload: NodeMutationPayload) => { + return editingNode + ? updateNode(editingNode.id, payload) + : createNode(payload); + }, + onSuccess: async () => { + setFeedback({ + tone: 'success', + message: editingNode ? '节点已更新。' : '节点已创建。', + }); + setEditingNode(null); + setIsEditorOpen(false); + await queryClient.invalidateQueries({queryKey: nodesQueryKey}); + }, + onError: (error) => { + setFeedback({tone: 'danger', message: getErrorMessage(error)}); + }, + }); - const deleteMutation = useMutation({ - mutationFn: deleteNode, - onSuccess: async () => { - setFeedback({ tone: 'success', message: '节点已删除。' }); - setEditingNode(null); - await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); + const deleteMutation = useMutation({ + mutationFn: deleteNode, + onSuccess: async () => { + setFeedback({tone: 'success', message: '节点已删除。'}); + setEditingNode(null); + await queryClient.invalidateQueries({queryKey: nodesQueryKey}); + }, + onError: (error) => { + setFeedback({tone: 'danger', message: getErrorMessage(error)}); + }, + }); - const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); - const activeVersion = useMemo( - () => - (configVersionsQuery.data ?? []).find((item) => item.is_active) - ?.version ?? '', - [configVersionsQuery.data], - ); - const riskFilter = useMemo(() => { - const current = searchParams.get('risk')?.trim().toLowerCase() ?? 'all'; - return supportedRiskFilters.includes(current as NodeRiskFilter) - ? (current as NodeRiskFilter) - : 'all'; - }, [searchParams]); - const filteredNodes = useMemo(() => { - switch (riskFilter) { - case 'offline': - return nodes.filter((node) => node.status === 'offline'); - case 'unhealthy': - return nodes.filter((node) => node.openresty_status === 'unhealthy'); - case 'lagging': - return nodes.filter((node) => { - if (!activeVersion) { - return false; - } - return node.current_version !== activeVersion; - }); - default: - return nodes; - } - }, [activeVersion, nodes, riskFilter]); + const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); + const activeVersion = useMemo( + () => + (configVersionsQuery.data ?? []).find((item) => item.is_active) + ?.version ?? '', + [configVersionsQuery.data], + ); + const nodeFilter = useMemo(() => { + const current = searchParams.get('filter')?.trim().toLowerCase() ?? ''; + if (supportedNodeFilters.includes(current as NodeFilter)) { + return current as NodeFilter; + } - const filterDescription = useMemo(() => { - switch (riskFilter) { - case 'offline': - return '当前仅展示离线节点。'; - case 'unhealthy': - return '当前仅展示 OpenResty 异常节点。'; - case 'lagging': - return activeVersion - ? `当前仅展示未追平激活版本 ${activeVersion} 的节点。` - : '当前没有激活版本,无法筛选配置落后节点。'; - default: - return '列表每 5 秒自动刷新一次。'; - } - }, [activeVersion, riskFilter]); + const legacyRisk = searchParams.get('risk')?.trim().toLowerCase() ?? ''; + if ( + legacyRisk === 'offline' || + legacyRisk === 'unhealthy' || + legacyRisk === 'lagging' + ) { + return 'abnormal'; + } - const handleReset = () => { - setFeedback(null); - setEditingNode(null); - setIsEditorOpen(false); - }; + return 'all'; + }, [searchParams]); + const filteredNodes = useMemo(() => { + switch (nodeFilter) { + case 'all': + return nodes; + case 'relay': + return nodes.filter((node) => node.node_type === 'tunnel_relay'); + case 'tunnel': + return nodes.filter((node) => node.node_type === 'tunnel_client'); + case 'abnormal': + return nodes.filter((node) => isNodeAbnormal(node, activeVersion)); + case 'edge': + default: + return nodes.filter((node) => node.node_type === 'edge_node'); + } + }, [activeVersion, nodeFilter, nodes]); - const handleCreate = () => { - setFeedback(null); - setEditingNode(null); - setIsEditorOpen(true); - }; + const filterDescription = useMemo(() => { + switch (nodeFilter) { + case 'all': + return '当前展示全部节点。'; + case 'relay': + return '当前仅展示 Relay 节点。'; + case 'tunnel': + return '当前仅展示 Tunnel 节点。'; + case 'abnormal': + return activeVersion + ? `当前仅展示异常节点(离线、运行异常或未追平激活版本 ${activeVersion})。` + : '当前仅展示异常节点(离线或运行异常)。'; + case 'edge': + default: + return '当前仅展示 Edge 节点。'; + } + }, [activeVersion, nodeFilter]); - const handleEdit = (node: NodeItem) => { - setFeedback(null); - setEditingNode(node); - setIsEditorOpen(true); - }; + const handleReset = () => { + setFeedback(null); + setEditingNode(null); + setIsEditorOpen(false); + }; - const handleDelete = (nodeId: number, nodeName: string) => { - if ( - !window.confirm( - `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`, - ) - ) { - return; - } + const handleCreate = () => { + setFeedback(null); + setEditingNode(null); + setIsEditorOpen(true); + }; - setFeedback(null); - deleteMutation.mutate(nodeId); - }; - return ( - <> -
- - - 新增节点 - - - 应用记录 - - - } - /> + const handleEdit = (node: NodeItem) => { + setFeedback(null); + setEditingNode(node); + setIsEditorOpen(true); + }; - {feedback ? ( - setFeedback(null)} - /> - ) : null} + const handleDelete = (nodeId: number, nodeName: string) => { + if ( + !window.confirm( + `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`, + ) + ) { + return; + } - - void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) - } - > - 立即刷新 - - } - > - {nodesQuery.isLoading ? ( - - ) : nodesQuery.isError ? ( - - ) : filteredNodes.length === 0 ? ( - - ) : ( -
-
- +
+ + + 新增节点 + + + 应用记录 + + + } + /> + + {feedback ? ( + setFeedback(null)} + /> + ) : null} + + + void queryClient.invalidateQueries({queryKey: nodesQueryKey}) + } + > + 立即刷新 + + } > - 全部节点 - - - 离线节点 - - - OpenResty 异常 - - - 配置落后 - -
+ {nodesQuery.isLoading ? ( + + ) : nodesQuery.isError ? ( + + ) : filteredNodes.length === 0 ? ( + + ) : ( +
+
+ + 全部节点 + + + Edge + + + Relay + + + Tunnel + + + 异常 + +
-
- - - - - - - - - - - - - - - {filteredNodes.map((node) => ( - - - - - - - - - - - ))} - -
节点状态 - Version - 运行健康当前版本最近应用最近心跳操作
-
-
-

- {node.name} -

- {node.node_type === 'tunnel_relay' ? ( - +
+ + + + + + + + + + + + + + + {filteredNodes.map((node) => ( + + + + + + + + + + + ))} + +
节点状态Version运行健康当前版本最近应用最近心跳操作
+
+
+

+ {node.name} +

+ {node.node_type === 'tunnel_relay' ? ( + Relay - ) : node.node_type === 'tunnel_client' ? ( - + ) : node.node_type === 'tunnel_client' ? ( + Client - ) : ( - + ) : ( + Edge - )} + )} +
+

+ IP:{node.ip || 'null'} + {node.ip_manual_override ? '(已锁定)' : ''} +

+

+ 位置:{node.geo_name || '未配置地图点位'} +

+
+
+ + + {node.version || 'unknown'} + +
+ {node.node_type === 'tunnel_relay' ? ( + + ) : node.node_type === 'tunnel_client' ? ( + + - + + ) : ( + + )} +
+
+ {node.current_version || '未应用'} + +
+ +
+
+ {isWSConnectedLastSeen(node.last_seen_at) + ? 'WS 已连接' + : isMeaningfulTime(node.last_seen_at) + ? `${formatRelativeTime( + node.last_seen_at, + )} · ${formatDateTime(node.last_seen_at)}` + : '暂无'} + +
+ + 详情 + + handleEdit(node)} + className="px-3 py-2 text-xs" + > + 编辑 + + handleDelete(node.id, node.name)} + disabled={deleteMutation.isPending} + className="px-3 py-2 text-xs" + > + 删除 + +
+
-

- IP:{node.ip || 'null'} - {node.ip_manual_override ? '(已锁定)' : ''} -

-

- 位置:{node.geo_name || '未配置地图点位'} -

-
-
- - - {node.version || 'unknown'} - -
- {node.node_type === 'tunnel_relay' ? ( - - ) : node.node_type === 'tunnel_client' ? ( - - - ) : ( - - )} -
-
- {node.current_version || '未应用'} - -
- -
-
- {isWSConnectedLastSeen(node.last_seen_at) - ? 'WS 已连接' - : isMeaningfulTime(node.last_seen_at) - ? `${formatRelativeTime( - node.last_seen_at, - )} · ${formatDateTime(node.last_seen_at)}` - : '暂无'} - -
- - 详情 - - handleEdit(node)} - className="px-3 py-2 text-xs" - > - 编辑 - - handleDelete(node.id, node.name)} - disabled={deleteMutation.isPending} - className="px-3 py-2 text-xs" - > - 删除 - -
-
-
+
+ )} +
- )} - -
- { - setFeedback(null); - saveMutation.mutate(payload); - }} - /> - - ); + { + setFeedback(null); + saveMutation.mutate(payload); + }} + /> + + ); } diff --git a/openflare_server/web/features/nodes/components/relay-detail-page.tsx b/openflare_server/web/features/nodes/components/relay-detail-page.tsx index 2b72ec11..5e5c8591 100644 --- a/openflare_server/web/features/nodes/components/relay-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/relay-detail-page.tsx @@ -27,10 +27,7 @@ import { updateNode, } from '@/features/nodes/api/nodes'; import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; -import type { - NodeItem, - NodeAgentReleaseInfo, -} from '@/features/nodes/types'; +import type { NodeItem, NodeAgentReleaseInfo } from '@/features/nodes/types'; import { CodeBlock, DangerButton, @@ -50,6 +47,7 @@ import { getServerUrl, getUpdateMode, isMeaningfulTime, + isWSConnectedLastSeen, } from '@/features/nodes/utils'; import { copyToClipboard, @@ -216,9 +214,11 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { }); const handleDelete = () => { - if (!window.confirm( - `确认删除中继节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, - )) { + if ( + !window.confirm( + `确认删除中继节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, + ) + ) { return; } setFeedback(null); @@ -265,7 +265,12 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { default: return observability?.health_events ?? []; } - }, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]); + }, [ + activeHealthEvents, + healthEventFilter, + observability?.health_events, + resolvedHealthEvents, + ]); const tabs = useMemo( () => @@ -290,12 +295,12 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { const normalizedServerUrl = getServerUrl(serverUrl); const relayInstallCommand = - normalizedServerUrl && node.agent_token - ? buildRelayInstallCommand(normalizedServerUrl, node.agent_token) + normalizedServerUrl && node.access_token + ? buildRelayInstallCommand(normalizedServerUrl, node.access_token) : ''; const relayDockerInstallCommand = - normalizedServerUrl && node.agent_token - ? buildRelayDockerInstallCommand(normalizedServerUrl, node.agent_token) + normalizedServerUrl && node.access_token + ? buildRelayDockerInstallCommand(normalizedServerUrl, node.access_token) : ''; const updateMode = getUpdateMode(node); @@ -570,7 +575,9 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { 总内存

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

@@ -614,7 +621,9 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {

@@ -768,17 +781,26 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {

- + {observability?.relay_dashboard?.proxies?.length ? (
- + - - + + @@ -790,8 +812,12 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { @@ -894,7 +920,9 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {

@@ -989,7 +1017,7 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { Discovery Token (中继注册 Token)

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

@@ -1045,6 +1073,18 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {

{node.relay_bind_port || '—'}

+
+

+ 最后心跳 +

+

+ {isWSConnectedLastSeen(node.last_seen_at) + ? 'WS 已连接' + : isMeaningfulTime(node.last_seen_at) + ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` + : '暂无'} +

+

中继节点最近错误 @@ -1199,12 +1239,8 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { /> ) : (

-

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

-

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

+

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

+

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

)} @@ -1273,8 +1309,12 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { {selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}

@@ -1327,7 +1367,9 @@ export function RelayDetailPage({ node }: { node: NodeItem }) { ? '发现可升级版本' : '当前已是最新版本' } - variant={selectedAgentRelease.has_update ? 'warning' : 'success'} + variant={ + selectedAgentRelease.has_update ? 'warning' : 'success' + } /> {selectedAgentRelease.prerelease ? ( diff --git a/openflare_server/web/features/nodes/types.ts b/openflare_server/web/features/nodes/types.ts index 97d800f5..a81691d6 100644 --- a/openflare_server/web/features/nodes/types.ts +++ b/openflare_server/web/features/nodes/types.ts @@ -19,7 +19,7 @@ export interface NodeItem { geo_latitude?: number | null; geo_longitude?: number | null; geo_manual_override: boolean; - agent_token: string; + access_token: string; auto_update_enabled: boolean; update_requested: boolean; update_channel: ReleaseChannel; diff --git a/openflare_server/web/features/nodes/utils.ts b/openflare_server/web/features/nodes/utils.ts index ca9e383f..c45bc6bf 100644 --- a/openflare_server/web/features/nodes/utils.ts +++ b/openflare_server/web/features/nodes/utils.ts @@ -112,6 +112,26 @@ export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) { return '未知'; } +export function isNodeAbnormal(node: NodeItem, activeVersion?: string) { + if (node.status === 'offline') { + return true; + } + + if (activeVersion && node.current_version !== activeVersion) { + return true; + } + + if (node.node_type === 'tunnel_relay') { + return node.relay_status === 'unhealthy'; + } + + if (node.node_type === 'edge_node') { + return node.openresty_status === 'unhealthy'; + } + + return false; +} + function parseVersionParts(version: string) { const normalized = version.trim().replace(/^v/i, ''); if (!normalized || normalized.toLowerCase() === 'unknown') { @@ -220,7 +240,10 @@ export function getRelayStatusLabel(status: string | null | undefined) { const relayInstallerScriptUrl = 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh'; -export function buildRelayInstallCommand(serverUrl: string, discoveryToken: string) { +export function buildRelayInstallCommand( + serverUrl: string, + discoveryToken: string, +) { return [ `curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`, ` --server-url ${serverUrl} \\`, @@ -244,7 +267,10 @@ export function buildRelayDockerInstallCommand( ].join('\n'); } -export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string) { +export function buildTunnelInstallCommand( + serverUrl: string, + tunnelToken: string, +) { const flaredInstallerScriptUrl = 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh'; return [ diff --git a/openflare_server/web/tests/unit/nodes-utils.test.ts b/openflare_server/web/tests/unit/nodes-utils.test.ts new file mode 100644 index 00000000..9c4b5100 --- /dev/null +++ b/openflare_server/web/tests/unit/nodes-utils.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import type { NodeItem } from '@/features/nodes/types'; +import { isNodeAbnormal } from '@/features/nodes/utils'; + +function buildNode(overrides: Partial = {}): NodeItem { + return { + id: 1, + node_id: 'node-1', + node_type: 'edge_node', + name: 'node-1', + ip: '127.0.0.1', + ip_manual_override: false, + relay_bind_port: 7000, + relay_client_access_addr: '', + relay_agent_access_addr: '', + relay_client_proxy_url: '', + relay_auth_token: '', + relay_status: 'healthy', + relay_frps_connections: 0, + relay_frps_proxy_count: 0, + geo_name: '', + geo_latitude: null, + geo_longitude: null, + geo_manual_override: false, + access_token: '', + auto_update_enabled: false, + update_requested: false, + update_channel: 'stable', + update_tag: '', + restart_openresty_requested: false, + version: '1.0.0', + ext_version: '', + openresty_status: 'healthy', + openresty_message: '', + status: 'online', + current_version: '20260601-001', + last_seen_at: '', + last_error: '', + latest_apply_result: '', + latest_apply_message: '', + latest_apply_checksum: '', + latest_main_config_checksum: '', + latest_route_config_checksum: '', + latest_support_file_count: 0, + latest_apply_at: null, + created_at: '', + updated_at: '', + ...overrides, + }; +} + +describe('isNodeAbnormal', () => { + it('treats offline nodes as abnormal', () => { + expect( + isNodeAbnormal(buildNode({ status: 'offline' }), '20260601-001'), + ).toBe(true); + }); + + it('treats unhealthy edge nodes as abnormal', () => { + expect( + isNodeAbnormal( + buildNode({ node_type: 'edge_node', openresty_status: 'unhealthy' }), + '20260601-001', + ), + ).toBe(true); + }); + + it('treats unhealthy relay nodes as abnormal', () => { + expect( + isNodeAbnormal( + buildNode({ node_type: 'tunnel_relay', relay_status: 'unhealthy' }), + '20260601-001', + ), + ).toBe(true); + }); + + it('treats nodes behind the active version as abnormal', () => { + expect( + isNodeAbnormal( + buildNode({ + node_type: 'tunnel_client', + current_version: '20260531-001', + }), + '20260601-001', + ), + ).toBe(true); + }); + + it('ignores version lag when there is no active version', () => { + expect( + isNodeAbnormal( + buildNode({ + node_type: 'tunnel_client', + current_version: '20260531-001', + }), + ), + ).toBe(false); + }); + + it('keeps healthy synced nodes out of the abnormal filter', () => { + expect(isNodeAbnormal(buildNode(), '20260601-001')).toBe(false); + }); +});
代理名称 (Proxy Name) + 代理名称 (Proxy Name) + 代理类型 在线状态客户端接入地址最近连接时间 + 客户端接入地址 + + 最近连接时间 +
{proxy.type} {proxy.client_addr}