diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 9c6aa068..6d4fbb06 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -17,6 +17,11 @@ sidebar: false ## [Unreleased] +### 优化 + +- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。 + + ### 🛠 修复 - 修复 Agent 自动注册响应凭证字段不一致导致节点不断重复创建的问题;新增持久化注册重试凭证与退避,响应丢失、并发重试或配置写入失败后仍复用原节点,且不会覆盖管理员修改或恢复已更换的节点凭证。支持目录刷盘的平台会确认原子替换后的目录持久化,失败时阻止使用未确认的注册凭证;接口文档同步列出两种兼容凭证字段。 diff --git a/docs/design/agent-design.md b/docs/design/agent-design.md index 310bcfe9..baef3936 100644 --- a/docs/design/agent-design.md +++ b/docs/design/agent-design.md @@ -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 双通道** 与控制面通信。 diff --git a/docs/docs.go b/docs/docs.go index 9a68de80..07afd15f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 7c3f0c5a..f71ba6e3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 20b3adc8..f034875d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 配置项,需要管理员权限 diff --git a/frontend/app/(main)/nodes/components/node-list-controls.tsx b/frontend/app/(main)/nodes/components/node-list-controls.tsx new file mode 100644 index 00000000..ca83ab86 --- /dev/null +++ b/frontend/app/(main)/nodes/components/node-list-controls.tsx @@ -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 ( +
+ + + + {t('pagination.search')} + + onSearchChange(event.target.value)} + placeholder={t('pagination.searchPlaceholder')} + maxLength={256} + aria-invalid={searchTooLong || undefined} + aria-describedby={searchTooLong ? 'node-search-error' : undefined} + /> + {searchTooLong && ( + + {t('pagination.searchTooLong')} + + )} + + + {t('table.status')} + + + + + {t('pagination.pageSize')} + + + + +
+ {hasData && ( +

+ {t('pagination.summary', { page, totalPages, total })} +

+ )} + +
+
+ ); +} diff --git a/frontend/app/(main)/nodes/components/nodes-table.tsx b/frontend/app/(main)/nodes/components/nodes-table.tsx index b57b9ac2..8e5e64ee 100644 --- a/frontend/app/(main)/nodes/components/nodes-table.tsx +++ b/frontend/app/(main)/nodes/components/nodes-table.tsx @@ -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 ( -
+
@@ -144,16 +149,22 @@ export function NodesTable({ variant='outline' size='sm' className='h-7 text-xs' - asChild + asChild={!actionsDisabled} + disabled={actionsDisabled} > - - {t('detail')} - + {actionsDisabled ? ( + t('detail') + ) : ( + + {t('detail')} + + )} - + + { + 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 ? ( - ) : filteredNodes.length === 0 ? ( + ) : nodes.length === 0 ? ( ) : ( { + if (!nodesQuery.isPlaceholderData) setDeleteTarget(node); + }} + actionsDisabled={nodesQuery.isPlaceholderData} /> )} @@ -205,13 +303,17 @@ export function NodesPageClient() { { 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() { - + {tc('cancel')} - deleteTarget && deleteMutation.mutate(deleteTarget.id) + deleteTarget && + !nodesQuery.isPlaceholderData && + deleteMutation.mutate(deleteTarget.id) } > {deleteMutation.isPending ? t('deleting') : t('confirmDelete')} diff --git a/frontend/lib/services/openflare/index.ts b/frontend/lib/services/openflare/index.ts index cf1a249b..b752fa33 100644 --- a/frontend/lib/services/openflare/index.ts +++ b/frontend/lib/services/openflare/index.ts @@ -38,6 +38,8 @@ export type { NodeAgentUpdatePayload, NodeBootstrapToken, NodeItem, + NodeListQuery, + NodeListResult, NodeMutationPayload, NodeHealthEvent, NodeObservability, diff --git a/frontend/lib/services/openflare/node.service.ts b/frontend/lib/services/openflare/node.service.ts index 64a254d0..7cd02ce5 100644 --- a/frontend/lib/services/openflare/node.service.ts +++ b/frontend/lib/services/openflare/node.service.ts @@ -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('/'); } + static async listNodePage( + query: NodeListQuery = {}, + ): Promise { + return this.get('/page', { ...query }); + } + + static async getNode(id: number): Promise { + return this.get(`/${id}`); + } + static async createNode(payload: NodeMutationPayload): Promise { return this.post('/', payload); } diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 48d72045..91a90a77 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -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; } diff --git a/frontend/messages/fragments/ops.en.json b/frontend/messages/fragments/ops.en.json index c59f17f6..7578a672 100644 --- a/frontend/messages/fragments/ops.en.json +++ b/frontend/messages/fragments/ops.en.json @@ -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", diff --git a/frontend/messages/fragments/ops.zh-CN.json b/frontend/messages/fragments/ops.zh-CN.json index 443a7b6d..3499e749 100644 --- a/frontend/messages/fragments/ops.zh-CN.json +++ b/frontend/messages/fragments/ops.zh-CN.json @@ -120,6 +120,17 @@ } }, "nodes": { + "pagination": { + "search": "搜索节点", + "searchPlaceholder": "名称、节点标识或 IP", + "searchTooLong": "搜索内容过长,请缩短后重试。", + "allStatuses": "全部状态", + "pageSize": "每页数量", + "summary": "第 {page} / {totalPages} 页 · 共 {total} 个节点", + "navigation": "节点分页", + "previous": "上一页", + "next": "下一页" + }, "title": "节点管理", "applyLogs": "应用记录", "create": "新增节点", diff --git a/frontend/tests/openflare/node-pagination.test.tsx b/frontend/tests/openflare/node-pagination.test.tsx new file mode 100644 index 00000000..4f270583 --- /dev/null +++ b/frontend/tests/openflare/node-pagination.test.tsx @@ -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()), + 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 }) => ( +
Node detail {node.id}
+ ), +})); +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( + + + {detail ? : } + + , + ); + 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( + + + + + , + ); + 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() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((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>>(); + 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>>(); + 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>>(); + 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(); +}); diff --git a/frontend/tests/openflare/node-service.test.ts b/frontend/tests/openflare/node-service.test.ts new file mode 100644 index 00000000..a6f43ee9 --- /dev/null +++ b/frontend/tests/openflare/node-service.test.ts @@ -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 }), + ); +}); diff --git a/internal/apps/openflare/integration/node_page_test.go b/internal/apps/openflare/integration/node_page_test.go new file mode 100644 index 00000000..06fa5574 --- /dev/null +++ b/internal/apps/openflare/integration/node_page_test.go @@ -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)) + } +} diff --git a/internal/apps/openflare/node/errs.go b/internal/apps/openflare/node/errs.go index 61f6c867..ce337f57 100644 --- a/internal/apps/openflare/node/errs.go +++ b/internal/apps/openflare/node/errs.go @@ -5,6 +5,8 @@ package node const ( + errInvalidNodeListQuery = "invalid node list parameters" + errListNodesFailed = "Failed to load nodes" errNodeNameRequired = "节点名不能为空" errNodeIPTooLong = "节点 IP 不能超过 64 个字符" errNodeIPInvalid = "节点 IP 格式无效" diff --git a/internal/apps/openflare/node/helpers.go b/internal/apps/openflare/node/helpers.go index b44098ee..e3d5828b 100644 --- a/internal/apps/openflare/node/helpers.go +++ b/internal/apps/openflare/node/helpers.go @@ -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 diff --git a/internal/apps/openflare/node/list.go b/internal/apps/openflare/node/list.go new file mode 100644 index 00000000..d7a50adc --- /dev/null +++ b/internal/apps/openflare/node/list.go @@ -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 +} diff --git a/internal/apps/openflare/node/list_test.go b/internal/apps/openflare/node/list_test.go new file mode 100644 index 00000000..e5b28f84 --- /dev/null +++ b/internal/apps/openflare/node/list_test.go @@ -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 +} diff --git a/internal/apps/openflare/node/logics.go b/internal/apps/openflare/node/logics.go index f88cb47d..4d76ab75 100644 --- a/internal/apps/openflare/node/logics.go +++ b/internal/apps/openflare/node/logics.go @@ -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 diff --git a/internal/apps/openflare/node/routers.go b/internal/apps/openflare/node/routers.go index 7ca318d7..e5fe4fec 100644 --- a/internal/apps/openflare/node/routers.go +++ b/internal/apps/openflare/node/routers.go @@ -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)) +} diff --git a/internal/infra/persistence/migrator/goose/postgres/202610260002_add_node_list_indexes.sql b/internal/infra/persistence/migrator/goose/postgres/202610260002_add_node_list_indexes.sql new file mode 100644 index 00000000..4d7528f6 --- /dev/null +++ b/internal/infra/persistence/migrator/goose/postgres/202610260002_add_node_list_indexes.sql @@ -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; diff --git a/internal/infra/persistence/migrator/goose/sqlite/202610260002_add_node_list_indexes.sql b/internal/infra/persistence/migrator/goose/sqlite/202610260002_add_node_list_indexes.sql new file mode 100644 index 00000000..4d7528f6 --- /dev/null +++ b/internal/infra/persistence/migrator/goose/sqlite/202610260002_add_node_list_indexes.sql @@ -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; diff --git a/internal/model/openflare_node.go b/internal/model/openflare_node.go index 786f48f0..9b1606c3 100644 --- a/internal/model/openflare_node.go +++ b/internal/model/openflare_node.go @@ -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 +} diff --git a/internal/repository/openflare_node_page.go b/internal/repository/openflare_node_page.go new file mode 100644 index 00000000..e8a83449 --- /dev/null +++ b/internal/repository/openflare_node_page.go @@ -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 +} diff --git a/internal/router/v1/openflare/register_node.go b/internal/router/v1/openflare/register_node.go index 9fa12189..f1e611c6 100644 --- a/internal/router/v1/openflare/register_node.go +++ b/internal/router/v1/openflare/register_node.go @@ -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)