perf(nodes): paginate node lists and fetch details directly (#54)

* perf(nodes): paginate node lists and fetch details directly

* fix(nodes): preserve Unicode search and page transition state

* fix(nodes): validate search using the UTF-8 byte limit
This commit is contained in:
ArthurKoba
2026-11-07 16:29:52 +03:00
committed by GitHub
parent 72d67c80f2
commit 3c7b347d7b
28 changed files with 1873 additions and 45 deletions
+5
View File
@@ -17,6 +17,11 @@ sidebar: false
## [Unreleased]
### 优化
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。
### 🛠 修复
- 修复 Agent 自动注册响应凭证字段不一致导致节点不断重复创建的问题;新增持久化注册重试凭证与退避,响应丢失、并发重试或配置写入失败后仍复用原节点,且不会覆盖管理员修改或恢复已更换的节点凭证。支持目录刷盘的平台会确认原子替换后的目录持久化,失败时阻止使用未确认的注册凭证;接口文档同步列出两种兼容凭证字段。
+10
View File
@@ -32,6 +32,16 @@ Agent 主要由以下核心子模块组成,共同配合完成其完整的生
---
## 节点列表与详情查询
节点管理页使用管理员接口 `GET /api/v1/d/nodes/page`,返回 `data: {total, results, page, page_size}`。默认每页 20 条,最大 100 条;按数据库 ID 降序排列,可通过 `search`(名称、节点标识或 IP 的字面子串,最多 256 字节)、`status` 和 `node_type` 进行服务端筛选。`pending` 表示尚未上报心跳,最近 60 秒内上报的节点为 `online`,其余为 `offline`;筛选和返回状态使用同一时间基准。未设置类型的旧节点按 `edge_node` 处理。搜索保留原始 Unicode 字符的字面匹配,同时兼容 ASCII 大小写折叠;SQLite 不保证完整的 Unicode 大小写不敏感匹配。前端使用相同的 256 字节 UTF-8 上限校验去除首尾空白后的搜索词,超限时保留上次有效查询并显示提示,缩短输入后恢复搜索。
配置下发记录仅为当前页节点批量加载;详情页通过 `GET /api/v1/d/nodes/{id}` 获取单个节点及相同的下发元数据。列表与详情每 15 秒刷新,在后台、离线或请求出错时暂停定时刷新,可通过窗口聚焦、网络恢复或手动刷新重试。筛选或每页数量变化后回到第一页,删除末页最后一条记录后自动回到有效页。同一筛选条件下切页时保留已加载的表格与总数,页码仍显示已加载页,等待响应期间禁用节点编辑、删除和详情入口;改变筛选条件时不会显示上一筛选的节点,请求失败时可手动重试。
为兼容现有集成和节点选择器,原 `GET /api/v1/d/nodes` 仍返回完整数组,未改变含义;选择器尚未迁移为分页搜索。仪表盘仍为全局统计和地图加载全部节点,并使用现有缓存;本次没有截断全局总数或地图数据。分页计数和当前页是独立查询,节点并发增删时可能短暂不一致,下一次刷新会更新。普通列表和类型筛选利用主键及类型索引,状态筛选可使用心跳时间索引;字面子串搜索仍可能扫描匹配候选,未宣称 B-tree 索引能加速任意子串。
---
## 与 Server 的交互链路
Agent 在生命周期中主要通过 **基于 Token 的自动注册** 和 **心跳/WebSocket 双通道** 与控制面通信。
+183
View File
@@ -7953,6 +7953,169 @@ const docTemplate = `{
}
}
},
"/api/v1/d/nodes/page": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取分页节点列表",
"parameters": [
{
"type": "integer",
"description": "页码(默认 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量(默认 20,最大 100)",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "名称、节点标识或 IP 的字面子串(最多 256 字节)",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "pending、online 或 offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "edge_node、tunnel_relay 或 tunnel_client",
"name": "node_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "节点分页",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.ListResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定节点及最新配置下发记录,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取节点详情",
"parameters": [
{
"type": "integer",
"description": "节点 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "节点详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.View"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}/agent-release": {
"get": {
"security": [
@@ -17755,6 +17918,26 @@ const docTemplate = `{
}
}
},
"node.ListResult": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/node.View"
}
},
"total": {
"type": "integer"
}
}
},
"node.ObservabilityView": {
"type": "object",
"properties": {
+183
View File
@@ -7946,6 +7946,169 @@
}
}
},
"/api/v1/d/nodes/page": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取分页节点列表",
"parameters": [
{
"type": "integer",
"description": "页码(默认 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量(默认 20,最大 100)",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "名称、节点标识或 IP 的字面子串(最多 256 字节)",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "pending、online 或 offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "edge_node、tunnel_relay 或 tunnel_client",
"name": "node_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "节点分页",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.ListResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定节点及最新配置下发记录,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取节点详情",
"parameters": [
{
"type": "integer",
"description": "节点 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "节点详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.View"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}/agent-release": {
"get": {
"security": [
@@ -17748,6 +17911,26 @@
}
}
},
"node.ListResult": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/node.View"
}
},
"total": {
"type": "integer"
}
}
},
"node.ObservabilityView": {
"type": "object",
"properties": {
+112
View File
@@ -1931,6 +1931,19 @@ definitions:
relay_web_server_enabled:
type: boolean
type: object
node.ListResult:
properties:
page:
type: integer
page_size:
type: integer
results:
items:
$ref: '#/definitions/node.View'
type: array
total:
type: integer
type: object
node.ObservabilityView:
properties:
analytics:
@@ -9119,6 +9132,48 @@ paths:
summary: 创建节点
tags:
- openflare-node
/api/v1/d/nodes/{id}:
get:
description: 返回指定节点及最新配置下发记录,需要管理员权限
parameters:
- description: 节点 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 节点详情
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/node.View'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限或不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 查询失败
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取节点详情
tags:
- openflare-node
/api/v1/d/nodes/{id}/agent-release:
get:
description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限
@@ -9506,6 +9561,63 @@ paths:
summary: 轮换引导令牌
tags:
- openflare-node
/api/v1/d/nodes/page:
get:
description: 返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限
parameters:
- description: 页码(默认 1)
in: query
name: page
type: integer
- description: 每页数量(默认 20,最大 100)
in: query
name: page_size
type: integer
- description: 名称、节点标识或 IP 的字面子串(最多 256 字节)
in: query
name: search
type: string
- description: pending、online 或 offline
in: query
name: status
type: string
- description: edge_node、tunnel_relay 或 tunnel_client
in: query
name: node_type
type: string
produces:
- application/json
responses:
"200":
description: 节点分页
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/node.ListResult'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限
schema:
$ref: '#/definitions/response.Any'
"500":
description: 查询失败
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取分页节点列表
tags:
- openflare-node
/api/v1/d/option:
get:
description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限
@@ -0,0 +1,148 @@
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { NodeStatus } from '@/lib/services/openflare';
export function NodeListControls({
search,
searchTooLong,
onSearchChange,
status,
onStatusChange,
page,
pageSize,
onPageSizeChange,
total,
totalPages,
loading,
hasData,
onPageChange,
}: {
search: string;
searchTooLong: boolean;
onSearchChange: (value: string) => void;
status: NodeStatus | '';
onStatusChange: (value: NodeStatus | '') => void;
page: number;
pageSize: number;
onPageSizeChange: (value: number) => void;
total: number;
totalPages: number;
loading: boolean;
hasData: boolean;
onPageChange: (value: number) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='flex flex-col gap-4'>
<FieldGroup className='flex flex-wrap gap-3 sm:flex-row'>
<Field className='flex-1' data-invalid={searchTooLong || undefined}>
<FieldLabel htmlFor='node-search'>
{t('pagination.search')}
</FieldLabel>
<Input
id='node-search'
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={t('pagination.searchPlaceholder')}
maxLength={256}
aria-invalid={searchTooLong || undefined}
aria-describedby={searchTooLong ? 'node-search-error' : undefined}
/>
{searchTooLong && (
<FieldError id='node-search-error'>
{t('pagination.searchTooLong')}
</FieldError>
)}
</Field>
<Field className='sm:w-44'>
<FieldLabel htmlFor='node-status'>{t('table.status')}</FieldLabel>
<Select
value={status || 'all'}
onValueChange={(value) =>
onStatusChange(value === 'all' ? '' : (value as NodeStatus))
}
>
<SelectTrigger id='node-status'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='all'>
{t('pagination.allStatuses')}
</SelectItem>
{(['pending', 'online', 'offline'] as const).map((value) => (
<SelectItem key={value} value={value}>
{t(`status.${value}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field className='sm:w-32'>
<FieldLabel htmlFor='node-page-size'>
{t('pagination.pageSize')}
</FieldLabel>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger id='node-page-size'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[20, 50, 100].map((value) => (
<SelectItem key={value} value={String(value)}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
<div className='flex flex-wrap items-center justify-between gap-3'>
{hasData && (
<p className='text-sm text-muted-foreground' aria-live='polite'>
{t('pagination.summary', { page, totalPages, total })}
</p>
)}
<nav aria-label={t('pagination.navigation')} className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={!hasData || loading || page <= 1}
onClick={() => onPageChange(page - 1)}
>
{t('pagination.previous')}
</Button>
<Button
variant='outline'
size='sm'
disabled={!hasData || loading || page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
{t('pagination.next')}
</Button>
</nav>
</div>
</div>
);
}
@@ -37,15 +37,20 @@ export function NodesTable({
deletingId,
onEdit,
onDelete,
actionsDisabled = false,
}: {
nodes: NodeItem[];
deletingId: number | null;
actionsDisabled?: boolean;
onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='border border-dashed rounded-lg overflow-hidden'>
<div
className='border border-dashed rounded-lg overflow-hidden'
aria-busy={actionsDisabled}
>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
@@ -144,16 +149,22 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs'
asChild
asChild={!actionsDisabled}
disabled={actionsDisabled}
>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
{actionsDisabled ? (
t('detail')
) : (
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
)}
</Button>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
disabled={actionsDisabled}
onClick={() => onEdit(node)}
>
<Pencil className='size-3 mr-1' />
@@ -163,7 +174,7 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs text-destructive hover:text-destructive'
disabled={deletingId === node.id}
disabled={actionsDisabled || deletingId === node.id}
onClick={() => onDelete(node)}
>
<Trash2 className='size-3 mr-1' />
@@ -1,7 +1,6 @@
'use client';
import Link from 'next/link';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Server } from 'lucide-react';
@@ -25,20 +24,20 @@ export function NodeDetailPageClient() {
const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? '';
const validNodeId =
Number.isSafeInteger(Number(nodeId)) && Number(nodeId) > 0;
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
queryKey: [...nodesQueryKey, 'detail', nodeId],
queryFn: () => NodeService.getNode(Number(nodeId)),
enabled: validNodeId,
refetchInterval: (query) =>
query.state.status === 'error' ? false : 15000,
refetchIntervalInBackground: false,
});
const node = useMemo(() => {
if (!nodeId) return null;
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
const node = nodesQuery.data;
if (!nodeId) {
if (!validNodeId) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder icon={Server} description={t('missingId')} />
+136 -26
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -29,46 +29,112 @@ import {
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { NodeItem, NodeMutationPayload } from '@/lib/services/openflare';
import type {
NodeItem,
NodeListQuery,
NodeMutationPayload,
NodeStatus,
NodeType,
} from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { NodeEditorDialog } from './components/node-editor-dialog';
import {
filterNodesByType,
getFilterDescription,
getNodeFilter,
NodeTypeFilter,
type NodeFilter,
} from './components/node-type-filter';
import { NodesTable } from './components/nodes-table';
import { NodeListControls } from './components/node-list-controls';
import { getErrorMessage } from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodesPageClient() {
const searchParams = useSearchParams();
const nodeFilter = getNodeFilter(
new URLSearchParams(searchParams.toString()),
);
return <NodesPage key={nodeFilter} nodeFilter={nodeFilter} />;
}
function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [status, setStatus] = useState<NodeStatus | ''>('');
const searchTooLong = new TextEncoder().encode(search.trim()).length > 256;
const nodeFilter = useMemo(
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const resetNodeActions = useCallback(() => {
setEditingNode(null);
setEditorOpen(false);
setDeleteTarget(null);
}, []);
useEffect(() => {
if (searchTooLong || search.trim() === debouncedSearch) return;
const timeout = setTimeout(() => {
setDebouncedSearch(search.trim());
setPage(1);
resetNodeActions();
}, 300);
return () => clearTimeout(timeout);
}, [search, searchTooLong, debouncedSearch, resetNodeActions]);
const nodeTypes: Record<NodeFilter, NodeType | ''> = {
all: '',
edge: 'edge_node',
relay: 'tunnel_relay',
tunnel: 'tunnel_client',
};
const query = {
page,
page_size: pageSize,
search: debouncedSearch,
status,
node_type: nodeTypes[nodeFilter],
};
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
queryKey: [...nodesQueryKey, 'page', query],
queryFn: () => NodeService.listNodePage(query),
placeholderData: (previousData, previousQuery) => {
const previousParams = previousQuery?.queryKey[3] as
| NodeListQuery
| undefined;
return previousParams?.search === query.search &&
previousParams?.status === query.status &&
previousParams?.node_type === query.node_type &&
previousParams?.page_size === query.page_size
? previousData
: undefined;
},
refetchInterval: (query) =>
query.state.status === 'error' ? false : 15000,
refetchIntervalInBackground: false,
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const filteredNodes = useMemo(
() => filterNodesByType(nodes, nodeFilter),
[nodeFilter, nodes],
const nodes = useMemo(
() => nodesQuery.data?.results ?? [],
[nodesQuery.data],
);
const total = nodesQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (
!nodesQuery.isPlaceholderData &&
nodesQuery.data?.page === page &&
page > totalPages
) {
setPage(totalPages);
resetNodeActions();
}
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
@@ -106,6 +172,7 @@ export function NodesPageClient() {
};
const handleEdit = (node: NodeItem) => {
if (nodesQuery.isPlaceholderData) return;
setEditingNode(node);
setEditorOpen(true);
};
@@ -115,7 +182,7 @@ export function NodesPageClient() {
};
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Server className='size-5 text-primary' />
@@ -166,8 +233,34 @@ export function NodesPageClient() {
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className='flex flex-col gap-4'>
<NodeTypeFilter />
<NodeListControls
search={search}
searchTooLong={searchTooLong}
onSearchChange={setSearch}
status={status}
onStatusChange={(value) => {
setStatus(value);
setPage(1);
resetNodeActions();
}}
page={nodesQuery.data?.page ?? page}
pageSize={pageSize}
onPageSizeChange={(value) => {
setPageSize(value);
setPage(1);
resetNodeActions();
}}
total={total}
totalPages={totalPages}
loading={nodesQuery.isFetching || nodesQuery.isPlaceholderData}
hasData={Boolean(nodesQuery.data)}
onPageChange={(value) => {
setPage(value);
resetNodeActions();
}}
/>
{nodesQuery.isLoading ? (
<LoadingStateWithBorder
@@ -182,21 +275,26 @@ export function NodesPageClient() {
className='justify-center'
/>
</div>
) : filteredNodes.length === 0 ? (
) : nodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
debouncedSearch || status || nodeFilter !== 'all'
? t('emptyFilter')
: t('emptyAll')
}
/>
) : (
<NodesTable
nodes={filteredNodes}
nodes={nodes}
deletingId={
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onDelete={(node) => {
if (!nodesQuery.isPlaceholderData) setDeleteTarget(node);
}}
actionsDisabled={nodesQuery.isPlaceholderData}
/>
)}
</CardContent>
@@ -205,13 +303,17 @@ export function NodesPageClient() {
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={saveMutation.isPending}
submitting={
saveMutation.isPending ||
(Boolean(editingNode) && nodesQuery.isPlaceholderData)
}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
if (!editingNode || !nodesQuery.isPlaceholderData)
await saveMutation.mutateAsync(payload);
}}
/>
@@ -227,14 +329,22 @@ export function NodesPageClient() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
<AlertDialogCancel
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
deleteTarget &&
!nodesQuery.isPlaceholderData &&
deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
+2
View File
@@ -38,6 +38,8 @@ export type {
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeListQuery,
NodeListResult,
NodeMutationPayload,
NodeHealthEvent,
NodeObservability,
@@ -4,6 +4,8 @@ import type {
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeListQuery,
NodeListResult,
NodeMutationPayload,
NodeObservability,
ReleaseChannel,
@@ -16,6 +18,16 @@ export class NodeService extends OpenFlareBaseService {
return this.get<NodeItem[]>('/');
}
static async listNodePage(
query: NodeListQuery = {},
): Promise<NodeListResult> {
return this.get<NodeListResult>('/page', { ...query });
}
static async getNode(id: number): Promise<NodeItem> {
return this.get<NodeItem>(`/${id}`);
}
static async createNode(payload: NodeMutationPayload): Promise<NodeItem> {
return this.post<NodeItem>('/', payload);
}
+15
View File
@@ -56,6 +56,21 @@ export interface NodeItem {
updated_at: string;
}
export interface NodeListQuery {
page?: number;
page_size?: number;
search?: string;
status?: NodeStatus | '';
node_type?: NodeType | '';
}
export interface NodeListResult {
total: number;
results: NodeItem[];
page: number;
page_size: number;
}
export interface NodeBootstrapToken {
discovery_token: string;
}
+11
View File
@@ -120,6 +120,17 @@
}
},
"nodes": {
"pagination": {
"search": "Search nodes",
"searchPlaceholder": "Name, node ID or IP",
"searchTooLong": "Search text is too long. Shorten it to continue.",
"allStatuses": "All statuses",
"pageSize": "Per page",
"summary": "Page {page} of {totalPages} · {total} nodes",
"navigation": "Node pages",
"previous": "Previous",
"next": "Next"
},
"title": "Nodes",
"applyLogs": "Apply logs",
"create": "Add node",
@@ -120,6 +120,17 @@
}
},
"nodes": {
"pagination": {
"search": "搜索节点",
"searchPlaceholder": "名称、节点标识或 IP",
"searchTooLong": "搜索内容过长,请缩短后重试。",
"allStatuses": "全部状态",
"pageSize": "每页数量",
"summary": "第 {page} / {totalPages} 页 · 共 {total} 个节点",
"navigation": "节点分页",
"previous": "上一页",
"next": "下一页"
},
"title": "节点管理",
"applyLogs": "应用记录",
"create": "新增节点",
@@ -0,0 +1,343 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { NodesPageClient } from '@/app/(main)/nodes/page-client';
import { NodeDetailPageClient } from '@/app/(main)/nodes/detail/page-client';
import { NodeService, type NodeItem } from '@/lib/services/openflare';
import en from '@/messages/en.json';
let params = new URLSearchParams();
vi.mock('next/navigation', () => ({ useSearchParams: () => params }));
vi.mock('@/lib/services/openflare', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/services/openflare')>()),
NodeService: {
listNodePage: vi.fn(),
listNodes: vi.fn(),
getNode: vi.fn(),
deleteNode: vi.fn(),
},
}));
vi.mock('@/app/(main)/nodes/components/node-editor-dialog', () => ({
NodeEditorDialog: () => null,
}));
vi.mock('@/app/(main)/nodes/components/edge-node-detail', () => ({
EdgeNodeDetail: ({ node }: { node: NodeItem }) => (
<div>Node detail {node.id}</div>
),
}));
vi.mock('@/app/(main)/nodes/components/relay-node-detail', () => ({
RelayNodeDetail: () => null,
}));
vi.mock('@/app/(main)/nodes/components/tunnel-node-detail', () => ({
TunnelNodeDetail: () => null,
}));
function node(id: number): NodeItem {
return { id, name: `Edge ${id}`, node_type: 'edge_node' } as NodeItem;
}
function renderPage(detail = false) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
const result = render(
<NextIntlClientProvider locale='en' messages={en} timeZone='UTC'>
<QueryClientProvider client={client}>
{detail ? <NodeDetailPageClient /> : <NodesPageClient />}
</QueryClientProvider>
</NextIntlClientProvider>,
);
return { ...result, client };
}
beforeEach(() => {
params = new URLSearchParams();
vi.clearAllMocks();
vi.mocked(NodeService.listNodePage).mockImplementation(
async (query = {}) => ({
page: query.page ?? 1,
page_size: query.page_size ?? 20,
total: 41,
results: [node(query.page ?? 1)],
}),
);
});
afterEach(() => {
cleanup();
});
it('fetches only visible pages and debounces server search while resetting the page', async () => {
renderPage();
await screen.findByText('Edge 1');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByText('Edge 2');
expect(NodeService.listNodePage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 2, page_size: 20 }),
);
expect(NodeService.listNodes).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('Search nodes'), {
target: { value: 'test' },
});
await waitFor(() =>
expect(NodeService.listNodePage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, search: 'test' }),
),
);
});
it('refetches the previous valid page after deleting the only node on the last page', async () => {
let deleted = false;
vi.mocked(NodeService.listNodePage).mockImplementation(
async (query = {}) => ({
page: query.page ?? 1,
page_size: 20,
total: deleted ? 20 : 21,
results:
deleted && query.page === 2 ? [] : [node(query.page === 2 ? 21 : 1)],
}),
);
vi.mocked(NodeService.deleteNode).mockImplementation(async () => {
deleted = true;
});
renderPage();
await screen.findByText('Edge 1');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByText('Edge 21');
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
fireEvent.click(
within(await screen.findByRole('alertdialog')).getByRole('button', {
name: 'Delete',
}),
);
await waitFor(() =>
expect(NodeService.listNodePage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1 }),
),
);
await screen.findByText('Edge 1');
});
it('resets the page immediately when the URL node type changes', async () => {
const page = renderPage();
await screen.findByText('Edge 1');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByText('Edge 2');
params = new URLSearchParams('filter=relay');
page.rerender(
<NextIntlClientProvider locale='en' messages={en} timeZone='UTC'>
<QueryClientProvider client={page.client}>
<NodesPageClient />
</QueryClientProvider>
</NextIntlClientProvider>,
);
await waitFor(() =>
expect(NodeService.listNodePage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, node_type: 'tunnel_relay' }),
),
);
});
it('keeps page errors retryable through refresh', async () => {
vi.mocked(NodeService.listNodePage).mockRejectedValueOnce(
new Error('temporary failure'),
);
renderPage();
await screen.findByText('temporary failure');
fireEvent.click(screen.getByRole('button', { name: 'Refresh now' }));
await screen.findByText('Edge 1');
});
it('fetches one detail and refetches it on shared node invalidation', async () => {
params = new URLSearchParams('id=42');
vi.mocked(NodeService.getNode).mockResolvedValue(node(42));
const { client } = renderPage(true);
await screen.findByText('Node detail 42');
expect(NodeService.getNode).toHaveBeenCalledWith(42);
expect(NodeService.listNodes).not.toHaveBeenCalled();
await act(async () => {
await client.invalidateQueries({ queryKey: ['openflare', 'nodes'] });
});
expect(NodeService.getNode).toHaveBeenCalledTimes(2);
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
it('validates the UTF-8 search limit before requesting and recovers after shortening', async () => {
renderPage();
await screen.findByText('Edge 1');
const input = screen.getByLabelText('Search nodes');
for (const search of ['a'.repeat(256), '汉'.repeat(85) + 'a']) {
fireEvent.change(input, { target: { value: search } });
await waitFor(() =>
expect(NodeService.listNodePage).toHaveBeenLastCalledWith(
expect.objectContaining({ search }),
),
);
}
const calls = vi.mocked(NodeService.listNodePage).mock.calls.length;
fireEvent.change(input, { target: { value: '汉'.repeat(86) } });
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByRole('alert')).toHaveTextContent(
'Search text is too long',
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350));
});
expect(NodeService.listNodePage).toHaveBeenCalledTimes(calls);
fireEvent.change(input, { target: { value: 'short' } });
await waitFor(() =>
expect(NodeService.listNodePage).toHaveBeenLastCalledWith(
expect.objectContaining({ search: 'short' }),
),
);
expect(input).not.toHaveAttribute('aria-invalid');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('retains the loaded page and total during navigation without leaving stale node actions', async () => {
const pending =
deferred<Awaited<ReturnType<typeof NodeService.listNodePage>>>();
vi.mocked(NodeService.listNodePage).mockImplementation(async (query = {}) =>
query.page === 2
? pending.promise
: { page: 1, page_size: 20, total: 41, results: [node(1)] },
);
renderPage();
await screen.findByText('Edge 1');
expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute(
'href',
'/nodes/detail?id=1',
);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled(),
);
expect(screen.getByText('Edge 1')).toBeInTheDocument();
expect(screen.getByText('Page 1 of 3 · 41 nodes')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Details' })).toBeDisabled();
expect(
screen.queryByRole('link', { name: 'Details' }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(NodeService.deleteNode).not.toHaveBeenCalled();
await act(async () => {
pending.resolve({ page: 2, page_size: 20, total: 41, results: [node(2)] });
});
await screen.findByText('Edge 2');
expect(screen.queryByText('Edge 1')).not.toBeInTheDocument();
expect(screen.getByText('Page 2 of 3 · 41 nodes')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Edit' })).toBeEnabled();
expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute(
'href',
'/nodes/detail?id=2',
);
});
it('does not reuse nodes or totals from a different search while it is loading', async () => {
const pending =
deferred<Awaited<ReturnType<typeof NodeService.listNodePage>>>();
vi.mocked(NodeService.listNodePage).mockImplementation(async (query = {}) =>
query.search
? pending.promise
: { page: 1, page_size: 20, total: 41, results: [node(1)] },
);
renderPage();
await screen.findByText('Edge 1');
fireEvent.change(screen.getByLabelText('Search nodes'), {
target: { value: 'another' },
});
await waitFor(() =>
expect(NodeService.listNodePage).toHaveBeenLastCalledWith(
expect.objectContaining({ search: 'another' }),
),
);
expect(screen.queryByText('Edge 1')).not.toBeInTheDocument();
expect(screen.queryByText('Page 1 of 3 · 41 nodes')).not.toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'Details' }),
).not.toBeInTheDocument();
await act(async () => {
pending.resolve({ page: 1, page_size: 20, total: 1, results: [node(9)] });
});
await screen.findByText('Edge 9');
expect(screen.getByText('Page 1 of 1 · 1 nodes')).toBeInTheDocument();
});
it('drops placeholder actions and unknown totals after a failed page request and allows retry', async () => {
const pending =
deferred<Awaited<ReturnType<typeof NodeService.listNodePage>>>();
vi.mocked(NodeService.listNodePage).mockImplementation(async (query = {}) =>
query.page === 2
? pending.promise
: { page: 1, page_size: 20, total: 41, results: [node(1)] },
);
renderPage();
await screen.findByText('Edge 1');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled(),
);
await act(async () => {
pending.reject(new Error('page request failed'));
});
await screen.findByText('page request failed');
expect(screen.queryByText('Edge 1')).not.toBeInTheDocument();
expect(screen.queryByText(/Page [0-9]+ of/)).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Delete' }),
).not.toBeInTheDocument();
vi.mocked(NodeService.listNodePage).mockResolvedValue({
page: 2,
page_size: 20,
total: 41,
results: [node(2)],
});
fireEvent.click(screen.getByRole('button', { name: 'Refresh now' }));
await screen.findByText('Edge 2');
expect(screen.getByText('Page 2 of 3 · 41 nodes')).toBeInTheDocument();
});
it('closes an open node confirmation when a background refresh clamps a depleted page', async () => {
let removed = false;
vi.mocked(NodeService.listNodePage).mockImplementation(
async (query = {}) => ({
page: query.page ?? 1,
page_size: 20,
total: removed ? 20 : 21,
results:
removed && query.page === 2 ? [] : [node(query.page === 2 ? 21 : 1)],
}),
);
const { client } = renderPage();
await screen.findByText('Edge 1');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await screen.findByText('Edge 21');
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await screen.findByRole('alertdialog');
removed = true;
await act(async () => {
await client.invalidateQueries({ queryKey: ['openflare', 'nodes'] });
});
await screen.findByText('Edge 1');
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(NodeService.deleteNode).not.toHaveBeenCalled();
});
@@ -0,0 +1,47 @@
import type { AxiosResponse } from 'axios';
import { beforeEach, expect, it, vi } from 'vitest';
import apiClient from '@/lib/services/core/api-client';
import { NodeService } from '@/lib/services/openflare/node.service';
vi.mock('@/lib/services/core/api-client', () => ({
default: { get: vi.fn() },
}));
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
it('keeps legacy arrays and requests bounded pages and individual details separately', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { error_msg: '', data: [] },
} as AxiosResponse);
await NodeService.listNodes();
expect(apiClient.get).toHaveBeenLastCalledWith(
'/api/v1/d/nodes/',
expect.objectContaining({ params: undefined }),
);
await NodeService.listNodePage({
page: 2,
page_size: 20,
status: 'online',
node_type: 'tunnel_relay',
search: 'edge',
});
expect(apiClient.get).toHaveBeenLastCalledWith(
'/api/v1/d/nodes/page',
expect.objectContaining({
params: {
page: 2,
page_size: 20,
status: 'online',
node_type: 'tunnel_relay',
search: 'edge',
},
}),
);
await NodeService.getNode(42);
expect(apiClient.get).toHaveBeenLastCalledWith(
'/api/v1/d/nodes/42',
expect.objectContaining({ params: undefined }),
);
});
@@ -0,0 +1,86 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
)
func TestNodePageAPI(t *testing.T) {
_, seed, cleanup := setupCoreChainTest(t)
engine := testhelper.NewTestGinEngine(sessions.Sessions("node-page-test", cookie.NewStore([]byte("node-page-test-session-secret"))))
mountOpenFlareTestRoutes(engine)
t.Cleanup(cleanup)
headers := adminAuthHeaders(seed.Token)
created := performJSONRequest(t, engine, http.MethodPost, apiPath("/nodes"), node.Input{Name: "Searchable Edge"}, headers)
if created.Code != http.StatusOK {
t.Fatalf("create node status=%d body=%s", created.Code, created.Body.String())
}
var createdEnvelope struct {
Data node.View `json:"data"`
}
if err := json.Unmarshal(created.Body.Bytes(), &createdEnvelope); err != nil {
t.Fatal(err)
}
for _, path := range []string{"/nodes/page", fmt.Sprintf("/nodes/%d", createdEnvelope.Data.ID)} {
unauthorized := performJSONRequest(t, engine, http.MethodGet, apiPath(path), nil, nil)
var denied response.Any
if err := json.Unmarshal(unauthorized.Body.Bytes(), &denied); err != nil {
t.Fatal(err)
}
if unauthorized.Code != http.StatusUnauthorized || denied.Data != nil || denied.ErrorMsg == "" {
t.Errorf("GET %s withoutauth status=%d body=%s,want401 withoutdata", path, unauthorized.Code, unauthorized.Body.String())
}
}
page := performJSONRequest(t, engine, http.MethodGet, apiPath("/nodes/page?search=searchable&status=pending&node_type=edge_node"), nil, headers)
var envelope struct {
ErrorMsg string `json:"error_msg"`
Data node.ListResult `json:"data"`
}
if err := json.Unmarshal(page.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if page.Code != http.StatusOK || envelope.ErrorMsg != "" || envelope.Data.Total != 1 || len(envelope.Data.Results) != 1 {
t.Fatalf("GET page status=%d body=%s,wantone", page.Code, page.Body.String())
}
detail := performJSONRequest(t, engine, http.MethodGet, apiPath(fmt.Sprintf("/nodes/%d", createdEnvelope.Data.ID)), nil, headers)
if detail.Code != http.StatusOK {
t.Errorf("GET detail status=%d body=%s,want200", detail.Code, detail.Body.String())
}
missing := performJSONRequest(t, engine, http.MethodGet, apiPath("/nodes/99999"), nil, headers)
if missing.Code != http.StatusNotFound {
t.Errorf("GET missing node status=%d,want404", missing.Code)
}
invalid := []string{"page=-1", "page=no", "page=9223372036854775807&page_size=100", "page_size=-1", "page_size=no", "status=healthy", "node_type=relay", "search=" + strings.Repeat("x", 257)}
for _, query := range invalid {
bad := performJSONRequest(t, engine, http.MethodGet, apiPath("/nodes/page?"+query), nil, headers)
var body response.Any
if err := json.Unmarshal(bad.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if bad.Code != http.StatusBadRequest || body.Data != nil || body.ErrorMsg == "" {
t.Errorf("GET page?%s status=%d body=%s,want400 null data", query, bad.Code, bad.Body.String())
}
}
legacy := performJSONRequest(t, engine, http.MethodGet, apiPath("/nodes"), nil, headers)
var old struct {
Data []node.View `json:"data"`
}
if err := json.Unmarshal(legacy.Body.Bytes(), &old); err != nil {
t.Fatalf("legacy array JSON: %v", err)
}
if len(old.Data) != 1 {
t.Errorf("legacy len=%d,want1", len(old.Data))
}
}
+2
View File
@@ -5,6 +5,8 @@
package node
const (
errInvalidNodeListQuery = "invalid node list parameters"
errListNodesFailed = "Failed to load nodes"
errNodeNameRequired = "节点名不能为空"
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
errNodeIPInvalid = "节点 IP 格式无效"
+5 -1
View File
@@ -197,6 +197,10 @@ func validateNodeGeoCoordinates(geoLatitude, geoLongitude *float64) error {
}
func computeNodeStatus(node *model.OpenFlareNode) string {
return computeNodeStatusAt(node, time.Now())
}
func computeNodeStatusAt(node *model.OpenFlareNode, now time.Time) string {
if node == nil {
return nodeStatusOffline
}
@@ -206,7 +210,7 @@ func computeNodeStatus(node *model.OpenFlareNode) string {
// 默认离线阈值 60 秒(与 node_offline_threshold 默认一致),避免在这里读取配置
// 实际阈值会在需要精确判断的地方通过 getNodeOfflineThreshold 读取
threshold := 60 * time.Second
if time.Since(*node.LastSeenAt) > threshold {
if now.Sub(*node.LastSeenAt) > threshold {
return nodeStatusOffline
}
return nodeStatusOnline
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
const (
defaultNodePageSize = 20
maxNodePageSize = 100
maxNodeSearchLength = 256
)
// ListQuery filters the bounded node list.
type ListQuery struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Search string `form:"search"`
Status string `form:"status"`
NodeType string `form:"node_type"`
}
// ListResult contains one node page and its filtered total.
type ListResult struct {
Total int64 `json:"total"`
Results []*View `json:"results"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func normalizeListQuery(query ListQuery) (ListQuery, error) {
if query.Page < 0 || query.PageSize < 0 {
return query, errors.New(errInvalidNodeListQuery)
}
if query.Page == 0 {
query.Page = 1
}
if query.PageSize == 0 {
query.PageSize = defaultNodePageSize
}
if query.PageSize > maxNodePageSize {
query.PageSize = maxNodePageSize
}
if query.Page-1 > int(^uint(0)>>1)/query.PageSize {
return query, errors.New(errInvalidNodeListQuery)
}
query.Search = strings.TrimSpace(query.Search)
if len(query.Search) > maxNodeSearchLength {
return query, errors.New(errInvalidNodeListQuery)
}
switch query.Status {
case "", nodeStatusPending, nodeStatusOnline, nodeStatusOffline:
default:
return query, errors.New(errInvalidNodeListQuery)
}
switch query.NodeType {
case "", nodeTypeEdgeNode, nodeTypeTunnelRelay, nodeTypeTunnelClient:
default:
return query, errors.New(errInvalidNodeListQuery)
}
return query, nil
}
// ListPage returns only the requested node page and latest apply metadata for its nodes.
func ListPage(ctx context.Context, input ListQuery) (*ListResult, error) {
query, err := normalizeListQuery(input)
if err != nil {
return nil, err
}
now := time.Now()
nodes, total, err := repository.ListOpenFlareNodePage(ctx, model.OpenFlareNodeListQuery{
Page: query.Page, PageSize: query.PageSize, Search: query.Search, Status: query.Status, NodeType: query.NodeType, Now: now,
})
if err != nil {
return nil, err
}
views, err := buildNodeViews(ctx, nodes, now)
if err != nil {
return nil, err
}
return &ListResult{Total: total, Results: views, Page: query.Page, PageSize: query.PageSize}, nil
}
// GetNode returns a single node with the same latest apply metadata as the list.
func GetNode(ctx context.Context, id uint) (*View, error) {
node, err := repository.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
views, err := buildNodeViews(ctx, []model.OpenFlareNode{*node}, time.Now())
if err != nil {
return nil, err
}
return views[0], nil
}
+277
View File
@@ -0,0 +1,277 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/glebarez/sqlite"
"github.com/pressly/goose/v3"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupNodePageDB(t testing.TB, count int) *gorm.DB {
t.Helper()
conn, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "nodes.sqlite")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open node test DB: %v", err)
}
sqlDB, err := conn.DB()
if err != nil {
t.Fatalf("get node SQL DB: %v", err)
}
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() {
db.SetDB(nil)
if err := sqlDB.Close(); err != nil {
t.Errorf("close node DB: %v", err)
}
})
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate node migration directory")
}
directory := filepath.Join(filepath.Dir(filename), "..", "..", "..", "infra", "persistence", "migrator", "goose", "sqlite")
provider, err := goose.NewProvider(goose.DialectSQLite3, sqlDB, os.DirFS(directory))
if err != nil {
t.Fatalf("create node migration provider: %v", err)
}
if _, err := provider.Up(context.Background()); err != nil {
t.Fatalf("migrate node test DB: %v", err)
}
db.SetDB(conn)
nodes := make([]model.OpenFlareNode, count)
for i := range nodes {
nodes[i] = model.OpenFlareNode{NodeID: fmt.Sprintf("node-%05d", i+1), Name: fmt.Sprintf("Edge %05d", i+1), NodeType: "edge_node"}
}
if len(nodes) > 0 {
if err := conn.CreateInBatches(nodes, 100).Error; err != nil {
t.Fatalf("seed %d nodes: %v", count, err)
}
}
return conn
}
func TestNodeListQueryValidation(t *testing.T) {
tests := []struct {
name string
query ListQuery
}{
{name: "negative page", query: ListQuery{Page: -1}},
{name: "negative size", query: ListQuery{PageSize: -1}},
{name: "overflow", query: ListQuery{Page: int(^uint(0) >> 1), PageSize: 100}},
{name: "invalid status", query: ListQuery{Status: "healthy"}},
{name: "invalid type", query: ListQuery{NodeType: "relay"}},
{name: "long search", query: ListQuery{Search: strings.Repeat("x", 257)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := normalizeListQuery(test.query); err == nil {
t.Errorf("normalizeListQuery(%+v) error=nil, want validation error", test.query)
}
})
}
query, err := normalizeListQuery(ListQuery{})
if err != nil || query.Page != 1 || query.PageSize != 20 {
t.Errorf("normalizeListQuery(empty)=(%+v,%v), want page1 size20", query, err)
}
query, err = normalizeListQuery(ListQuery{PageSize: 1000})
if err != nil || query.PageSize != 100 {
t.Errorf("normalizeListQuery(size1000)=(%+v,%v), want size100", query, err)
}
}
func TestNodePageTwentyThousandNodes(t *testing.T) {
conn := setupNodePageDB(t, 20000)
ctx := context.Background()
if err := conn.Create(&model.OpenFlareApplyLog{NodeID: "node-20000", Result: "success", Message: "latest", Checksum: "new"}).Error; err != nil {
t.Fatal(err)
}
recorder := &nodePageSQLRecorder{Interface: conn.Logger}
conn.Logger = recorder
page, err := ListPage(ctx, ListQuery{})
if err != nil {
t.Fatalf("ListPage(empty) error=%v", err)
}
if page.Total != 20000 || len(page.Results) != 20 || page.Results[0].ID != 20000 || page.Results[19].ID != 19981 {
t.Fatalf("ListPage(empty) total=%d len=%d first=%d last=%d, want20000/20/20000/19981", page.Total, len(page.Results), page.Results[0].ID, page.Results[len(page.Results)-1].ID)
}
if page.Results[0].LatestApplyChecksum != "new" {
t.Errorf("ListPage latest checksum=%q,want new", page.Results[0].LatestApplyChecksum)
}
if len(recorder.statements) != 3 || recorder.rows > 22 {
t.Errorf("ListPage SQL queries=%v selectedRows=%d,want3 and <=22", recorder.statements, recorder.rows)
}
conn.Logger = recorder.Interface
second, err := ListPage(ctx, ListQuery{Page: 2})
if err != nil || len(second.Results) != 20 || second.Results[0].ID != 19980 {
t.Fatalf("ListPage(page2)=(%+v,%v),want firstID19980", second, err)
}
capped, err := ListPage(ctx, ListQuery{PageSize: 1000})
if err != nil || len(capped.Results) != 100 {
t.Errorf("ListPage(size1000) results=%v,error=%v,want100", len(capped.Results), err)
}
empty, err := ListPage(ctx, ListQuery{Page: 1001})
if err != nil || len(empty.Results) != 0 || empty.Results == nil || empty.Total != 20000 {
t.Errorf("ListPage(beyondend)=(%+v,%v),want empty nonnil results total20000", empty, err)
}
detail, err := GetNode(ctx, 20000)
if err != nil || detail.LatestApplyChecksum != "new" {
t.Errorf("GetNode(20000)=(%+v,%v),want matching apply metadata", detail, err)
}
legacy, err := ListNodes(ctx)
if err != nil || len(legacy) != 20000 {
t.Errorf("legacy ListNodes len=%d,error=%v,want20000", len(legacy), err)
}
}
func TestNodePageFiltersUseComputedStatus(t *testing.T) {
conn := setupNodePageDB(t, 0)
now := time.Now().UTC().Truncate(time.Second)
cutoff := now.Add(-time.Minute)
old := cutoff.Add(-time.Second)
zero := time.Time{}
nodes := []model.OpenFlareNode{
{NodeID: "literal", Name: "EDGE 100%_!", NodeType: "edge_node", Status: "offline", LastSeenAt: &cutoff},
{NodeID: "old", Name: "old", NodeType: "tunnel_relay", Status: "online", LastSeenAt: &old},
{NodeID: "pending", Name: "pending", NodeType: "edge_node"},
{NodeID: "zero", Name: "zero", NodeType: "edge_node", LastSeenAt: &zero},
}
if err := conn.Create(&nodes).Error; err != nil {
t.Fatal(err)
}
if err := conn.Model(&nodes[2]).Update("node_type", "").Error; err != nil {
t.Fatal(err)
}
cases := []struct {
query model.OpenFlareNodeListQuery
want int64
}{
{query: model.OpenFlareNodeListQuery{Status: "online"}, want: 1},
{query: model.OpenFlareNodeListQuery{Status: "offline"}, want: 1},
{query: model.OpenFlareNodeListQuery{Status: "pending"}, want: 2},
{query: model.OpenFlareNodeListQuery{NodeType: "edge_node"}, want: 3},
{query: model.OpenFlareNodeListQuery{NodeType: "tunnel_relay"}, want: 1},
{query: model.OpenFlareNodeListQuery{Search: "100%_!"}, want: 1},
{query: model.OpenFlareNodeListQuery{Search: "edge"}, want: 1},
{query: model.OpenFlareNodeListQuery{Status: "pending", NodeType: "tunnel_relay"}, want: 0},
}
for _, test := range cases {
test.query.Page = 1
test.query.PageSize = 20
test.query.Now = now
rows, total, err := repository.ListOpenFlareNodePage(context.Background(), test.query)
if err != nil || total != test.want || int64(len(rows)) != test.want {
t.Errorf("ListOpenFlareNodePage(%+v) len=%d,total=%d,error=%v,want%d", test.query, len(rows), total, err, test.want)
}
for _, row := range rows {
if test.query.Status != "" && computeNodeStatusAt(&row, now) != test.query.Status {
t.Errorf("status SQL/view mismatch query%+v node%+v", test.query, row)
}
}
}
}
func TestNodePageUnicodeLiteralSearch(t *testing.T) {
conn := setupNodePageDB(t, 0)
nodes := []model.OpenFlareNode{
{NodeID: "unicode-latin", Name: "Étage 100%_!", NodeType: "edge_node"},
{NodeID: "unicode-cyrillic", Name: "Москва 100%_!", NodeType: "edge_node"},
{NodeID: "unicode-decoy", Name: "Étage 100xx!", NodeType: "edge_node"},
}
if err := conn.Create(&nodes).Error; err != nil {
t.Fatal(err)
}
cases := []struct {
search string
wantID string
}{
{search: "Étage 100%_!", wantID: "unicode-latin"},
{search: "ÉTAGE 100%_!", wantID: "unicode-latin"},
{search: "Москва 100%_!", wantID: "unicode-cyrillic"},
{search: "unicode-CYRILLIC", wantID: "unicode-cyrillic"},
}
for _, test := range cases {
page, err := ListPage(context.Background(), ListQuery{Search: test.search})
if err != nil {
t.Fatalf("ListPage(search=%q) error=%v", test.search, err)
}
if page.Total != 1 || len(page.Results) != 1 {
t.Errorf("ListPage(search=%q) total=%d,len=%d,wantone", test.search, page.Total, len(page.Results))
continue
}
if page.Results[0].NodeID != test.wantID {
t.Errorf("ListPage(search=%q) nodeID=%q,want%q", test.search, page.Results[0].NodeID, test.wantID)
}
}
}
func TestNodePageIndexes(t *testing.T) {
conn := setupNodePageDB(t, 20000)
queries := []struct {
sql string
arg any
index string
}{
{sql: "SELECT count(*) FROM of_nodes WHERE node_type = ?", arg: "tunnel_client", index: "idx_of_nodes_type_id"},
{sql: "SELECT count(*) FROM of_nodes WHERE last_seen_at >= ?", arg: time.Now().Add(-time.Minute), index: "idx_of_nodes_last_seen_id"},
}
for _, query := range queries {
var rows []struct{ Detail string }
if err := conn.Raw("EXPLAIN QUERY PLAN "+query.sql, query.arg).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
found := false
for _, row := range rows {
t.Log(row.Detail)
found = found || strings.Contains(row.Detail, query.index)
}
if !found {
t.Errorf("EXPLAIN %q=%v,want index%s", query.sql, rows, query.index)
}
}
}
func BenchmarkNodeListTwentyThousand(b *testing.B) {
setupNodePageDB(b, 20000)
b.Run("legacy_all", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := ListNodes(context.Background()); err != nil {
b.Fatal(err)
}
}
})
b.Run("page20", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := ListPage(context.Background(), ListQuery{}); err != nil {
b.Fatal(err)
}
}
})
}
type nodePageSQLRecorder struct {
logger.Interface
statements []string
rows int64
}
func (recorder *nodePageSQLRecorder) Trace(_ context.Context, _ time.Time, query func() (string, int64), _ error) {
sql, rows := query()
recorder.statements = append(recorder.statements, sql)
recorder.rows += rows
}
+5 -1
View File
@@ -140,6 +140,10 @@ func ListNodes(ctx context.Context) ([]*View, error) {
if err != nil {
return nil, err
}
return buildNodeViews(ctx, nodes, time.Now())
}
func buildNodeViews(ctx context.Context, nodes []model.OpenFlareNode, now time.Time) ([]*View, error) {
nodeIDs := make([]string, 0, len(nodes))
for _, node := range nodes {
nodeIDs = append(nodeIDs, node.NodeID)
@@ -151,7 +155,7 @@ func ListNodes(ctx context.Context) ([]*View, error) {
views := make([]*View, 0, len(nodes))
for _, node := range nodes {
view := buildNodeView(&node)
view.Status = computeNodeStatus(&node)
view.Status = computeNodeStatusAt(&node, now)
if log, ok := latestLogs[node.NodeID]; ok {
view.LatestApplyResult = log.Result
view.LatestApplyMessage = log.Message
+70
View File
@@ -11,7 +11,9 @@ import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
@@ -321,3 +323,71 @@ func bindOptionalJSON(body io.Reader, target any) error {
}
return nil
}
// ListNodePageHandler lists a bounded page of nodes.
// @Summary 获取分页节点列表
// @Description 返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param page query int false "页码(默认 1)"
// @Param page_size query int false "每页数量(默认 20,最大 100)"
// @Param search query string false "名称、节点标识或 IP 的字面子串(最多 256 字节)"
// @Param status query string false "pending、online 或 offline"
// @Param node_type query string false "edge_node、tunnel_relay 或 tunnel_client"
// @Success 200 {object} response.Any{data=node.ListResult} "节点分页"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限"
// @Failure 500 {object} response.Any "查询失败"
// @Router /api/v1/d/nodes/page [get]
func ListNodePageHandler(c *gin.Context) {
var query ListQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.AbortBadRequest(c, errInvalidNodeListQuery)
return
}
normalized, err := normalizeListQuery(query)
if err != nil {
response.AbortBadRequest(c, errInvalidNodeListQuery)
return
}
result, err := ListPage(c.Request.Context(), normalized)
if err != nil {
logger.ErrorF(c.Request.Context(), "Failed to list node page: %v", err)
response.AbortInternal(c, errListNodesFailed)
return
}
c.JSON(http.StatusOK, response.OK(result))
}
// GetNodeHandler returns a single node.
// @Summary 获取节点详情
// @Description 返回指定节点及最新配置下发记录,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=node.View} "节点详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "查询失败"
// @Router /api/v1/d/nodes/{id} [get]
func GetNodeHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
view, err := GetNode(c.Request.Context(), id)
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortNotFound(c, errNodeNotFound)
return
}
if err != nil {
logger.ErrorF(c.Request.Context(), "Failed to get node: %v", err)
response.AbortInternal(c, errListNodesFailed)
return
}
c.JSON(http.StatusOK, response.OK(view))
}
@@ -0,0 +1,7 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_of_nodes_type_id ON of_nodes (node_type, id);
CREATE INDEX IF NOT EXISTS idx_of_nodes_last_seen_id ON of_nodes (last_seen_at, id);
-- +goose Down
DROP INDEX IF EXISTS idx_of_nodes_last_seen_id;
DROP INDEX IF EXISTS idx_of_nodes_type_id;
@@ -0,0 +1,7 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_of_nodes_type_id ON of_nodes (node_type, id);
CREATE INDEX IF NOT EXISTS idx_of_nodes_last_seen_id ON of_nodes (last_seen_at, id);
-- +goose Down
DROP INDEX IF EXISTS idx_of_nodes_last_seen_id;
DROP INDEX IF EXISTS idx_of_nodes_type_id;
+10
View File
@@ -50,3 +50,13 @@ type OpenFlareNode struct {
func (OpenFlareNode) TableName() string {
return "of_nodes"
}
// OpenFlareNodeListQuery describes a bounded node query.
type OpenFlareNodeListQuery struct {
Page int
PageSize int
Search string
Status string
NodeType string
Now time.Time
}
@@ -0,0 +1,54 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"context"
"errors"
"strings"
"time"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// ListOpenFlareNodePage returns a bounded page and the count for the same filters.
func ListOpenFlareNodePage(ctx context.Context, query model.OpenFlareNodeListQuery) ([]model.OpenFlareNode, int64, error) {
conn := db.DB(ctx)
if conn == nil {
return nil, 0, errors.New(errDatabaseNotInitialized)
}
filtered := filterOpenFlareNodes(conn.Model(&model.OpenFlareNode{}), query)
var total int64
if err := filtered.Count(&total).Error; err != nil {
return nil, 0, err
}
nodes := make([]model.OpenFlareNode, 0)
err := filtered.Order("id DESC").Limit(query.PageSize).Offset((query.Page - 1) * query.PageSize).Find(&nodes).Error
return nodes, total, err
}
func filterOpenFlareNodes(query *gorm.DB, filters model.OpenFlareNodeListQuery) *gorm.DB {
if filters.Search != "" {
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(filters.Search)
pattern := "%" + escaped + "%"
query = query.Where("(LOWER(name) LIKE LOWER(?) ESCAPE '!' OR LOWER(node_id) LIKE LOWER(?) ESCAPE '!' OR LOWER(ip) LIKE LOWER(?) ESCAPE '!')", pattern, pattern, pattern)
}
if filters.NodeType == "edge_node" {
query = query.Where("(node_type = ? OR node_type = '')", filters.NodeType)
} else if filters.NodeType != "" {
query = query.Where("node_type = ?", filters.NodeType)
}
cutoff := filters.Now.Add(-time.Minute)
switch filters.Status {
case "pending":
query = query.Where("(last_seen_at IS NULL OR last_seen_at = ?)", time.Time{})
case "online":
query = query.Where("last_seen_at >= ? AND last_seen_at <> ?", cutoff, time.Time{})
case "offline":
query = query.Where("last_seen_at < ? AND last_seen_at <> ?", cutoff, time.Time{})
}
return query
}
@@ -13,6 +13,8 @@ func registerNodeRoutes(apiGroup *gin.RouterGroup) {
nodeRoute := apiGroup.Group("/nodes")
nodeRoute.Use(apiutil.AdminMiddlewares()...)
{
nodeRoute.GET("/page", node.ListNodePageHandler)
nodeRoute.GET("/:id", node.GetNodeHandler)
nodeRoute.GET("/bootstrap-token", node.GetBootstrapTokenHandler)
nodeRoute.POST("/bootstrap-token/rotate", node.RotateBootstrapTokenHandler)
apiutil.RegisterCollection(nodeRoute, "GET", node.ListNodesHandler)