Compare commits

...

26 Commits

Author SHA1 Message Date
ryan 891cb7b9c1 [优化] 更新 swaggo/swag 依赖版本至 v1.16.4,并更新文档生成指令 2026-03-19 09:28:57 +08:00
ryan 007b1d8929 [优化] 移除 OpenRestyResolvers 配置,统一上游渲染为带 keepalive 的 named upstream 2026-03-18 23:24:37 +08:00
ryan 782304012c [功能] 添加节点健康事件清理功能,优化节点观测数据管理 2026-03-18 23:11:48 +08:00
ryan 4945b8b44f [修复] 更新数据库字段类型为text,添加消息截断逻辑以支持更长的消息内容 2026-03-18 22:57:14 +08:00
ryan 1fbe156a7c [功能] 添加支持多个上游地址,优化代理路由配置和负载均衡逻辑 2026-03-18 22:24:05 +08:00
ryan c844f4c784 [优化] 更新HTTPS配置,启用reuseport和epoll事件模型,优化性能 2026-03-18 22:15:48 +08:00
ryan 67197220ae [优化] 添加命名上游支持,优化代理配置生成逻辑 2026-03-18 22:15:48 +08:00
ryan 0cb4e06b11 [功能] 添加缓存策略支持,优化代理路由配置和验证逻辑 2026-03-18 22:08:55 +08:00
ryan c84d5bd540 [功能] 更新OpenResty配置,添加连接升级映射和默认服务器块,优化HTTPS和HTTP重定向逻辑 2026-03-18 22:02:32 +08:00
ryan 51a875ab50 [功能] 更新HTTPS配置,启用HTTP/2支持并优化相关文档 2026-03-18 21:34:47 +08:00
ryan ed38aa1d79 [功能] 优化仪表板概览数据结构,添加压缩和规范化功能 2026-03-18 15:37:14 +08:00
ryan 9ced0eb6f0 [功能] 添加获取配置版本详情的API,优化配置版本管理逻辑 2026-03-18 15:19:39 +08:00
ryan 4433ab5af4 [功能] 添加应用日志分页查询和清理功能,优化日志管理逻辑 2026-03-18 14:34:25 +08:00
ryan 0ab0145f8d [修复] 精简access-logs-page和performance-page组件的导入和属性设置 2026-03-18 14:05:17 +08:00
ryan 7a22167997 [功能] 添加OpenRestyResolvers支持,优化DNS解析器配置和验证逻辑 2026-03-18 13:52:58 +08:00
ryan e2202d1456 [功能] 添加对应用结果的警告支持,优化配置激活和回滚逻辑 2026-03-18 13:09:28 +08:00
ryan 2ece13d08e [功能] 重构Lua和证书文件管理逻辑,优化文件同步和清理机制 2026-03-18 12:28:41 +08:00
ryan 4c4f7f9ced [功能] 添加OpenResty解析器指令支持,增强配置模板和运行时解析能力 2026-03-18 11:16:56 +08:00
ryan bb284c2f37 [功能] 优化DockerExecutor的Reload方法,添加挂载源验证并支持在运行中的容器内重载 2026-03-18 10:46:19 +08:00
ryan 915be62ca1 [修复] 添加对Docker挂载源的验证,确保配置文件和目录的有效性 2026-03-18 10:38:45 +08:00
ryan 29a6fedbe9 [功能] 添加访问日志折叠、IP汇总和趋势查询功能,并实现日志清理功能 2026-03-18 10:33:35 +08:00
ryan 2e875f583b [功能] 调整访问日志分页大小为20,并更新相关组件以支持动态分页 2026-03-18 09:52:39 +08:00
ryan 70da7c772c 更新README 2026-03-17 19:48:41 +08:00
ryan f1d469c18f 更新README 2026-03-17 19:23:21 +08:00
ryan 244a43ba77 更新README 2026-03-17 19:22:36 +08:00
ryan 5e8007da17 更新README 2026-03-17 15:46:48 +08:00
72 changed files with 9446 additions and 1989 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
.cache
.gocache*
.idea
.vscode
upload
+4 -8
View File
@@ -11,16 +11,13 @@
3. [docs/development-plan.md](./docs/development-plan.md)
作用:理解当前开发阶段、实施顺序、阶段目标和验收标准。
4. [docs/frontend-revamp-plan.md](./docs/frontend-revamp-plan.md)
作用:理解当前前端从 CRA + Semantic UI 迁移到 Next.js + Tailwind CSS + NextUI 的专项改造目标、实施阶段和风险边界。
5. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
作用:理解新版前端的技术选型、目录分层、组件规范、请求层、状态管理、样式和测试约束。
6. [docs/deployment.md](./docs/deployment.md)
5. [docs/deployment.md](./docs/deployment.md)
作用:理解当前的部署方式和联调步骤,确保开发过程中产出的功能能够成功部署和验证。
7. [docs/app-config.md](./docs/app-config.md)
6. [docs/app-config.md](./docs/app-config.md)
作用:系统启动时支持的环境变量和配置项说明,确保开发过程中新增的配置项能够正确使用和文档化。
@@ -29,7 +26,7 @@
* 如果实现内容超出 `docs/design.md` 的范围,先修改设计文档,再继续编码。
* 如果实现方式违反 `docs/development-guidelines.md`,应优先调整方案,而不是绕过规范。
* 如果需求与当前开发阶段冲突,优先遵守 `docs/development-plan.md` 的阶段顺序。
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-revamp-plan.md` 与 `docs/frontend-development-guidelines.md`。
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-development-guidelines.md`。
## 文档维护要求
@@ -40,6 +37,5 @@
* 产品范围或系统边界变化:更新 `docs/design.md`
* 开发约束、代码规范、接口约定变化:更新 `docs/development-guidelines.md`
* 阶段目标、顺序、验收标准变化:更新 `docs/development-plan.md`
* 前端技术栈、迁移阶段、页面范围变化:更新 `docs/frontend-revamp-plan.md`
* 前端目录分层、组件规范、样式体系、测试基线变化:更新 `docs/frontend-development-guidelines.md`
* 环境变量或配置项变化:更新 `docs/app-config.md`
-243
View File
@@ -1,243 +0,0 @@
<p align="right">
<a href="./README.md">中文</a> | <strong>English</strong>
</p>
<div align="center">
<img src="./openflare_server/web/public/logo.png" width="120" height="120" alt="OpenFlare logo">
# OpenFlare
A lightweight, self-hosted OpenResty control plane for reverse proxy management, configuration rollout, node sync, TLS assets, and practical observability.
</div>
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
</a>
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
</a>
<a href="https://goreportcard.com/report/github.com/Rain-kl/OpenFlare">
<img src="https://goreportcard.com/badge/github.com/Rain-kl/OpenFlare" alt="GoReportCard">
</a>
</p>
OpenFlare `1.0.0` is the current stable baseline. Phase six is complete and fully shipped; the repository documentation now focuses on the living system rather than historical implementation notes.
## Why It Exists
OpenFlare is built for a simple but recurring operational need:
* manage domain-to-origin reverse proxy rules from one control plane
* publish immutable OpenResty configuration versions
* let Agents pull, validate, reload, and roll back safely
* manage certificates, domains, node credentials, and version state
* expose practical dashboards for traffic, node health, and rollout status
It is not trying to be a CDN SaaS platform, a multi-tenant control plane, or a general-purpose logging system.
## Core Capabilities
* Versioned configuration with preview, publish, activate, and rollback
* Node onboarding with `discovery_token` or per-node `agent_token`
* Automated Agent apply flow with `openresty -t`, reload, and rollback
* Managed OpenResty templates, performance settings, and cache settings
* TLS certificate and domain management with exact and wildcard matching
* Request analytics, node snapshots, and health event reporting
* Controlled Server and Agent upgrade flows
* A production frontend built with Next.js App Router, React 19, and Tailwind CSS 4
## Architecture
```text
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL + Web UI)
|
| HTTP API / Config Pull
v
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
v
Local OpenResty or Docker OpenResty
|
v
Origin
```
Responsibilities:
* `openflare_server`: admin UI, management APIs, Agent APIs, rendering, rollout, and state storage
* `openflare_agent`: node registration, heartbeat, sync, local apply, validation, reload, rollback, and self-update
* `openflare_server/web`: the production admin frontend, exported statically and served by the Go server
## UI Preview
### Dashboard Overview
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### Node Detail and Install Command
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### Version Release Workflow
![OpenFlare version release](./docs/assets/readme/version-release.png)
## Quick Start
### 1. Start the Server
```yaml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: openflare
POSTGRES_USER: openflare
POSTGRES_PASSWORD: replace-with-strong-password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
interval: 10s
timeout: 5s
retries: 5
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-random-string
SQLITE_PATH: /data/openflare.db
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
PORT: "3000"
volumes:
- openflare-data:/data
volumes:
postgres-data:
openflare-data:
```
```bash
docker compose up -d
```
Open `http://localhost:3000`
Default credentials:
* Username: `root`
* Password: `123456`
### 2. Install an Agent
First-time registration with `discovery_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
Registration with a per-node `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
The installer writes to `/opt/openflare-agent` by default, creates `openflare-agent.service`, and can be re-run for reinstall or upgrade.
### 3. Publish Your First Config
1. Sign in and create a reverse proxy rule
2. Review the preview or diff
3. Activate the new version
4. Wait for Agents to pick it up on the next heartbeat
Version numbers follow `YYYYMMDD-NNN`. Versions are immutable; rollback is implemented by reactivating an older version.
## Repository Layout
* `openflare_server`: monolithic control plane built with Gin, GORM, and SQLite/PostgreSQL
* `openflare_server/web`: admin frontend built with Next.js 15 App Router
* `openflare_agent`: Go Agent
* `scripts`: install and helper scripts
* `docs`: design, guidelines, deployment, and configuration docs
## Local Development
### Server
```bash
cd openflare_server
export SESSION_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
# Optional: switch to PostgreSQL by setting either DSN or SQL_DSN.
# If the PostgreSQL database is empty and ./openflare.db exists,
# OpenFlare migrates SQLite data automatically at startup.
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
go run .
```
### Frontend
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
```
### Agent
```bash
cd openflare_agent
go run ./cmd/agent -config /path/to/agent.json
```
### Useful Checks
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
## Admin Surface
The admin UI currently covers:
* reverse proxy rules
* config versions
* node management
* apply logs
* TLS certificates
* domain management
* user management
* settings
* version upgrades
Swagger UI is available at `/swagger/index.html` after login.
## License
OpenFlare is released under the [Apache License 2.0](./LICENSE).
+15 -35
View File
@@ -3,7 +3,8 @@
</p>
<div align="center">
<img src="./openflare_server/web/public/logo.png" width="120" height="120" alt="OpenFlare logo">
[//]: # ( <img src="./openflare_server/web/public/logo.png" width="120" height="120" alt="OpenFlare logo">)
# OpenFlare
@@ -21,13 +22,8 @@
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
</a>
<a href="https://goreportcard.com/report/github.com/Rain-kl/OpenFlare">
<img src="https://goreportcard.com/badge/github.com/Rain-kl/OpenFlare" alt="GoReportCard">
</a>
</p>
OpenFlare `1.0.0` 是当前稳定基线。第六版开发工作已经完成,相关能力已并入正式版,仓库文档不再保留阶段性实施记录,而只维护当前有效的设计、约束和部署方式。
## 为什么存在
OpenFlare 解决的是一类朴素但高频的运维问题:
@@ -38,18 +34,13 @@ OpenFlare 解决的是一类朴素但高频的运维问题:
* 统一托管证书、域名、节点凭证与版本状态
* 提供足够实用的总览、节点详情与访问分析能力
它不是 CDN SaaS,也不试图在 1.0 阶段演变成多租户平台、日志平台或通用调度系统。
## 核心能力
* 配置版本化:支持预览、发布、激活、历史回滚,版本不可变
* 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token`
* 配置版本化:支持预览、发布、激活、历史回滚
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
* OpenResty 托管:统一管理主配置模板、性能参数、缓存参数与受管路由
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
* 访问与节点观测:支持请求窗口聚合、状态码分布、来源分布、节点资源与健康事件展示
* 版本运维:支持 Server 与 Agent 的正式版升级,以及受控的 preview 检查与手动升级
* 管理端 UI:基于 Next.js App Router、React 19、Tailwind CSS 4 的正式前端
## 系统架构
@@ -79,11 +70,11 @@ Origin
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 节点详情与安装命令
### 节点详情
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### 配置发布与版本管理
### 配置新增
![OpenFlare version release](./docs/assets/readme/version-release.png)
@@ -118,11 +109,9 @@ services:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-random-string
SQLITE_PATH: /data/openflare.db
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
PORT: "3000"
volumes:
- openflare-data:/data
@@ -144,7 +133,9 @@ docker compose up -d
### 2. 接入 Agent
使用 `discovery_token` 首次接入:
**注意:** 安装agent前需确保存已经安装了Docker, 虽然支持裸Openresty,但未得到充分验证,可能存在未知问题.
使用 `discovery_token` 接入:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
@@ -197,9 +188,8 @@ go run .
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
pnpm dev
```
### Agent
@@ -209,18 +199,6 @@ cd openflare_agent
go run ./cmd/agent -config /path/to/agent.json
```
### 常用验证命令
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
## 文档导航
建议按以下顺序阅读:
@@ -228,10 +206,9 @@ GOCACHE=/tmp/openflare-go-cache go test ./...
1. [docs/design.md](./docs/design.md)
2. [docs/development-guidelines.md](./docs/development-guidelines.md)
3. [docs/development-plan.md](./docs/development-plan.md)
4. [docs/frontend-revamp-plan.md](./docs/frontend-revamp-plan.md)
5. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
6. [docs/deployment.md](./docs/deployment.md)
7. [docs/app-config.md](./docs/app-config.md)
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
5. [docs/deployment.md](./docs/deployment.md)
6. [docs/app-config.md](./docs/app-config.md)
## 管理端与接口
@@ -249,6 +226,9 @@ GOCACHE=/tmp/openflare-go-cache go test ./...
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
如需重新生成 Swagger 文档,请使用与服务端依赖一致的版本:
`go install github.com/swaggo/swag/cmd/swag@v1.16.4`
## 开源协议
本项目采用 [Apache License 2.0](./LICENSE) 开源。
+6
View File
@@ -82,6 +82,12 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
这类参数必须以结构化方式校验、保存并参与版本渲染。
* 管理端不再暴露 `resolver` 配置;规则上游统一渲染为 named `upstream` 并启用 keepalive,单上游如带 base path 或 query,会在 `proxy_pass` 中补回原始 URI。
* 多上游仍要求每个上游都为纯 `scheme://host[:port]`,且同一规则内协议一致,避免在负载均衡模式下引入不可预测的 URI 差异。
* `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定,不再默认对所有规则开启缓存。
* 默认缓存 Key 为 `$scheme$host$request_uri`,更贴近代理域名维度;如需按其他维度命中,可在性能页显式覆盖。
* 默认 `keepalive_timeout` 为 `20` 秒,默认 `proxy_connect_timeout` 为 `3` 秒,优先兼顾资源占用与回源失败切换速度。
* 默认事件模型为 `epoll`,并默认开启 `multi_accept`;HTTPS 监听默认使用独立 `http2 on;` 指令,避免新版 Nginx/OpenResty 对 `listen ... http2` 的弃用告警。
### 1.5 前端构建环境变量
| 环境变量 | 作用 | 默认值 |
+1 -1
View File
@@ -107,7 +107,7 @@ docker compose up -d
如需在本地重新生成文档:
```bash
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd openflare_server
swag init -g main.go -o docs
```
+5 -2
View File
@@ -119,10 +119,12 @@ Origin
稳定约束:
* 一个域名只对应一个 `origin_url`
* 一个域名只对应一条 `proxy_routes` 规则
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
* `proxy_routes.domain` 必须唯一
* `origin_url` 必须为合法 `http://` 或 `https://`
* 所有上游地址都必须为合法 `http://` 或 `https://`
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
@@ -171,6 +173,7 @@ Origin
* 主配置、路由配置、证书与 Lua 资源写入
* 执行 `openresty -t` / `openresty -s reload`
* 失败回滚
* 对已失败并回退的目标版本做本地熔断,直到控制面出现新的激活版本
* 节点观测采集与结果上报
### 7.3 `openflare_server/web`
+6 -4
View File
@@ -115,7 +115,8 @@
通用约束:
* 不新增平台化对象,除非设计文档明确要求
* `proxy_routes` 维持一条域名对应一个 `origin_url`
* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
* `config_versions` 必须保存完整快照与渲染结果
* 全局同时只能有一个激活版本
@@ -188,9 +189,10 @@ Agent 必须满足:
* 常规同步优先依据 heartbeat 返回的版本摘要判断
* 发现新版本时先备份旧文件
* 写入主配置、路由配置与必要证书文件
* 先执行 `openresty -t`
* 成功后执行 `openresty -s reload`
* 失败时自动回滚并上报最终结果
* 写入新配置后以运行态恢复为目标执行激活,Docker 模式优先重建容器并确认容器保持运行
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty
* 回滚后 OpenResty 恢复正常时上报警告;回滚后仍无法恢复运行时上报失败
* 某个目标 `version + checksum` 一旦应用失败并回退,Agent 必须在本地状态中阻断该目标的重复应用;只有远端激活版本或 checksum 发生变化时,才允许再次尝试
## 6. 测试与交付要求
+1 -1
View File
@@ -1,6 +1,6 @@
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "89d0efbf7bcc8fa53fe48889dce4d045",
"agent_token": "2380de64b00e99093e16590beb91e1a0",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
+1
View File
@@ -73,6 +73,7 @@ func main() {
NginxLuaDir: cfg.OpenrestyLuaDir,
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
OpenrestyResolverDirective: "",
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath,
DockerBinary: cfg.DockerBinary,
+27
View File
@@ -33,6 +33,7 @@ type Config struct {
AgentVersion string `json:"-"`
NginxVersion string `json:"-"`
OpenrestyPath string `json:"openresty_path"`
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
OpenrestyContainerName string `json:"openresty_container_name"`
OpenrestyDockerImage string `json:"openresty_docker_image"`
DockerBinary string `json:"docker_binary"`
@@ -59,6 +60,7 @@ type configFile struct {
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
OpenrestyPath string `json:"openresty_path"`
OpenrestyResolvers []string `json:"openresty_resolvers"`
OpenrestyContainerName string `json:"openresty_container_name"`
OpenrestyDockerImage string `json:"openresty_docker_image"`
DockerBinary string `json:"docker_binary"`
@@ -93,6 +95,7 @@ func Load(path string) (*Config, error) {
NodeName: file.NodeName,
NodeIP: file.NodeIP,
OpenrestyPath: file.OpenrestyPath,
OpenrestyResolvers: append([]string{}, file.OpenrestyResolvers...),
OpenrestyContainerName: file.OpenrestyContainerName,
OpenrestyDockerImage: file.OpenrestyDockerImage,
DockerBinary: file.DockerBinary,
@@ -121,6 +124,7 @@ func Load(path string) (*Config, error) {
func applyDefaults(cfg *Config, baseDir string) {
baseDir = filepath.Clean(baseDir)
cfg.AgentVersion = AgentVersion
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
if cfg.OpenrestyContainerName == "" {
cfg.OpenrestyContainerName = "openflare-openresty"
}
@@ -290,6 +294,29 @@ func detectHostname() string {
return strings.TrimSpace(host)
}
func normalizeResolverList(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -120,6 +120,40 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
}
}
func TestLoadNormalizesExplicitResolvers(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
"openresty_resolvers": []string{" 10.0.0.2 ", "10.0.0.2", "", "1.1.1.1"},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
expected := []string{"10.0.0.2", "1.1.1.1"}
if len(cfg.OpenrestyResolvers) != len(expected) {
t.Fatalf("unexpected resolver count: %#v", cfg.OpenrestyResolvers)
}
for index, value := range expected {
if cfg.OpenrestyResolvers[index] != value {
t.Fatalf("unexpected resolver at %d: got %q want %q", index, cfg.OpenrestyResolvers[index], value)
}
}
}
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
@@ -209,6 +243,7 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
cfg.NginxVersion = "1.27.1.2"
cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second)
cfg.RequestTimeout = MillisecondDuration(7 * time.Second)
cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"}
if err = cfg.Save(); err != nil {
t.Fatalf("Save failed: %v", err)
@@ -234,6 +269,10 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
if decoded["request_timeout"] != float64(7000) {
t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"])
}
resolvers, ok := decoded["openresty_resolvers"].([]any)
if !ok || len(resolvers) != 2 || resolvers[0] != "10.0.0.2" || resolvers[1] != "1.1.1.1" {
t.Fatalf("unexpected resolvers: %#v", decoded["openresty_resolvers"])
}
if decoded["openresty_observability_port"] != float64(defaultOpenRestyObservabilityPort) {
t.Fatalf("unexpected observability port: %#v", decoded["openresty_observability_port"])
}
+422 -68
View File
@@ -8,6 +8,8 @@ import (
"fmt"
"io/fs"
"log/slog"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -24,6 +26,7 @@ const AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__"
const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/openflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/openflare_access.log"
@@ -116,6 +119,9 @@ type DockerExecutor struct {
func (e *DockerExecutor) Test(ctx context.Context) error {
slog.Debug("running docker openresty test", "container", e.ContainerName, "image", e.Image)
if err := e.validateMountSources(); err != nil {
return err
}
output, err := e.runEphemeralRuntimeCommand(ctx, "-t")
if err != nil {
return fmt.Errorf("docker %s -t failed: %w: %s", dockerRuntimeCommand, err, string(output))
@@ -125,7 +131,25 @@ func (e *DockerExecutor) Test(ctx context.Context) error {
}
func (e *DockerExecutor) Reload(ctx context.Context) error {
return e.EnsureRuntime(ctx, true)
if err := e.validateMountSources(); err != nil {
return err
}
output, err := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "{{.State.Running}}", e.ContainerName)
if err != nil || strings.TrimSpace(string(output)) != "true" {
return e.EnsureRuntime(ctx, false)
}
output, err = e.Runner.Run(ctx, e.DockerBinary, "exec", e.ContainerName, dockerRuntimeCommand, "-s", "reload")
if err != nil {
if e.shouldRecreateAfterReloadFailure(string(output)) {
slog.Warn("docker openresty reload failed due to missing mounted files, recreating container", "container", e.ContainerName)
if recreateErr := e.EnsureRuntime(ctx, true); recreateErr != nil {
return fmt.Errorf("docker exec %s reload failed: %w: %s; recreate failed: %v", dockerRuntimeCommand, err, string(output), recreateErr)
}
return nil
}
return fmt.Errorf("docker exec %s reload failed: %w: %s", dockerRuntimeCommand, err, string(output))
}
return nil
}
func (e *DockerExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
@@ -157,7 +181,7 @@ func (e *DockerExecutor) CheckHealth(ctx context.Context) error {
return fmt.Errorf("docker inspect openresty failed: %w: %s", err, string(output))
}
if strings.TrimSpace(string(output)) != "true" {
return errors.New("docker openresty container is not running")
return e.containerNotRunningError(ctx)
}
return nil
}
@@ -182,6 +206,9 @@ func (e *DockerExecutor) removeContainer(ctx context.Context) error {
func (e *DockerExecutor) runContainer(ctx context.Context) error {
slog.Info("starting docker openresty container", "container", e.ContainerName, "image", e.Image)
if err := e.validateMountSources(); err != nil {
return err
}
runArgs := []string{
"run", "-d",
"--name", e.ContainerName,
@@ -198,10 +225,127 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
if runErr != nil {
return fmt.Errorf("docker run openresty failed: %w: %s", runErr, string(runOutput))
}
if err := e.CheckHealth(ctx); err != nil {
return err
}
slog.Info("docker openresty container started", "container", e.ContainerName)
return nil
}
func (e *DockerExecutor) validateMountSources() error {
if err := ensureRegularFile(e.MainConfigPath, "openresty main config"); err != nil {
return err
}
if err := ensureDirectory(e.RouteConfigDir, "openresty route config dir"); err != nil {
return err
}
if err := ensureDirectory(e.CertDir, "openresty cert dir"); err != nil {
return err
}
if err := ensureDirectory(e.LuaDir, "openresty lua dir"); err != nil {
return err
}
return nil
}
func (e *DockerExecutor) shouldRecreateAfterReloadFailure(output string) bool {
text := strings.ToLower(strings.TrimSpace(output))
if text == "" {
return false
}
if !strings.Contains(text, "no such file") && !strings.Contains(text, "cannot load certificate") {
return false
}
paths := []string{
strings.ToLower(e.NginxCertDir),
strings.ToLower(e.NginxLuaDir),
strings.ToLower(DockerMainConfigPath),
strings.ToLower("/etc/nginx/conf.d"),
}
for _, path := range paths {
if strings.TrimSpace(path) != "" && strings.Contains(text, path) {
return true
}
}
return false
}
func (e *DockerExecutor) containerNotRunningError(ctx context.Context) error {
inspectSummary := ""
inspectOutput, inspectErr := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "status={{.State.Status}} exit_code={{.State.ExitCode}} error={{printf \"%q\" .State.Error}} oom_killed={{.State.OOMKilled}} finished_at={{.State.FinishedAt}}", e.ContainerName)
if inspectErr == nil {
inspectSummary = strings.TrimSpace(string(inspectOutput))
}
logTail := ""
logOutput, logErr := e.Runner.Run(ctx, e.DockerBinary, "logs", "--tail", "50", e.ContainerName)
if logErr == nil {
logTail = strings.TrimSpace(string(logOutput))
}
message := "docker openresty container is not running"
if inspectSummary != "" {
message += ": " + inspectSummary
}
if logTail != "" {
message += "; recent logs: " + compactDiagnosticText(logTail)
}
return errors.New(message)
}
func compactDiagnosticText(text string) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
lines := strings.Split(trimmed, "\n")
if len(lines) > 8 {
lines = lines[len(lines)-8:]
}
joined := strings.Join(lines, " | ")
joined = strings.Join(strings.Fields(joined), " ")
if len(joined) > 800 {
return joined[len(joined)-800:]
}
return joined
}
func ensureRegularFile(path string, label string) error {
cleanPath := strings.TrimSpace(path)
if cleanPath == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s %q does not exist; run a config apply first so Docker does not create a directory mount source", label, cleanPath)
}
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
}
if info.IsDir() {
return fmt.Errorf("%s %q is a directory; expected a file for Docker bind mount", label, cleanPath)
}
return nil
}
func ensureDirectory(path string, label string) error {
cleanPath := strings.TrimSpace(path)
if cleanPath == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s %q does not exist; expected a directory for Docker bind mount", label, cleanPath)
}
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
}
if !info.IsDir() {
return fmt.Errorf("%s %q is not a directory; expected a directory for Docker bind mount", label, cleanPath)
}
return nil
}
type Manager struct {
MainConfigPath string
RouteConfigPath string
@@ -212,74 +356,117 @@ type Manager struct {
NginxLuaDir string
OpenrestyObservabilityListen string
OpenrestyObservabilityPort int
OpenrestyResolverDirective string
Executor Executor
}
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
type ApplyStatus string
const (
ApplyStatusSuccess ApplyStatus = "success"
ApplyStatusWarning ApplyStatus = "warning"
ApplyStatusFatal ApplyStatus = "fatal"
)
type ApplyOutcome struct {
Status ApplyStatus
Message string
}
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome {
slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles))
backup, err := m.backup()
if err != nil {
return fatalApplyOutcome(fmt.Errorf("backup openresty config failed: %w", err))
}
if err = m.writeTargetFiles(mainConfig, routeConfig, supportFiles); err != nil {
return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("write openresty config failed: %w", err))
}
if err = m.activateConfig(ctx); err != nil {
return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("activate openresty runtime failed: %w", err))
}
slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath)
return ApplyOutcome{Status: ApplyStatusSuccess}
}
func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
if err := m.EnsureLuaAssets(); err != nil {
return err
}
if err = m.EnsureLuaAssets(); err != nil {
slog.Error("writing lua assets failed, restoring backup", "error", err)
_ = m.restore(backup)
if err := m.writeCertFiles(supportFiles); err != nil {
return err
}
if err = m.writeCertFiles(supportFiles); err != nil {
slog.Error("writing cert files failed, restoring backup", "error", err)
_ = m.restore(backup)
return err
if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") {
slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured")
}
renderedMainConfig := m.renderMainConfig(mainConfig)
if err = os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil {
slog.Error("writing openresty main config failed, restoring backup", "error", err)
_ = m.restore(backup)
if err := os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil {
return err
}
renderedRouteConfig := m.renderRouteConfig(routeConfig)
if err = os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil {
slog.Error("writing openresty route config failed, restoring backup", "error", err)
_ = m.restore(backup)
if err := os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil {
return err
}
if err = m.Executor.Test(ctx); err != nil {
slog.Error("openresty test failed after config write, restoring backup", "error", err)
_ = m.restore(backup)
return err
}
if err = m.Executor.Reload(ctx); err != nil {
slog.Error("openresty reload failed after config write, restoring backup", "error", err)
_ = m.restore(backup)
return err
}
slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath)
return nil
}
func (m *Manager) activateConfig(ctx context.Context) error {
if m.Executor == nil {
return errors.New("executor 未配置")
}
if _, ok := m.Executor.(*DockerExecutor); ok {
return m.Executor.EnsureRuntime(ctx, true)
}
return m.Executor.Reload(ctx)
}
func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupState, applyErr error) ApplyOutcome {
slog.Warn("openresty apply failed, restoring previous config", "error", applyErr)
if err := m.restore(backup); err != nil {
return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %v: %w", applyErr, err))
}
if err := m.activateConfig(ctx); err != nil {
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err))
}
message := fmt.Sprintf("apply failed, rolled back to previous config: %v", applyErr)
slog.Warn("openresty apply rolled back successfully", "message", message)
return ApplyOutcome{
Status: ApplyStatusWarning,
Message: message,
}
}
func fatalApplyOutcome(err error) ApplyOutcome {
if err == nil {
return ApplyOutcome{Status: ApplyStatusFatal}
}
return ApplyOutcome{
Status: ApplyStatusFatal,
Message: strings.TrimSpace(err.Error()),
}
}
func (m *Manager) EnsureLuaAssets() error {
if strings.TrimSpace(m.LuaDir) == "" {
return nil
}
if err := os.RemoveAll(m.LuaDir); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(m.LuaDir, 0o755); err != nil {
return err
}
files := make([]managedFile, 0, len(ManagedObservabilityLuaFiles()))
for _, file := range ManagedObservabilityLuaFiles() {
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, []byte(file.Content), 0o644); err != nil {
relativePath, err := filepath.Rel(m.LuaDir, targetPath)
if err != nil {
return err
}
files = append(files, managedFile{
Path: filepath.ToSlash(relativePath),
Content: []byte(file.Content),
Mode: 0o644,
})
}
return nil
return syncManagedFiles(m.LuaDir, files)
}
func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
@@ -342,6 +529,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
if m.OpenrestyObservabilityPort > 0 {
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), ObservabilityPortPlaceholder)
}
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder)
}
normalizedRoute := string(data)
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
@@ -505,6 +695,12 @@ type backupState struct {
Files []protocol.SupportFile
}
type managedFile struct {
Path string
Content []byte
Mode fs.FileMode
}
func (m *Manager) backup() (*backupState, error) {
if m.MainConfigPath == "" {
return nil, errors.New("main config path 不能为空")
@@ -569,50 +765,34 @@ func (m *Manager) restore(state *backupState) error {
if m.CertDir == "" {
return nil
}
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
return err
}
for _, file := range state.Files {
targetPath, err := m.certFileTargetPath(file.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, []byte(file.Content), certFileMode(file.Path)); err != nil {
return err
}
}
return nil
return m.writeManagedCertFiles(state.Files)
}
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
if m.CertDir == "" {
return nil
}
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
return err
}
return m.writeManagedCertFiles(certFiles)
}
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
targetPath, err := m.certFileTargetPath(file.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, []byte(file.Content), certFileMode(file.Path)); err != nil {
relativePath, err := filepath.Rel(m.CertDir, targetPath)
if err != nil {
return err
}
files = append(files, managedFile{
Path: filepath.ToSlash(relativePath),
Content: []byte(file.Content),
Mode: certFileMode(file.Path),
})
}
return nil
return syncManagedFiles(m.CertDir, files)
}
func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
@@ -719,6 +899,94 @@ func luaFileTargetPath(baseDir string, relativePath string) (string, error) {
return targetPath, nil
}
func syncManagedFiles(baseDir string, files []managedFile) error {
if strings.TrimSpace(baseDir) == "" {
return errors.New("managed dir cannot be empty")
}
if info, err := os.Stat(baseDir); err == nil && !info.IsDir() {
return fmt.Errorf("managed dir %q is not a directory", baseDir)
} else if err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return err
}
desired := make(map[string]managedFile, len(files))
for _, file := range files {
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimSpace(file.Path)))
if cleanPath == "." || cleanPath == "" {
return errors.New("managed file path cannot be empty")
}
desired[cleanPath] = managedFile{
Path: cleanPath,
Content: file.Content,
Mode: file.Mode,
}
}
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relativePath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
if _, ok := desired[filepath.Clean(relativePath)]; ok {
return nil
}
return os.Remove(path)
}); err != nil {
return err
}
for _, file := range desired {
targetPath := filepath.Join(baseDir, file.Path)
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, file.Content, file.Mode); err != nil {
return err
}
}
return removeEmptyManagedDirs(baseDir)
}
func removeEmptyManagedDirs(baseDir string) error {
dirs := make([]string, 0)
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && path != baseDir {
dirs = append(dirs, path)
}
return nil
}); err != nil {
return err
}
sort.Slice(dirs, func(i int, j int) bool {
return len(dirs[i]) > len(dirs[j])
})
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
if len(entries) == 0 {
if err := os.Remove(dir); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
func (m *Manager) renderRouteConfig(content string) string {
if m.NginxCertDir == "" {
return content
@@ -743,6 +1011,9 @@ func (m *Manager) renderMainConfig(content string) string {
if m.OpenrestyObservabilityPort > 0 {
rendered = strings.ReplaceAll(rendered, ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
}
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective)
}
return rendered
}
@@ -756,6 +1027,89 @@ func ObservabilityListenAddress(openrestyPath string, port int) string {
return fmt.Sprintf("%d", port)
}
func ResolverDirective(openrestyPath string, explicitResolvers []string) string {
resolvers := resolverAddresses(openrestyPath, explicitResolvers)
if len(resolvers) == 0 {
return ""
}
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
}
func resolverAddresses(openrestyPath string, explicitResolvers []string) []string {
if resolvers := normalizeResolverAddresses(explicitResolvers); len(resolvers) > 0 {
return resolvers
}
data, err := os.ReadFile("/etc/resolv.conf")
if err != nil {
return nil
}
return parseResolverAddresses(string(data), strings.TrimSpace(openrestyPath) == "")
}
func parseResolverAddresses(content string, dockerMode bool) []string {
lines := strings.Split(content, "\n")
resolvers := make([]string, 0, 2)
seen := make(map[string]struct{})
for _, line := range lines {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 2 || fields[0] != "nameserver" {
continue
}
addr := strings.TrimSpace(fields[1])
if addr == "" {
continue
}
if dockerMode && !isUsableDockerResolver(addr) {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
resolvers = append(resolvers, addr)
}
return resolvers
}
func isUsableDockerResolver(addr string) bool {
ip := net.ParseIP(addr)
if ip == nil {
return false
}
return !ip.IsLoopback() && !ip.IsUnspecified()
}
func normalizeResolverAddresses(values []string) []string {
if len(values) == 0 {
return nil
}
resolvers := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
addr := strings.TrimSpace(value)
if addr == "" {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
resolvers = append(resolvers, addr)
}
if len(resolvers) == 0 {
return nil
}
return resolvers
}
func RequiresRuntimeResolver(originURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(originURL))
if err != nil || parsed.Hostname() == "" {
return false
}
return net.ParseIP(parsed.Hostname()) == nil
}
func (m *Manager) routeConfigIncludePath() string {
if strings.TrimSpace(m.RuntimeRouteConfigPath) != "" {
return strings.TrimSpace(m.RuntimeRouteConfigPath)
+433 -45
View File
@@ -28,6 +28,11 @@ type fakeExecutor struct {
reloadErr error
}
type scriptedExecutor struct {
reloadErrors []error
reloadCalls int
}
func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)})
if r.runFn != nil {
@@ -56,6 +61,31 @@ func (e *fakeExecutor) Restart(ctx context.Context) error {
return e.reloadErr
}
func (e *scriptedExecutor) Test(ctx context.Context) error {
return nil
}
func (e *scriptedExecutor) Reload(ctx context.Context) error {
index := e.reloadCalls
e.reloadCalls++
if index >= len(e.reloadErrors) {
return nil
}
return e.reloadErrors[index]
}
func (e *scriptedExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
return nil
}
func (e *scriptedExecutor) CheckHealth(ctx context.Context) error {
return nil
}
func (e *scriptedExecutor) Restart(ctx context.Context) error {
return nil
}
func TestPathExecutorCommands(t *testing.T) {
runner := &fakeRunner{}
executor := &PathExecutor{
@@ -113,6 +143,15 @@ func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 4 && args[0] == "inspect" && args[2] == "{{.State.Running}}" {
return []byte("false"), nil
}
if len(args) >= 4 && args[0] == "inspect" {
return []byte("status=exited exit_code=1 error=\"\" oom_killed=false finished_at=2026-03-18T10:08:30Z"), nil
}
if len(args) >= 1 && args[0] == "logs" {
return []byte("nginx: [emerg] host not found in upstream \"c1\" in /etc/nginx/conf.d/openflare_routes.conf:30"), nil
}
return []byte("false"), nil
},
}
@@ -130,10 +169,38 @@ func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
}
if err := executor.CheckHealth(context.Background()); err == nil {
t.Fatal("expected CheckHealth to fail when container is not running")
} else {
text := err.Error()
if !strings.Contains(text, "exit_code=1") {
t.Fatalf("expected exit code in health error, got %v", err)
}
if !strings.Contains(text, "host not found in upstream") {
t.Fatalf("expected recent docker logs in health error, got %v", err)
}
}
}
func prepareDockerMountSources(t *testing.T) (string, string, string, string) {
t.Helper()
tempDir := t.TempDir()
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
if err := os.WriteFile(mainConfigPath, []byte("events {}\nhttp {}\n"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
return mainConfigPath, routeConfigDir, certDir, luaDir
}
func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
@@ -146,11 +213,11 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"),
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Clean("/tmp/lua"),
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
@@ -171,10 +238,16 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
}
func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
inspectCalls := 0
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 2 && args[0] == "inspect" {
return []byte("false"), nil
inspectCalls++
if inspectCalls < 3 {
return []byte("false"), nil
}
return []byte("true"), nil
}
return []byte("ok"), nil
},
@@ -183,11 +256,11 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"),
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Clean("/tmp/lua"),
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
@@ -196,26 +269,111 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
t.Fatalf("Reload failed: %v", err)
}
if len(runner.calls) != 3 {
t.Fatalf("expected 3 calls, got %d", len(runner.calls))
if len(runner.calls) != 5 {
t.Fatalf("expected 5 calls, got %d", len(runner.calls))
}
if runner.calls[0].args[0] != "inspect" {
t.Fatalf("expected docker inspect on first call, got %#v", runner.calls[0])
}
if runner.calls[1].args[0] != "rm" {
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
if runner.calls[1].args[0] != "inspect" {
t.Fatalf("expected docker inspect on second call, got %#v", runner.calls[1])
}
if runner.calls[2].args[0] != "run" {
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
if runner.calls[2].args[0] != "rm" {
t.Fatalf("expected docker rm on third call, got %#v", runner.calls[2])
}
if runner.calls[3].args[0] != "run" {
t.Fatalf("expected docker run on fourth call, got %#v", runner.calls[3])
}
if runner.calls[4].args[0] != "inspect" {
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[4])
}
}
func TestDockerExecutorReloadsRunningContainerInPlace(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
if err := executor.Reload(context.Background()); err != nil {
t.Fatalf("Reload failed: %v", err)
}
expected := []runCall{
{name: "docker", args: []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}},
{name: "docker", args: []string{"exec", "openflare-openresty", "openresty", "-s", "reload"}},
}
if !reflect.DeepEqual(runner.calls, expected) {
t.Fatalf("unexpected calls: %#v", runner.calls)
}
}
func TestDockerExecutorReloadRecreatesContainerWhenMountedCertMissing(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
if len(args) >= 2 && args[0] == "exec" {
return []byte(`nginx: [emerg] cannot load certificate "/etc/nginx/openflare-certs/1.crt": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory)`), errors.New("exit status 1")
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
Runner: runner,
}
if err := executor.Reload(context.Background()); err != nil {
t.Fatalf("Reload failed: %v", err)
}
if len(runner.calls) != 6 {
t.Fatalf("expected 6 calls, got %d", len(runner.calls))
}
if runner.calls[2].args[0] != "inspect" || runner.calls[3].args[0] != "rm" || runner.calls[4].args[0] != "run" || runner.calls[5].args[0] != "inspect" {
t.Fatalf("expected recreate after reload failure, got %#v", runner.calls)
}
}
func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
mainConfigPath := filepath.Clean("/tmp/managed/nginx.conf")
routeConfigDir := filepath.Clean("/tmp/managed/conf.d")
certDir := filepath.Clean("/tmp/managed/certs")
luaDir := filepath.Clean("/tmp/managed/lua")
runner := &fakeRunner{}
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
@@ -234,8 +392,8 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
t.Fatalf("runContainer failed: %v", err)
}
if len(runner.calls) != 1 {
t.Fatalf("expected one docker run call, got %d", len(runner.calls))
if len(runner.calls) != 2 {
t.Fatalf("expected docker run plus health check, got %d calls", len(runner.calls))
}
expectedArgs := []string{
@@ -253,9 +411,13 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
t.Fatalf("unexpected docker run args: %#v", runner.calls[0].args)
}
if !reflect.DeepEqual(runner.calls[1].args, []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}) {
t.Fatalf("unexpected docker health check args: %#v", runner.calls[1].args)
}
}
func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
@@ -268,11 +430,11 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"),
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Clean("/tmp/lua"),
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
Runner: runner,
@@ -281,8 +443,8 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
t.Fatalf("EnsureRuntime failed: %v", err)
}
if len(runner.calls) != 3 {
t.Fatalf("expected 3 calls, got %d", len(runner.calls))
if len(runner.calls) != 4 {
t.Fatalf("expected 4 calls, got %d", len(runner.calls))
}
if runner.calls[1].args[0] != "rm" {
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
@@ -290,6 +452,76 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
if runner.calls[2].args[0] != "run" {
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
}
if runner.calls[3].args[0] != "inspect" {
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[3])
}
}
func TestDockerExecutorRunContainerRejectsMissingMainConfigFile(t *testing.T) {
tempDir := t.TempDir()
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: &fakeRunner{},
}
err := executor.runContainer(context.Background())
if err == nil {
t.Fatal("expected missing main config file to be rejected")
}
if !strings.Contains(err.Error(), "run a config apply first") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDockerExecutorRunContainerRejectsMainConfigDirectory(t *testing.T) {
tempDir := t.TempDir()
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
for _, dir := range []string{mainConfigPath, routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: &fakeRunner{},
}
err := executor.runContainer(context.Background())
if err == nil {
t.Fatal("expected main config directory to be rejected")
}
if !strings.Contains(err.Error(), "expected a file") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
@@ -355,21 +587,21 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
Executor: &fakeExecutor{},
}
err := manager.Apply(
outcome := manager.Apply(
context.Background(),
"include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;\n",
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
)
if err != nil {
t.Fatalf("Apply failed: %v", err)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
expectedMain := "include " + routePath + ";\naccess_log " + filepath.Join(filepath.Dir(routePath), "openflare_access.log") + " openflare_json;\n"
expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(filepath.Join(filepath.Dir(routePath), "openflare_access.log")) + " openflare_json;\n"
if string(mainData) != expectedMain {
t.Fatalf("unexpected main config: %s", string(mainData))
}
@@ -411,8 +643,8 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
Executor: &fakeExecutor{},
}
if err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); err != nil {
t.Fatalf("Apply failed: %v", err)
if outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
@@ -485,15 +717,16 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityListen: "18081",
OpenrestyResolverDirective: " resolver 127.0.0.11 valid=30s ipv6=off;\n resolver_timeout 5s;\n",
Executor: &fakeExecutor{},
}
err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\nserver { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{
outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\n__OPENFLARE_RESOLVER_DIRECTIVE__server { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{
{Path: "1.crt", Content: "cert-data"},
{Path: "1.key", Content: "key-data"},
})
if err != nil {
t.Fatalf("Apply failed: %v", err)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
routeData, err := os.ReadFile(manager.RouteConfigPath)
@@ -510,6 +743,9 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if !strings.Contains(string(mainData), "listen 18081;") {
t.Fatalf("expected observability listen placeholder replacement in main config, got %s", string(mainData))
}
if !strings.Contains(string(mainData), "resolver 127.0.0.11 valid=30s ipv6=off;") {
t.Fatalf("expected resolver directive placeholder replacement in main config, got %s", string(mainData))
}
certData, err := os.ReadFile(filepath.Join(manager.CertDir, "1.crt"))
if err != nil {
t.Fatalf("failed to read cert file: %v", err)
@@ -521,11 +757,118 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
if luaInfo.Mode().Perm() != 0o644 {
if runtime.GOOS != "windows" && luaInfo.Mode().Perm() != 0o644 {
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
}
}
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") {
t.Fatalf("expected explicit resolver directive, got %q", got)
}
}
func TestParseResolverAddressesFiltersLoopbackForDocker(t *testing.T) {
content := strings.Join([]string{
"nameserver 127.0.0.53",
"nameserver 10.0.0.2",
"nameserver ::1",
"nameserver 1.1.1.1",
}, "\n")
got := parseResolverAddresses(content, true)
expected := []string{"10.0.0.2", "1.1.1.1"}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("unexpected docker resolvers: got %#v want %#v", got, expected)
}
}
func TestParseResolverAddressesKeepsLoopbackForLocalBinary(t *testing.T) {
content := strings.Join([]string{
"nameserver 127.0.0.53",
"nameserver 10.0.0.2",
}, "\n")
got := parseResolverAddresses(content, false)
expected := []string{"127.0.0.53", "10.0.0.2"}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("unexpected local resolvers: got %#v want %#v", got, expected)
}
}
func TestRequiresRuntimeResolver(t *testing.T) {
testCases := []struct {
name string
originURL string
want bool
}{
{name: "hostname", originURL: "https://origin.internal", want: true},
{name: "ipv4", originURL: "https://10.0.0.8", want: false},
{name: "ipv6", originURL: "https://[2001:db8::1]", want: false},
{name: "invalid", originURL: "://bad", want: false},
}
for _, testCase := range testCases {
if got := RequiresRuntimeResolver(testCase.originURL); got != testCase.want {
t.Fatalf("%s: got %v want %v", testCase.name, got, testCase.want)
}
}
}
func TestWriteCertFilesKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
tempDir := t.TempDir()
certDir := filepath.Join(tempDir, "certs")
if err := os.MkdirAll(filepath.Join(certDir, "stale"), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(filepath.Join(certDir, "stale", "old.crt"), []byte("old"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{CertDir: certDir}
if err := manager.writeCertFiles([]protocol.SupportFile{
{Path: "1.crt", Content: "cert"},
{Path: "1.key", Content: "key"},
}); err != nil {
t.Fatalf("writeCertFiles failed: %v", err)
}
if _, err := os.Stat(certDir); err != nil {
t.Fatalf("expected cert dir to persist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(certDir, "stale", "old.crt")); !os.IsNotExist(err) {
t.Fatalf("expected stale cert file to be removed, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(certDir, "1.crt")); err != nil {
t.Fatalf("expected new cert file to exist, stat err = %v", err)
}
}
func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
tempDir := t.TempDir()
luaDir := filepath.Join(tempDir, "lua")
if err := os.MkdirAll(filepath.Join(luaDir, "stale"), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(filepath.Join(luaDir, "stale", "old.lua"), []byte("old"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{LuaDir: luaDir}
if err := manager.EnsureLuaAssets(); err != nil {
t.Fatalf("EnsureLuaAssets failed: %v", err)
}
if _, err := os.Stat(luaDir); err != nil {
t.Fatalf("expected lua dir to persist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "stale", "old.lua")); !os.IsNotExist(err) {
t.Fatalf("expected stale lua file to be removed, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
}
func TestCertFileMode(t *testing.T) {
testCases := []struct {
path string
@@ -590,15 +933,15 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) {
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &fakeExecutor{
testErr: errors.New("openresty test failed"),
reloadErr: errors.New("openresty reload failed"),
},
}
err := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
{Path: "1.crt", Content: "new-cert"},
})
if err == nil {
t.Fatal("expected Apply to fail")
if outcome.Status != ApplyStatusFatal {
t.Fatalf("expected fatal apply outcome, got %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
@@ -624,6 +967,51 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) {
}
}
func TestManagerApplyReturnsWarningWhenRollbackRecoversRuntime(t *testing.T) {
tempDir := t.TempDir()
routePath := filepath.Join(tempDir, "routes.conf")
mainPath := filepath.Join(tempDir, "nginx.conf")
certDir := filepath.Join(tempDir, "certs")
if err := os.MkdirAll(certDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(mainPath, []byte("old-main"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
if err := os.WriteFile(filepath.Join(certDir, "1.crt"), []byte("old-cert"), 0o600); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &scriptedExecutor{
reloadErrors: []error{errors.New("target config failed"), nil},
},
}
outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
{Path: "1.crt", Content: "new-cert"},
})
if outcome.Status != ApplyStatusWarning {
t.Fatalf("expected warning apply outcome, got %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
if string(mainData) != "old-main" {
t.Fatalf("expected main rollback, got %s", string(mainData))
}
}
func TestManagerCertFileTargetPathRejectsEscapes(t *testing.T) {
manager := &Manager{CertDir: filepath.Join(t.TempDir(), "certs")}
if err := os.MkdirAll(manager.CertDir, 0o755); err != nil {
@@ -675,11 +1063,11 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
Executor: &fakeExecutor{},
}
err := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
{Path: "../escape.crt", Content: "bad"},
})
if err == nil {
t.Fatal("expected Apply to reject traversal path")
if outcome.Status != ApplyStatusWarning {
t.Fatalf("expected warning apply outcome, got %#v", outcome)
}
if _, statErr := os.Stat(filepath.Join(tempDir, "escape.crt")); !os.IsNotExist(statErr) {
+3
View File
@@ -13,6 +13,9 @@ type Snapshot struct {
NodeID string `json:"node_id"`
CurrentVersion string `json:"current_version"`
CurrentChecksum string `json:"current_checksum"`
BlockedVersion string `json:"blocked_version"`
BlockedChecksum string `json:"blocked_checksum"`
BlockedReason string `json:"blocked_reason"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"`
OpenrestyMessage string `json:"openresty_message"`
+142 -34
View File
@@ -4,15 +4,18 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"strings"
"openflare-agent/internal/nginx"
"openflare-agent/internal/protocol"
"openflare-agent/internal/state"
)
const (
ApplyResultSuccess = "success"
ApplyResultWarning = "warning"
ApplyResultFailed = "failed"
)
@@ -22,7 +25,7 @@ type ConfigClient interface {
}
type NginxManager interface {
Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error
Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome
EnsureRuntime(ctx context.Context, recreate bool) error
CurrentChecksum() (string, error)
}
@@ -102,13 +105,27 @@ func (s *Service) sync(ctx context.Context, startup bool, target *protocol.Activ
}
snapshot.CurrentVersion = target.Version
snapshot.CurrentChecksum = target.Checksum
clearBlockedTarget(snapshot)
snapshot.LastError = ""
slog.Debug("sync finished without changes", "mode", mode, "version", target.Version)
return s.stateStore.Save(snapshot)
}
if isBlockedTarget(snapshot, target.Version, target.Checksum) {
slog.Warn("skipping blocked config version after previous failed apply", "mode", mode, "version", target.Version, "checksum", target.Checksum)
if startup {
if err = s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
return err
}
return s.stateStore.Save(snapshot)
}
return nil
}
if hasBlockedTarget(snapshot) {
clearBlockedTarget(snapshot)
}
if snapshot.CurrentVersion == target.Version && snapshot.CurrentChecksum == target.Checksum && !startup {
slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum)
return nil
return s.stateStore.Save(snapshot)
}
config, err := s.client.GetActiveConfig(ctx)
@@ -136,6 +153,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
}
snapshot.CurrentVersion = config.Version
snapshot.CurrentChecksum = config.Checksum
clearBlockedTarget(snapshot)
snapshot.LastError = ""
slog.Debug("sync finished without changes", "mode", mode, "version", config.Version)
return s.stateStore.Save(snapshot)
@@ -143,9 +161,22 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
if target != nil && (target.Version != config.Version || target.Checksum != config.Checksum) {
slog.Warn("active config changed between heartbeat and fetch", "heartbeat_version", target.Version, "heartbeat_checksum", target.Checksum, "fetched_version", config.Version, "fetched_checksum", config.Checksum)
}
if isBlockedTarget(snapshot, config.Version, config.Checksum) {
slog.Warn("skipping blocked config after fetch because the same version previously failed", "mode", mode, "version", config.Version, "checksum", config.Checksum)
if startup {
if err := s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
return err
}
return s.stateStore.Save(snapshot)
}
return nil
}
if hasBlockedTarget(snapshot) {
clearBlockedTarget(snapshot)
}
if snapshot.CurrentVersion == config.Version && snapshot.CurrentChecksum == config.Checksum && !startup {
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
return nil
return s.stateStore.Save(snapshot)
}
routeConfig := config.RouteConfig
if routeConfig == "" {
@@ -154,55 +185,132 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
mainConfigChecksum := checksumString(config.MainConfig)
routeConfigChecksum := checksumString(routeConfig)
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
if err := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles); err != nil {
slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "error", err)
snapshot.LastError = err.Error()
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = err.Error()
_ = s.stateStore.Save(snapshot)
reportErr := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
NodeID: snapshot.NodeID,
Version: config.Version,
Result: ApplyResultFailed,
Message: err.Error(),
Checksum: config.Checksum,
MainConfigChecksum: mainConfigChecksum,
RouteConfigChecksum: routeConfigChecksum,
SupportFileCount: len(config.SupportFiles),
})
if reportErr != nil {
slog.Error("report failed apply log failed", "version", config.Version, "error", reportErr)
return reportErr
outcome := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles)
message := strings.TrimSpace(outcome.Message)
if outcome.Status == "" {
outcome.Status = nginx.ApplyStatusFatal
if message == "" {
message = "openresty apply returned empty outcome"
}
slog.Warn("failed apply log reported", "version", config.Version)
return err
}
slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version)
snapshot.CurrentVersion = config.Version
snapshot.CurrentChecksum = config.Checksum
snapshot.LastError = ""
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = ""
reportResult := ApplyResultFailed
switch outcome.Status {
case nginx.ApplyStatusSuccess:
slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version)
snapshot.CurrentVersion = config.Version
snapshot.CurrentChecksum = config.Checksum
clearBlockedTarget(snapshot)
snapshot.LastError = ""
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = ""
reportResult = ApplyResultSuccess
if message == "" {
message = "apply success"
}
case nginx.ApplyStatusWarning:
if message == "" {
message = "apply rolled back to previous config"
}
slog.Warn("openresty config apply rolled back", "mode", mode, "version", config.Version, "message", message)
markBlockedTarget(snapshot, config.Version, config.Checksum, message)
snapshot.LastError = message
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = message
reportResult = ApplyResultWarning
default:
if message == "" {
message = "openresty apply failed"
}
slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "message", message)
markBlockedTarget(snapshot, config.Version, config.Checksum, message)
snapshot.LastError = message
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = message
}
if err := s.stateStore.Save(snapshot); err != nil {
return err
}
if err := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
NodeID: snapshot.NodeID,
Version: config.Version,
Result: ApplyResultSuccess,
Message: "apply success",
Result: reportResult,
Message: message,
Checksum: config.Checksum,
MainConfigChecksum: mainConfigChecksum,
RouteConfigChecksum: routeConfigChecksum,
SupportFileCount: len(config.SupportFiles),
}); err != nil {
slog.Error("report successful apply log failed", "version", config.Version, "error", err)
slog.Error("report apply log failed", "version", config.Version, "result", reportResult, "error", err)
return err
}
slog.Debug("successful apply log reported", "version", config.Version)
if reportResult == ApplyResultFailed {
slog.Warn("failed apply log reported", "version", config.Version)
return outcomeError(config.Version, message)
}
slog.Debug("apply log reported", "version", config.Version, "result", reportResult)
return nil
}
func outcomeError(version string, message string) error {
trimmed := strings.TrimSpace(message)
if trimmed == "" {
trimmed = "openresty apply failed"
}
return fmt.Errorf("apply version %s failed: %s", version, trimmed)
}
func (s *Service) ensureRuntimeForCurrentConfig(ctx context.Context, mode string, snapshot *state.Snapshot, currentChecksum string) error {
if strings.TrimSpace(currentChecksum) == "" {
slog.Warn("blocked config cannot be retried and no local checksum is available for runtime recovery", "mode", mode, "blocked_version", snapshot.BlockedVersion)
return nil
}
slog.Info("ensuring runtime with current local config while active target remains blocked", "mode", mode, "current_version", snapshot.CurrentVersion, "current_checksum", currentChecksum, "blocked_version", snapshot.BlockedVersion)
if err := s.nginxManager.EnsureRuntime(ctx, true); err != nil {
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = err.Error()
_ = s.stateStore.Save(snapshot)
return err
}
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
if strings.TrimSpace(snapshot.OpenrestyMessage) == strings.TrimSpace(snapshot.BlockedReason) {
snapshot.OpenrestyMessage = ""
}
return nil
}
func markBlockedTarget(snapshot *state.Snapshot, version string, checksum string, reason string) {
if snapshot == nil {
return
}
snapshot.BlockedVersion = strings.TrimSpace(version)
snapshot.BlockedChecksum = strings.TrimSpace(checksum)
snapshot.BlockedReason = strings.TrimSpace(reason)
}
func clearBlockedTarget(snapshot *state.Snapshot) {
if snapshot == nil {
return
}
snapshot.BlockedVersion = ""
snapshot.BlockedChecksum = ""
snapshot.BlockedReason = ""
}
func hasBlockedTarget(snapshot *state.Snapshot) bool {
return snapshot != nil && (strings.TrimSpace(snapshot.BlockedVersion) != "" || strings.TrimSpace(snapshot.BlockedChecksum) != "")
}
func isBlockedTarget(snapshot *state.Snapshot, version string, checksum string) bool {
if snapshot == nil {
return false
}
return strings.TrimSpace(snapshot.BlockedVersion) == strings.TrimSpace(version) &&
strings.TrimSpace(snapshot.BlockedChecksum) == strings.TrimSpace(checksum) &&
(strings.TrimSpace(version) != "" || strings.TrimSpace(checksum) != "")
}
func checksumString(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
+238 -35
View File
@@ -24,7 +24,7 @@ type fakeClient struct {
}
type fakeManager struct {
applyErr error
applyOutcome nginx.ApplyOutcome
currentChecksum string
currentChecksumErr error
ensureErr error
@@ -64,11 +64,14 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
return nil
}
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome {
m.applyMainContents = append(m.applyMainContents, mainConfig)
m.applyRouteContents = append(m.applyRouteContents, routeConfig)
m.applyFiles = append(m.applyFiles, append([]protocol.SupportFile(nil), supportFiles...))
return m.applyErr
if m.applyOutcome.Status == "" {
return nginx.ApplyOutcome{Status: nginx.ApplyStatusSuccess}
}
return m.applyOutcome
}
func (m *fakeManager) EnsureRuntime(ctx context.Context, recreate bool) error {
@@ -166,17 +169,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
},
}
tempDir := t.TempDir()
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "routes.conf")
if err := os.WriteFile(mainPath, []byte("worker_processes auto;"), 0o644); err != nil {
t.Fatalf("failed to seed main file: %v", err)
}
if err := os.WriteFile(routePath, []byte("server { listen 80; }"), 0o644); err != nil {
t.Fatalf("failed to seed route file: %v", err)
}
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
@@ -189,11 +182,10 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
t.Fatalf("failed to seed state: %v", err)
}
service := New(client, &nginx.Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
Executor: &fakeExecutor{
testErr: context.DeadlineExceeded,
service := New(client, &fakeManager{
applyOutcome: nginx.ApplyOutcome{
Status: nginx.ApplyStatusFatal,
Message: "openresty failed after rollback",
},
}, stateStore)
@@ -202,22 +194,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
Checksum: client.config.Checksum,
})
if err == nil {
t.Fatal("expected SyncOnce to fail when nginx test fails")
}
data, readErr := os.ReadFile(routePath)
if readErr != nil {
t.Fatalf("failed to read route file after rollback: %v", readErr)
}
if string(data) != "server { listen 80; }" {
t.Fatal("expected original route config to be restored after rollback")
}
mainData, readErr := os.ReadFile(mainPath)
if readErr != nil {
t.Fatalf("failed to read main file after rollback: %v", readErr)
}
if string(mainData) != "worker_processes auto;" {
t.Fatal("expected original main config to be restored after rollback")
t.Fatal("expected SyncOnce to fail when apply outcome is fatal")
}
snapshot, loadErr := stateStore.Load()
if loadErr != nil {
@@ -226,6 +203,12 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
if snapshot.CurrentVersion != "20260309-001" {
t.Fatal("expected failed sync not to overwrite current version")
}
if snapshot.BlockedVersion != "20260309-002" || snapshot.BlockedChecksum != "checksum-2" {
t.Fatalf("expected failed target version to be blocked, got %+v", snapshot)
}
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusUnhealthy {
t.Fatalf("expected unhealthy openresty status, got %q", snapshot.OpenrestyStatus)
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultFailed {
t.Fatal("expected failed apply report to be sent")
}
@@ -240,6 +223,67 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
}
}
func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-002",
Checksum: "checksum-2",
MainConfig: "worker_processes 2;",
RouteConfig: "server { listen 81; }",
RenderedConfig: "server { listen 81; }",
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-001",
CurrentChecksum: "checksum-1",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
service := New(client, &fakeManager{
applyOutcome: nginx.ApplyOutcome{
Status: nginx.ApplyStatusWarning,
Message: "apply failed, rolled back to previous config",
},
}, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: client.config.Version,
Checksum: client.config.Checksum,
}); err != nil {
t.Fatalf("expected warning outcome to keep sync successful, got %v", err)
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.CurrentVersion != "20260309-001" || snapshot.CurrentChecksum != "checksum-1" {
t.Fatal("expected warning apply to keep previous version state")
}
if snapshot.BlockedVersion != "20260309-002" || snapshot.BlockedChecksum != "checksum-2" {
t.Fatalf("expected rolled-back target version to be blocked, got %+v", snapshot)
}
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy {
t.Fatalf("expected healthy openresty after rollback, got %q", snapshot.OpenrestyStatus)
}
if snapshot.LastError == "" {
t.Fatal("expected rollback warning to be recorded")
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultWarning {
t.Fatal("expected warning apply report to be sent")
}
}
func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
@@ -330,6 +374,165 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
}
}
func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-006",
Checksum: "checksum-6",
MainConfig: "worker_processes 6;",
RouteConfig: "server { listen 86; }",
RenderedConfig: "server { listen 86; }",
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-005",
CurrentChecksum: "checksum-5",
BlockedVersion: "20260309-006",
BlockedChecksum: "checksum-6",
BlockedReason: "apply failed, rolled back to previous config",
LastError: "apply failed, rolled back to previous config",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
manager := &fakeManager{currentChecksum: "checksum-5"}
service := New(client, manager, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: "20260309-006",
Checksum: "checksum-6",
}); err != nil {
t.Fatalf("expected blocked version to be skipped, got %v", err)
}
if client.fetchCalls != 0 {
t.Fatalf("expected blocked version to skip fetch, got %d", client.fetchCalls)
}
if len(manager.applyMainContents) != 0 {
t.Fatal("expected blocked version to skip apply")
}
if len(client.reports) != 0 {
t.Fatal("expected blocked version to skip reporting duplicate apply result")
}
}
func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-007",
Checksum: "checksum-7",
MainConfig: "worker_processes 7;",
RouteConfig: "server { listen 87; }",
RenderedConfig: "server { listen 87; }",
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-005",
CurrentChecksum: "checksum-5",
BlockedVersion: "20260309-007",
BlockedChecksum: "checksum-7",
BlockedReason: "apply failed, rolled back to previous config",
OpenrestyStatus: protocol.OpenrestyStatusUnhealthy,
OpenrestyMessage: "apply failed, rolled back to previous config",
LastError: "apply failed, rolled back to previous config",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
manager := &fakeManager{currentChecksum: "checksum-5"}
service := New(client, manager, stateStore)
if err = service.SyncOnStartup(context.Background(), &protocol.ActiveConfigMeta{
Version: "20260309-007",
Checksum: "checksum-7",
}); err != nil {
t.Fatalf("expected blocked startup target to be skipped, got %v", err)
}
if len(manager.ensureCalls) != 1 || !manager.ensureCalls[0] {
t.Fatal("expected startup skip to ensure runtime with current local config")
}
if client.fetchCalls != 0 {
t.Fatalf("expected blocked startup target to skip fetch, got %d", client.fetchCalls)
}
if len(client.reports) != 0 {
t.Fatal("expected blocked startup target to skip duplicate apply report")
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.BlockedVersion != "20260309-007" || snapshot.BlockedChecksum != "checksum-7" {
t.Fatalf("expected blocked target to remain recorded, got %+v", snapshot)
}
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy {
t.Fatalf("expected startup runtime recovery to mark openresty healthy, got %q", snapshot.OpenrestyStatus)
}
}
func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-008",
Checksum: "checksum-8",
MainConfig: "worker_processes 8;",
RouteConfig: "server { listen 88; }",
RenderedConfig: "server { listen 88; }",
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-005",
CurrentChecksum: "checksum-5",
BlockedVersion: "20260309-007",
BlockedChecksum: "checksum-7",
BlockedReason: "apply failed, rolled back to previous config",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
manager := &fakeManager{}
service := New(client, manager, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: "20260309-008",
Checksum: "checksum-8",
}); err != nil {
t.Fatalf("expected new target version to be applied, got %v", err)
}
if client.fetchCalls != 1 {
t.Fatalf("expected new target to trigger fetch, got %d", client.fetchCalls)
}
if len(manager.applyMainContents) != 1 {
t.Fatal("expected new target to trigger apply")
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.BlockedVersion != "" || snapshot.BlockedChecksum != "" {
t.Fatalf("expected blocked target to be cleared after new version succeeds, got %+v", snapshot)
}
if snapshot.CurrentVersion != "20260309-008" || snapshot.CurrentChecksum != "checksum-8" {
t.Fatalf("expected current version to move to new target, got %+v", snapshot)
}
}
func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
+6 -6
View File
@@ -60,16 +60,16 @@ var GeoIPProvider = "ipinfo"
var OpenRestyWorkerProcesses = "auto"
var OpenRestyWorkerConnections = 4096
var OpenRestyWorkerRlimitNofile = 65535
var OpenRestyEventsUse = ""
var OpenRestyEventsMultiAcceptEnabled = false
var OpenRestyKeepaliveTimeout = 65
var OpenRestyEventsUse = "epoll"
var OpenRestyEventsMultiAcceptEnabled = true
var OpenRestyKeepaliveTimeout = 20
var OpenRestyKeepaliveRequests = 1000
var OpenRestyClientHeaderTimeout = 15
var OpenRestyClientBodyTimeout = 15
var OpenRestyClientMaxBodySize = "64m"
var OpenRestyLargeClientHeaderBuffers = "4 16k"
var OpenRestySendTimeout = 30
var OpenRestyProxyConnectTimeout = 5
var OpenRestyProxyConnectTimeout = 3
var OpenRestyProxySendTimeout = 60
var OpenRestyProxyReadTimeout = 60
var OpenRestyWebsocketEnabled = true
@@ -86,7 +86,7 @@ var OpenRestyCachePath = ""
var OpenRestyCacheLevels = "1:2"
var OpenRestyCacheInactive = "30m"
var OpenRestyCacheMaxSize = "1g"
var OpenRestyCacheKeyTemplate = "$scheme$proxy_host$request_uri"
var OpenRestyCacheKeyTemplate = "$scheme$host$request_uri"
var OpenRestyCacheLockEnabled = true
var OpenRestyCacheLockTimeout = "5s"
var OpenRestyCacheUseStale = "error timeout updating http_500 http_502 http_503 http_504"
@@ -102,7 +102,7 @@ events {
http {
include mime.types;
default_type application/octet-stream;
log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
access_log {{OpenRestyAccessLogPath}} openflare_json;
sendfile on;
tcp_nopush on;
+136 -3
View File
@@ -1,6 +1,7 @@
package controller
import (
"net/http"
"openflare/service"
"strconv"
@@ -13,17 +14,149 @@ import (
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param path query string false "Path"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ [get]
func GetAccessLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("p", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "0"))
logs, err := service.ListAccessLogs(c.Query("node_id"), page, pageSize)
logs, err := service.ListAccessLogs(readAccessLogQuery(c))
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, logs)
}
// GetFoldedAccessLogs godoc
// @Summary List folded access logs
// @Tags AccessLogs
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param path query string false "Path"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Param fold_minutes query int false "Fold minutes"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/folds [get]
func GetFoldedAccessLogs(c *gin.Context) {
query := readAccessLogQuery(c)
query.FoldMinutes = readQueryInt(c, "fold_minutes")
logs, err := service.ListFoldedAccessLogs(query)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, logs)
}
// GetAccessLogIPSummaries godoc
// @Summary List access log IP summaries
// @Tags AccessLogs
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ip-summary [get]
func GetAccessLogIPSummaries(c *gin.Context) {
result, err := service.ListAccessLogIPSummaries(service.AccessLogIPSummaryQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
// GetAccessLogIPTrend godoc
// @Summary Get access log IP trend
// @Tags AccessLogs
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string true "Remote address"
// @Param host query string false "Host"
// @Param hours query int false "Hours"
// @Param bucket_minutes query int false "Bucket minutes"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ip-summary/trend [get]
func GetAccessLogIPTrend(c *gin.Context) {
result, err := service.GetAccessLogIPTrend(service.AccessLogIPTrendQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Hours: readQueryInt(c, "hours"),
BucketMinutes: readQueryInt(c, "bucket_minutes"),
})
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
// CleanupAccessLogs godoc
// @Summary Cleanup access logs by retention days
// @Tags AccessLogs
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/cleanup [post]
func CleanupAccessLogs(c *gin.Context) {
var input service.AccessLogCleanupInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "参数错误",
"error": err.Error(),
})
return
}
result, err := service.CleanupAccessLogs(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
func readAccessLogQuery(c *gin.Context) service.AccessLogQuery {
return service.AccessLogQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Path: c.Query("path"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
}
}
func readQueryInt(c *gin.Context, key string) int {
value, _ := strconv.Atoi(c.DefaultQuery(key, "0"))
return value
}
+37 -1
View File
@@ -3,6 +3,7 @@ package controller
import (
"openflare/model"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
@@ -144,10 +145,45 @@ func GetNodes(c *gin.Context) {
// @Success 200 {object} map[string]interface{}
// @Router /api/apply-logs/ [get]
func GetApplyLogs(c *gin.Context) {
logs, err := service.ListApplyLogs(c.Query("node_id"))
logs, err := service.ListApplyLogsPage(service.ApplyLogListQuery{
NodeID: c.Query("node_id"),
PageNo: readIntQueryFallback(c, "pageNo", "page_no"),
PageSize: readIntQueryFallback(c, "pageSize", "page_size"),
})
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, logs)
}
// CleanupApplyLogs godoc
// @Summary Cleanup apply logs
// @Tags ApplyLogs
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/apply-logs/cleanup [post]
func CleanupApplyLogs(c *gin.Context) {
var input service.ApplyLogCleanupInput
if err := c.ShouldBindJSON(&input); err != nil {
respondBadRequest(c, "")
return
}
result, err := service.CleanupApplyLogs(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
func readIntQueryFallback(c *gin.Context, primary string, secondary string) int {
value := c.Query(primary)
if value == "" {
value = c.Query(secondary)
}
parsed, _ := strconv.Atoi(value)
return parsed
}
+36 -2
View File
@@ -1,10 +1,11 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
// GetConfigVersions godoc
@@ -30,6 +31,39 @@ func GetConfigVersions(c *gin.Context) {
})
}
// GetConfigVersion godoc
// @Summary Get config version detail
// @Tags ConfigVersions
// @Produce json
// @Security BearerAuth
// @Param id path int true "Version ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id} [get]
func GetConfigVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid id",
})
return
}
version, err := service.GetConfigVersionDetail(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": version,
})
}
// GetActiveConfigVersion godoc
// @Summary Get active config version
// @Tags ConfigVersions
@@ -137,7 +171,7 @@ func ActivateConfigVersion(c *gin.Context) {
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid id",
})
return
}
+149 -1
View File
@@ -6,6 +6,29 @@ import (
"github.com/gin-gonic/gin"
)
type dashboardOverviewPayload struct {
GeneratedAt any `json:"generated_at"`
Summary service.DashboardSummary `json:"summary"`
Traffic service.DashboardTraffic `json:"traffic"`
Capacity service.DashboardCapacity `json:"capacity"`
Distributions dashboardDistributionsPayload `json:"distributions"`
Trends dashboardTrendsPayload `json:"trends"`
Nodes [][]any `json:"nodes"`
}
type dashboardDistributionsPayload struct {
StatusCodes [][]any `json:"status_codes"`
TopDomains [][]any `json:"top_domains"`
SourceCountries [][]any `json:"source_countries"`
}
type dashboardTrendsPayload struct {
Traffic24h [][]any `json:"traffic_24h"`
Capacity24h [][]any `json:"capacity_24h"`
Network24h [][]any `json:"network_24h"`
DiskIO24h [][]any `json:"disk_io_24h"`
}
// GetDashboardOverview godoc
// @Summary Get dashboard overview
// @Tags Dashboard
@@ -20,5 +43,130 @@ func GetDashboardOverview(c *gin.Context) {
respondFailure(c, err.Error())
return
}
respondSuccess(c, view)
respondSuccess(c, compressDashboardOverview(view))
}
func compressDashboardOverview(view *service.DashboardOverviewView) *dashboardOverviewPayload {
if view == nil {
return &dashboardOverviewPayload{
Distributions: dashboardDistributionsPayload{
StatusCodes: [][]any{},
TopDomains: [][]any{},
SourceCountries: [][]any{},
},
Trends: dashboardTrendsPayload{
Traffic24h: [][]any{},
Capacity24h: [][]any{},
Network24h: [][]any{},
DiskIO24h: [][]any{},
},
Nodes: [][]any{},
}
}
return &dashboardOverviewPayload{
GeneratedAt: view.GeneratedAt,
Summary: view.Summary,
Traffic: view.Traffic,
Capacity: view.Capacity,
Distributions: dashboardDistributionsPayload{
StatusCodes: compressDistributionItems(view.Distributions.StatusCodes),
TopDomains: compressDistributionItems(view.Distributions.TopDomains),
SourceCountries: compressDistributionItems(view.Distributions.SourceCountries),
},
Trends: dashboardTrendsPayload{
Traffic24h: compressTrafficTrendPoints(view.Trends.Traffic24h),
Capacity24h: compressCapacityTrendPoints(view.Trends.Capacity24h),
Network24h: compressNetworkTrendPoints(view.Trends.Network24h),
DiskIO24h: compressDiskIOTrendPoints(view.Trends.DiskIO24h),
},
Nodes: compressDashboardNodes(view.Nodes),
}
}
func compressDistributionItems(items []service.DistributionItem) [][]any {
rows := make([][]any, 0, len(items))
for _, item := range items {
rows = append(rows, []any{item.Key, item.Value})
}
return rows
}
func compressTrafficTrendPoints(points []service.TrafficTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.RequestCount,
point.ErrorCount,
point.UniqueVisitorCount,
})
}
return rows
}
func compressCapacityTrendPoints(points []service.CapacityTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.AverageCPUUsagePercent,
point.AverageMemoryUsagePercent,
point.ReportedNodes,
})
}
return rows
}
func compressNetworkTrendPoints(points []service.NetworkTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.NetworkRxBytes,
point.NetworkTxBytes,
point.OpenrestyRxBytes,
point.OpenrestyTxBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDiskIOTrendPoints(points []service.DiskIOTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.DiskReadBytes,
point.DiskWriteBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDashboardNodes(nodes []service.DashboardNodeHealth) [][]any {
rows := make([][]any, 0, len(nodes))
for _, node := range nodes {
rows = append(rows, []any{
node.ID,
node.NodeID,
node.Name,
node.GeoName,
node.GeoLatitude,
node.GeoLongitude,
node.Status,
node.OpenrestyStatus,
node.CurrentVersion,
node.LastSeenAt,
node.ActiveEventCount,
node.CPUUsagePercent,
node.MemoryUsagePercent,
node.StorageUsagePercent,
node.RequestCount,
node.ErrorCount,
node.UniqueVisitorCount,
})
}
return rows
}
+24
View File
@@ -247,3 +247,27 @@ func GetNodeObservability(c *gin.Context) {
}
respondSuccess(c, view)
}
// CleanupNodeHealthEvents godoc
// @Summary Cleanup node health events
// @Tags Nodes
// @Produce json
// @Security BearerAuth
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/observability/cleanup [post]
func CleanupNodeHealthEvents(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
respondBadRequest(c, "")
return
}
result, err := service.CleanupNodeHealthEvents(uint(id))
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
+2
View File
@@ -113,6 +113,8 @@ func validateOpenRestyOption(key string, value string) error {
default:
return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key)
}
case "OpenRestyResolvers":
return fmt.Errorf("%s 已废弃,不再支持配置 resolver", key)
case "OpenRestyEventsMultiAcceptEnabled",
"OpenRestyWebsocketEnabled",
"OpenRestyProxyRequestBufferingEnabled",
@@ -14,6 +14,7 @@ func TestValidateOpenRestyOption(t *testing.T) {
{name: "worker processes invalid", key: "OpenRestyWorkerProcesses", value: "0", wantErr: true},
{name: "events use empty", key: "OpenRestyEventsUse", value: ""},
{name: "events use invalid", key: "OpenRestyEventsUse", value: "io_uring", wantErr: true},
{name: "resolvers deprecated", key: "OpenRestyResolvers", value: "1.1.1.1", wantErr: true},
{name: "proxy buffers valid", key: "OpenRestyProxyBuffers", value: "16 16k"},
{name: "proxy buffers invalid", key: "OpenRestyProxyBuffers", value: "16x16k", wantErr: true},
{name: "cache max size valid", key: "OpenRestyCacheMaxSize", value: "2g"},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+692 -70
View File
@@ -1,5 +1,12 @@
basePath: /
definitions:
controller.geoIPLookupRequest:
properties:
ip:
type: string
provider:
type: string
type: object
model.Option:
properties:
key:
@@ -7,12 +14,94 @@ definitions:
value:
type: string
type: object
service.AgentBufferedObservabilityRecord:
properties:
access_logs:
items:
$ref: '#/definitions/service.AgentNodeAccessLog'
type: array
snapshot:
$ref: '#/definitions/service.AgentNodeMetricSnapshot'
traffic_report:
$ref: '#/definitions/service.AgentNodeTrafficReport'
window_started_at_unix:
type: integer
type: object
service.AgentNodeAccessLog:
properties:
host:
type: string
logged_at_unix:
type: integer
path:
type: string
remote_addr:
type: string
status_code:
type: integer
type: object
service.AgentNodeHealthEvent:
properties:
event_type:
type: string
message:
type: string
metadata:
additionalProperties:
type: string
type: object
severity:
type: string
triggered_at_unix:
type: integer
type: object
service.AgentNodeMetricSnapshot:
properties:
captured_at_unix:
type: integer
cpu_usage_percent:
type: number
disk_read_bytes:
type: integer
disk_write_bytes:
type: integer
memory_total_bytes:
type: integer
memory_used_bytes:
type: integer
network_rx_bytes:
type: integer
network_tx_bytes:
type: integer
openresty_connections:
type: integer
openresty_rx_bytes:
type: integer
openresty_tx_bytes:
type: integer
storage_total_bytes:
type: integer
storage_used_bytes:
type: integer
type: object
service.AgentNodePayload:
properties:
access_logs:
items:
$ref: '#/definitions/service.AgentNodeAccessLog'
type: array
agent_version:
type: string
buffered_observability:
items:
$ref: '#/definitions/service.AgentBufferedObservabilityRecord'
type: array
current_version:
type: string
health_events:
items:
$ref: '#/definitions/service.AgentNodeHealthEvent'
type: array
ip:
type: string
last_error:
@@ -27,15 +116,82 @@ definitions:
type: string
openresty_status:
type: string
profile:
$ref: '#/definitions/service.AgentNodeSystemProfile'
snapshot:
$ref: '#/definitions/service.AgentNodeMetricSnapshot'
traffic_report:
$ref: '#/definitions/service.AgentNodeTrafficReport'
type: object
service.AgentNodeSystemProfile:
properties:
architecture:
type: string
cpu_cores:
type: integer
cpu_model:
type: string
hostname:
type: string
kernel_version:
type: string
os_name:
type: string
os_version:
type: string
reported_at_unix:
type: integer
total_disk_bytes:
type: integer
total_memory_bytes:
type: integer
uptime_seconds:
type: integer
type: object
service.AgentNodeTrafficReport:
properties:
error_count:
type: integer
request_count:
type: integer
source_countries:
additionalProperties:
format: int64
type: integer
type: object
status_codes:
additionalProperties:
format: int64
type: integer
type: object
top_domains:
additionalProperties:
format: int64
type: integer
type: object
unique_visitor_count:
type: integer
window_ended_at_unix:
type: integer
window_started_at_unix:
type: integer
type: object
service.ApplyLogPayload:
properties:
checksum:
type: string
main_config_checksum:
type: string
message:
type: string
node_id:
type: string
result:
type: string
route_config_checksum:
type: string
support_file_count:
type: integer
version:
type: string
type: object
@@ -54,6 +210,16 @@ definitions:
properties:
auto_update_enabled:
type: boolean
geo_latitude:
type: number
geo_longitude:
type: number
geo_manual_override:
type: boolean
geo_name:
type: string
ip:
type: string
name:
type: string
type: object
@@ -66,6 +232,14 @@ definitions:
type: object
service.ProxyRouteInput:
properties:
cache_enabled:
type: boolean
cache_policy:
type: string
cache_rules:
items:
type: string
type: array
cert_id:
type: integer
custom_headers:
@@ -78,14 +252,18 @@ definitions:
type: boolean
enabled:
type: boolean
origin_url:
type: string
origin_host:
type: string
origin_url:
type: string
redirect_http:
type: boolean
remark:
type: string
upstreams:
items:
type: string
type: array
type: object
service.TLSCertificateInput:
properties:
@@ -104,6 +282,204 @@ info:
title: OpenFlare Server API
version: "3.0"
paths:
/api/access-logs/:
get:
parameters:
- description: Node ID
in: query
name: node_id
type: string
- description: Remote address
in: query
name: remote_addr
type: string
- description: Host
in: query
name: host
type: string
- description: Path
in: query
name: path
type: string
- description: Page index
in: query
name: p
type: integer
- description: Page size
in: query
name: page_size
type: integer
- description: Sort by
in: query
name: sort_by
type: string
- description: Sort order
in: query
name: sort_order
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: List access logs
tags:
- AccessLogs
/api/access-logs/cleanup:
post:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Cleanup access logs by retention days
tags:
- AccessLogs
/api/access-logs/folds:
get:
parameters:
- description: Node ID
in: query
name: node_id
type: string
- description: Remote address
in: query
name: remote_addr
type: string
- description: Host
in: query
name: host
type: string
- description: Path
in: query
name: path
type: string
- description: Page index
in: query
name: p
type: integer
- description: Page size
in: query
name: page_size
type: integer
- description: Sort by
in: query
name: sort_by
type: string
- description: Sort order
in: query
name: sort_order
type: string
- description: Fold minutes
in: query
name: fold_minutes
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: List folded access logs
tags:
- AccessLogs
/api/access-logs/ip-summary:
get:
parameters:
- description: Node ID
in: query
name: node_id
type: string
- description: Remote address
in: query
name: remote_addr
type: string
- description: Host
in: query
name: host
type: string
- description: Page index
in: query
name: p
type: integer
- description: Page size
in: query
name: page_size
type: integer
- description: Sort by
in: query
name: sort_by
type: string
- description: Sort order
in: query
name: sort_order
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: List access log IP summaries
tags:
- AccessLogs
/api/access-logs/ip-summary/trend:
get:
parameters:
- description: Node ID
in: query
name: node_id
type: string
- description: Remote address
in: query
name: remote_addr
required: true
type: string
- description: Host
in: query
name: host
type: string
- description: Hours
in: query
name: hours
type: integer
- description: Bucket minutes
in: query
name: bucket_minutes
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Get access log IP trend
tags:
- AccessLogs
/api/agent/apply-logs:
post:
consumes:
@@ -226,6 +602,23 @@ paths:
summary: List apply logs
tags:
- ApplyLogs
/api/apply-logs/cleanup:
post:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Cleanup apply logs
tags:
- ApplyLogs
/api/config-versions/:
get:
produces:
@@ -241,8 +634,34 @@ paths:
summary: List config versions
tags:
- ConfigVersions
/api/config-versions/{id}:
get:
parameters:
- description: Version ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Get config version detail
tags:
- ConfigVersions
/api/config-versions/{id}/activate:
put:
post:
parameters:
- description: Version ID
in: path
@@ -327,6 +746,26 @@ paths:
summary: Publish a new config version
tags:
- ConfigVersions
/api/dashboard/overview:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Get dashboard overview
tags:
- Dashboard
/api/managed-domains/:
get:
produces:
@@ -370,8 +809,8 @@ paths:
summary: Create managed domain
tags:
- ManagedDomains
/api/managed-domains/{id}:
delete:
/api/managed-domains/{id}/delete:
post:
parameters:
- description: Managed domain ID
in: path
@@ -396,7 +835,8 @@ paths:
summary: Delete managed domain
tags:
- ManagedDomains
put:
/api/managed-domains/{id}/update:
post:
consumes:
- application/json
parameters:
@@ -493,65 +933,6 @@ paths:
summary: Create node
tags:
- Nodes
/api/nodes/{id}:
delete:
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Delete node
tags:
- Nodes
put:
consumes:
- application/json
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
- description: Node payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/service.NodeInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Update node
tags:
- Nodes
/api/nodes/{id}/agent-release:
get:
parameters:
@@ -608,6 +989,92 @@ paths:
summary: Request agent self-update on node
tags:
- Nodes
/api/nodes/{id}/delete:
post:
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Delete node
tags:
- Nodes
/api/nodes/{id}/observability:
get:
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
- description: Lookback window in hours
in: query
name: hours
type: integer
- description: Max records per section
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Get node observability details
tags:
- Nodes
/api/nodes/{id}/observability/cleanup:
post:
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Cleanup node health events
tags:
- Nodes
/api/nodes/{id}/openresty-restart:
post:
parameters:
@@ -634,6 +1101,40 @@ paths:
summary: Request openresty restart on node
tags:
- Nodes
/api/nodes/{id}/update:
post:
consumes:
- application/json
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
- description: Node payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/service.NodeInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Update node
tags:
- Nodes
/api/nodes/bootstrap-token:
get:
produces:
@@ -677,7 +1178,35 @@ paths:
summary: List editable options
tags:
- Options
put:
/api/option/geoip/lookup:
post:
consumes:
- application/json
parameters:
- description: GeoIP lookup payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/controller.geoIPLookupRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
summary: Test GeoIP lookup
tags:
- Options
/api/option/update:
post:
consumes:
- application/json
parameters:
@@ -746,8 +1275,8 @@ paths:
summary: Create proxy route
tags:
- ProxyRoutes
/api/proxy-routes/{id}:
delete:
/api/proxy-routes/{id}/delete:
post:
parameters:
- description: Route ID
in: path
@@ -772,7 +1301,8 @@ paths:
summary: Delete proxy route
tags:
- ProxyRoutes
put:
/api/proxy-routes/{id}/update:
post:
consumes:
- application/json
parameters:
@@ -862,7 +1392,59 @@ paths:
tags:
- TLSCertificates
/api/tls-certificates/{id}:
delete:
get:
parameters:
- description: Certificate ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Get TLS certificate detail
tags:
- TLSCertificates
/api/tls-certificates/{id}/content:
get:
parameters:
- description: Certificate ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Get TLS certificate PEM content
tags:
- TLSCertificates
/api/tls-certificates/{id}/delete:
post:
parameters:
- description: Certificate ID
in: path
@@ -887,6 +1469,40 @@ paths:
summary: Delete TLS certificate
tags:
- TLSCertificates
/api/tls-certificates/{id}/update:
post:
consumes:
- application/json
parameters:
- description: Certificate ID
in: path
name: id
required: true
type: integer
- description: TLS certificate payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/service.TLSCertificateInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Update TLS certificate from PEM
tags:
- TLSCertificates
/api/tls-certificates/import-file:
post:
consumes:
@@ -942,6 +1558,12 @@ paths:
summary: Get latest GitHub release
tags:
- Update
/api/update/logs/ws:
get:
responses: {}
summary: Stream server upgrade logs over websocket
tags:
- Update
/api/update/manual-upgrade:
post:
consumes:
+1 -1
View File
@@ -16,7 +16,7 @@ require (
github.com/oschwald/maxminddb-golang v1.13.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.8.12
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0
gorm.io/driver/postgres v1.6.0
+2 -2
View File
@@ -174,8 +174,8 @@ github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w=
github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+42 -6
View File
@@ -1,13 +1,23 @@
package model
import "time"
import (
"time"
"gorm.io/gorm"
)
type ApplyLogQuery struct {
NodeID string
PageNo int
PageSize int
}
type ApplyLog struct {
ID uint `json:"id" gorm:"primaryKey"`
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
Version string `json:"version" gorm:"size:32;not null"`
Result string `json:"result" gorm:"size:32;not null"`
Message string `json:"message" gorm:"size:1024"`
Message string `json:"message" gorm:"type:text"`
Checksum string `json:"checksum" gorm:"size:64;not null;default:''"`
MainConfigChecksum string `json:"main_config_checksum" gorm:"size:64;not null;default:''"`
RouteConfigChecksum string `json:"route_config_checksum" gorm:"size:64;not null;default:''"`
@@ -15,13 +25,29 @@ type ApplyLog struct {
CreatedAt time.Time `json:"created_at"`
}
func ListApplyLogs(nodeID string) (logs []*ApplyLog, err error) {
query := DB.Order("id desc")
func ListApplyLogs(query ApplyLogQuery) (logs []*ApplyLog, err error) {
db := DB.Order("id desc")
if query.NodeID != "" {
db = db.Where("node_id = ?", query.NodeID)
}
if query.PageSize > 0 {
offset := 0
if query.PageNo > 1 {
offset = (query.PageNo - 1) * query.PageSize
}
db = db.Limit(query.PageSize).Offset(offset)
}
err = db.Find(&logs).Error
return logs, err
}
func CountApplyLogs(nodeID string) (total int64, err error) {
query := DB.Model(&ApplyLog{})
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
err = query.Find(&logs).Error
return logs, err
err = query.Count(&total).Error
return total, err
}
func GetLatestApplyLog(nodeID string) (*ApplyLog, error) {
@@ -49,3 +75,13 @@ func GetLatestApplyLogsByNodeIDs(nodeIDs []string) (map[string]*ApplyLog, error)
}
return result, nil
}
func DeleteAllApplyLogs() (deleted int64, err error) {
result := DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ApplyLog{})
return result.RowsAffected, result.Error
}
func DeleteApplyLogsBefore(before time.Time) (deleted int64, err error) {
result := DB.Where("created_at < ?", before).Delete(&ApplyLog{})
return result.RowsAffected, result.Error
}
+14 -2
View File
@@ -2,6 +2,15 @@ package model
import "time"
type ConfigVersionSummary struct {
ID uint `json:"id"`
Version string `json:"version"`
Checksum string `json:"checksum"`
IsActive bool `json:"is_active"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
type ConfigVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
@@ -15,8 +24,11 @@ type ConfigVersion struct {
CreatedAt time.Time `json:"created_at"`
}
func ListConfigVersions() (versions []*ConfigVersion, err error) {
err = DB.Order("id desc").Find(&versions).Error
func ListConfigVersionSummaries() (versions []*ConfigVersionSummary, err error) {
err = DB.Model(&ConfigVersion{}).
Select("id", "version", "checksum", "is_active", "created_by", "created_at").
Order("id desc").
Find(&versions).Error
return versions, err
}
+30
View File
@@ -126,6 +126,33 @@ func autoMigrateAll(db *gorm.DB) error {
return nil
}
func migrateTextColumns(db *gorm.DB, backend string) error {
if backend != "postgres" {
return nil
}
type textColumn struct {
model any
table string
column string
}
columns := []textColumn{
{model: &Node{}, table: "nodes", column: "openresty_message"},
{model: &Node{}, table: "nodes", column: "last_error"},
{model: &ApplyLog{}, table: "apply_logs", column: "message"},
{model: &NodeHealthEvent{}, table: "node_health_events", column: "message"},
}
for _, item := range columns {
if !db.Migrator().HasTable(item.model) || !db.Migrator().HasColumn(item.model, item.column) {
continue
}
sql := fmt.Sprintf(`ALTER TABLE "%s" ALTER COLUMN "%s" TYPE text`, item.table, item.column)
if err := db.Exec(sql).Error; err != nil {
return fmt.Errorf("migrate column %s.%s to text failed: %w", item.table, item.column, err)
}
}
return nil
}
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
for _, item := range registeredModels() {
var count int64
@@ -272,6 +299,9 @@ func InitDB() (err error) {
if err = autoMigrateAll(db); err != nil {
return err
}
if err = migrateTextColumns(db, backend); err != nil {
return err
}
if err = migrateSQLiteDataIfNeeded(db, backend); err != nil {
return err
}
+10 -2
View File
@@ -20,11 +20,11 @@ type Node struct {
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
NginxVersion string `json:"nginx_version" gorm:"size:64"`
OpenrestyStatus string `json:"openresty_status" gorm:"size:16;not null;default:'unknown'"`
OpenrestyMessage string `json:"openresty_message" gorm:"size:2048"`
OpenrestyMessage string `json:"openresty_message" gorm:"type:text"`
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
CurrentVersion string `json:"current_version" gorm:"size:32"`
LastSeenAt time.Time `json:"last_seen_at"`
LastError string `json:"last_error" gorm:"size:1024"`
LastError string `json:"last_error" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -34,6 +34,14 @@ func ListNodes() (nodes []*Node, err error) {
return nodes, err
}
func ListNodesByNodeIDs(nodeIDs []string) (nodes []*Node, err error) {
if len(nodeIDs) == 0 {
return []*Node{}, nil
}
err = DB.Where("node_id IN ?", nodeIDs).Find(&nodes).Error
return nodes, err
}
func GetNodeByNodeID(nodeID string) (*Node, error) {
node := &Node{}
err := DB.Where("node_id = ?", nodeID).First(node).Error
+291 -33
View File
@@ -1,16 +1,22 @@
package model
import "time"
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
type NodeAccessLog struct {
ID uint `json:"id" gorm:"primaryKey"`
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
LoggedAt time.Time `json:"logged_at" gorm:"index"`
RemoteAddr string `json:"remote_addr" gorm:"size:128"`
NodeID string `json:"node_id" gorm:"index:idx_node_access_logs_node_logged_at,priority:1;size:64;not null"`
LoggedAt time.Time `json:"logged_at" gorm:"index:idx_node_access_logs_logged_at;index:idx_node_access_logs_node_logged_at,priority:2"`
RemoteAddr string `json:"remote_addr" gorm:"index:idx_node_access_logs_remote_addr;size:128"`
Region string `json:"region" gorm:"size:128"`
Host string `json:"host" gorm:"size:255"`
Host string `json:"host" gorm:"index:idx_node_access_logs_host;size:255"`
Path string `json:"path" gorm:"size:2048"`
StatusCode int `json:"status_code"`
StatusCode int `json:"status_code" gorm:"index:idx_node_access_logs_status_code"`
RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
}
@@ -20,39 +26,91 @@ type NodeAccessLogRegionCount struct {
Count int64 `json:"count"`
}
func ListNodeAccessLogs(nodeID string, since time.Time, offset int, limit int) (logs []*NodeAccessLog, err error) {
query := DB.Order("logged_at desc, id desc")
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
if !since.IsZero() {
query = query.Where("logged_at >= ?", since)
}
if offset > 0 {
query = query.Offset(offset)
}
if limit > 0 {
query = query.Limit(limit)
}
err = query.Find(&logs).Error
type NodeAccessLogQuery struct {
NodeID string
RemoteAddr string
Host string
Path string
Since time.Time
Page int
PageSize int
SortBy string
SortOrder string
}
type NodeAccessLogBucketQuery struct {
NodeID string
RemoteAddr string
Host string
Path string
Since time.Time
Page int
PageSize int
SortBy string
SortOrder string
FoldMinutes int
}
type NodeAccessLogBucketRow struct {
BucketEpoch int64 `json:"bucket_epoch"`
RequestCount int64 `json:"request_count"`
UniqueIPCount int64 `json:"unique_ip_count"`
UniqueHostCount int64 `json:"unique_host_count"`
SuccessCount int64 `json:"success_count"`
ClientErrorCount int64 `json:"client_error_count"`
ServerErrorCount int64 `json:"server_error_count"`
}
type NodeAccessLogIPSummaryQuery struct {
NodeID string
RemoteAddr string
Host string
Since time.Time
Page int
PageSize int
SortBy string
SortOrder string
}
type NodeAccessLogIPSummaryRow struct {
RemoteAddr string `json:"remote_addr"`
TotalRequests int64 `json:"total_requests"`
RecentRequests int64 `json:"recent_requests"`
LastSeenEpoch int64 `json:"last_seen_epoch"`
}
type NodeAccessLogIPTrendQuery struct {
NodeID string
RemoteAddr string
Host string
Since time.Time
BucketMinutes int
}
type NodeAccessLogTrendPointRow struct {
BucketEpoch int64 `json:"bucket_epoch"`
RequestCount int64 `json:"request_count"`
}
func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err error) {
offset := query.Page * query.PageSize
db := buildNodeAccessLogQuery(DB, query).
Order(buildNodeAccessLogSortClause(query.SortBy, query.SortOrder)).
Limit(query.PageSize).
Offset(offset)
err = db.Find(&logs).Error
return logs, err
}
func CountNodeAccessLogs(nodeID string, since time.Time) (totalRecords int64, totalIPs int64, err error) {
query := DB.Model(&NodeAccessLog{})
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
if !since.IsZero() {
query = query.Where("logged_at >= ?", since)
}
if err = query.Count(&totalRecords).Error; err != nil {
func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) {
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query)
if err = base.Count(&totalRecords).Error; err != nil {
return 0, 0, err
}
if err = query.
distinctQuery := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query).
Where("remote_addr <> ''").
Distinct("remote_addr").
Count(&totalIPs).Error; err != nil {
Distinct("remote_addr")
if err = distinctQuery.Count(&totalIPs).Error; err != nil {
return 0, 0, err
}
return totalRecords, totalIPs, nil
@@ -75,3 +133,203 @@ func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (i
err = query.Scan(&items).Error
return items, err
}
func ListNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (items []*NodeAccessLogBucketRow, err error) {
offset := query.Page * query.PageSize
bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes)
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Path: query.Path,
Since: query.Since,
})
err = base.Select(fmt.Sprintf(
"%s as bucket_epoch, count(*) as request_count, count(distinct remote_addr) as unique_ip_count, count(distinct host) as unique_host_count, sum(case when status_code < 400 then 1 else 0 end) as success_count, sum(case when status_code >= 400 and status_code < 500 then 1 else 0 end) as client_error_count, sum(case when status_code >= 500 then 1 else 0 end) as server_error_count",
bucketExpr,
)).
Group(bucketExpr).
Order(buildNodeAccessLogBucketSortClause(query.SortBy, query.SortOrder)).
Limit(query.PageSize).
Offset(offset).
Scan(&items).Error
return items, err
}
func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err error) {
bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes)
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Path: query.Path,
Since: query.Since,
})
rows := []struct {
BucketEpoch int64 `gorm:"column:bucket_epoch"`
}{}
err = base.Select(fmt.Sprintf("%s as bucket_epoch", bucketExpr)).
Group(bucketExpr).
Scan(&rows).Error
if err != nil {
return 0, err
}
return int64(len(rows)), nil
}
func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) {
offset := query.Page * query.PageSize
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
}).Where("remote_addr <> ''")
lastSeenExpr := accessLogEpochExpr("max(logged_at)")
err = base.Select(
"remote_addr as remote_addr, count(*) as total_requests, sum(case when logged_at >= ? then 1 else 0 end) as recent_requests, "+lastSeenExpr+" as last_seen_epoch",
recentSince,
).
Group("remote_addr").
Order(buildNodeAccessLogIPSummarySortClause(query.SortBy, query.SortOrder)).
Limit(query.PageSize).
Offset(offset).
Scan(&items).Error
return items, err
}
func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int64, err error) {
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
}).Where("remote_addr <> ''")
rows := []struct {
RemoteAddr string `gorm:"column:remote_addr"`
}{}
err = base.Select("remote_addr").
Group("remote_addr").
Scan(&rows).Error
if err != nil {
return 0, err
}
return int64(len(rows)), nil
}
func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAccessLogTrendPointRow, err error) {
bucketExpr := accessLogBucketEpochExpr(query.BucketMinutes)
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
}).Where("remote_addr = ?", strings.TrimSpace(query.RemoteAddr))
err = base.Select(fmt.Sprintf("%s as bucket_epoch, count(*) as request_count", bucketExpr)).
Group(bucketExpr).
Order("bucket_epoch asc").
Scan(&items).Error
return items, err
}
func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) {
result := DB.Where("logged_at < ?", before).Delete(&NodeAccessLog{})
return result.RowsAffected, result.Error
}
func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
if db == nil {
db = DB.Model(&NodeAccessLog{})
}
if db.Statement == nil || db.Statement.Model == nil {
db = db.Model(&NodeAccessLog{})
}
if trimmed := strings.TrimSpace(query.NodeID); trimmed != "" {
db = db.Where("node_id LIKE ?", "%"+trimmed+"%")
}
if trimmed := strings.TrimSpace(query.RemoteAddr); trimmed != "" {
db = db.Where("remote_addr LIKE ?", "%"+trimmed+"%")
}
if trimmed := strings.TrimSpace(query.Host); trimmed != "" {
db = db.Where("host LIKE ?", "%"+trimmed+"%")
}
if trimmed := strings.TrimSpace(query.Path); trimmed != "" {
db = db.Where("path LIKE ?", "%"+trimmed+"%")
}
if !query.Since.IsZero() {
db = db.Where("logged_at >= ?", query.Since)
}
return db
}
func buildNodeAccessLogSortClause(sortBy string, sortOrder string) string {
column := "logged_at"
switch strings.TrimSpace(sortBy) {
case "status_code":
column = "status_code"
case "remote_addr":
column = "remote_addr"
case "host":
column = "host"
case "path":
column = "path"
}
order := normalizeSortOrder(sortOrder)
if column == "logged_at" {
return fmt.Sprintf("%s %s, id %s", column, order, order)
}
return fmt.Sprintf("%s %s, logged_at desc, id desc", column, order)
}
func buildNodeAccessLogBucketSortClause(sortBy string, sortOrder string) string {
order := normalizeSortOrder(sortOrder)
switch strings.TrimSpace(sortBy) {
case "request_count":
return fmt.Sprintf("request_count %s, bucket_epoch desc", order)
default:
return fmt.Sprintf("bucket_epoch %s", order)
}
}
func buildNodeAccessLogIPSummarySortClause(sortBy string, sortOrder string) string {
order := normalizeSortOrder(sortOrder)
switch strings.TrimSpace(sortBy) {
case "recent_requests":
return fmt.Sprintf("recent_requests %s, last_seen_epoch desc, remote_addr asc", order)
case "last_seen_at":
return fmt.Sprintf("last_seen_epoch %s, total_requests desc, remote_addr asc", order)
case "remote_addr":
return fmt.Sprintf("remote_addr %s", order)
default:
return fmt.Sprintf("total_requests %s, last_seen_epoch desc, remote_addr asc", order)
}
}
func accessLogBucketEpochExpr(bucketMinutes int) string {
bucketSeconds := bucketMinutes * 60
if bucketSeconds <= 0 {
bucketSeconds = 180
}
switch DB.Dialector.Name() {
case "postgres":
return fmt.Sprintf("CAST(floor(extract(epoch from logged_at) / %d) * %d AS BIGINT)", bucketSeconds, bucketSeconds)
default:
return fmt.Sprintf("CAST((strftime('%%s', logged_at) / %d) * %d AS INTEGER)", bucketSeconds, bucketSeconds)
}
}
func accessLogEpochExpr(expression string) string {
switch DB.Dialector.Name() {
case "postgres":
return fmt.Sprintf("CAST(extract(epoch from %s) AS BIGINT)", expression)
default:
return fmt.Sprintf("CAST(strftime('%%s', %s) AS INTEGER)", expression)
}
}
func normalizeSortOrder(sortOrder string) string {
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
return "asc"
}
return "desc"
}
+6 -1
View File
@@ -8,7 +8,7 @@ type NodeHealthEvent struct {
EventType string `json:"event_type" gorm:"index;size:64;not null"`
Severity string `json:"severity" gorm:"size:16;not null"`
Status string `json:"status" gorm:"index;size:16;not null"`
Message string `json:"message" gorm:"size:2048"`
Message string `json:"message" gorm:"type:text"`
FirstTriggeredAt time.Time `json:"first_triggered_at" gorm:"index"`
LastTriggeredAt time.Time `json:"last_triggered_at" gorm:"index"`
ReportedAt time.Time `json:"reported_at" gorm:"index"`
@@ -40,3 +40,8 @@ func ListActiveNodeHealthEvents() (events []*NodeHealthEvent, err error) {
err = DB.Where("status = ?", "active").Order("last_triggered_at desc").Find(&events).Error
return events, err
}
func DeleteNodeHealthEvents(nodeID string) (deleted int64, err error) {
result := DB.Where("node_id = ?", nodeID).Delete(&NodeHealthEvent{})
return result.RowsAffected, result.Error
}
+5
View File
@@ -131,6 +131,11 @@ func updateOptionMap(key string, value string) {
common.OptionMap = make(map[string]string)
}
common.OptionMap[key] = value
if key == "OpenRestyResolvers" {
delete(common.OptionMap, key)
common.OptionMapRWMutex.Unlock()
return
}
if strings.HasSuffix(key, "Permission") {
intValue, _ := strconv.Atoi(value)
switch key {
+8
View File
@@ -7,10 +7,14 @@ type ProxyRoute struct {
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
@@ -42,10 +46,14 @@ func (route *ProxyRoute) Update() error {
"domain": route.Domain,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
"enabled": route.Enabled,
"enable_https": route.EnableHTTPS,
"cert_id": route.CertID,
"redirect_http": route.RedirectHTTP,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"remark": route.Remark,
}).Error
+7
View File
@@ -108,6 +108,7 @@ func SetApiRouter(router *gin.Engine) {
configVersionRoute.GET("/active", controller.GetActiveConfigVersion)
configVersionRoute.GET("/preview", controller.PreviewConfigVersion)
configVersionRoute.GET("/diff", controller.DiffConfigVersion)
configVersionRoute.GET("/:id", controller.GetConfigVersion)
configVersionRoute.POST("/publish", controller.PublishConfigVersion)
configVersionRoute.POST("/:id/activate", controller.ActivateConfigVersion)
}
@@ -125,6 +126,7 @@ func SetApiRouter(router *gin.Engine) {
nodeRoute.POST("/", controller.CreateNode)
nodeRoute.GET("/:id/agent-release", controller.GetNodeAgentRelease)
nodeRoute.GET("/:id/observability", controller.GetNodeObservability)
nodeRoute.POST("/:id/observability/cleanup", controller.CleanupNodeHealthEvents)
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
nodeRoute.POST("/:id/openresty-restart", controller.RequestNodeOpenrestyRestart)
nodeRoute.POST("/:id/update", controller.UpdateNode)
@@ -134,11 +136,16 @@ func SetApiRouter(router *gin.Engine) {
applyLogRoute.Use(middleware.AdminAuth())
{
applyLogRoute.GET("/", controller.GetApplyLogs)
applyLogRoute.POST("/cleanup", controller.CleanupApplyLogs)
}
accessLogRoute := apiRouter.Group("/access-logs")
accessLogRoute.Use(middleware.AdminAuth())
{
accessLogRoute.GET("/", controller.GetAccessLogs)
accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs)
accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries)
accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend)
accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs)
}
agentRoute := apiRouter.Group("/agent")
{
+61 -13
View File
@@ -44,11 +44,15 @@ func TestPhase1PublishLifecycle(t *testing.T) {
token := prepareRootToken(t)
createBody := map[string]any{
"domain": "app.example.com",
"origin_url": "https://origin-a.internal",
"origin_host": "origin-a.internal",
"enabled": true,
"remark": "primary route",
"domain": "app.example.com",
"origin_url": "https://10.0.0.11:8443",
"upstreams": []string{"https://10.0.0.12:8443"},
"origin_host": "origin-a.internal",
"enabled": true,
"cache_enabled": true,
"cache_policy": "path_prefix",
"cache_rules": []string{"/assets", "/static"},
"remark": "primary route",
}
resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody)
var createdRoute model.ProxyRoute
@@ -59,6 +63,15 @@ func TestPhase1PublishLifecycle(t *testing.T) {
if createdRoute.OriginHost != "origin-a.internal" {
t.Fatalf("unexpected created route origin host: %s", createdRoute.OriginHost)
}
if !createdRoute.CacheEnabled || createdRoute.CachePolicy != "path_prefix" {
t.Fatalf("expected route cache settings to persist, got %+v", createdRoute)
}
if !strings.Contains(createdRoute.Upstreams, "10.0.0.12:8443") {
t.Fatalf("expected route upstream list to persist, got %s", createdRoute.Upstreams)
}
if !strings.Contains(createdRoute.CacheRules, "/assets") {
t.Fatalf("expected route cache rules to persist, got %s", createdRoute.CacheRules)
}
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
var routes []model.ProxyRoute
@@ -103,21 +116,31 @@ func TestPhase1PublishLifecycle(t *testing.T) {
initialRendered := version1.RenderedConfig
updateBody := map[string]any{
"domain": "app.example.com",
"origin_url": "https://origin-b.internal",
"origin_host": "origin-b.internal",
"enabled": true,
"remark": "updated route",
"domain": "app.example.com",
"origin_url": "https://10.0.0.21:8443",
"upstreams": []string{"https://10.0.0.22:8443"},
"origin_host": "origin-b.internal",
"enabled": true,
"cache_enabled": true,
"cache_policy": "path_exact",
"cache_rules": []string{"/robots.txt"},
"remark": "updated route",
}
routePath := "/api/proxy-routes/" + toString(createdRoute.ID)
resp = performJSONRequest(t, engine, token, http.MethodPost, routePath+"/update", updateBody)
decodeResponseData(t, resp, &createdRoute)
if createdRoute.OriginURL != "https://origin-b.internal" {
if createdRoute.OriginURL != "https://10.0.0.21:8443" {
t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL)
}
if createdRoute.OriginHost != "origin-b.internal" {
t.Fatalf("unexpected updated route origin host: %s", createdRoute.OriginHost)
}
if createdRoute.CachePolicy != "path_exact" || !strings.Contains(createdRoute.CacheRules, "/robots.txt") {
t.Fatalf("expected updated route cache rules to persist, got %+v", createdRoute)
}
if !strings.Contains(createdRoute.Upstreams, "10.0.0.22:8443") {
t.Fatalf("expected updated route upstream list to persist, got %s", createdRoute.Upstreams)
}
resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
var version2 model.ConfigVersion
@@ -127,11 +150,33 @@ func TestPhase1PublishLifecycle(t *testing.T) {
}
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/", nil)
var versions []model.ConfigVersion
var versions []map[string]any
decodeResponseData(t, resp, &versions)
if len(versions) != 2 {
t.Fatalf("expected 2 versions, got %d", len(versions))
}
if _, ok := versions[0]["snapshot_json"]; ok {
t.Fatal("expected config version list to omit snapshot_json")
}
if _, ok := versions[0]["main_config"]; ok {
t.Fatal("expected config version list to omit main_config")
}
if _, ok := versions[0]["rendered_config"]; ok {
t.Fatal("expected config version list to omit rendered_config")
}
if _, ok := versions[0]["support_files_json"]; ok {
t.Fatal("expected config version list to omit support_files_json")
}
detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/"+toString(version2.ID), nil)
var versionDetail model.ConfigVersion
decodeResponseData(t, detailResp, &versionDetail)
if versionDetail.ID != version2.ID {
t.Fatalf("expected config version detail %d, got %d", version2.ID, versionDetail.ID)
}
if versionDetail.SnapshotJSON == "" || versionDetail.MainConfig == "" || versionDetail.RenderedConfig == "" {
t.Fatal("expected config version detail endpoint to include full payload")
}
activeResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/active", nil)
var activeVersion model.ConfigVersion
@@ -305,7 +350,10 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
t.Fatal("expected active config to render managed main config")
}
if !strings.Contains(version.RenderedConfig, "listen 443 ssl;") {
t.Fatal("expected active config to render https listener")
t.Fatal("expected active config to render https ssl listener")
}
if !strings.Contains(version.RenderedConfig, "http2 on;") {
t.Fatal("expected active config to render dedicated http2 directive")
}
if !strings.Contains(version.RenderedConfig, "return 301 https://$host$request_uri;") {
t.Fatal("expected active config to render redirect server")
+74 -5
View File
@@ -170,7 +170,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
createRouteAndPublishVersion(t, engine, adminToken)
dashboardResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/dashboard/overview", nil)
var dashboard service.DashboardOverviewView
var dashboard struct {
Summary service.DashboardSummary `json:"summary"`
}
decodeResponseData(t, dashboardResp, &dashboard)
if dashboard.Summary.TotalNodes != 0 {
t.Fatalf("expected empty dashboard node summary before node registration, got %+v", dashboard.Summary)
@@ -279,12 +281,41 @@ func TestPhase2AgentLifecycle(t *testing.T) {
t.Fatal("expected node list to expose openresty message")
}
if err := model.DB.Create(&model.NodeHealthEvent{
NodeID: createdNode.NodeID,
EventType: "openresty_down",
Severity: service.NodeHealthSeverityCritical,
Status: service.NodeHealthEventStatusActive,
Message: "docker run openresty failed: bind 80 already allocated",
FirstTriggeredAt: time.Now().Add(-2 * time.Minute),
LastTriggeredAt: time.Now().Add(-time.Minute),
ReportedAt: time.Now().Add(-time.Minute),
}).Error; err != nil {
t.Fatalf("failed to insert node health event: %v", err)
}
observabilityResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/"+toString(createdNode.ID)+"/observability?hours=24&limit=20", nil)
var observability service.NodeObservabilityView
decodeResponseData(t, observabilityResp, &observability)
if observability.NodeID != createdNode.NodeID {
t.Fatalf("expected observability response for node %s, got %s", createdNode.NodeID, observability.NodeID)
}
if len(observability.HealthEvents) != 1 {
t.Fatalf("expected observability response to include health events, got %+v", observability.HealthEvents)
}
cleanupHealthResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/observability/cleanup", nil)
var cleanupHealthResult service.NodeHealthEventCleanupResult
decodeResponseData(t, cleanupHealthResp, &cleanupHealthResult)
if cleanupHealthResult.NodeID != createdNode.NodeID || cleanupHealthResult.DeletedCount != 1 {
t.Fatalf("unexpected node health cleanup result: %+v", cleanupHealthResult)
}
observabilityAfterCleanupResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/"+toString(createdNode.ID)+"/observability?hours=24&limit=20", nil)
decodeResponseData(t, observabilityAfterCleanupResp, &observability)
if len(observability.HealthEvents) != 0 {
t.Fatalf("expected health events to be cleaned up, got %+v", observability.HealthEvents)
}
restartResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/openresty-restart", nil)
decodeResponseData(t, restartResp, &createdNode)
@@ -323,11 +354,49 @@ func TestPhase2AgentLifecycle(t *testing.T) {
t.Fatal("expected heartbeat response to include active config summary")
}
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil)
var logs []model.ApplyLog
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID+"&pageNo=1&pageSize=1", nil)
var logs service.ApplyLogListResult
decodeResponseData(t, logsResp, &logs)
if len(logs) != 2 {
t.Fatalf("expected 2 apply logs, got %d", len(logs))
if logs.Current != 1 || logs.Total != 2 || logs.TotalPage != 2 {
t.Fatalf("unexpected paged apply logs result: %+v", logs)
}
if len(logs.Rows) != 1 {
t.Fatalf("expected 1 apply log row on page 1, got %d", len(logs.Rows))
}
if logs.Rows[0].Result != service.ApplyResultFailed {
t.Fatalf("expected newest apply log first, got %s", logs.Rows[0].Result)
}
oldApplyLogTime := time.Now().Add(-48 * time.Hour)
if err := model.DB.Model(&model.ApplyLog{}).Where("id = ?", successApplyLog.ID).Update("created_at", oldApplyLogTime).Error; err != nil {
t.Fatalf("failed to backdate apply log: %v", err)
}
cleanupResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/apply-logs/cleanup", map[string]any{
"retention_days": 1,
})
var cleanupResult service.ApplyLogCleanupResult
decodeResponseData(t, cleanupResp, &cleanupResult)
if cleanupResult.DeleteAll {
t.Fatal("expected retention cleanup instead of delete-all cleanup")
}
if cleanupResult.RetentionDays != 1 || cleanupResult.DeletedCount != 1 {
t.Fatalf("unexpected cleanup result: %+v", cleanupResult)
}
postCleanupResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil)
decodeResponseData(t, postCleanupResp, &logs)
if logs.Total != 1 || len(logs.Rows) != 1 {
t.Fatalf("expected one apply log after retention cleanup, got %+v", logs)
}
deleteAllResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/apply-logs/cleanup", map[string]any{
"delete_all": true,
})
decodeResponseData(t, deleteAllResp, &cleanupResult)
if !cleanupResult.DeleteAll || cleanupResult.DeletedCount != 1 {
t.Fatalf("unexpected delete-all cleanup result: %+v", cleanupResult)
}
emptyLogsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil)
decodeResponseData(t, emptyLogsResp, &logs)
if logs.Total != 0 || len(logs.Rows) != 0 || logs.Current != 1 || logs.TotalPage != 0 {
t.Fatalf("expected empty apply log page after delete-all cleanup, got %+v", logs)
}
updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/update", map[string]any{
+411 -30
View File
@@ -1,16 +1,36 @@
package service
import (
"errors"
"openflare/model"
"strings"
"time"
)
const (
defaultAccessLogPageSize = 50
maxAccessLogPageSize = 200
defaultAccessLogPageSize = 20
maxAccessLogPageSize = 200
defaultAccessLogSortBy = "logged_at"
defaultAccessLogSortOrder = "desc"
defaultAccessLogFoldMinute = 3
defaultIPTrendHours = 24
defaultIPTrendBucketMinute = 30
maxIPTrendHours = 168
nodeAccessLogRetentionDays = 90
)
type AccessLogQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
FoldMinutes int `json:"fold_minutes"`
}
type AccessLogView struct {
ID uint `json:"id"`
NodeID string `json:"node_id"`
@@ -32,40 +52,99 @@ type AccessLogList struct {
TotalIP int64 `json:"total_ip"`
}
func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, error) {
normalizedPage := normalizeAccessLogPage(page)
normalizedPageSize := normalizeAccessLogPageSize(pageSize)
offset := normalizedPage * normalizedPageSize
trimmedNodeID := strings.TrimSpace(nodeID)
since := time.Now().Add(-nodeAccessLogRetentionWindow)
logs, err := model.ListNodeAccessLogs(
trimmedNodeID,
since,
offset,
normalizedPageSize+1,
)
type FoldedAccessLogView struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
UniqueIPCount int64 `json:"unique_ip_count"`
UniqueHostCount int64 `json:"unique_host_count"`
SuccessCount int64 `json:"success_count"`
ClientErrorCount int64 `json:"client_error_count"`
ServerErrorCount int64 `json:"server_error_count"`
}
type FoldedAccessLogList struct {
Items []FoldedAccessLogView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalBucket int64 `json:"total_bucket"`
TotalRecord int64 `json:"total_record"`
TotalIP int64 `json:"total_ip"`
FoldMinutes int `json:"fold_minutes"`
}
type AccessLogIPSummaryQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
type AccessLogIPSummaryView struct {
RemoteAddr string `json:"remote_addr"`
TotalRequests int64 `json:"total_requests"`
RecentRequests int64 `json:"recent_requests"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type AccessLogIPSummaryList struct {
Items []AccessLogIPSummaryView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalIP int64 `json:"total_ip"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
type AccessLogIPTrendQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Hours int `json:"hours"`
BucketMinutes int `json:"bucket_minutes"`
}
type AccessLogIPTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
}
type AccessLogIPTrendView struct {
RemoteAddr string `json:"remote_addr"`
Hours int `json:"hours"`
BucketMinutes int `json:"bucket_minutes"`
Points []AccessLogIPTrendPoint `json:"points"`
}
type AccessLogCleanupInput struct {
RetentionDays int `json:"retention_days"`
}
type AccessLogCleanupResult struct {
RetentionDays int `json:"retention_days"`
DeletedCount int64 `json:"deleted_count"`
Cutoff time.Time `json:"cutoff"`
}
func ListAccessLogs(input AccessLogQuery) (*AccessLogList, error) {
normalized := normalizeAccessLogQuery(input)
modelQuery := buildModelAccessLogQuery(normalized)
logs, err := model.ListNodeAccessLogs(modelQuery)
if err != nil {
return nil, err
}
totalRecords, totalIPs, err := model.CountNodeAccessLogs(trimmedNodeID, since)
totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery)
if err != nil {
return nil, err
}
nodes, err := model.ListNodes()
nodeNames, err := listNodeNameMap(logs)
if err != nil {
return nil, err
}
nodeNames := make(map[string]string, len(nodes))
for _, node := range nodes {
if node == nil {
continue
}
nodeNames[node.NodeID] = node.Name
}
hasMore := len(logs) > normalizedPageSize
if hasMore {
logs = logs[:normalizedPageSize]
}
views := make([]AccessLogView, 0, len(logs))
for _, item := range logs {
if item == nil {
@@ -85,14 +164,270 @@ func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, erro
}
return &AccessLogList{
Items: views,
Page: normalizedPage,
PageSize: normalizedPageSize,
HasMore: hasMore,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalRecords,
TotalRecord: totalRecords,
TotalIP: totalIPs,
}, nil
}
func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) {
normalized := normalizeAccessLogQuery(input)
foldMinutes, err := normalizeFoldMinutes(normalized.FoldMinutes)
if err != nil {
return nil, err
}
modelQuery := buildModelAccessLogQuery(normalized)
bucketQuery := model.NodeAccessLogBucketQuery{
NodeID: modelQuery.NodeID,
RemoteAddr: modelQuery.RemoteAddr,
Host: modelQuery.Host,
Path: modelQuery.Path,
Since: modelQuery.Since,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalizeFoldSortBy(normalized.SortBy),
SortOrder: normalized.SortOrder,
FoldMinutes: foldMinutes,
}
items, err := model.ListNodeAccessLogBuckets(bucketQuery)
if err != nil {
return nil, err
}
totalBuckets, err := model.CountNodeAccessLogBuckets(bucketQuery)
if err != nil {
return nil, err
}
totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery)
if err != nil {
return nil, err
}
views := make([]FoldedAccessLogView, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
views = append(views, FoldedAccessLogView{
BucketStartedAt: time.Unix(item.BucketEpoch, 0).UTC(),
RequestCount: item.RequestCount,
UniqueIPCount: item.UniqueIPCount,
UniqueHostCount: item.UniqueHostCount,
SuccessCount: item.SuccessCount,
ClientErrorCount: item.ClientErrorCount,
ServerErrorCount: item.ServerErrorCount,
})
}
return &FoldedAccessLogList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalBuckets,
TotalBucket: totalBuckets,
TotalRecord: totalRecords,
TotalIP: totalIPs,
FoldMinutes: foldMinutes,
}, nil
}
func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
normalized := normalizeAccessLogIPSummaryQuery(input)
since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow)
recentSince := time.Now().UTC().Add(-3 * time.Hour)
query := model.NodeAccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(normalized.NodeID),
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
Host: strings.TrimSpace(normalized.Host),
Since: since,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}
items, err := model.ListNodeAccessLogIPSummaries(query, recentSince)
if err != nil {
return nil, err
}
totalIP, err := model.CountNodeAccessLogIPSummaries(query)
if err != nil {
return nil, err
}
views := make([]AccessLogIPSummaryView, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
views = append(views, AccessLogIPSummaryView{
RemoteAddr: item.RemoteAddr,
TotalRequests: item.TotalRequests,
RecentRequests: item.RecentRequests,
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
})
}
return &AccessLogIPSummaryList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP,
TotalIP: totalIP,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}, nil
}
func GetAccessLogIPTrend(input AccessLogIPTrendQuery) (*AccessLogIPTrendView, error) {
normalized, err := normalizeAccessLogIPTrendQuery(input)
if err != nil {
return nil, err
}
points, err := model.ListNodeAccessLogIPTrend(model.NodeAccessLogIPTrendQuery{
NodeID: strings.TrimSpace(normalized.NodeID),
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
Host: strings.TrimSpace(normalized.Host),
Since: time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour),
BucketMinutes: normalized.BucketMinutes,
})
if err != nil {
return nil, err
}
pointMap := make(map[int64]int64, len(points))
for _, item := range points {
if item == nil {
continue
}
pointMap[item.BucketEpoch] = item.RequestCount
}
bucketDuration := time.Duration(normalized.BucketMinutes) * time.Minute
start := time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour).Truncate(bucketDuration)
end := time.Now().UTC().Truncate(bucketDuration)
views := make([]AccessLogIPTrendPoint, 0, int(end.Sub(start)/bucketDuration)+1)
for cursor := start; !cursor.After(end); cursor = cursor.Add(bucketDuration) {
views = append(views, AccessLogIPTrendPoint{
BucketStartedAt: cursor,
RequestCount: pointMap[cursor.Unix()],
})
}
return &AccessLogIPTrendView{
RemoteAddr: normalized.RemoteAddr,
Hours: normalized.Hours,
BucketMinutes: normalized.BucketMinutes,
Points: views,
}, nil
}
func CleanupAccessLogs(input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
return nil, errors.New("retention_days 必须在 1 到 90 之间")
}
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
deleted, err := model.DeleteNodeAccessLogsBefore(cutoff)
if err != nil {
return nil, err
}
return &AccessLogCleanupResult{
RetentionDays: input.RetentionDays,
DeletedCount: deleted,
Cutoff: cutoff,
}, nil
}
func buildModelAccessLogQuery(input AccessLogQuery) model.NodeAccessLogQuery {
return model.NodeAccessLogQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path),
Since: time.Now().UTC().Add(-nodeAccessLogRetentionWindow),
Page: input.Page,
PageSize: input.PageSize,
SortBy: input.SortBy,
SortOrder: input.SortOrder,
}
}
func listNodeNameMap(logs []*model.NodeAccessLog) (map[string]string, error) {
nodeIDs := make([]string, 0, len(logs))
seen := make(map[string]struct{}, len(logs))
for _, item := range logs {
if item == nil || item.NodeID == "" {
continue
}
if _, exists := seen[item.NodeID]; exists {
continue
}
seen[item.NodeID] = struct{}{}
nodeIDs = append(nodeIDs, item.NodeID)
}
nodes, err := model.ListNodesByNodeIDs(nodeIDs)
if err != nil {
return nil, err
}
result := make(map[string]string, len(nodes))
for _, node := range nodes {
if node == nil {
continue
}
result[node.NodeID] = node.Name
}
return result, nil
}
func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
return AccessLogQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path),
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeAccessLogSortBy(input.SortBy),
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
FoldMinutes: input.FoldMinutes,
}
}
func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIPSummaryQuery {
return AccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeIPSummarySortBy(input.SortBy),
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
}
}
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" {
return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空")
}
hours := input.Hours
if hours <= 0 {
hours = defaultIPTrendHours
}
if hours > maxIPTrendHours {
hours = maxIPTrendHours
}
bucketMinutes := input.BucketMinutes
if bucketMinutes <= 0 {
bucketMinutes = defaultIPTrendBucketMinute
}
switch bucketMinutes {
case 5, 10, 15, 30, 60:
default:
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60")
}
return AccessLogIPTrendQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: remoteAddr,
Host: strings.TrimSpace(input.Host),
Hours: hours,
BucketMinutes: bucketMinutes,
}, nil
}
func normalizeAccessLogPage(page int) int {
if page < 0 {
return 0
@@ -109,3 +444,49 @@ func normalizeAccessLogPageSize(pageSize int) int {
}
return pageSize
}
func normalizeAccessLogSortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "status_code", "remote_addr", "host", "path":
return strings.TrimSpace(sortBy)
default:
return defaultAccessLogSortBy
}
}
func normalizeAccessLogSortOrder(sortOrder string) string {
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
return "asc"
}
return defaultAccessLogSortOrder
}
func normalizeFoldSortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "request_count":
return "request_count"
default:
return "bucket_started_at"
}
}
func normalizeIPSummarySortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "recent_requests", "last_seen_at", "remote_addr":
return strings.TrimSpace(sortBy)
default:
return "total_requests"
}
}
func normalizeFoldMinutes(value int) (int, error) {
if value <= 0 {
return defaultAccessLogFoldMinute, nil
}
switch value {
case 3, 5:
return value, nil
default:
return 0, errors.New("fold_minutes 仅支持 3 或 5")
}
}
+162 -2
View File
@@ -64,7 +64,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
t.Fatalf("failed to seed access logs: %v", err)
}
result, err := ListAccessLogs("", 0, 2)
result, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 2})
if err != nil {
t.Fatalf("ListAccessLogs failed: %v", err)
}
@@ -84,7 +84,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
t.Fatal("expected has_more to be true")
}
filtered, err := ListAccessLogs("node-a", 0, 50)
filtered, err := ListAccessLogs(AccessLogQuery{NodeID: "node-a", Page: 0, PageSize: 50})
if err != nil {
t.Fatalf("ListAccessLogs filtered failed: %v", err)
}
@@ -98,3 +98,163 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
t.Fatalf("expected filtered items=2, got %d", len(filtered.Items))
}
}
func TestListAccessLogsUsesDefaultPageSize(t *testing.T) {
setupServiceTestDB(t)
now := time.Now()
if err := model.DB.Create(&model.Node{
NodeID: "node-default-page-size",
Name: "edge-default-page-size",
}).Error; err != nil {
t.Fatalf("failed to seed node: %v", err)
}
logs := make([]*model.NodeAccessLog, 0, 25)
for index := range 25 {
logs = append(logs, &model.NodeAccessLog{
NodeID: "node-default-page-size",
LoggedAt: now.Add(-time.Duration(index) * time.Minute),
RemoteAddr: "1.1.1.1",
Host: "example.com",
Path: "/default-page-size",
StatusCode: 200,
})
}
if err := model.DB.Create(&logs).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
result, err := ListAccessLogs(AccessLogQuery{})
if err != nil {
t.Fatalf("ListAccessLogs failed: %v", err)
}
if result.PageSize != 20 {
t.Fatalf("expected default page_size=20, got %d", result.PageSize)
}
if len(result.Items) != 20 {
t.Fatalf("expected current page items=20, got %d", len(result.Items))
}
if !result.HasMore {
t.Fatal("expected has_more to be true")
}
}
func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create(&model.Node{
NodeID: "node-folded",
Name: "edge-folded",
}).Error; err != nil {
t.Fatalf("failed to seed node: %v", err)
}
logs := []*model.NodeAccessLog{
{
NodeID: "node-folded",
LoggedAt: now.Add(-4 * time.Minute),
RemoteAddr: "203.0.113.1",
Host: "alpha.example.com",
Path: "/first",
StatusCode: 200,
},
{
NodeID: "node-folded",
LoggedAt: now.Add(-3 * time.Minute),
RemoteAddr: "203.0.113.1",
Host: "alpha.example.com",
Path: "/second",
StatusCode: 502,
},
{
NodeID: "node-folded",
LoggedAt: now.Add(-2 * time.Minute),
RemoteAddr: "203.0.113.2",
Host: "beta.example.com",
Path: "/third",
StatusCode: 404,
},
}
if err := model.DB.Create(&logs).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
folded, err := ListFoldedAccessLogs(AccessLogQuery{
NodeID: "node-folded",
Page: 0,
PageSize: 10,
FoldMinutes: 5,
})
if err != nil {
t.Fatalf("ListFoldedAccessLogs failed: %v", err)
}
if len(folded.Items) != 2 {
t.Fatalf("expected two folded buckets, got %+v", folded.Items)
}
if folded.TotalRecord != 3 || folded.TotalBucket != 2 {
t.Fatalf("unexpected folded totals: %+v", folded)
}
if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 {
t.Fatalf("unexpected folded request count sum: %+v", folded.Items)
}
ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{
NodeID: "node-folded",
Page: 0,
PageSize: 10,
SortBy: "total_requests",
SortOrder: "desc",
})
if err != nil {
t.Fatalf("ListAccessLogIPSummaries failed: %v", err)
}
if len(ipSummaries.Items) != 2 {
t.Fatalf("expected two ip summary rows, got %+v", ipSummaries.Items)
}
if ipSummaries.Items[0].RemoteAddr != "203.0.113.1" || ipSummaries.Items[0].TotalRequests != 2 {
t.Fatalf("unexpected top ip summary row: %+v", ipSummaries.Items[0])
}
}
func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create([]*model.NodeAccessLog{
{
NodeID: "node-cleanup",
LoggedAt: now.Add(-10 * 24 * time.Hour),
RemoteAddr: "203.0.113.9",
Host: "cleanup.example.com",
Path: "/old",
StatusCode: 200,
},
{
NodeID: "node-cleanup",
LoggedAt: now.Add(-2 * 24 * time.Hour),
RemoteAddr: "203.0.113.10",
Host: "cleanup.example.com",
Path: "/recent",
StatusCode: 200,
},
}).Error; err != nil {
t.Fatalf("failed to seed cleanup logs: %v", err)
}
result, err := CleanupAccessLogs(AccessLogCleanupInput{RetentionDays: 7})
if err != nil {
t.Fatalf("CleanupAccessLogs failed: %v", err)
}
if result.DeletedCount != 1 {
t.Fatalf("expected 1 deleted record, got %+v", result)
}
remaining, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 10, NodeID: "node-cleanup"})
if err != nil {
t.Fatalf("ListAccessLogs failed after cleanup: %v", err)
}
if len(remaining.Items) != 1 || remaining.Items[0].Path != "/recent" {
t.Fatalf("unexpected remaining logs after cleanup: %+v", remaining.Items)
}
}
+117 -4
View File
@@ -17,6 +17,7 @@ const (
NodeStatusOffline = "offline"
NodeStatusPending = "pending"
ApplyResultOK = "success"
ApplyResultWarning = "warning"
ApplyResultFailed = "failed"
OpenrestyStatusHealthy = "healthy"
OpenrestyStatusUnhealthy = "unhealthy"
@@ -52,6 +53,31 @@ type ApplyLogPayload struct {
SupportFileCount int `json:"support_file_count"`
}
type ApplyLogListQuery struct {
NodeID string `json:"node_id"`
PageNo int `json:"pageNo"`
PageSize int `json:"pageSize"`
}
type ApplyLogListResult struct {
Rows []*model.ApplyLog `json:"rows"`
Current int `json:"current"`
Total int `json:"total"`
TotalPage int `json:"totalPage"`
}
type ApplyLogCleanupInput struct {
DeleteAll bool `json:"delete_all"`
RetentionDays int `json:"retention_days"`
}
type ApplyLogCleanupResult struct {
DeleteAll bool `json:"delete_all"`
RetentionDays int `json:"retention_days"`
DeletedCount int64 `json:"deleted_count"`
Cutoff *time.Time `json:"cutoff,omitempty"`
}
type AgentConfigResponse struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
@@ -228,14 +254,15 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
payload.Checksum = strings.TrimSpace(payload.Checksum)
payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum)
payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum)
payload.Message = truncateForDatabase(payload.Message, 16000)
if payload.NodeID == "" {
return nil, errors.New("node_id 不能为空")
}
if payload.Version == "" {
return nil, errors.New("version 不能为空")
}
if payload.Result != ApplyResultOK && payload.Result != ApplyResultFailed {
return nil, errors.New("result 仅支持 success 或 failed")
if payload.Result != ApplyResultOK && payload.Result != ApplyResultWarning && payload.Result != ApplyResultFailed {
return nil, errors.New("result 仅支持 success、warning 或 failed")
}
slog.Debug("agent apply log received", "node_id", payload.NodeID, "version", payload.Version, "result", payload.Result)
@@ -273,6 +300,8 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
}
if payload.Result == ApplyResultOK {
slog.Debug("agent apply reported success", "node_id", payload.NodeID, "version", payload.Version)
} else if payload.Result == ApplyResultWarning {
slog.Warn("agent apply reported warning", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message)
} else {
slog.Error("agent apply reported failure", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message)
}
@@ -311,8 +340,92 @@ func ListNodeViews() ([]*NodeView, error) {
return views, nil
}
func ListApplyLogs(nodeID string) ([]*model.ApplyLog, error) {
return model.ListApplyLogs(strings.TrimSpace(nodeID))
func truncateForDatabase(value string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
const (
defaultApplyLogPageSize = 20
maxApplyLogPageSize = 200
maxApplyLogRetentionDays = 3650
)
func ListApplyLogsPage(input ApplyLogListQuery) (*ApplyLogListResult, error) {
pageNo := normalizeApplyLogPageNo(input.PageNo)
pageSize := normalizeApplyLogPageSize(input.PageSize)
nodeID := strings.TrimSpace(input.NodeID)
rows, err := model.ListApplyLogs(model.ApplyLogQuery{
NodeID: nodeID,
PageNo: pageNo,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
total, err := model.CountApplyLogs(nodeID)
if err != nil {
return nil, err
}
totalPage := 0
if total > 0 {
totalPage = int((total + int64(pageSize) - 1) / int64(pageSize))
}
return &ApplyLogListResult{
Rows: rows,
Current: pageNo,
Total: int(total),
TotalPage: totalPage,
}, nil
}
func CleanupApplyLogs(input ApplyLogCleanupInput) (*ApplyLogCleanupResult, error) {
if input.DeleteAll {
deleted, err := model.DeleteAllApplyLogs()
if err != nil {
return nil, err
}
return &ApplyLogCleanupResult{
DeleteAll: true,
DeletedCount: deleted,
}, nil
}
if input.RetentionDays <= 0 || input.RetentionDays > maxApplyLogRetentionDays {
return nil, errors.New("retention_days 必须在 1 到 3650 之间")
}
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
deleted, err := model.DeleteApplyLogsBefore(cutoff)
if err != nil {
return nil, err
}
return &ApplyLogCleanupResult{
RetentionDays: input.RetentionDays,
DeletedCount: deleted,
Cutoff: &cutoff,
}, nil
}
func normalizeApplyLogPageNo(pageNo int) int {
if pageNo <= 0 {
return 1
}
return pageNo
}
func normalizeApplyLogPageSize(pageSize int) int {
if pageSize <= 0 {
return defaultApplyLogPageSize
}
if pageSize > maxApplyLogPageSize {
return maxApplyLogPageSize
}
return pageSize
}
func upsertNode(payload AgentNodePayload) (*model.Node, error) {
+280 -20
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"openflare/common"
"openflare/model"
"regexp"
"sort"
"strings"
"time"
@@ -36,6 +37,10 @@ type ConfigPreviewResult struct {
RouteCount int `json:"route_count"`
}
type ConfigVersionSummary = model.ConfigVersionSummary
type ConfigVersionDetail = model.ConfigVersion
type ConfigDiffResult struct {
ActiveVersion string `json:"active_version,omitempty"`
AddedDomains []string `json:"added_domains"`
@@ -56,14 +61,32 @@ type snapshotRoute struct {
Domain string `json:"domain"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host,omitempty"`
Upstreams []string `json:"upstreams,omitempty"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id,omitempty"`
RedirectHTTP bool `json:"redirect_http"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
Remark string `json:"remark,omitempty"`
}
type routeCacheConfig struct {
Enabled bool
Policy string
Rules []string
}
type routeUpstreamConfig struct {
Name string
Scheme string
ProxyPassURI string
Servers []string
UsesNamedUpstream bool
}
type openRestyConfigSnapshot struct {
WorkerProcesses string `json:"worker_processes"`
WorkerConnections int `json:"worker_connections"`
@@ -130,6 +153,8 @@ var requiredMainConfigTemplatePlaceholders = []string{
"{{OpenRestyWorkerProcesses}}",
"{{OpenRestyWorkerConnections}}",
"{{OpenRestyWorkerRlimitNofile}}",
"{{OpenRestyConnectionUpgradeMap}}",
"{{OpenRestyDefaultServerBlock}}",
"{{OpenRestyAccessLogPath}}",
"{{OpenRestyEventsUseDirective}}",
"{{OpenRestyEventsMultiAcceptDirective}}",
@@ -155,11 +180,15 @@ var requiredMainConfigTemplatePlaceholders = []string{
"{{OpenRestyRouteConfigInclude}}",
}
func ListConfigVersions() ([]*model.ConfigVersion, error) {
return model.ListConfigVersions()
func ListConfigVersions() ([]*ConfigVersionSummary, error) {
return model.ListConfigVersionSummaries()
}
func GetActiveConfigVersion() (*model.ConfigVersion, error) {
func GetConfigVersionDetail(id uint) (*ConfigVersionDetail, error) {
return model.GetConfigVersionByID(id)
}
func GetActiveConfigVersion() (*ConfigVersionDetail, error) {
return model.GetActiveConfigVersion()
}
@@ -353,7 +382,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil {
return nil, err
}
routeConfig, supportFiles, err := renderRouteConfig(routes)
routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig)
if err != nil {
return nil, err
}
@@ -378,14 +407,26 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
if err != nil {
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
}
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
if err != nil {
return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
}
items = append(items, snapshotRoute{
Domain: route.Domain,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: route.CertID,
RedirectHTTP: route.RedirectHTTP,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
Remark: route.Remark,
})
@@ -422,14 +463,40 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
if err == nil {
routes[index].CustomHeaders = normalizedHeaders
}
normalizedUpstreams, err := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams)
if err == nil {
routes[index].OriginURL = normalizedUpstreams[0]
routes[index].Upstreams = normalizedUpstreams
}
normalizedCacheRules, err := normalizeCacheRules(routes[index].CacheEnabled, routes[index].CachePolicy, routes[index].CacheRules)
if err == nil {
routes[index].CachePolicy = normalizeCachePolicy(routes[index].CacheEnabled, routes[index].CachePolicy)
routes[index].CacheRules = normalizedCacheRules
}
}
return routes
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) {
if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) {
return false
}
if len(left.Upstreams) != len(right.Upstreams) {
return false
}
for index := range left.Upstreams {
if left.Upstreams[index] != right.Upstreams[index] {
return false
}
}
if len(left.CacheRules) != len(right.CacheRules) {
return false
}
for index := range left.CacheRules {
if left.CacheRules[index] != right.CacheRules[index] {
return false
}
}
if len(left.CustomHeaders) != len(right.CustomHeaders) {
return false
}
@@ -586,7 +653,7 @@ func openRestyOptionKeys() []string {
}
}
func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error) {
func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) (string, []SupportFile, error) {
var builder strings.Builder
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
supportFiles := make([]SupportFile, 0)
@@ -595,8 +662,25 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error
if err != nil {
return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
}
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
if err != nil {
return "", nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
return "", nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
}
cacheConfig := routeCacheConfig{
Enabled: route.CacheEnabled,
Policy: route.CachePolicy,
Rules: cacheRules,
}
upstreamConfig := buildRouteUpstreamConfig(route, upstreams)
if upstreamConfig.UsesNamedUpstream {
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders))
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
continue
}
if route.CertID == nil || *route.CertID == 0 {
@@ -613,9 +697,9 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error
if route.RedirectHTTP {
builder.WriteString(renderHTTPRedirectServer(route.Domain))
} else {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders))
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
}
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders))
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, upstreamConfig, cfg))
}
return builder.String(), dedupeSupportFiles(supportFiles), nil
}
@@ -650,6 +734,8 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
"{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
"{{OpenRestyConnectionUpgradeMap}}", renderConnectionUpgradeMap(),
"{{OpenRestyDefaultServerBlock}}", renderDefaultServerBlock(),
"{{OpenRestyAccessLogPath}}", nginxAccessLogPlaceholder,
"{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
"{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"),
@@ -671,6 +757,7 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
"{{OpenRestyGzip}}", onOff(cfg.GzipEnabled),
"{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength),
"{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel),
"{{OpenRestyResolverDirective}}", "",
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg),
"{{OpenRestyRouteConfigInclude}}", nginxRouteConfigPlaceholder,
)
@@ -749,25 +836,29 @@ func nextVersionNumber(now time.Time) (string, error) {
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
}
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), originURL)
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
}
func renderHTTPRedirectServer(domain string) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n return 301 https://$host$request_uri;\n}\n\n", domain, renderExactHostGuard(domain))
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", domain)
}
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput) string {
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), originURL)
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
}
func renderExactHostGuard(domain string) string {
return fmt.Sprintf(" if ($host != %q) {\n return 404;\n }\n", domain)
func renderConnectionUpgradeMap() string {
return " map $http_upgrade $connection_upgrade {\n default upgrade;\n '' \"\";\n }\n\n"
}
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput) string {
func renderDefaultServerBlock() string {
return " server {\n listen 80 default_server;\n server_name _;\n\n return 404;\n }\n\n"
}
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, upstreamConfig routeUpstreamConfig) string {
var builder strings.Builder
if strings.TrimSpace(originHost) != "" {
builder.WriteString(fmt.Sprintf(" proxy_set_header Host %s;\n", quoteNginxHeaderValue(originHost)))
@@ -783,15 +874,180 @@ func renderProxyHeaderBlock(originURL string, originHost string, customHeaders [
builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
if common.OpenRestyWebsocketEnabled {
builder.WriteString(" proxy_http_version 1.1;\n")
builder.WriteString(" proxy_set_header Connection $connection_upgrade;\n")
builder.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
builder.WriteString(" proxy_set_header Connection $http_connection;\n")
} else if upstreamConfig.UsesNamedUpstream {
builder.WriteString(" proxy_http_version 1.1;\n")
builder.WriteString(" proxy_set_header Connection \"\";\n")
}
for _, header := range customHeaders {
builder.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", header.Key, quoteNginxHeaderValue(header.Value)))
}
if common.OpenRestyCacheEnabled {
builder.WriteString(" proxy_cache openflare_cache;\n")
return builder.String()
}
func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg openRestyConfigSnapshot) string {
if !cfg.CacheEnabled || !cacheConfig.Enabled {
return ""
}
var builder strings.Builder
builder.WriteString(" set $openflare_skip_cache 0;\n")
builder.WriteString(" if ($request_method != GET) {\n set $openflare_skip_cache 1;\n }\n")
builder.WriteString(" if ($http_authorization != \"\") {\n set $openflare_skip_cache 1;\n }\n")
builder.WriteString(" if ($http_cookie ~* \"(session|sess|token|auth|jwt|logged_in|remember|laravel_session|connect\\\\.sid|_session)\") {\n set $openflare_skip_cache 1;\n }\n")
builder.WriteString(" if ($http_cache_control ~* \"(no-cache|no-store|private)\") {\n set $openflare_skip_cache 1;\n }\n")
if policyCondition := renderRouteCachePolicyCondition(cacheConfig); policyCondition != "" {
builder.WriteString(policyCondition)
}
builder.WriteString(" proxy_cache openflare_cache;\n")
builder.WriteString(" proxy_cache_methods GET;\n")
builder.WriteString(" proxy_cache_bypass $openflare_skip_cache;\n")
builder.WriteString(" proxy_no_cache $openflare_skip_cache;\n")
return builder.String()
}
func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string {
switch cacheConfig.Policy {
case proxyRouteCachePolicySuffix:
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(cacheConfig.Rules)))
case proxyRouteCachePolicyPathPrefix:
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathPrefixMatchPattern(cacheConfig.Rules)))
case proxyRouteCachePolicyPathExact:
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathExactMatchPattern(cacheConfig.Rules)))
default:
return ""
}
}
func buildSuffixMatchPattern(rules []string) string {
parts := make([]string, 0, len(rules))
for _, rule := range rules {
parts = append(parts, regexp.QuoteMeta(rule))
}
return fmt.Sprintf("\\.(?:%s)$", strings.Join(parts, "|"))
}
func buildPathPrefixMatchPattern(rules []string) string {
parts := make([]string, 0, len(rules))
for _, rule := range rules {
trimmed := strings.TrimRight(rule, "/")
if trimmed == "" {
trimmed = "/"
}
if trimmed == "/" {
parts = append(parts, "/")
continue
}
parts = append(parts, fmt.Sprintf("%s(?:/|$)", regexp.QuoteMeta(trimmed)))
}
return fmt.Sprintf("^(?:%s)", strings.Join(parts, "|"))
}
func buildPathExactMatchPattern(rules []string) string {
parts := make([]string, 0, len(rules))
for _, rule := range rules {
parts = append(parts, regexp.QuoteMeta(rule))
}
return fmt.Sprintf("^(?:%s)$", strings.Join(parts, "|"))
}
func renderProxyPassBlock(originURL string, upstreamConfig routeUpstreamConfig) string {
parsed, err := url.Parse(originURL)
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
}
if upstreamConfig.UsesNamedUpstream {
return fmt.Sprintf(" proxy_pass %s://%s%s;\n", upstreamConfig.Scheme, upstreamConfig.Name, upstreamConfig.ProxyPassURI)
}
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
}
func buildRouteUpstreamConfig(route *model.ProxyRoute, upstreams []string) routeUpstreamConfig {
if len(upstreams) == 0 {
return routeUpstreamConfig{}
}
if len(upstreams) == 1 {
parsed, err := url.Parse(strings.TrimSpace(upstreams[0]))
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
return routeUpstreamConfig{}
}
return routeUpstreamConfig{
Name: buildRouteUpstreamName(route),
Scheme: parsed.Scheme,
ProxyPassURI: buildUpstreamProxyPassURI(parsed),
Servers: []string{parsed.Host},
UsesNamedUpstream: true,
}
}
servers := make([]string, 0, len(upstreams))
var scheme string
for _, upstream := range upstreams {
parsed, err := url.Parse(strings.TrimSpace(upstream))
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
return routeUpstreamConfig{}
}
if strings.TrimSpace(parsed.EscapedPath()) != "" && strings.TrimSpace(parsed.EscapedPath()) != "/" {
return routeUpstreamConfig{}
}
if parsed.RawQuery != "" {
return routeUpstreamConfig{}
}
if scheme == "" {
scheme = parsed.Scheme
} else if scheme != parsed.Scheme {
return routeUpstreamConfig{}
}
servers = append(servers, parsed.Host)
}
return routeUpstreamConfig{
Name: buildRouteUpstreamName(route),
Scheme: scheme,
Servers: servers,
UsesNamedUpstream: true,
}
}
func buildUpstreamProxyPassURI(parsed *url.URL) string {
if parsed == nil {
return ""
}
path := parsed.EscapedPath()
if path == "/" {
path = ""
}
if parsed.RawQuery == "" {
return path
}
return fmt.Sprintf("%s?%s", path, parsed.RawQuery)
}
func buildRouteUpstreamName(route *model.ProxyRoute) string {
sanitized := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r + ('a' - 'A')
case r >= '0' && r <= '9':
return r
default:
return '_'
}
}, route.Domain)
sanitized = strings.Trim(sanitized, "_")
if sanitized == "" {
sanitized = "backend"
}
return fmt.Sprintf("backend_%s_%d", sanitized, route.ID)
}
func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name))
for _, server := range upstreamConfig.Servers {
builder.WriteString(fmt.Sprintf(" server %s max_fails=3 fail_timeout=10s;\n", server))
}
builder.WriteString(" keepalive 128;\n}\n\n")
return builder.String()
}
@@ -811,6 +1067,10 @@ func resolveUpstreamServerName(originURL string, originHost string) string {
}
func quoteNginxHeaderValue(value string) string {
return quoteNginxStringLiteral(value)
}
func quoteNginxStringLiteral(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return fmt.Sprintf(`"%s"`, escaped)
+314 -16
View File
@@ -63,14 +63,32 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
if !strings.Contains(result.Version.MainConfig, "listen __OPENFLARE_OBSERVABILITY_LISTEN__;") {
t.Fatal("expected main config to include managed openresty observability listen placeholder")
}
if strings.Contains(result.Version.MainConfig, "resolver ") {
t.Fatal("expected main config to omit resolver directive when no resolvers are configured")
}
if !strings.Contains(result.Version.MainConfig, "use epoll;") {
t.Fatal("expected main config to default to epoll event model")
}
if !strings.Contains(result.Version.MainConfig, "multi_accept on;") {
t.Fatal("expected main config to default multi_accept to on")
}
if !strings.Contains(result.Version.MainConfig, "keepalive_timeout 20;") {
t.Fatal("expected main config to default keepalive_timeout to 20")
}
if !strings.Contains(result.Version.MainConfig, "proxy_connect_timeout 3;") {
t.Fatal("expected main config to default proxy_connect_timeout to 3")
}
if strings.Contains(result.Version.MainConfig, "allow 127.0.0.1;") {
t.Fatal("expected main config to avoid hard-coded allow rules on observability server")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
t.Fatal("expected rendered config to include https server block")
t.Fatal("expected rendered config to include https ssl listener")
}
if !strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) {
t.Fatal("expected rendered config to reject unmatched host headers with 404")
if !strings.Contains(result.Version.RenderedConfig, "http2 on;") {
t.Fatal("expected rendered config to enable http2 with dedicated directive")
}
if strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) {
t.Fatal("expected rendered config to avoid per-route host guard")
}
if !strings.Contains(result.Version.RenderedConfig, "return 301 https://$host$request_uri;") {
t.Fatal("expected rendered config to include http redirect")
@@ -135,8 +153,184 @@ func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) {
if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Upgrade $http_upgrade;") {
t.Fatal("expected rendered config to forward websocket upgrade header")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Connection $http_connection;") {
t.Fatal("expected rendered config to forward websocket connection header")
if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Connection $connection_upgrade;") {
t.Fatal("expected rendered config to use normalized websocket connection header")
}
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_custom_example_com_1 {") {
t.Fatal("expected hostname origin to render named upstream")
}
if !strings.Contains(result.Version.RenderedConfig, "server origin.internal max_fails=3 fail_timeout=10s;") {
t.Fatal("expected hostname origin to render upstream server entry")
}
if !strings.Contains(result.Version.RenderedConfig, "keepalive 128;") {
t.Fatal("expected named upstream to enable keepalive")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_custom_example_com_1;") {
t.Fatal("expected hostname origin to proxy through named upstream")
}
}
func TestCreateProxyRouteRejectsCachePolicyWithoutRules(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "cache.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicySuffix,
})
if err == nil || !strings.Contains(err.Error(), "至少填写一个后缀") {
t.Fatalf("expected cache rule validation error, got %v", err)
}
}
func TestPublishConfigVersionRendersRouteLevelCachePolicy(t *testing.T) {
setupServiceTestDB(t)
if err := model.UpdateOption("OpenRestyCacheEnabled", "true"); err != nil {
t.Fatalf("UpdateOption OpenRestyCacheEnabled failed: %v", err)
}
if err := model.UpdateOption("OpenRestyCachePath", "/var/cache/openresty/openflare"); err != nil {
t.Fatalf("UpdateOption OpenRestyCachePath failed: %v", err)
}
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "static.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicySuffix,
CacheRules: []string{"jpg", ".css", "js"},
})
if err != nil {
t.Fatalf("CreateProxyRoute cached failed: %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
Domain: "nocache.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute uncached failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.MainConfig, "proxy_cache_path /var/cache/openresty/openflare") {
t.Fatal("expected main config to include cache zone when cache infra is enabled")
}
if !strings.Contains(result.Version.MainConfig, `proxy_cache_key "$scheme$host$request_uri";`) {
t.Fatal("expected main config to default cache key to host dimension")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_methods GET;") {
t.Fatal("expected rendered config to only cache GET requests")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_bypass $openflare_skip_cache;") {
t.Fatal("expected rendered config to bypass cache when request is unsafe")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_no_cache $openflare_skip_cache;") {
t.Fatal("expected rendered config to avoid storing unsafe requests in cache")
}
if !strings.Contains(result.Version.RenderedConfig, "if ($http_authorization != \"\")") {
t.Fatal("expected rendered config to bypass authenticated requests")
}
if !strings.Contains(result.Version.RenderedConfig, "if ($request_method != GET)") {
t.Fatal("expected rendered config to bypass non-GET requests")
}
if !strings.Contains(result.Version.RenderedConfig, "if ($uri !~* \"\\\\.(?:jpg|css|js)$\")") {
t.Fatal("expected rendered config to render suffix cache matching rule")
}
if strings.Count(result.Version.RenderedConfig, "proxy_cache openflare_cache;") != 1 {
t.Fatal("expected only cache-enabled route to include proxy_cache directive")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_static_example_com_1;") {
t.Fatal("expected cache-enabled hostname route to proxy through named upstream")
}
if !strings.Contains(result.Version.SnapshotJSON, `"cache_enabled":true`) {
t.Fatal("expected snapshot to include route cache toggle")
}
if !strings.Contains(result.Version.SnapshotJSON, `"cache_policy":"suffix"`) {
t.Fatal("expected snapshot to include route cache policy")
}
}
func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "lb.example.com",
OriginURL: "http://10.0.0.11:39010",
Upstreams: []string{"http://10.0.0.12:39010", "http://10.0.0.13:39010"},
Enabled: true,
OriginHost: "lb.example.com",
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if !strings.Contains(route.Upstreams, "10.0.0.12:39010") {
t.Fatalf("expected route upstreams to persist, got %s", route.Upstreams)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_lb_example_com_1 {") {
t.Fatal("expected rendered config to define upstream block for load balancing route")
}
if strings.Count(result.Version.RenderedConfig, "max_fails=3 fail_timeout=10s;") < 3 {
t.Fatal("expected rendered config to include every upstream server")
}
if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.11:39010 max_fails=3 fail_timeout=10s;") {
t.Fatal("expected rendered config to include primary upstream server")
}
if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.12:39010 max_fails=3 fail_timeout=10s;") {
t.Fatal("expected rendered config to include secondary upstream server")
}
if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.13:39010 max_fails=3 fail_timeout=10s;") {
t.Fatal("expected rendered config to include tertiary upstream server")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_lb_example_com_1;") {
t.Fatal("expected rendered config to proxy through load balancing upstream")
}
if !strings.Contains(result.Version.SnapshotJSON, `"upstreams":["http://10.0.0.11:39010","http://10.0.0.12:39010","http://10.0.0.13:39010"]`) {
t.Fatal("expected snapshot to include upstream list")
}
}
func TestPublishConfigVersionRendersHostnameLoadBalancingUpstream(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "hostname-lb.example.com",
OriginURL: "http://c1:39010",
Upstreams: []string{"http://c2:39010"},
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_hostname_lb_example_com_1 {") {
t.Fatal("expected hostname load balancing route to define named upstream")
}
if !strings.Contains(result.Version.RenderedConfig, "server c1:39010 max_fails=3 fail_timeout=10s;") {
t.Fatal("expected rendered config to include primary hostname upstream")
}
if !strings.Contains(result.Version.RenderedConfig, "server c2:39010 max_fails=3 fail_timeout=10s;") {
t.Fatal("expected rendered config to include secondary hostname upstream")
}
if strings.Contains(result.Version.RenderedConfig, " resolve ") {
t.Fatal("expected hostname upstreams to avoid resolver-based server parameters")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_hostname_lb_example_com_1;") {
t.Fatal("expected hostname load balancing route to proxy through named upstream")
}
}
@@ -166,14 +360,97 @@ func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) {
if !strings.Contains(result.Version.RenderedConfig, `proxy_ssl_name "git.arctel.net";`) {
t.Fatal("expected rendered config to set proxy ssl name from origin host override")
}
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_git_arctel_de_1 {") {
t.Fatal("expected hostname origin to render named upstream")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_git_arctel_de_1;") {
t.Fatal("expected rendered config to proxy through named upstream for hostname origin")
}
if !strings.Contains(result.Version.SnapshotJSON, `"origin_host":"git.arctel.net"`) {
t.Fatal("expected snapshot to include origin_host override")
}
}
func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) {
func TestPublishConfigVersionUsesNamedUpstreamForOriginBasePath(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "resolver.example.com",
OriginURL: "https://origin.internal/api/",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_resolver_example_com_1 {") {
t.Fatal("expected hostname origin with base path to still render named upstream")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_resolver_example_com_1/api/;") {
t.Fatal("expected rendered config to preserve base path while proxying through named upstream")
}
}
func TestPublishConfigVersionUsesNamedUpstreamForHostnameOrigins(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "resolver-upstream.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_resolver_upstream_example_com_1 {") {
t.Fatal("expected rendered config to define named upstream for hostname origin")
}
if !strings.Contains(result.Version.RenderedConfig, "server origin.internal max_fails=3 fail_timeout=10s;") {
t.Fatal("expected rendered config to include hostname upstream server entry")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_resolver_upstream_example_com_1;") {
t.Fatal("expected rendered config to proxy through named upstream for hostname origin")
}
}
func TestPublishConfigVersionUsesNamedUpstreamForIPOrigins(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "ip-origin.example.com",
OriginURL: "http://10.0.0.8:8080",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_ip_origin_example_com_1 {") {
t.Fatal("expected rendered config to define named upstream for static IP origins")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_ip_origin_example_com_1;") {
t.Fatal("expected rendered config to proxy through named upstream for IP origin")
}
if strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "http://10.0.0.8:8080"`) {
t.Fatal("expected rendered config to avoid runtime resolver variables for IP origin")
}
}
func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "ws-off.example.com",
OriginURL: "https://origin.internal",
@@ -190,15 +467,15 @@ func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) {
if err != nil {
t.Fatalf("PreviewConfigVersion failed: %v", err)
}
if strings.Contains(preview.RenderedConfig, "proxy_http_version 1.1;") {
t.Fatal("expected preview config to omit websocket proxy_http_version when disabled")
if !strings.Contains(preview.RenderedConfig, "proxy_http_version 1.1;") {
t.Fatal("expected preview config to keep HTTP/1.1 proxying for named upstream keepalive")
}
if !strings.Contains(preview.RenderedConfig, `proxy_set_header Connection "";`) {
t.Fatal("expected preview config to clear connection header when websocket upgrades are disabled")
}
if strings.Contains(preview.RenderedConfig, "proxy_set_header Upgrade $http_upgrade;") {
t.Fatal("expected preview config to omit websocket upgrade header when disabled")
}
if strings.Contains(preview.RenderedConfig, "proxy_set_header Connection $http_connection;") {
t.Fatal("expected preview config to omit websocket connection header when disabled")
}
}
func TestPreviewAndDiffConfigVersion(t *testing.T) {
@@ -345,7 +622,7 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
}
}
func TestRenderConfigRejectsUnknownSubdomainHosts(t *testing.T) {
func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
@@ -365,11 +642,17 @@ func TestRenderConfigRejectsUnknownSubdomainHosts(t *testing.T) {
if !strings.Contains(preview.RenderedConfig, `server_name git.arctel.net;`) {
t.Fatal("expected rendered config to include exact server_name")
}
if !strings.Contains(preview.RenderedConfig, `if ($host != "git.arctel.net") {`) {
t.Fatal("expected rendered config to guard against unknown subdomain host matches")
if strings.Contains(preview.RenderedConfig, `if ($host != "git.arctel.net") {`) {
t.Fatal("expected rendered config to avoid per-route host guard")
}
if !strings.Contains(preview.RenderedConfig, "return 404;") {
t.Fatal("expected rendered config to return 404 when host does not exactly match route domain")
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
t.Fatal("expected preview main config to include default http server")
}
if !strings.Contains(preview.MainConfig, "server_name _;") {
t.Fatal("expected preview main config to include default server_name")
}
if !strings.Contains(preview.MainConfig, "return 404;") {
t.Fatal("expected preview main config to return 404 for unmatched hosts")
}
}
@@ -417,6 +700,12 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
if !strings.Contains(preview.MainConfig, "access_log __OPENFLARE_ACCESS_LOG__ openflare_json;") {
t.Fatal("expected preview main config to preserve managed access log placeholder")
}
if !strings.Contains(preview.MainConfig, "map $http_upgrade $connection_upgrade {") {
t.Fatal("expected preview main config to preserve managed websocket upgrade map")
}
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
t.Fatal("expected preview main config to preserve managed default server block")
}
invalidTemplate := strings.ReplaceAll(
common.OpenRestyMainConfigTemplate,
@@ -435,6 +724,15 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
t.Fatal("expected template without managed access log placeholder to fail validation")
}
invalidTemplate = strings.ReplaceAll(
common.OpenRestyMainConfigTemplate,
"{{OpenRestyConnectionUpgradeMap}}",
"",
)
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
t.Fatal("expected template without managed websocket upgrade map placeholder to fail validation")
}
}
func TestOpenRestyCommonRequestOptionsRender(t *testing.T) {
+4 -4
View File
@@ -418,9 +418,9 @@ func normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload {
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.LastError = strings.TrimSpace(payload.LastError)
payload.LastError = truncateForDatabase(payload.LastError, 16000)
payload.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
payload.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
payload.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
return payload
}
@@ -444,11 +444,11 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b
node.AgentVersion = strings.TrimSpace(payload.AgentVersion)
node.NginxVersion = strings.TrimSpace(payload.NginxVersion)
node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
node.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
node.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
node.Status = NodeStatusOnline
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
node.LastSeenAt = time.Now()
node.LastError = strings.TrimSpace(payload.LastError)
node.LastError = truncateForDatabase(payload.LastError, 16000)
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
}
@@ -42,6 +42,11 @@ type NodeObservabilityTrends struct {
DiskIO24h []DiskIOTrendPoint `json:"disk_io_24h"`
}
type NodeHealthEventCleanupResult struct {
NodeID string `json:"node_id"`
DeletedCount int64 `json:"deleted_count"`
}
func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) {
now := time.Now()
node, err := model.GetNodeByID(id)
@@ -105,6 +110,21 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
}, nil
}
func CleanupNodeHealthEvents(id uint) (*NodeHealthEventCleanupResult, error) {
node, err := model.GetNodeByID(id)
if err != nil {
return nil, err
}
deletedCount, err := model.DeleteNodeHealthEvents(node.NodeID)
if err != nil {
return nil, err
}
return &NodeHealthEventCleanupResult{
NodeID: node.NodeID,
DeletedCount: deletedCount,
}, nil
}
func latestMetricSnapshot(snapshots []*model.NodeMetricSnapshot) *model.NodeMetricSnapshot {
for _, snapshot := range snapshots {
if snapshot != nil {
+82 -4
View File
@@ -708,7 +708,12 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
t.Fatalf("unexpected request reports: %+v", reports)
}
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10)
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{
NodeID: node.NodeID,
Since: time.Time{},
Page: 0,
PageSize: 10,
})
if err != nil {
t.Fatalf("expected node access logs query to succeed: %v", err)
}
@@ -822,7 +827,12 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
t.Fatalf("expected current and buffered reports, got %+v", reports)
}
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10)
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{
NodeID: node.NodeID,
Since: time.Time{},
Page: 0,
PageSize: 10,
})
if err != nil {
t.Fatalf("expected node access logs query to succeed: %v", err)
}
@@ -930,7 +940,11 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
t.Fatalf("failed to seed access logs: %v", err)
}
pageOne, err := ListAccessLogs(node.NodeID, 0, 2)
pageOne, err := ListAccessLogs(AccessLogQuery{
NodeID: node.NodeID,
Page: 0,
PageSize: 2,
})
if err != nil {
t.Fatalf("ListAccessLogs page 1 failed: %v", err)
}
@@ -944,7 +958,11 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
t.Fatalf("expected paged access log region to be returned, got %+v", pageOne.Items[0])
}
pageTwo, err := ListAccessLogs(node.NodeID, 1, 2)
pageTwo, err := ListAccessLogs(AccessLogQuery{
NodeID: node.NodeID,
Page: 1,
PageSize: 2,
})
if err != nil {
t.Fatalf("ListAccessLogs page 2 failed: %v", err)
}
@@ -1183,6 +1201,66 @@ func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
}
}
func TestCleanupNodeHealthEvents(t *testing.T) {
setupServiceTestDB(t)
node := &model.Node{
NodeID: "node-health-cleanup",
Name: "health-cleanup-edge",
IP: "10.0.0.72",
AgentToken: "token-health-cleanup",
AgentVersion: "v0.6.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
}
if err := node.Insert(); err != nil {
t.Fatalf("failed to insert node: %v", err)
}
resolvedAt := time.Now().Add(-4 * time.Minute)
if err := model.DB.Create(&model.NodeHealthEvent{
NodeID: node.NodeID,
EventType: "sync_error",
Severity: NodeHealthSeverityWarning,
Status: NodeHealthEventStatusActive,
Message: "checksum mismatch",
FirstTriggeredAt: time.Now().Add(-2 * time.Minute),
LastTriggeredAt: time.Now().Add(-time.Minute),
ReportedAt: time.Now().Add(-time.Minute),
}).Error; err != nil {
t.Fatalf("failed to insert first node health event: %v", err)
}
if err := model.DB.Create(&model.NodeHealthEvent{
NodeID: node.NodeID,
EventType: "openresty_down",
Severity: NodeHealthSeverityCritical,
Status: NodeHealthEventStatusResolved,
Message: "openresty exited unexpectedly",
FirstTriggeredAt: time.Now().Add(-10 * time.Minute),
LastTriggeredAt: time.Now().Add(-5 * time.Minute),
ReportedAt: time.Now().Add(-5 * time.Minute),
ResolvedAt: &resolvedAt,
}).Error; err != nil {
t.Fatalf("failed to insert second node health event: %v", err)
}
result, err := CleanupNodeHealthEvents(node.ID)
if err != nil {
t.Fatalf("CleanupNodeHealthEvents failed: %v", err)
}
if result.NodeID != node.NodeID || result.DeletedCount != 2 {
t.Fatalf("unexpected cleanup result: %+v", result)
}
events, err := model.ListNodeHealthEvents(node.NodeID, false, 10)
if err != nil {
t.Fatalf("failed to list node health events after cleanup: %v", err)
}
if len(events) != 0 {
t.Fatalf("expected node health events to be removed, got %+v", events)
}
}
func TestGetDashboardOverview(t *testing.T) {
setupServiceTestDB(t)
+7 -3
View File
@@ -17,7 +17,7 @@ const (
NodeHealthSeverityInfo = "info"
NodeHealthSeverityWarning = "warning"
NodeHealthSeverityCritical = "critical"
nodeAccessLogRetentionWindow = 24 * time.Hour
nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
)
type AgentNodeSystemProfile struct {
@@ -271,7 +271,7 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea
triggeredAt := timeFromUnix(event.TriggeredAtUnix, reportedAt)
if existing, ok := activeByType[eventType]; ok {
existing.Severity = event.Severity
existing.Message = strings.TrimSpace(event.Message)
existing.Message = normalizeHealthEventMessage(event.Message)
existing.LastTriggeredAt = triggeredAt
existing.ReportedAt = reportedAt
existing.RawJSON = marshalJSON(event)
@@ -286,7 +286,7 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea
EventType: eventType,
Severity: event.Severity,
Status: NodeHealthEventStatusActive,
Message: strings.TrimSpace(event.Message),
Message: normalizeHealthEventMessage(event.Message),
FirstTriggeredAt: triggeredAt,
LastTriggeredAt: triggeredAt,
ReportedAt: reportedAt,
@@ -330,6 +330,10 @@ func normalizeHealthSeverity(severity string) string {
}
}
func normalizeHealthEventMessage(message string) string {
return truncateForDatabase(message, 4096)
}
func timeFromUnix(unixSeconds int64, fallback time.Time) time.Time {
if unixSeconds <= 0 {
return fallback
+200 -4
View File
@@ -11,6 +11,13 @@ import (
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
const (
proxyRouteCachePolicyURL = "url"
proxyRouteCachePolicySuffix = "suffix"
proxyRouteCachePolicyPathPrefix = "path_prefix"
proxyRouteCachePolicyPathExact = "path_exact"
)
type ProxyRouteCustomHeaderInput struct {
Key string `json:"key"`
Value string `json:"value"`
@@ -20,10 +27,14 @@ type ProxyRouteInput struct {
Domain string `json:"domain"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host"`
Upstreams []string `json:"upstreams"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id"`
RedirectHTTP bool `json:"redirect_http"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
Remark string `json:"remark"`
}
@@ -77,10 +88,27 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
originURL := strings.TrimSpace(input.OriginURL)
originHost := strings.TrimSpace(input.OriginHost)
remark := strings.TrimSpace(input.Remark)
upstreams, err := normalizeUpstreams(originURL, input.Upstreams)
if err != nil {
return nil, err
}
cachePolicy := strings.TrimSpace(input.CachePolicy)
cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules)
if err != nil {
return nil, err
}
customHeaders, err := normalizeCustomHeaders(input.CustomHeaders)
if err != nil {
return nil, err
}
cacheRulesJSON, err := json.Marshal(cacheRules)
if err != nil {
return nil, err
}
upstreamsJSON, err := json.Marshal(upstreams)
if err != nil {
return nil, err
}
customHeadersJSON, err := json.Marshal(customHeaders)
if err != nil {
return nil, err
@@ -91,9 +119,6 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
if strings.Contains(domain, "://") || strings.Contains(domain, "/") {
return nil, errors.New("域名格式不合法")
}
if err := validateOriginURL(originURL); err != nil {
return nil, err
}
if err := validateOriginHost(originHost); err != nil {
return nil, err
}
@@ -116,12 +141,16 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route = &model.ProxyRoute{}
}
route.Domain = domain
route.OriginURL = originURL
route.OriginURL = upstreams[0]
route.OriginHost = originHost
route.Upstreams = string(upstreamsJSON)
route.Enabled = input.Enabled
route.EnableHTTPS = input.EnableHTTPS
route.CertID = input.CertID
route.RedirectHTTP = input.RedirectHTTP
route.CacheEnabled = input.CacheEnabled
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
route.CacheRules = string(cacheRulesJSON)
route.CustomHeaders = string(customHeadersJSON)
route.Remark = remark
return route, nil
@@ -155,6 +184,59 @@ func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRoute
return normalized, nil
}
func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) {
candidates := make([]string, 0, len(upstreams)+1)
if strings.TrimSpace(originURL) != "" {
candidates = append(candidates, originURL)
}
candidates = append(candidates, upstreams...)
trimmed := make([]string, 0, len(candidates))
for _, candidate := range candidates {
item := strings.TrimSpace(candidate)
if item == "" {
continue
}
trimmed = append(trimmed, item)
}
unique := make([]string, 0, len(trimmed))
seen := make(map[string]struct{}, len(trimmed))
for _, item := range trimmed {
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
unique = append(unique, item)
}
normalized := make([]string, 0, len(unique))
var scheme string
multiUpstream := len(unique) > 1
for _, item := range unique {
if err := validateOriginURL(item); err != nil {
return nil, err
}
parsed, err := url.ParseRequestURI(item)
if err != nil {
return nil, errors.New("源站地址格式不合法")
}
if multiUpstream && parsed.Path != "" && parsed.Path != "/" {
return nil, errors.New("多上游模式暂不支持带路径的源站地址")
}
if multiUpstream && parsed.RawQuery != "" {
return nil, errors.New("多上游模式暂不支持带查询参数的源站地址")
}
if scheme == "" {
scheme = parsed.Scheme
} else if scheme != parsed.Scheme {
return nil, errors.New("同一规则的多个上游必须使用相同协议")
}
normalized = append(normalized, item)
}
if len(normalized) == 0 {
return nil, errors.New("至少填写一个上游地址")
}
return normalized, nil
}
func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error) {
text := strings.TrimSpace(raw)
if text == "" {
@@ -167,6 +249,120 @@ func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error
return normalizeCustomHeaders(headers)
}
func normalizeCachePolicy(enabled bool, raw string) string {
if !enabled {
return ""
}
policy := strings.TrimSpace(raw)
if policy == "" {
return proxyRouteCachePolicyURL
}
return policy
}
func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]string, error) {
if !enabled {
return []string{}, nil
}
policy := normalizeCachePolicy(enabled, rawPolicy)
switch policy {
case proxyRouteCachePolicyURL:
return []string{}, nil
case proxyRouteCachePolicySuffix:
return normalizeCacheSuffixRules(rules)
case proxyRouteCachePolicyPathPrefix:
return normalizeCachePathRules(rules, true)
case proxyRouteCachePolicyPathExact:
return normalizeCachePathRules(rules, false)
default:
return nil, errors.New("缓存策略不支持")
}
}
func normalizeCacheSuffixRules(rules []string) ([]string, error) {
normalized := make([]string, 0, len(rules))
seen := make(map[string]struct{}, len(rules))
for _, rule := range rules {
item := strings.TrimSpace(strings.TrimPrefix(rule, "."))
if item == "" {
continue
}
if strings.ContainsAny(item, "/\\ \t\r\n") {
return nil, errors.New("缓存后缀格式不合法")
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
normalized = append(normalized, item)
}
if len(normalized) == 0 {
return nil, errors.New("按后缀缓存时至少填写一个后缀")
}
return normalized, nil
}
func normalizeCachePathRules(rules []string, allowPrefix bool) ([]string, error) {
normalized := make([]string, 0, len(rules))
seen := make(map[string]struct{}, len(rules))
for _, rule := range rules {
item := strings.TrimSpace(rule)
if item == "" {
continue
}
if !strings.HasPrefix(item, "/") || strings.Contains(item, "://") || strings.ContainsAny(item, " \t\r\n") {
return nil, errors.New("缓存路径规则格式不合法")
}
if !allowPrefix && strings.HasSuffix(item, "/") && len(item) > 1 {
item = strings.TrimRight(item, "/")
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
normalized = append(normalized, item)
}
if len(normalized) == 0 {
if allowPrefix {
return nil, errors.New("按路径前缀缓存时至少填写一个路径")
}
return nil, errors.New("按精确路径缓存时至少填写一个路径")
}
return normalized, nil
}
func decodeStoredCacheRules(raw string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return []string{}, nil
}
var rules []string
if err := json.Unmarshal([]byte(text), &rules); err != nil {
return nil, errors.New("缓存规则格式不合法")
}
normalized := make([]string, 0, len(rules))
for _, rule := range rules {
item := strings.TrimSpace(rule)
if item == "" {
continue
}
normalized = append(normalized, item)
}
return normalized, nil
}
func decodeStoredUpstreams(raw string, fallbackOriginURL string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return normalizeUpstreams(fallbackOriginURL, nil)
}
var upstreams []string
if err := json.Unmarshal([]byte(text), &upstreams); err != nil {
return nil, errors.New("上游配置格式不合法")
}
return normalizeUpstreams(fallbackOriginURL, upstreams)
}
func validateOriginURL(raw string) error {
if raw == "" {
return errors.New("源站地址不能为空")
@@ -1,15 +1,56 @@
import { apiRequest } from '@/lib/api/client';
import type { AccessLogList } from '@/features/access-logs/types';
import type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryList,
AccessLogIPTrend,
AccessLogIPTrendFilters,
AccessLogList,
FoldedAccessLogFilters,
FoldedAccessLogList,
} from '@/features/access-logs/types';
export function getAccessLogs(page: number, nodeId?: string, pageSize = 50) {
const normalizedNodeId = nodeId?.trim();
const searchParams = new URLSearchParams({
p: String(Math.max(page, 0)),
page_size: String(pageSize),
function buildSearchParams(filters: object) {
const searchParams = new URLSearchParams();
Object.entries(filters as Record<string, string | number | undefined>).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
searchParams.set(key, String(value));
});
return searchParams.toString();
}
export function getAccessLogs(filters: AccessLogFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogList>(`/access-logs/${query ? `?${query}` : ''}`);
}
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
const query = buildSearchParams(filters);
return apiRequest<FoldedAccessLogList>(`/access-logs/folds${query ? `?${query}` : ''}`);
}
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogIPSummaryList>(
`/access-logs/ip-summary${query ? `?${query}` : ''}`,
);
}
export function getAccessLogIPTrend(filters: AccessLogIPTrendFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogIPTrend>(
`/access-logs/ip-summary/trend${query ? `?${query}` : ''}`,
);
}
export function cleanupAccessLogs(payload: AccessLogCleanupPayload) {
return apiRequest<AccessLogCleanupResult>('/access-logs/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
if (normalizedNodeId) {
searchParams.set('node_id', normalizedNodeId);
}
return apiRequest<AccessLogList>(`/access-logs/?${searchParams.toString()}`);
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,14 @@
export interface AccessLogFilters {
node_id?: string;
remote_addr?: string;
host?: string;
path?: string;
p?: number;
page_size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AccessLogItem {
id: number;
node_id: string;
@@ -18,3 +29,85 @@ export interface AccessLogList {
total_record: number;
total_ip: number;
}
export interface FoldedAccessLogFilters extends AccessLogFilters {
fold_minutes: 3 | 5;
}
export interface FoldedAccessLogItem {
bucket_started_at: string;
request_count: number;
unique_ip_count: number;
unique_host_count: number;
success_count: number;
client_error_count: number;
server_error_count: number;
}
export interface FoldedAccessLogList {
items: FoldedAccessLogItem[];
page: number;
page_size: number;
has_more: boolean;
total_bucket: number;
total_record: number;
total_ip: number;
fold_minutes: number;
}
export interface AccessLogIPSummaryFilters {
node_id?: string;
remote_addr?: string;
host?: string;
p?: number;
page_size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AccessLogIPSummaryItem {
remote_addr: string;
total_requests: number;
recent_requests: number;
last_seen_at: string;
}
export interface AccessLogIPSummaryList {
items: AccessLogIPSummaryItem[];
page: number;
page_size: number;
has_more: boolean;
total_ip: number;
sort_by: string;
sort_order: 'asc' | 'desc';
}
export interface AccessLogIPTrendFilters {
node_id?: string;
remote_addr: string;
host?: string;
hours?: number;
bucket_minutes?: number;
}
export interface AccessLogIPTrendPoint {
bucket_started_at: string;
request_count: number;
}
export interface AccessLogIPTrend {
remote_addr: string;
hours: number;
bucket_minutes: number;
points: AccessLogIPTrendPoint[];
}
export interface AccessLogCleanupPayload {
retention_days: number;
}
export interface AccessLogCleanupResult {
retention_days: number;
deleted_count: number;
cutoff: string;
}
@@ -1,9 +1,31 @@
import { apiRequest } from '@/lib/api/client';
import type { ApplyLogItem } from '@/features/apply-logs/types';
import type {
ApplyLogCleanupPayload,
ApplyLogCleanupResult,
ApplyLogList,
ApplyLogListQuery,
} from '@/features/apply-logs/types';
export function getApplyLogs(nodeId?: string) {
const normalizedNodeId = nodeId?.trim();
const query = normalizedNodeId ? `?node_id=${encodeURIComponent(normalizedNodeId)}` : '';
return apiRequest<ApplyLogItem[]>(`/apply-logs/${query}`);
export function getApplyLogs(query: ApplyLogListQuery = {}) {
const params = new URLSearchParams();
const normalizedNodeId = query.node_id?.trim();
if (normalizedNodeId) {
params.set('node_id', normalizedNodeId);
}
if (query.pageNo) {
params.set('pageNo', String(query.pageNo));
}
if (query.pageSize) {
params.set('pageSize', String(query.pageSize));
}
const suffix = params.size > 0 ? `?${params.toString()}` : '';
return apiRequest<ApplyLogList>(`/apply-logs/${suffix}`);
}
export function cleanupApplyLogs(payload: ApplyLogCleanupPayload) {
return apiRequest<ApplyLogCleanupResult>('/apply-logs/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -1,9 +1,10 @@
'use client';
import Link from 'next/link';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AppModal } from '@/components/ui/app-modal';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
@@ -11,16 +12,36 @@ import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
import type { ApplyLogItem } from '@/features/apply-logs/types';
import {
cleanupApplyLogs,
getApplyLogs,
} from '@/features/apply-logs/api/apply-logs';
import type {
ApplyLogCleanupPayload,
ApplyLogItem,
} from '@/features/apply-logs/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const;
const applyLogsQueryKey = (
nodeId: string,
pageNo: number,
pageSize: number,
) => ['apply-logs', nodeId, pageNo, pageSize] as const;
const pageSizeOptions = [20, 50, 100];
const emptyApplyLogRows: ApplyLogItem[] = [];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
@@ -30,65 +51,92 @@ function getResultMeta(result: string) {
if (result === 'success') {
return { label: '成功', variant: 'success' as const };
}
if (result === 'warning') {
return { label: '警告', variant: 'warning' as const };
}
return { label: '失败', variant: 'danger' as const };
}
function buildSummary(logs: ApplyLogItem[]) {
const nodeIds = new Set(logs.map((item) => item.node_id));
return [
{ label: '记录总数', value: logs.length },
{
label: '成功',
value: logs.filter((item) => item.result === 'success').length,
},
{
label: '失败',
value: logs.filter((item) => item.result !== 'success').length,
},
{ label: '节点数', value: nodeIds.size },
];
}
function truncateHash(value: string) {
if (!value) {
return '—';
}
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
}
function buildSummary(rows: ApplyLogItem[], total: number, current: number, totalPage: number) {
const nodeIds = new Set(rows.map((item) => item.node_id));
return [
{ label: '总记录数', value: total },
{ label: '当前页', value: current },
{ label: '总页数', value: totalPage },
{ label: '当前页节点数', value: nodeIds.size },
];
}
export function ApplyLogsPage() {
const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState('');
const [nodeFilter, setNodeFilter] = useState('');
const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [pageNo, setPageNo] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null);
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
const [cleanupMode, setCleanupMode] = useState<'all' | 'custom'>('custom');
const [customRetentionDays, setCustomRetentionDays] = useState('30');
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const logsQuery = useQuery({
queryKey: applyLogsQueryKey(nodeFilter),
queryFn: () => getApplyLogs(nodeFilter),
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
queryFn: () =>
getApplyLogs({
node_id: nodeFilter || undefined,
pageNo,
pageSize,
}),
placeholderData: (previous) => previous,
});
const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]);
const summary = useMemo(() => buildSummary(logs), [logs]);
const cleanupMutation = useMutation({
mutationFn: (payload: ApplyLogCleanupPayload) => cleanupApplyLogs(payload),
onSuccess: async (result) => {
setCleanupModalOpen(false);
setPageNo(1);
setFeedback({
tone: 'success',
message: result.delete_all
? `已删除全部应用日志,共 ${result.deleted_count} 条。`
: `已清理保留期之外的应用日志,共 ${result.deleted_count} 条。`,
});
await queryClient.invalidateQueries({ queryKey: ['apply-logs'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const rows = logsQuery.data?.rows ?? emptyApplyLogRows;
const current = logsQuery.data?.current ?? pageNo;
const total = logsQuery.data?.total ?? 0;
const totalPage = logsQuery.data?.totalPage ?? 0;
const summary = useMemo(
() => buildSummary(rows, total, current, totalPage),
[rows, total, current, totalPage],
);
useEffect(() => {
if (logs.length === 0) {
setSelectedLogId(null);
if (rows.length === 0 && selectedLog) {
setSelectedLog(null);
return;
}
if (!logs.some((item) => item.id === selectedLogId)) {
setSelectedLogId(logs[0].id);
if (selectedLog && !rows.some((item) => item.id === selectedLog.id)) {
setSelectedLog(null);
}
}, [logs, selectedLogId]);
const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null;
}, [rows, selectedLog]);
const handleSearch = () => {
setFeedback(null);
setPageNo(1);
setNodeFilter(nodeFilterInput.trim());
};
@@ -96,36 +144,67 @@ export function ApplyLogsPage() {
setFeedback(null);
setNodeFilter('');
setNodeFilterInput('');
setPageNo(1);
};
const handleRefresh = async () => {
setFeedback(null);
await queryClient.invalidateQueries({
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
});
};
const handleCleanupConfirm = () => {
const payload: ApplyLogCleanupPayload =
cleanupMode === 'all'
? { delete_all: true }
: {
retention_days: Number.parseInt(customRetentionDays, 10),
};
cleanupMutation.mutate(payload);
};
return (
<div className="space-y-6">
<PageHeader
title="应用记录"
description="查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。"
title="应用日志"
description="查看节点应用配置的成功、警告和失败记录,支持分页查询、详情弹窗和按保留天数清理。"
action={
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回节点页
</Link>
<div className="flex flex-wrap gap-2">
<SecondaryButton type="button" onClick={handleRefresh}>
刷新
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setFeedback(null);
setCleanupModalOpen(true);
}}
>
删除日志
</PrimaryButton>
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回节点
</Link>
</div>
}
/>
{feedback ? <InlineMessage tone="info" message={feedback} /> : null}
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard
title="记录摘要"
description="帮助快速识别失败趋势和受影响节点范围。"
>
<AppCard title="日志摘要" description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
@@ -136,54 +215,60 @@ export function ApplyLogsPage() {
</div>
</AppCard>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<AppCard
title="过滤与列表"
description="默认展示全部记录,可按 node_id 快速筛选单节点应用结果。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: applyLogsQueryKey(nodeFilter),
})
}
>
刷新
</SecondaryButton>
}
>
<div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder="输入 node_id 过滤应用记录"
className="lg:max-w-md"
/>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
<AppCard
title="过滤与列表"
description="支持按 node_id 过滤,并按页查看应用结果。默认每页 20 条。"
>
<div className="space-y-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
<ResourceField label="Node ID">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder="输入 node_id 过滤应用日志"
/>
</ResourceField>
<ResourceField label="每页条数">
<ResourceSelect
value={String(pageSize)}
onChange={(event) => {
setPageSize(Number.parseInt(event.target.value, 10));
setPageNo(1);
}}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option} 条
</option>
))}
</ResourceSelect>
</ResourceField>
</div>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
</div>
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : logs.length === 0 ? (
<EmptyState
title="暂无应用记录"
description="当前筛选条件下没有可展示的应用结果。"
/>
) : (
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState
title="应用日志加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : rows.length === 0 ? (
<EmptyState
title="暂无应用日志"
description="当前筛选条件下没有可展示的应用记录。"
/>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
@@ -193,11 +278,12 @@ export function ApplyLogsPage() {
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">详情</th>
<th className="px-3 py-3 font-medium">消息</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{logs.map((log) => {
{rows.map((log) => {
const resultMeta = getResultMeta(log.result);
return (
<tr key={log.id} className="align-top">
@@ -227,16 +313,16 @@ export function ApplyLogsPage() {
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="max-w-72 break-words whitespace-pre-wrap">
{log.message || '—'}
</div>
</td>
<td className="px-3 py-4">
<SecondaryButton
type="button"
onClick={() => {
setSelectedLogId(log.id);
setFeedback(
`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`,
);
}}
className="px-3 py-2 text-xs"
onClick={() => setSelectedLog(log)}
>
查看详情
</SecondaryButton>
@@ -247,99 +333,176 @@ export function ApplyLogsPage() {
</tbody>
</table>
</div>
)}
</div>
</AppCard>
<AppCard
title="记录详情"
description="展示所选应用记录的完整结果与错误信息。"
>
{selectedLog ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge
label={`Node:${selectedLog.node_id}`}
variant="info"
/>
<StatusBadge
label={`版本:${selectedLog.version}`}
variant="warning"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(selectedLog.created_at)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
相对时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatRelativeTime(selectedLog.created_at)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
目标 Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.checksum || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
支持文件数
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{selectedLog.support_file_count}
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
主配置摘要
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.main_config_checksum || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
路由配置摘要
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.route_config_checksum || '无'}
</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
应用信息
</p>
<p className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
{selectedLog.message || '无附加信息'}
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]">
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
</p>
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
disabled={current <= 1}
onClick={() => setPageNo((previous) => Math.max(1, previous - 1))}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
disabled={totalPage === 0 || current >= totalPage}
onClick={() =>
setPageNo((previous) =>
totalPage > 0 ? Math.min(totalPage, previous + 1) : previous,
)
}
>
下一页
</SecondaryButton>
</div>
</div>
</div>
) : (
<EmptyState
title="未选择记录"
description="请先从左侧列表中选择一条应用记录查看详情。"
/>
</>
)}
</AppCard>
</div>
</div>
</AppCard>
<AppModal
isOpen={selectedLog !== null}
title="应用日志详情"
description="查看单条应用日志的完整结果、消息和校验信息。"
size="lg"
onClose={() => setSelectedLog(null)}
>
{selectedLog ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge label={`Node:${selectedLog.node_id}`} variant="info" />
<StatusBadge label={`版本:${selectedLog.version}`} variant="warning" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<MetricCard label="创建时间" value={formatDateTime(selectedLog.created_at)} />
<MetricCard label="相对时间" value={formatRelativeTime(selectedLog.created_at)} />
<MetricCard label="目标 Checksum" value={selectedLog.checksum || '—'} breakAll />
<MetricCard
label="支持文件数"
value={String(selectedLog.support_file_count)}
/>
<MetricCard
label="主配置摘要"
value={selectedLog.main_config_checksum || '—'}
breakAll
/>
<MetricCard
label="路由配置摘要"
value={selectedLog.route_config_checksum || '—'}
breakAll
/>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
消息
</p>
<pre className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--foreground-primary)]">
{selectedLog.message || '—'}
</pre>
</div>
</div>
) : null}
</AppModal>
<AppModal
isOpen={isCleanupModalOpen}
title="删除应用日志"
description="可以选择删除全部应用日志,或仅保留最近自定义天数内的记录。"
onClose={() => setCleanupModalOpen(false)}
footer={
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}>
取消
</SecondaryButton>
<PrimaryButton
type="button"
disabled={cleanupMutation.isPending}
onClick={handleCleanupConfirm}
>
{cleanupMutation.isPending ? '处理中...' : '确认删除'}
</PrimaryButton>
</div>
}
>
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCleanupMode('all')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupMode === 'all'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
全部删除
</button>
<button
type="button"
onClick={() => setCleanupMode('custom')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupMode === 'custom'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
保留自定义天数
</button>
</div>
{cleanupMode === 'custom' ? (
<ResourceField label="保留天数" hint="当前支持 1 到 3650 天。">
<ResourceInput
value={customRetentionDays}
onChange={(event) => setCustomRetentionDays(event.target.value)}
type="number"
min={1}
max={3650}
placeholder="输入保留天数"
/>
</ResourceField>
) : null}
{cleanupMutation.isError ? (
<ErrorState
title="删除应用日志失败"
description={getErrorMessage(cleanupMutation.error)}
/>
) : null}
</div>
</AppModal>
</div>
);
}
function MetricCard({
label,
value,
breakAll = false,
}: {
label: string;
value: string;
breakAll?: boolean;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
{label}
</p>
<p
className={`mt-2 text-sm text-[var(--foreground-primary)] ${
breakAll ? 'break-all' : ''
}`}
>
{value}
</p>
</div>
);
}
@@ -10,3 +10,28 @@ export interface ApplyLogItem {
support_file_count: number;
created_at: string;
}
export interface ApplyLogList {
rows: ApplyLogItem[];
current: number;
total: number;
totalPage: number;
}
export interface ApplyLogListQuery {
node_id?: string;
pageNo?: number;
pageSize?: number;
}
export interface ApplyLogCleanupPayload {
delete_all?: boolean;
retention_days?: number;
}
export interface ApplyLogCleanupResult {
delete_all: boolean;
retention_days: number;
deleted_count: number;
cutoff?: string;
}
@@ -1,14 +1,23 @@
import { apiRequest } from '@/lib/api/client';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
} from '@/features/config-versions/types';
export function getConfigVersions() {
return apiRequest<ConfigVersionItem[]>('/config-versions/');
}
import { apiRequest } from '@/lib/api/client';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionDetail,
ConfigVersionSummary,
} from '@/features/config-versions/types';
export function getConfigVersions() {
return apiRequest<ConfigVersionSummary[]>('/config-versions/');
}
export function getConfigVersion(id: number) {
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}`);
}
export function getActiveConfigVersion() {
return apiRequest<ConfigVersionDetail>('/config-versions/active');
}
export function getConfigVersionPreview() {
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
@@ -18,14 +27,14 @@ export function getConfigVersionDiff() {
return apiRequest<ConfigDiffResult>('/config-versions/diff');
}
export function publishConfigVersion() {
return apiRequest<ConfigVersionItem>('/config-versions/publish', {
method: 'POST',
});
}
export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionItem>(`/config-versions/${id}/activate`, {
export function publishConfigVersion() {
return apiRequest<ConfigVersionDetail>('/config-versions/publish', {
method: 'POST',
});
}
export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
method: 'POST',
});
}
@@ -1,20 +1,41 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import type { ConfigVersionItem } from '@/features/config-versions/types';
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
import type { ConfigVersionSummary } from '@/features/config-versions/types';
import {
CodeBlock,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function ConfigVersionSnapshotModal({
version,
onClose,
}: {
version: ConfigVersionItem | null;
version: ConfigVersionSummary | null;
onClose: () => void;
}) {
const versionDetailQuery = useQuery({
queryKey: ['config-versions', 'detail', version?.id ?? 0],
queryFn: () => {
if (!version) {
throw new Error('missing config version');
}
return getConfigVersion(version.id);
},
enabled: Boolean(version?.id),
});
const versionDetail = versionDetailQuery.data ?? null;
return (
<AppModal
isOpen={Boolean(version)}
@@ -30,7 +51,14 @@ export function ConfigVersionSnapshotModal({
</div>
}
>
{version ? (
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
<LoadingState />
) : versionDetailQuery.isError ? (
<ErrorState
title="配置版本详情加载失败"
description={getErrorMessage(versionDetailQuery.error)}
/>
) : versionDetail ? (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -38,7 +66,7 @@ export function ConfigVersionSnapshotModal({
Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{version.checksum}
{versionDetail.checksum}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -46,7 +74,7 @@ export function ConfigVersionSnapshotModal({
创建人
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{version.created_by || '系统'}
{versionDetail.created_by || '系统'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -54,7 +82,7 @@ export function ConfigVersionSnapshotModal({
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(version.created_at)}
{formatDateTime(versionDetail.created_at)}
</p>
</div>
</div>
@@ -64,23 +92,25 @@ export function ConfigVersionSnapshotModal({
快照 JSON
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json}
{versionDetail.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.main_config}
{versionDetail.main_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config}
{versionDetail.rendered_config}
</CodeBlock>
</div>
</div>
@@ -12,6 +12,7 @@ import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
activateConfigVersion,
getActiveConfigVersion,
getConfigVersionDiff,
getConfigVersionPreview,
getConfigVersions,
@@ -22,7 +23,8 @@ import type {
ConfigOptionDiffItem,
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
ConfigVersionDetail,
ConfigVersionSummary,
SupportFile,
} from '@/features/config-versions/types';
import {
@@ -168,14 +170,20 @@ function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
function PublishPreviewCard({
preview,
diff,
activeVersion,
activeVersionMeta,
activeVersionDetail,
isActiveVersionDetailLoading,
activeVersionDetailError,
isPublishing,
onConfirm,
onCancel,
}: {
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
activeVersion: ConfigVersionItem | null;
activeVersionMeta: ConfigVersionSummary | null;
activeVersionDetail: ConfigVersionDetail | null;
isActiveVersionDetailLoading: boolean;
activeVersionDetailError: string | null;
isPublishing: boolean;
onConfirm: () => void;
onCancel: () => void;
@@ -263,18 +271,29 @@ function PublishPreviewCard({
<OptionDiffTable items={diff.changed_option_details} />
</div>
{diff.main_config_changed && activeVersion ? (
{diff.main_config_changed && activeVersionMeta ? (
<div className="grid gap-5 xl:grid-cols-2">
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Current Active Main Config
</p>
<StatusBadge label={activeVersion.version} variant="info" />
<StatusBadge label={activeVersionMeta.version} variant="info" />
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersion.main_config}
</CodeBlock>
{isActiveVersionDetailLoading ? (
<LoadingState />
) : activeVersionDetailError ? (
<InlineMessage tone="danger" message={activeVersionDetailError} />
) : activeVersionDetail ? (
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersionDetail.main_config}
</CodeBlock>
) : (
<InlineMessage
tone="info"
message="当前激活版本详情暂不可用。"
/>
)}
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
@@ -406,6 +425,14 @@ export function ConfigVersionsPage() {
() => versions.find((item) => item.id === selectedVersionId) ?? null,
[selectedVersionId, versions],
);
const shouldLoadActiveVersionDetail = Boolean(
activeVersion?.id && publishPreview?.diff.main_config_changed,
);
const activeVersionDetailQuery = useQuery({
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
queryFn: getActiveConfigVersion,
enabled: shouldLoadActiveVersionDetail,
});
const publishMutation = useMutation({
mutationFn: publishConfigVersion,
@@ -455,7 +482,7 @@ export function ConfigVersionsPage() {
}
};
const handleActivate = (version: ConfigVersionItem) => {
const handleActivate = (version: ConfigVersionSummary) => {
if (version.is_active) {
return;
}
@@ -493,7 +520,14 @@ export function ConfigVersionsPage() {
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
activeVersion={activeVersion}
activeVersionMeta={activeVersion}
activeVersionDetail={activeVersionDetailQuery.data ?? null}
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
activeVersionDetailError={
activeVersionDetailQuery.isError
? getErrorMessage(activeVersionDetailQuery.error)
: null
}
isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)}
@@ -1,16 +1,19 @@
export interface ConfigVersionItem {
export interface ConfigVersionSummary {
id: number;
version: string;
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
checksum: string;
is_active: boolean;
created_by: string;
created_at: string;
}
export interface ConfigVersionDetail extends ConfigVersionSummary {
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
}
export interface SupportFile {
path: string;
content: string;
@@ -1,38 +1,229 @@
import { apiRequest } from '@/lib/api/client';
import type { DashboardOverview } from '@/features/dashboard/types';
import type {
CompactCapacityTrendPoint,
CompactDashboardNodeHealth,
CompactDiskIOTrendPoint,
CompactDistributionItem,
CompactNetworkTrendPoint,
CompactTrafficTrendPoint,
DashboardCapacity,
DashboardNodeHealth,
DashboardOverview,
DashboardOverviewCompact,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/features/dashboard/types';
function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : [];
}
function isCompactDistributionItem(
value: DistributionItem | CompactDistributionItem,
): value is CompactDistributionItem {
return Array.isArray(value);
}
function isCompactTrafficTrendPoint(
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint,
): value is CompactTrafficTrendPoint {
return Array.isArray(value);
}
function isCompactCapacityTrendPoint(
value:
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint,
): value is CompactCapacityTrendPoint {
return Array.isArray(value);
}
function isCompactNetworkTrendPoint(
value:
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint,
): value is CompactNetworkTrendPoint {
return Array.isArray(value);
}
function isCompactDiskIOTrendPoint(
value:
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint,
): value is CompactDiskIOTrendPoint {
return Array.isArray(value);
}
function isCompactDashboardNode(
value: DashboardNodeHealth | CompactDashboardNodeHealth,
): value is CompactDashboardNodeHealth {
return Array.isArray(value);
}
function normalizeDistributionItems(
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
): DistributionItem[] {
return arrayOrEmpty(items).map((item) =>
isCompactDistributionItem(item)
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
: item,
);
}
function normalizeTrafficTrendPoints(
items:
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactTrafficTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
request_count: Number(item[1] ?? 0),
error_count: Number(item[2] ?? 0),
unique_visitor_count: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeCapacityTrendPoints(
items:
| Array<
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactCapacityTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
average_cpu_usage_percent: Number(item[1] ?? 0),
average_memory_usage_percent: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeNetworkTrendPoints(
items:
| Array<
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactNetworkTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
network_rx_bytes: Number(item[1] ?? 0),
network_tx_bytes: Number(item[2] ?? 0),
openresty_rx_bytes: Number(item[3] ?? 0),
openresty_tx_bytes: Number(item[4] ?? 0),
reported_nodes: Number(item[5] ?? 0),
}
: item,
);
}
function normalizeDiskIOTrendPoints(
items:
| Array<
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactDiskIOTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
disk_read_bytes: Number(item[1] ?? 0),
disk_write_bytes: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeDashboardNodes(
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item)
? {
id: Number(item[0] ?? 0),
node_id: String(item[1] ?? ''),
name: String(item[2] ?? ''),
geo_name: String(item[3] ?? ''),
geo_latitude:
item[4] === null || item[4] === undefined ? null : Number(item[4]),
geo_longitude:
item[5] === null || item[5] === undefined ? null : Number(item[5]),
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
openresty_status: (item[7] ??
'unknown') as DashboardNodeHealth['openresty_status'],
current_version: String(item[8] ?? ''),
last_seen_at: String(item[9] ?? ''),
active_event_count: Number(item[10] ?? 0),
cpu_usage_percent: Number(item[11] ?? 0),
memory_usage_percent: Number(item[12] ?? 0),
storage_usage_percent: Number(item[13] ?? 0),
request_count: Number(item[14] ?? 0),
error_count: Number(item[15] ?? 0),
unique_visitor_count: Number(item[16] ?? 0),
}
: item,
);
}
function normalizeDashboardOverview(
overview: DashboardOverview | null | undefined,
overview:
| DashboardOverview
| DashboardOverviewCompact
| null
| undefined,
): DashboardOverview | null {
if (!overview) {
return null;
}
const summary = (overview.summary ?? {}) as DashboardSummary;
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
return {
...overview,
nodes: arrayOrEmpty(overview.nodes),
generated_at: String(overview.generated_at ?? ''),
summary,
traffic,
capacity,
nodes: normalizeDashboardNodes(overview.nodes),
distributions: {
...overview.distributions,
source_countries: arrayOrEmpty(overview.distributions?.source_countries),
status_codes: arrayOrEmpty(overview.distributions?.status_codes),
top_domains: arrayOrEmpty(overview.distributions?.top_domains),
source_countries: normalizeDistributionItems(
overview.distributions?.source_countries,
),
status_codes: normalizeDistributionItems(overview.distributions?.status_codes),
top_domains: normalizeDistributionItems(overview.distributions?.top_domains),
},
trends: {
...overview.trends,
traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h),
capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h),
network_24h: arrayOrEmpty(overview.trends?.network_24h),
disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h),
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
},
};
}
export async function getDashboardOverview() {
const overview = await apiRequest<DashboardOverview>('/dashboard/overview');
const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>(
'/dashboard/overview',
);
return normalizeDashboardOverview(overview);
}
@@ -27,6 +27,8 @@ export interface DistributionItem {
value: number;
}
export type CompactDistributionItem = [string, number];
export interface TrafficTrendPoint {
bucket_started_at: string;
request_count: number;
@@ -99,3 +101,53 @@ export interface DashboardOverview {
trends: DashboardTrends;
nodes: DashboardNodeHealth[];
}
export type CompactTrafficTrendPoint = [string, number, number, number];
export type CompactCapacityTrendPoint = [string, number, number, number];
export type CompactNetworkTrendPoint = [
string,
number,
number,
number,
number,
number,
];
export type CompactDiskIOTrendPoint = [string, number, number, number];
export type CompactDashboardNodeHealth = [
number,
string,
string,
string,
number | null,
number | null,
DashboardNodeHealth['status'],
DashboardNodeHealth['openresty_status'],
string,
string,
number,
number,
number,
number,
number,
number,
number,
];
export interface DashboardOverviewCompact {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: {
status_codes: CompactDistributionItem[];
top_domains: CompactDistributionItem[];
source_countries: CompactDistributionItem[];
};
trends: {
traffic_24h: CompactTrafficTrendPoint[];
capacity_24h: CompactCapacityTrendPoint[];
network_24h: CompactNetworkTrendPoint[];
disk_io_24h: CompactDiskIOTrendPoint[];
};
nodes: CompactDashboardNodeHealth[];
}
@@ -85,3 +85,12 @@ export function getNodeObservability(
`/nodes/${id}/observability${query ? `?${query}` : ''}`,
);
}
export function cleanupNodeHealthEvents(id: number) {
return apiRequest<{ node_id: string; deleted_count: number }>(
`/nodes/${id}/observability/cleanup`,
{
method: 'POST',
},
);
}
@@ -17,9 +17,10 @@ import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
import type { ConfigVersionItem } from '@/features/config-versions/types';
import type { ConfigVersionSummary } from '@/features/config-versions/types';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
import {
cleanupNodeHealthEvents,
deleteNode,
getNodeAgentRelease,
getNodeObservability,
@@ -250,6 +251,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const [healthEventFilter, setHealthEventFilter] =
useState<HealthEventFilter>('all');
const [activeTab, setActiveTab] = useState<NodeDetailTab>('dashboard');
const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] =
useState(false);
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
@@ -276,8 +279,13 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}, [nodeId, nodesQuery.data]);
const applyLogsQuery = useQuery({
queryKey: ['apply-logs', node?.node_id ?? ''],
queryFn: () => getApplyLogs(node?.node_id),
queryKey: ['apply-logs', node?.node_id ?? '', 1, 10],
queryFn: () =>
getApplyLogs({
node_id: node?.node_id,
pageNo: 1,
pageSize: 10,
}),
enabled: Boolean(node?.node_id),
refetchInterval: 5000,
});
@@ -368,6 +376,27 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
},
});
const cleanupHealthEventsMutation = useMutation({
mutationFn: () => cleanupNodeHealthEvents(Number(nodeId)),
onSuccess: async (result) => {
setFeedback({
tone: 'success',
message:
result.deleted_count > 0
? `已清理 ${result.deleted_count} 条健康事件日志。`
: '当前没有可清理的健康事件日志。',
});
setHealthEventCleanupModalOpen(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId] }),
queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleDelete = () => {
if (!node) {
return;
@@ -411,7 +440,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}
};
const activeConfigVersion = useMemo<ConfigVersionItem | null>(() => {
const activeConfigVersion = useMemo<ConfigVersionSummary | null>(() => {
return (
(configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null
);
@@ -525,7 +554,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
selectedReleaseChannel === 'preview'
? previewAgentReleaseQuery.isFetching
: stableAgentReleaseQuery.isFetching;
const applyLogs = applyLogsQuery.data ?? [];
const applyLogs = applyLogsQuery.data?.rows ?? [];
const dominantStatusCode = statusCodeDistribution[0] ?? null;
const dominantDomain = topDomains[0] ?? null;
const topSourceCountry =
@@ -1210,6 +1239,20 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<AppCard
title="健康事件时间线"
description="保留活动与已恢复事件,帮助判断问题是持续中、间歇性还是已经恢复。"
action={
<DangerButton
type="button"
disabled={
cleanupHealthEventsMutation.isPending ||
!observability?.health_events.length
}
onClick={() => setHealthEventCleanupModalOpen(true)}
>
{cleanupHealthEventsMutation.isPending
? '清理中...'
: '清理日志'}
</DangerButton>
}
>
{observability?.health_events.length ? (
<div className="space-y-4">
@@ -1623,7 +1666,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{applyLogs.slice(0, 10).map((log) => (
{applyLogs.map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]">
{log.version}
@@ -1698,6 +1741,49 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}}
/>
<AppModal
isOpen={isHealthEventCleanupModalOpen}
onClose={() => setHealthEventCleanupModalOpen(false)}
title="清理健康事件日志"
description={
node
? `确认清理节点“${node.name}”的健康事件时间线吗?已清理的历史记录将立即从当前页面移除,后续只有新的节点上报才会再次出现。`
: '确认清理当前节点的健康事件时间线吗?'
}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setHealthEventCleanupModalOpen(false)}
>
取消
</SecondaryButton>
<DangerButton
type="button"
disabled={cleanupHealthEventsMutation.isPending}
onClick={() => {
setFeedback(null);
cleanupHealthEventsMutation.mutate();
}}
>
{cleanupHealthEventsMutation.isPending ? '清理中...' : '确认清理'}
</DangerButton>
</div>
}
>
{cleanupHealthEventsMutation.isError ? (
<ErrorState
title="健康事件清理失败"
description={getErrorMessage(cleanupHealthEventsMutation.error)}
/>
) : (
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
<p>该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。</p>
<p>这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。</p>
</div>
)}
</AppModal>
<AppModal
isOpen={isAgentUpdateModalOpen}
onClose={() => setIsAgentUpdateModalOpen(false)}
+205 -205
View File
@@ -1,205 +1,205 @@
import type { ReleaseChannel } from '@/features/update/types';
export interface NodeItem {
id: number;
node_id: string;
name: string;
ip: string;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
agent_token: string;
auto_update_enabled: boolean;
update_requested: boolean;
update_channel: ReleaseChannel;
update_tag: string;
restart_openresty_requested: boolean;
agent_version: string;
nginx_version: string;
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
openresty_message: string;
status: 'online' | 'offline' | 'pending';
current_version: string;
last_seen_at: string;
last_error: string;
latest_apply_result: 'success' | 'failed' | '';
latest_apply_message: string;
latest_apply_checksum: string;
latest_main_config_checksum: string;
latest_route_config_checksum: string;
latest_support_file_count: number;
latest_apply_at?: string | null;
created_at: string;
updated_at: string;
}
export interface NodeBootstrapToken {
discovery_token: string;
}
export interface NodeMutationPayload {
name: string;
ip: string;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
}
export interface NodeAgentReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
current_version: string;
has_update: boolean;
channel: ReleaseChannel;
prerelease: boolean;
update_requested: boolean;
requested_channel: ReleaseChannel;
requested_tag: string;
}
export interface NodeAgentUpdatePayload {
channel?: ReleaseChannel;
tag_name?: string;
}
export interface NodeSystemProfile {
hostname: string;
os_name: string;
os_version: string;
kernel_version: string;
architecture: string;
cpu_model: string;
cpu_cores: number;
total_memory_bytes: number;
total_disk_bytes: number;
uptime_seconds: number;
reported_at: string;
}
export interface NodeMetricSnapshot {
captured_at: string;
cpu_usage_percent: number;
memory_used_bytes: number;
memory_total_bytes: number;
storage_used_bytes: number;
storage_total_bytes: number;
disk_read_bytes: number;
disk_write_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
openresty_connections: number;
}
export interface NodeTrafficReport {
window_started_at: string;
window_ended_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
status_codes_json: string;
top_domains_json: string;
source_countries_json: string;
}
export interface NodeTrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface NodeCapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NodeNetworkTrendPoint {
bucket_started_at: string;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
reported_nodes: number;
}
export interface NodeDiskIOTrendPoint {
bucket_started_at: string;
disk_read_bytes: number;
disk_write_bytes: number;
reported_nodes: number;
}
export interface NodeDistributionItem {
key: string;
value: number;
}
export interface NodeTrafficDistributions {
status_codes: NodeDistributionItem[];
top_domains: NodeDistributionItem[];
source_countries: NodeDistributionItem[];
}
export interface NodeTrafficSummary {
window_started_at: string;
window_ended_at: string;
request_count: number;
unique_visitor_count: number;
error_count: number;
estimated_qps: number;
error_rate_percent: number;
}
export interface NodeHealthSummary {
active_alerts: number;
critical_alerts: number;
warning_alerts: number;
info_alerts: number;
resolved_alerts: number;
has_capacity_risk: boolean;
has_traffic_risk: boolean;
has_runtime_risk: boolean;
}
export interface NodeObservabilityAnalytics {
traffic: NodeTrafficSummary;
distributions: NodeTrafficDistributions;
health: NodeHealthSummary;
}
export interface NodeObservabilityTrends {
traffic_24h: NodeTrafficTrendPoint[];
capacity_24h: NodeCapacityTrendPoint[];
network_24h: NodeNetworkTrendPoint[];
disk_io_24h: NodeDiskIOTrendPoint[];
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
status: string;
message: string;
first_triggered_at: string;
last_triggered_at: string;
reported_at: string;
resolved_at?: string | null;
}
export interface NodeObservability {
node_id: string;
profile: NodeSystemProfile | null;
metric_snapshots: NodeMetricSnapshot[];
traffic_reports: NodeTrafficReport[];
health_events: NodeHealthEvent[];
analytics: NodeObservabilityAnalytics;
trends: NodeObservabilityTrends;
}
import type { ReleaseChannel } from '@/features/update/types';
export interface NodeItem {
id: number;
node_id: string;
name: string;
ip: string;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
agent_token: string;
auto_update_enabled: boolean;
update_requested: boolean;
update_channel: ReleaseChannel;
update_tag: string;
restart_openresty_requested: boolean;
agent_version: string;
nginx_version: string;
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
openresty_message: string;
status: 'online' | 'offline' | 'pending';
current_version: string;
last_seen_at: string;
last_error: string;
latest_apply_result: 'success' | 'warning' | 'failed' | '';
latest_apply_message: string;
latest_apply_checksum: string;
latest_main_config_checksum: string;
latest_route_config_checksum: string;
latest_support_file_count: number;
latest_apply_at?: string | null;
created_at: string;
updated_at: string;
}
export interface NodeBootstrapToken {
discovery_token: string;
}
export interface NodeMutationPayload {
name: string;
ip: string;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
}
export interface NodeAgentReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
current_version: string;
has_update: boolean;
channel: ReleaseChannel;
prerelease: boolean;
update_requested: boolean;
requested_channel: ReleaseChannel;
requested_tag: string;
}
export interface NodeAgentUpdatePayload {
channel?: ReleaseChannel;
tag_name?: string;
}
export interface NodeSystemProfile {
hostname: string;
os_name: string;
os_version: string;
kernel_version: string;
architecture: string;
cpu_model: string;
cpu_cores: number;
total_memory_bytes: number;
total_disk_bytes: number;
uptime_seconds: number;
reported_at: string;
}
export interface NodeMetricSnapshot {
captured_at: string;
cpu_usage_percent: number;
memory_used_bytes: number;
memory_total_bytes: number;
storage_used_bytes: number;
storage_total_bytes: number;
disk_read_bytes: number;
disk_write_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
openresty_connections: number;
}
export interface NodeTrafficReport {
window_started_at: string;
window_ended_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
status_codes_json: string;
top_domains_json: string;
source_countries_json: string;
}
export interface NodeTrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface NodeCapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NodeNetworkTrendPoint {
bucket_started_at: string;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
reported_nodes: number;
}
export interface NodeDiskIOTrendPoint {
bucket_started_at: string;
disk_read_bytes: number;
disk_write_bytes: number;
reported_nodes: number;
}
export interface NodeDistributionItem {
key: string;
value: number;
}
export interface NodeTrafficDistributions {
status_codes: NodeDistributionItem[];
top_domains: NodeDistributionItem[];
source_countries: NodeDistributionItem[];
}
export interface NodeTrafficSummary {
window_started_at: string;
window_ended_at: string;
request_count: number;
unique_visitor_count: number;
error_count: number;
estimated_qps: number;
error_rate_percent: number;
}
export interface NodeHealthSummary {
active_alerts: number;
critical_alerts: number;
warning_alerts: number;
info_alerts: number;
resolved_alerts: number;
has_capacity_risk: boolean;
has_traffic_risk: boolean;
has_runtime_risk: boolean;
}
export interface NodeObservabilityAnalytics {
traffic: NodeTrafficSummary;
distributions: NodeTrafficDistributions;
health: NodeHealthSummary;
}
export interface NodeObservabilityTrends {
traffic_24h: NodeTrafficTrendPoint[];
capacity_24h: NodeCapacityTrendPoint[];
network_24h: NodeNetworkTrendPoint[];
disk_io_24h: NodeDiskIOTrendPoint[];
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
status: string;
message: string;
first_triggered_at: string;
last_triggered_at: string;
reported_at: string;
resolved_at?: string | null;
}
export interface NodeObservability {
node_id: string;
profile: NodeSystemProfile | null;
metric_snapshots: NodeMetricSnapshot[];
traffic_reports: NodeTrafficReport[];
health_events: NodeHealthEvent[];
analytics: NodeObservabilityAnalytics;
trends: NodeObservabilityTrends;
}
+169 -161
View File
@@ -1,161 +1,169 @@
import type { NodeItem } from '@/features/nodes/types';
export function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
if (node.update_channel === 'preview') {
return { label: '等待预览更新', variant: 'warning' as const };
}
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动', variant: 'success' as const };
}
return { label: '手动', variant: 'info' as const };
}
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
function parseVersionParts(version: string) {
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') {
return null;
}
return normalized.split('.').map((segment) => {
const matched = segment.trim().match(/^\d+/);
return matched ? Number.parseInt(matched[0], 10) : 0;
});
}
function isOlderVersion(current: string, target: string) {
const currentParts = parseVersionParts(current);
const targetParts = parseVersionParts(target);
if (!currentParts || !targetParts) {
return false;
}
const maxLength = Math.max(currentParts.length, targetParts.length);
for (let index = 0; index < maxLength; index += 1) {
const currentPart = currentParts[index] ?? 0;
const targetPart = targetParts[index] ?? 0;
if (currentPart < targetPart) {
return true;
}
if (currentPart > targetPart) {
return false;
}
}
return false;
}
export function shouldShowManualUpdate(
agentVersion: string,
serverVersion: string,
) {
const normalizedServerVersion = serverVersion.trim();
const normalizedAgentVersion = agentVersion.trim();
if (
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' ||
!normalizedAgentVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown'
) {
return false;
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
import type { NodeItem } from '@/features/nodes/types';
export function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'warning') {
return 'warning';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'warning') {
return '警告';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
if (node.update_channel === 'preview') {
return { label: '等待预览更新', variant: 'warning' as const };
}
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动', variant: 'success' as const };
}
return { label: '手动', variant: 'info' as const };
}
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
function parseVersionParts(version: string) {
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') {
return null;
}
return normalized.split('.').map((segment) => {
const matched = segment.trim().match(/^\d+/);
return matched ? Number.parseInt(matched[0], 10) : 0;
});
}
function isOlderVersion(current: string, target: string) {
const currentParts = parseVersionParts(current);
const targetParts = parseVersionParts(target);
if (!currentParts || !targetParts) {
return false;
}
const maxLength = Math.max(currentParts.length, targetParts.length);
for (let index = 0; index < maxLength; index += 1) {
const currentPart = currentParts[index] ?? 0;
const targetPart = targetParts[index] ?? 0;
if (currentPart < targetPart) {
return true;
}
if (currentPart > targetPart) {
return false;
}
}
return false;
}
export function shouldShowManualUpdate(
agentVersion: string,
serverVersion: string,
) {
const normalizedServerVersion = serverVersion.trim();
const normalizedAgentVersion = agentVersion.trim();
if (
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' ||
!normalizedAgentVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown'
) {
return false;
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
@@ -34,16 +34,16 @@ const defaultPerformanceFields = {
OpenRestyWorkerProcesses: 'auto',
OpenRestyWorkerConnections: '4096',
OpenRestyWorkerRlimitNofile: '65535',
OpenRestyEventsUse: '',
OpenRestyEventsMultiAcceptEnabled: false,
OpenRestyKeepaliveTimeout: '65',
OpenRestyEventsUse: 'epoll',
OpenRestyEventsMultiAcceptEnabled: true,
OpenRestyKeepaliveTimeout: '20',
OpenRestyKeepaliveRequests: '1000',
OpenRestyClientHeaderTimeout: '15',
OpenRestyClientBodyTimeout: '15',
OpenRestyClientMaxBodySize: '64m',
OpenRestyLargeClientHeaderBuffers: '4 16k',
OpenRestySendTimeout: '30',
OpenRestyProxyConnectTimeout: '5',
OpenRestyProxyConnectTimeout: '3',
OpenRestyProxySendTimeout: '60',
OpenRestyProxyReadTimeout: '60',
OpenRestyWebsocketEnabled: true,
@@ -60,7 +60,7 @@ const defaultPerformanceFields = {
OpenRestyCacheLevels: '1:2',
OpenRestyCacheInactive: '30m',
OpenRestyCacheMaxSize: '1g',
OpenRestyCacheKeyTemplate: '$scheme$proxy_host$request_uri',
OpenRestyCacheKeyTemplate: '$scheme$host$request_uri',
OpenRestyCacheLockEnabled: true,
OpenRestyCacheLockTimeout: '5s',
OpenRestyCacheUseStale:
@@ -75,9 +75,9 @@ const performanceFieldTooltips: Record<string, string> = {
worker_rlimit_nofile:
'提升 worker 可打开的文件描述符上限,避免高并发下连接或文件句柄不足。',
events_use:
'指定事件驱动模型。Linux 常见是 epoll,留空时由 OpenResty 自动选择。',
'指定事件驱动模型。默认使用 epoll,Linux 高并发场景通常优先选择它。',
multi_accept:
'开启后,worker 会尽可能一次接受多个新连接,适合高吞吐接入场景。',
'默认开启。worker 会尽可能一次接受多个新连接,适合高吞吐接入场景。',
keepalive_timeout: '客户端 Keep-Alive 空闲保持时间,单位秒。',
keepalive_requests: '单个长连接允许复用的最大请求数。',
client_header_timeout: '读取客户端请求头的超时时间,单位秒。',
@@ -203,12 +203,12 @@ export function PerformancePage() {
optionMap.OpenRestyWorkerConnections ?? '4096',
OpenRestyWorkerRlimitNofile:
optionMap.OpenRestyWorkerRlimitNofile ?? '65535',
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? '',
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? 'epoll',
OpenRestyEventsMultiAcceptEnabled: toBoolean(
optionMap.OpenRestyEventsMultiAcceptEnabled,
false,
true,
),
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '65',
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '20',
OpenRestyKeepaliveRequests:
optionMap.OpenRestyKeepaliveRequests ?? '1000',
OpenRestyClientHeaderTimeout:
@@ -219,7 +219,7 @@ export function PerformancePage() {
optionMap.OpenRestyLargeClientHeaderBuffers ?? '4 16k',
OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30',
OpenRestyProxyConnectTimeout:
optionMap.OpenRestyProxyConnectTimeout ?? '5',
optionMap.OpenRestyProxyConnectTimeout ?? '3',
OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60',
OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60',
OpenRestyWebsocketEnabled: toBoolean(
@@ -247,7 +247,7 @@ export function PerformancePage() {
OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? '30m',
OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? '1g',
OpenRestyCacheKeyTemplate:
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$proxy_host$request_uri',
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$host$request_uri',
OpenRestyCacheLockEnabled: toBoolean(
optionMap.OpenRestyCacheLockEnabled,
true,
@@ -50,6 +50,8 @@ const customHeaderSchema = z.object({
value: z.string(),
});
const cachePolicyValues = ['url', 'suffix', 'path_prefix', 'path_exact'] as const;
const proxyRouteSchema = z
.object({
domain: z.string().trim().min(1, '请输入域名'),
@@ -87,10 +89,14 @@ const proxyRouteSchema = z
})()),
'请输入合法的回源主机名',
),
upstreams_text: z.string(),
enabled: z.boolean(),
enable_https: z.boolean(),
cert_id: z.string(),
redirect_http: z.boolean(),
cache_enabled: z.boolean(),
cache_policy: z.enum(cachePolicyValues),
cache_rules_text: z.string(),
custom_headers: z.array(customHeaderSchema).min(1),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
@@ -103,6 +109,26 @@ const proxyRouteSchema = z
});
}
const upstreams = parseUpstreamsText(value.origin_url, value.upstreams_text);
if (upstreams.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_url'],
message: '至少需要一个上游地址',
});
}
if (value.cache_enabled) {
const cacheRules = parseCacheRulesText(value.cache_rules_text);
if (value.cache_policy !== 'url' && cacheRules.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['cache_rules_text'],
message: '当前缓存策略至少需要填写一条规则',
});
}
}
value.custom_headers.forEach((header, index) => {
const key = header.key.trim();
const headerValue = header.value.trim();
@@ -148,10 +174,14 @@ const defaultValues: ProxyRouteFormValues = {
domain: '',
origin_url: '',
origin_host: '',
upstreams_text: '',
enabled: true,
enable_https: false,
cert_id: '',
redirect_http: false,
cache_enabled: false,
cache_policy: 'url',
cache_rules_text: '',
custom_headers: [{ key: '', value: '' }],
remark: '',
};
@@ -195,6 +225,71 @@ function parseCustomHeaders(rawValue: string) {
}
}
function parseCacheRules(rawValue: string) {
if (!rawValue) {
return [] as string[];
}
try {
const parsed = JSON.parse(rawValue) as string[];
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
} catch {
return [];
}
}
function parseCacheRulesText(value: string) {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function parseUpstreams(rawValue: string) {
if (!rawValue) {
return [] as string[];
}
try {
const parsed = JSON.parse(rawValue) as string[];
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
} catch {
return [];
}
}
function parseUpstreamsText(primary: string, value: string) {
return [primary.trim(), ...value.split(/\r?\n/)]
.map((item) => item.trim())
.filter(Boolean);
}
function buildCachePolicyLabel(policy: string) {
switch (policy) {
case 'suffix':
return '按后缀';
case 'path_prefix':
return '按前缀';
case 'path_exact':
return '按路径';
default:
return '按 URL';
}
}
function getCacheRulesHint(policy: string) {
switch (policy) {
case 'suffix':
return '每行一个后缀,例如:jpg、css、js。';
case 'path_prefix':
return '每行一个路径前缀,例如:/assets、/static/images。';
case 'path_exact':
return '每行一个精确路径,例如:/robots.txt、/manifest.json。';
default:
return '按 URL 缓存时无需额外规则,系统会按请求 URL 粒度缓存。';
}
}
function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
@@ -206,11 +301,17 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
domain: values.domain.trim(),
origin_url: values.origin_url.trim(),
origin_host: values.origin_host.trim(),
upstreams: parseUpstreamsText(values.origin_url, values.upstreams_text).slice(1),
enabled: values.enabled,
enable_https: values.enable_https,
cert_id:
values.enable_https && values.cert_id ? Number(values.cert_id) : null,
redirect_http: values.enable_https ? values.redirect_http : false,
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
cache_rules: values.cache_enabled
? parseCacheRulesText(values.cache_rules_text)
: [],
custom_headers: values.custom_headers
.map((item) => ({ key: item.key.trim(), value: item.value.trim() }))
.filter((item) => item.key || item.value),
@@ -220,15 +321,21 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues {
const headers = parseCustomHeaders(route.custom_headers);
const cacheRules = parseCacheRules(route.cache_rules);
const upstreams = parseUpstreams(route.upstreams);
return {
domain: route.domain,
origin_url: route.origin_url,
origin_host: route.origin_host || '',
upstreams_text: upstreams.slice(1).join('\n'),
enabled: route.enabled,
enable_https: route.enable_https,
cert_id: route.cert_id ? String(route.cert_id) : '',
redirect_http: route.redirect_http,
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as ProxyRouteFormValues['cache_policy'],
cache_rules_text: cacheRules.join('\n'),
custom_headers: headers.length > 0 ? headers : [{ key: '', value: '' }],
remark: route.remark || '',
};
@@ -288,6 +395,14 @@ export function ProxyRoutesPage() {
control: form.control,
name: 'redirect_http',
});
const watchedCacheEnabled = useWatch({
control: form.control,
name: 'cache_enabled',
});
const watchedCachePolicy = useWatch({
control: form.control,
name: 'cache_policy',
});
const watchedCertId = useWatch({ control: form.control, name: 'cert_id' });
const routesQuery = useQuery({
@@ -514,6 +629,7 @@ export function ProxyRoutesPage() {
<th className="px-3 py-3 font-medium">域名</th>
<th className="px-3 py-3 font-medium">源站地址</th>
<th className="px-3 py-3 font-medium">HTTPS</th>
<th className="px-3 py-3 font-medium">缓存</th>
<th className="px-3 py-3 font-medium">请求头</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">备注</th>
@@ -524,6 +640,8 @@ export function ProxyRoutesPage() {
<tbody className="divide-y divide-[var(--border-default)]">
{routes.map((route) => {
const headers = parseCustomHeaders(route.custom_headers);
const cacheRules = parseCacheRules(route.cache_rules);
const upstreams = parseUpstreams(route.upstreams);
return (
<tr key={route.id} className="align-top">
@@ -536,6 +654,9 @@ export function ProxyRoutesPage() {
<p className="text-xs text-[var(--foreground-muted)]">
回源主机名: {route.origin_host || '$host'}
</p>
<p className="text-xs text-[var(--foreground-muted)]">
上游数量: {Math.max(upstreams.length, 1)}
</p>
</div>
</td>
<td className="px-3 py-4">
@@ -554,6 +675,23 @@ export function ProxyRoutesPage() {
<StatusBadge label="HTTP" variant="warning" />
)}
</td>
<td className="px-3 py-4">
{route.cache_enabled ? (
<div className="space-y-2">
<StatusBadge
label={buildCachePolicyLabel(route.cache_policy)}
variant="success"
/>
<p className="text-xs text-[var(--foreground-muted)]">
{cacheRules.length > 0
? `${cacheRules.length} 条规则`
: '按 URL 粒度缓存'}
</p>
</div>
) : (
<StatusBadge label="关闭" variant="warning" />
)}
</td>
<td className="px-3 py-4">
<StatusBadge
label={
@@ -669,6 +807,17 @@ export function ProxyRoutesPage() {
/>
</ResourceField>
<ResourceField
label="附加上游"
hint="可选。每行一个上游地址,用于同一规则下的负载均衡;需与主上游保持相同协议,且不能带路径或查询参数。"
error={form.formState.errors.upstreams_text?.message}
>
<ResourceTextarea
placeholder={'https://origin-b.internal\nhttps://origin-c.internal'}
{...form.register('upstreams_text')}
/>
</ResourceField>
<div className="grid gap-4 lg:grid-cols-2">
<ToggleField
label="启用规则"
@@ -680,7 +829,7 @@ export function ProxyRoutesPage() {
/>
<ToggleField
label="启用 HTTPS"
description="启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。"
description="启用后必须关联 TLS 证书,并会默认为客户端开启 HTTP/2;可选择是否将 HTTP 自动重定向到 HTTPS。"
checked={watchedEnableHttps}
onChange={(checked) => {
form.setValue('enable_https', checked, {
@@ -739,6 +888,74 @@ export function ProxyRoutesPage() {
/>
</div>
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<ToggleField
label="启用规则缓存"
description="仅对当前规则生效;系统会自动绕过非 GET、Authorization 和常见登录态 Cookie 请求。"
checked={watchedCacheEnabled}
onChange={(checked) => {
form.setValue('cache_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
});
if (!checked) {
form.setValue('cache_policy', 'url', {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('cache_rules_text', '', {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
<ResourceField
label="缓存策略"
hint="按 URL 会缓存所有符合安全条件的 URL;其余策略会先匹配规则再决定是否缓存。"
>
<ResourceSelect
value={watchedCachePolicy}
disabled={!watchedCacheEnabled}
onChange={(event) =>
form.setValue(
'cache_policy',
event.target.value as ProxyRouteFormValues['cache_policy'],
{
shouldDirty: true,
shouldValidate: true,
},
)
}
>
<option value="url">按 URL 缓存</option>
<option value="suffix">按后缀匹配缓存</option>
<option value="path_prefix">按路径前缀缓存</option>
<option value="path_exact">按精确路径缓存</option>
</ResourceSelect>
</ResourceField>
</div>
<ResourceField
label="缓存规则"
hint={getCacheRulesHint(watchedCachePolicy)}
error={form.formState.errors.cache_rules_text?.message}
>
<ResourceTextarea
placeholder={
watchedCachePolicy === 'suffix'
? 'jpg\ncss\njs'
: watchedCachePolicy === 'path_prefix'
? '/assets\n/static/images'
: watchedCachePolicy === 'path_exact'
? '/robots.txt\n/manifest.json'
: '按 URL 缓存无需填写规则'
}
disabled={!watchedCacheEnabled || watchedCachePolicy === 'url'}
{...form.register('cache_rules_text')}
/>
</ResourceField>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -3,32 +3,40 @@ export interface ProxyRouteCustomHeader {
value: string;
}
export interface ProxyRouteItem {
id: number;
domain: string;
origin_url: string;
origin_host: string;
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
redirect_http: boolean;
custom_headers: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface ProxyRouteItem {
id: number;
domain: string;
origin_url: string;
origin_host: string;
upstreams: string;
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
redirect_http: boolean;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string;
custom_headers: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface ProxyRouteMutationPayload {
domain: string;
origin_url: string;
origin_host: string;
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
redirect_http: boolean;
custom_headers: ProxyRouteCustomHeader[];
remark: string;
}
origin_url: string;
origin_host: string;
upstreams: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
redirect_http: boolean;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string[];
custom_headers: ProxyRouteCustomHeader[];
remark: string;
}
export interface TlsCertificateItem {
id: number;
@@ -78,14 +78,14 @@ const defaultOperationFields = {
OpenRestyWorkerProcesses: 'auto',
OpenRestyWorkerConnections: '4096',
OpenRestyWorkerRlimitNofile: '65535',
OpenRestyEventsUse: '',
OpenRestyEventsMultiAcceptEnabled: false,
OpenRestyKeepaliveTimeout: '65',
OpenRestyEventsUse: 'epoll',
OpenRestyEventsMultiAcceptEnabled: true,
OpenRestyKeepaliveTimeout: '20',
OpenRestyKeepaliveRequests: '1000',
OpenRestyClientHeaderTimeout: '15',
OpenRestyClientBodyTimeout: '15',
OpenRestySendTimeout: '30',
OpenRestyProxyConnectTimeout: '5',
OpenRestyProxyConnectTimeout: '3',
OpenRestyProxySendTimeout: '60',
OpenRestyProxyReadTimeout: '60',
OpenRestyProxyBufferingEnabled: true,
@@ -100,7 +100,7 @@ const defaultOperationFields = {
OpenRestyCacheLevels: '1:2',
OpenRestyCacheInactive: '30m',
OpenRestyCacheMaxSize: '1g',
OpenRestyCacheKeyTemplate: '$scheme$proxy_host$request_uri',
OpenRestyCacheKeyTemplate: '$scheme$host$request_uri',
OpenRestyCacheLockEnabled: true,
OpenRestyCacheLockTimeout: '5s',
OpenRestyCacheUseStale:
@@ -348,12 +348,12 @@ export function SettingsPage() {
optionMap.OpenRestyWorkerConnections ?? '4096',
OpenRestyWorkerRlimitNofile:
optionMap.OpenRestyWorkerRlimitNofile ?? '65535',
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? '',
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? 'epoll',
OpenRestyEventsMultiAcceptEnabled: toBoolean(
optionMap.OpenRestyEventsMultiAcceptEnabled,
false,
true,
),
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '65',
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '20',
OpenRestyKeepaliveRequests:
optionMap.OpenRestyKeepaliveRequests ?? '1000',
OpenRestyClientHeaderTimeout:
@@ -361,7 +361,7 @@ export function SettingsPage() {
OpenRestyClientBodyTimeout: optionMap.OpenRestyClientBodyTimeout ?? '15',
OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30',
OpenRestyProxyConnectTimeout:
optionMap.OpenRestyProxyConnectTimeout ?? '5',
optionMap.OpenRestyProxyConnectTimeout ?? '3',
OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60',
OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60',
OpenRestyProxyBufferingEnabled: toBoolean(
@@ -381,7 +381,7 @@ export function SettingsPage() {
OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? '30m',
OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? '1g',
OpenRestyCacheKeyTemplate:
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$proxy_host$request_uri',
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$host$request_uri',
OpenRestyCacheLockEnabled: toBoolean(
optionMap.OpenRestyCacheLockEnabled,
true,
@@ -88,87 +88,87 @@ describe('DashboardOverview', () => {
},
distributions: {
source_countries: [
{ key: 'CN', value: 440 },
{ key: 'US', value: 320 },
{ key: 'SG', value: 140 },
['CN', 440],
['US', 320],
['SG', 140],
],
status_codes: [
{ key: '200', value: 820 },
{ key: '502', value: 24 },
{ key: '500', value: 12 },
['200', 820],
['502', 24],
['500', 12],
],
top_domains: [
{ key: 'app.example.com', value: 580 },
{ key: 'api.example.com', value: 220 },
['app.example.com', 580],
['api.example.com', 220],
],
},
trends: {
traffic_24h: Array.from({ length: 24 }, (_, index) => ({
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
request_count: index * 10,
error_count: index,
unique_visitor_count: index * 3,
})),
capacity_24h: Array.from({ length: 24 }, (_, index) => ({
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
average_cpu_usage_percent: index,
average_memory_usage_percent: index + 10,
reported_nodes: 2,
})),
network_24h: Array.from({ length: 24 }, (_, index) => ({
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
network_rx_bytes: index * 100,
network_tx_bytes: index * 120,
openresty_rx_bytes: index * 140,
openresty_tx_bytes: index * 160,
reported_nodes: 2,
})),
disk_io_24h: Array.from({ length: 24 }, (_, index) => ({
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
disk_read_bytes: index * 50,
disk_write_bytes: index * 70,
reported_nodes: 2,
})),
traffic_24h: Array.from({ length: 24 }, (_, index) => [
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
index * 10,
index,
index * 3,
]),
capacity_24h: Array.from({ length: 24 }, (_, index) => [
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
index,
index + 10,
2,
]),
network_24h: Array.from({ length: 24 }, (_, index) => [
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
index * 100,
index * 120,
index * 140,
index * 160,
2,
]),
disk_io_24h: Array.from({ length: 24 }, (_, index) => [
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
index * 50,
index * 70,
2,
]),
},
nodes: [
{
id: 1,
node_id: 'node-a',
name: 'edge-a',
geo_name: 'Shanghai',
geo_latitude: 31.2304,
geo_longitude: 121.4737,
status: 'online',
openresty_status: 'healthy',
current_version: '20260314-001',
last_seen_at: '2026-03-14T08:00:00Z',
active_event_count: 0,
cpu_usage_percent: 45,
memory_usage_percent: 50,
storage_usage_percent: 60,
request_count: 600,
error_count: 6,
unique_visitor_count: 120,
},
{
id: 2,
node_id: 'node-b',
name: 'edge-b',
geo_name: 'San Francisco',
geo_latitude: 37.7749,
geo_longitude: -122.4194,
status: 'online',
openresty_status: 'unhealthy',
current_version: '20260313-001',
last_seen_at: '2026-03-14T08:00:00Z',
active_event_count: 2,
cpu_usage_percent: 92,
memory_usage_percent: 88,
storage_usage_percent: 95,
request_count: 300,
error_count: 30,
unique_visitor_count: 80,
},
[
1,
'node-a',
'edge-a',
'Shanghai',
31.2304,
121.4737,
'online',
'healthy',
'20260314-001',
'2026-03-14T08:00:00Z',
0,
45,
50,
60,
600,
6,
120,
],
[
2,
'node-b',
'edge-b',
'San Francisco',
37.7749,
-122.4194,
'online',
'unhealthy',
'20260313-001',
'2026-03-14T08:00:00Z',
2,
92,
88,
95,
300,
30,
80,
],
],
},
}),
@@ -183,7 +183,6 @@ describe('DashboardOverview', () => {
renderDashboardOverview();
expect(await screen.findByText('全球态势板')).toBeInTheDocument();
expect(await screen.findByText('系统健康摘要')).toBeInTheDocument();
expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument();
expect(await screen.findByText('来源分布')).toBeInTheDocument();
expect(await screen.findByText('Top Domain')).toBeInTheDocument();