From 76f3428f5da5cbe83b3e5a685d0722dd8e779c6d Mon Sep 17 00:00:00 2001
From: ryan
Date: Mon, 1 Jun 2026 15:34:05 +0800
Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E5=AD=97=E6=AE=B5?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docker-compose.yaml | 6 +-
.../internal/protocol/agent_api.go | 4 +-
openflare_relay/internal/relay/runner.go | 71 +-
openflare_relay/internal/wsclient/client.go | 10 +
.../nodes/components/node-detail-page.tsx | 10 +-
.../features/nodes/components/nodes-page.tsx | 815 +++++++++---------
.../nodes/components/relay-detail-page.tsx | 108 ++-
openflare_server/web/features/nodes/types.ts | 2 +-
openflare_server/web/features/nodes/utils.ts | 30 +-
.../web/tests/unit/nodes-utils.test.ts | 104 +++
10 files changed, 692 insertions(+), 468 deletions(-)
create mode 100644 openflare_server/web/tests/unit/nodes-utils.test.ts
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
+
+
+ 异常
+
+
-
-
-
-
- | 节点 |
- 状态 |
-
- Version
- |
- 运行健康 |
- 当前版本 |
- 最近应用 |
- 最近心跳 |
- 操作 |
-
-
-
- {filteredNodes.map((node) => (
-
-
-
-
-
- {node.name}
-
- {node.node_type === 'tunnel_relay' ? (
-
+
+
+
+
+ | 节点 |
+ 状态 |
+ Version |
+ 运行健康 |
+ 当前版本 |
+ 最近应用 |
+ 最近心跳 |
+ 操作 |
+
+
+
+ {filteredNodes.map((node) => (
+
+
+
+
+
+ {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 ? (
- | 代理名称 (Proxy Name) |
+
+ 代理名称 (Proxy Name)
+ |
代理类型 |
在线状态 |
- 客户端接入地址 |
- 最近连接时间 |
+
+ 客户端接入地址
+ |
+
+ 最近连接时间
+ |
@@ -790,8 +812,12 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
{proxy.type} |
|
{proxy.client_addr} |
@@ -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);
+ });
+});