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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。
|
||||||
|
|
||||||
|
|
||||||
### 🛠 修复
|
### 🛠 修复
|
||||||
|
|
||||||
- 修复 Agent 自动注册响应凭证字段不一致导致节点不断重复创建的问题;新增持久化注册重试凭证与退避,响应丢失、并发重试或配置写入失败后仍复用原节点,且不会覆盖管理员修改或恢复已更换的节点凭证。支持目录刷盘的平台会确认原子替换后的目录持久化,失败时阻止使用未确认的注册凭证;接口文档同步列出两种兼容凭证字段。
|
- 修复 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 的交互链路
|
## 与 Server 的交互链路
|
||||||
|
|
||||||
Agent 在生命周期中主要通过 **基于 Token 的自动注册** 和 **心跳/WebSocket 双通道** 与控制面通信。
|
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": {
|
"/api/v1/d/nodes/{id}/agent-release": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"node.ObservabilityView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"/api/v1/d/nodes/{id}/agent-release": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"node.ObservabilityView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1931,6 +1931,19 @@ definitions:
|
|||||||
relay_web_server_enabled:
|
relay_web_server_enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
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:
|
node.ObservabilityView:
|
||||||
properties:
|
properties:
|
||||||
analytics:
|
analytics:
|
||||||
@@ -9119,6 +9132,48 @@ paths:
|
|||||||
summary: 创建节点
|
summary: 创建节点
|
||||||
tags:
|
tags:
|
||||||
- openflare-node
|
- 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:
|
/api/v1/d/nodes/{id}/agent-release:
|
||||||
get:
|
get:
|
||||||
description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限
|
description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限
|
||||||
@@ -9506,6 +9561,63 @@ paths:
|
|||||||
summary: 轮换引导令牌
|
summary: 轮换引导令牌
|
||||||
tags:
|
tags:
|
||||||
- openflare-node
|
- 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:
|
/api/v1/d/option:
|
||||||
get:
|
get:
|
||||||
description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限
|
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,
|
deletingId,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
actionsDisabled = false,
|
||||||
}: {
|
}: {
|
||||||
nodes: NodeItem[];
|
nodes: NodeItem[];
|
||||||
deletingId: number | null;
|
deletingId: number | null;
|
||||||
|
actionsDisabled?: boolean;
|
||||||
onEdit: (node: NodeItem) => void;
|
onEdit: (node: NodeItem) => void;
|
||||||
onDelete: (node: NodeItem) => void;
|
onDelete: (node: NodeItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('nodes');
|
const t = useTranslations('nodes');
|
||||||
return (
|
return (
|
||||||
<div className='border border-dashed rounded-lg overflow-hidden'>
|
<div
|
||||||
|
className='border border-dashed rounded-lg overflow-hidden'
|
||||||
|
aria-busy={actionsDisabled}
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className='border-dashed hover:bg-transparent'>
|
<TableRow className='border-dashed hover:bg-transparent'>
|
||||||
@@ -144,16 +149,22 @@ export function NodesTable({
|
|||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
className='h-7 text-xs'
|
className='h-7 text-xs'
|
||||||
asChild
|
asChild={!actionsDisabled}
|
||||||
|
disabled={actionsDisabled}
|
||||||
>
|
>
|
||||||
<Link href={`/nodes/detail?id=${node.id}`}>
|
{actionsDisabled ? (
|
||||||
{t('detail')}
|
t('detail')
|
||||||
</Link>
|
) : (
|
||||||
|
<Link href={`/nodes/detail?id=${node.id}`}>
|
||||||
|
{t('detail')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
className='h-7 text-xs'
|
className='h-7 text-xs'
|
||||||
|
disabled={actionsDisabled}
|
||||||
onClick={() => onEdit(node)}
|
onClick={() => onEdit(node)}
|
||||||
>
|
>
|
||||||
<Pencil className='size-3 mr-1' />
|
<Pencil className='size-3 mr-1' />
|
||||||
@@ -163,7 +174,7 @@ export function NodesTable({
|
|||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
className='h-7 text-xs text-destructive hover:text-destructive'
|
className='h-7 text-xs text-destructive hover:text-destructive'
|
||||||
disabled={deletingId === node.id}
|
disabled={actionsDisabled || deletingId === node.id}
|
||||||
onClick={() => onDelete(node)}
|
onClick={() => onDelete(node)}
|
||||||
>
|
>
|
||||||
<Trash2 className='size-3 mr-1' />
|
<Trash2 className='size-3 mr-1' />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, Server } from 'lucide-react';
|
import { ArrowLeft, Server } from 'lucide-react';
|
||||||
@@ -25,20 +24,20 @@ export function NodeDetailPageClient() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const nodeId = searchParams.get('id')?.trim() ?? '';
|
const nodeId = searchParams.get('id')?.trim() ?? '';
|
||||||
|
|
||||||
|
const validNodeId =
|
||||||
|
Number.isSafeInteger(Number(nodeId)) && Number(nodeId) > 0;
|
||||||
const nodesQuery = useQuery({
|
const nodesQuery = useQuery({
|
||||||
queryKey: nodesQueryKey,
|
queryKey: [...nodesQueryKey, 'detail', nodeId],
|
||||||
queryFn: () => NodeService.listNodes(),
|
queryFn: () => NodeService.getNode(Number(nodeId)),
|
||||||
refetchInterval: 5000,
|
enabled: validNodeId,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.status === 'error' ? false : 15000,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const node = useMemo(() => {
|
const node = nodesQuery.data;
|
||||||
if (!nodeId) return null;
|
|
||||||
return (
|
|
||||||
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
|
|
||||||
);
|
|
||||||
}, [nodeId, nodesQuery.data]);
|
|
||||||
|
|
||||||
if (!nodeId) {
|
if (!validNodeId) {
|
||||||
return (
|
return (
|
||||||
<div className='py-6 px-1'>
|
<div className='py-6 px-1'>
|
||||||
<EmptyStateWithBorder icon={Server} description={t('missingId')} />
|
<EmptyStateWithBorder icon={Server} description={t('missingId')} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { useSearchParams } from 'next/navigation';
|
||||||
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
|
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
@@ -29,46 +29,112 @@ import {
|
|||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
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 { NodeService } from '@/lib/services/openflare';
|
||||||
|
|
||||||
import { NodeEditorDialog } from './components/node-editor-dialog';
|
import { NodeEditorDialog } from './components/node-editor-dialog';
|
||||||
import {
|
import {
|
||||||
filterNodesByType,
|
|
||||||
getFilterDescription,
|
getFilterDescription,
|
||||||
getNodeFilter,
|
getNodeFilter,
|
||||||
NodeTypeFilter,
|
NodeTypeFilter,
|
||||||
|
type NodeFilter,
|
||||||
} from './components/node-type-filter';
|
} from './components/node-type-filter';
|
||||||
import { NodesTable } from './components/nodes-table';
|
import { NodesTable } from './components/nodes-table';
|
||||||
|
import { NodeListControls } from './components/node-list-controls';
|
||||||
import { getErrorMessage } from './components/node-utils';
|
import { getErrorMessage } from './components/node-utils';
|
||||||
|
|
||||||
const nodesQueryKey = ['openflare', 'nodes'];
|
const nodesQueryKey = ['openflare', 'nodes'];
|
||||||
|
|
||||||
export function NodesPageClient() {
|
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 t = useTranslations('nodes');
|
||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(null);
|
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(
|
const resetNodeActions = useCallback(() => {
|
||||||
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
|
setEditingNode(null);
|
||||||
[searchParams],
|
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({
|
const nodesQuery = useQuery({
|
||||||
queryKey: nodesQueryKey,
|
queryKey: [...nodesQueryKey, 'page', query],
|
||||||
queryFn: () => NodeService.listNodes(),
|
queryFn: () => NodeService.listNodePage(query),
|
||||||
refetchInterval: 5000,
|
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 nodes = useMemo(
|
||||||
const filteredNodes = useMemo(
|
() => nodesQuery.data?.results ?? [],
|
||||||
() => filterNodesByType(nodes, nodeFilter),
|
[nodesQuery.data],
|
||||||
[nodeFilter, nodes],
|
|
||||||
);
|
);
|
||||||
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (payload: NodeMutationPayload) => {
|
mutationFn: async (payload: NodeMutationPayload) => {
|
||||||
@@ -106,6 +172,7 @@ export function NodesPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (node: NodeItem) => {
|
const handleEdit = (node: NodeItem) => {
|
||||||
|
if (nodesQuery.isPlaceholderData) return;
|
||||||
setEditingNode(node);
|
setEditingNode(node);
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
@@ -115,7 +182,7 @@ export function NodesPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 justify-between gap-3'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Server className='size-5 text-primary' />
|
<Server className='size-5 text-primary' />
|
||||||
@@ -166,8 +233,34 @@ export function NodesPageClient() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='flex flex-col gap-4'>
|
||||||
<NodeTypeFilter />
|
<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 ? (
|
{nodesQuery.isLoading ? (
|
||||||
<LoadingStateWithBorder
|
<LoadingStateWithBorder
|
||||||
@@ -182,21 +275,26 @@ export function NodesPageClient() {
|
|||||||
className='justify-center'
|
className='justify-center'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : filteredNodes.length === 0 ? (
|
) : nodes.length === 0 ? (
|
||||||
<EmptyStateWithBorder
|
<EmptyStateWithBorder
|
||||||
icon={Server}
|
icon={Server}
|
||||||
description={
|
description={
|
||||||
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
|
debouncedSearch || status || nodeFilter !== 'all'
|
||||||
|
? t('emptyFilter')
|
||||||
|
: t('emptyAll')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NodesTable
|
<NodesTable
|
||||||
nodes={filteredNodes}
|
nodes={nodes}
|
||||||
deletingId={
|
deletingId={
|
||||||
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
|
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
|
||||||
}
|
}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={setDeleteTarget}
|
onDelete={(node) => {
|
||||||
|
if (!nodesQuery.isPlaceholderData) setDeleteTarget(node);
|
||||||
|
}}
|
||||||
|
actionsDisabled={nodesQuery.isPlaceholderData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -205,13 +303,17 @@ export function NodesPageClient() {
|
|||||||
<NodeEditorDialog
|
<NodeEditorDialog
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
node={editingNode}
|
node={editingNode}
|
||||||
submitting={saveMutation.isPending}
|
submitting={
|
||||||
|
saveMutation.isPending ||
|
||||||
|
(Boolean(editingNode) && nodesQuery.isPlaceholderData)
|
||||||
|
}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
setEditingNode(null);
|
setEditingNode(null);
|
||||||
}}
|
}}
|
||||||
onSubmit={async (payload) => {
|
onSubmit={async (payload) => {
|
||||||
await saveMutation.mutateAsync(payload);
|
if (!editingNode || !nodesQuery.isPlaceholderData)
|
||||||
|
await saveMutation.mutateAsync(payload);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -227,14 +329,22 @@ export function NodesPageClient() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
<AlertDialogCancel
|
||||||
|
disabled={
|
||||||
|
deleteMutation.isPending || nodesQuery.isPlaceholderData
|
||||||
|
}
|
||||||
|
>
|
||||||
{tc('cancel')}
|
{tc('cancel')}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className='bg-destructive text-white hover:bg-destructive/90'
|
className='bg-destructive text-white hover:bg-destructive/90'
|
||||||
disabled={deleteMutation.isPending}
|
disabled={
|
||||||
|
deleteMutation.isPending || nodesQuery.isPlaceholderData
|
||||||
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
deleteTarget &&
|
||||||
|
!nodesQuery.isPlaceholderData &&
|
||||||
|
deleteMutation.mutate(deleteTarget.id)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export type {
|
|||||||
NodeAgentUpdatePayload,
|
NodeAgentUpdatePayload,
|
||||||
NodeBootstrapToken,
|
NodeBootstrapToken,
|
||||||
NodeItem,
|
NodeItem,
|
||||||
|
NodeListQuery,
|
||||||
|
NodeListResult,
|
||||||
NodeMutationPayload,
|
NodeMutationPayload,
|
||||||
NodeHealthEvent,
|
NodeHealthEvent,
|
||||||
NodeObservability,
|
NodeObservability,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
NodeAgentUpdatePayload,
|
NodeAgentUpdatePayload,
|
||||||
NodeBootstrapToken,
|
NodeBootstrapToken,
|
||||||
NodeItem,
|
NodeItem,
|
||||||
|
NodeListQuery,
|
||||||
|
NodeListResult,
|
||||||
NodeMutationPayload,
|
NodeMutationPayload,
|
||||||
NodeObservability,
|
NodeObservability,
|
||||||
ReleaseChannel,
|
ReleaseChannel,
|
||||||
@@ -16,6 +18,16 @@ export class NodeService extends OpenFlareBaseService {
|
|||||||
return this.get<NodeItem[]>('/');
|
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> {
|
static async createNode(payload: NodeMutationPayload): Promise<NodeItem> {
|
||||||
return this.post<NodeItem>('/', payload);
|
return this.post<NodeItem>('/', payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ export interface NodeItem {
|
|||||||
updated_at: string;
|
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 {
|
export interface NodeBootstrapToken {
|
||||||
discovery_token: string;
|
discovery_token: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nodes": {
|
"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",
|
"title": "Nodes",
|
||||||
"applyLogs": "Apply logs",
|
"applyLogs": "Apply logs",
|
||||||
"create": "Add node",
|
"create": "Add node",
|
||||||
|
|||||||
@@ -120,6 +120,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"pagination": {
|
||||||
|
"search": "搜索节点",
|
||||||
|
"searchPlaceholder": "名称、节点标识或 IP",
|
||||||
|
"searchTooLong": "搜索内容过长,请缩短后重试。",
|
||||||
|
"allStatuses": "全部状态",
|
||||||
|
"pageSize": "每页数量",
|
||||||
|
"summary": "第 {page} / {totalPages} 页 · 共 {total} 个节点",
|
||||||
|
"navigation": "节点分页",
|
||||||
|
"previous": "上一页",
|
||||||
|
"next": "下一页"
|
||||||
|
},
|
||||||
"title": "节点管理",
|
"title": "节点管理",
|
||||||
"applyLogs": "应用记录",
|
"applyLogs": "应用记录",
|
||||||
"create": "新增节点",
|
"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
|
package node
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
errInvalidNodeListQuery = "invalid node list parameters"
|
||||||
|
errListNodesFailed = "Failed to load nodes"
|
||||||
errNodeNameRequired = "节点名不能为空"
|
errNodeNameRequired = "节点名不能为空"
|
||||||
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
|
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
|
||||||
errNodeIPInvalid = "节点 IP 格式无效"
|
errNodeIPInvalid = "节点 IP 格式无效"
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ func validateNodeGeoCoordinates(geoLatitude, geoLongitude *float64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func computeNodeStatus(node *model.OpenFlareNode) string {
|
func computeNodeStatus(node *model.OpenFlareNode) string {
|
||||||
|
return computeNodeStatusAt(node, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeNodeStatusAt(node *model.OpenFlareNode, now time.Time) string {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return nodeStatusOffline
|
return nodeStatusOffline
|
||||||
}
|
}
|
||||||
@@ -206,7 +210,7 @@ func computeNodeStatus(node *model.OpenFlareNode) string {
|
|||||||
// 默认离线阈值 60 秒(与 node_offline_threshold 默认一致),避免在这里读取配置
|
// 默认离线阈值 60 秒(与 node_offline_threshold 默认一致),避免在这里读取配置
|
||||||
// 实际阈值会在需要精确判断的地方通过 getNodeOfflineThreshold 读取
|
// 实际阈值会在需要精确判断的地方通过 getNodeOfflineThreshold 读取
|
||||||
threshold := 60 * time.Second
|
threshold := 60 * time.Second
|
||||||
if time.Since(*node.LastSeenAt) > threshold {
|
if now.Sub(*node.LastSeenAt) > threshold {
|
||||||
return nodeStatusOffline
|
return nodeStatusOffline
|
||||||
}
|
}
|
||||||
return nodeStatusOnline
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
nodeIDs := make([]string, 0, len(nodes))
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
nodeIDs = append(nodeIDs, node.NodeID)
|
nodeIDs = append(nodeIDs, node.NodeID)
|
||||||
@@ -151,7 +155,7 @@ func ListNodes(ctx context.Context) ([]*View, error) {
|
|||||||
views := make([]*View, 0, len(nodes))
|
views := make([]*View, 0, len(nodes))
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
view := buildNodeView(&node)
|
view := buildNodeView(&node)
|
||||||
view.Status = computeNodeStatus(&node)
|
view.Status = computeNodeStatusAt(&node, now)
|
||||||
if log, ok := latestLogs[node.NodeID]; ok {
|
if log, ok := latestLogs[node.NodeID]; ok {
|
||||||
view.LatestApplyResult = log.Result
|
view.LatestApplyResult = log.Result
|
||||||
view.LatestApplyMessage = log.Message
|
view.LatestApplyMessage = log.Message
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
||||||
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleLogicError(c *gin.Context, err error) bool {
|
func handleLogicError(c *gin.Context, err error) bool {
|
||||||
@@ -321,3 +323,71 @@ func bindOptionalJSON(body io.Reader, target any) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (OpenFlareNode) TableName() string {
|
||||||
return "of_nodes"
|
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 := apiGroup.Group("/nodes")
|
||||||
nodeRoute.Use(apiutil.AdminMiddlewares()...)
|
nodeRoute.Use(apiutil.AdminMiddlewares()...)
|
||||||
{
|
{
|
||||||
|
nodeRoute.GET("/page", node.ListNodePageHandler)
|
||||||
|
nodeRoute.GET("/:id", node.GetNodeHandler)
|
||||||
nodeRoute.GET("/bootstrap-token", node.GetBootstrapTokenHandler)
|
nodeRoute.GET("/bootstrap-token", node.GetBootstrapTokenHandler)
|
||||||
nodeRoute.POST("/bootstrap-token/rotate", node.RotateBootstrapTokenHandler)
|
nodeRoute.POST("/bootstrap-token/rotate", node.RotateBootstrapTokenHandler)
|
||||||
apiutil.RegisterCollection(nodeRoute, "GET", node.ListNodesHandler)
|
apiutil.RegisterCollection(nodeRoute, "GET", node.ListNodesHandler)
|
||||||
|
|||||||
Reference in New Issue
Block a user