mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
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:
@@ -17,6 +17,11 @@ sidebar: false
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 优化
|
||||
|
||||
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。
|
||||
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- 修复 Agent 自动注册响应凭证字段不一致导致节点不断重复创建的问题;新增持久化注册重试凭证与退避,响应丢失、并发重试或配置写入失败后仍复用原节点,且不会覆盖管理员修改或恢复已更换的节点凭证。支持目录刷盘的平台会确认原子替换后的目录持久化,失败时阻止使用未确认的注册凭证;接口文档同步列出两种兼容凭证字段。
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
package node
|
||||
|
||||
const (
|
||||
errInvalidNodeListQuery = "invalid node list parameters"
|
||||
errListNodesFailed = "Failed to load nodes"
|
||||
errNodeNameRequired = "节点名不能为空"
|
||||
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
|
||||
errNodeIPInvalid = "节点 IP 格式无效"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+7
@@ -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;
|
||||
+7
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user