mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 891cb7b9c1 | |||
| 007b1d8929 | |||
| 782304012c | |||
| 4945b8b44f | |||
| 1fbe156a7c | |||
| c844f4c784 | |||
| 67197220ae | |||
| 0cb4e06b11 | |||
| c84d5bd540 | |||
| 51a875ab50 | |||
| ed38aa1d79 | |||
| 9ced0eb6f0 | |||
| 4433ab5af4 | |||
| 0ab0145f8d | |||
| 7a22167997 | |||
| e2202d1456 | |||
| 2ece13d08e | |||
| 4c4f7f9ced | |||
| bb284c2f37 | |||
| 915be62ca1 | |||
| 29a6fedbe9 | |||
| 2e875f583b | |||
| 70da7c772c | |||
| f1d469c18f | |||
| 244a43ba77 | |||
| 5e8007da17 |
+2
-1
@@ -1,4 +1,5 @@
|
||||
|
||||
.cache
|
||||
.gocache*
|
||||
.idea
|
||||
.vscode
|
||||
upload
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||

|
||||
|
||||
### Node Detail and Install Command
|
||||
|
||||

|
||||
|
||||
### Version Release Workflow
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
### 节点详情与安装命令
|
||||
### 节点详情
|
||||
|
||||

|
||||
|
||||
### 配置发布与版本管理
|
||||
### 配置新增
|
||||
|
||||

|
||||
|
||||
@@ -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) 开源。
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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[:])
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
+1150
-180
File diff suppressed because it is too large
Load Diff
+1149
-181
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
+39
-9
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user