Compare commits

...

39 Commits

Author SHA1 Message Date
ryan dc72c78b7f [优化] 界面优化 2026-03-19 21:00:43 +08:00
ryan 9eeccb5fc6 [功能] 添加数据库观测数据清理功能,支持手动和自动清理策略 2026-03-19 20:48:45 +08:00
ryan a1b3204204 [功能] 添加遗留观察性索引和表的删除逻辑,优化数据库迁移过程 2026-03-19 20:26:18 +08:00
ryan 8737e146d1 [修改] 分片逻辑修改为基于ID 2026-03-19 17:57:30 +08:00
ryan ae72f2da9a [功能] 实现数据库版本管理与迁移逻辑,确保数据库结构与版本一致性 2026-03-19 16:45:22 +08:00
ryan f26fcd028e [功能] 添加迁移遗留观察性列的功能,支持从 raw_json 填充 metadata_json 2026-03-19 16:31:05 +08:00
ryan dd49b2777d [功能] 实现节点访问日志的分片支持,优化日志查询和管理逻辑 2026-03-19 16:19:46 +08:00
ryan 891cb7b9c1 [优化] 更新 swaggo/swag 依赖版本至 v1.16.4,并更新文档生成指令 2026-03-19 09:28:57 +08:00
ryan 007b1d8929 [优化] 移除 OpenRestyResolvers 配置,统一上游渲染为带 keepalive 的 named upstream 2026-03-18 23:24:37 +08:00
ryan 782304012c [功能] 添加节点健康事件清理功能,优化节点观测数据管理 2026-03-18 23:11:48 +08:00
ryan 4945b8b44f [修复] 更新数据库字段类型为text,添加消息截断逻辑以支持更长的消息内容 2026-03-18 22:57:14 +08:00
ryan 1fbe156a7c [功能] 添加支持多个上游地址,优化代理路由配置和负载均衡逻辑 2026-03-18 22:24:05 +08:00
ryan c844f4c784 [优化] 更新HTTPS配置,启用reuseport和epoll事件模型,优化性能 2026-03-18 22:15:48 +08:00
ryan 67197220ae [优化] 添加命名上游支持,优化代理配置生成逻辑 2026-03-18 22:15:48 +08:00
ryan 0cb4e06b11 [功能] 添加缓存策略支持,优化代理路由配置和验证逻辑 2026-03-18 22:08:55 +08:00
ryan c84d5bd540 [功能] 更新OpenResty配置,添加连接升级映射和默认服务器块,优化HTTPS和HTTP重定向逻辑 2026-03-18 22:02:32 +08:00
ryan 51a875ab50 [功能] 更新HTTPS配置,启用HTTP/2支持并优化相关文档 2026-03-18 21:34:47 +08:00
ryan ed38aa1d79 [功能] 优化仪表板概览数据结构,添加压缩和规范化功能 2026-03-18 15:37:14 +08:00
ryan 9ced0eb6f0 [功能] 添加获取配置版本详情的API,优化配置版本管理逻辑 2026-03-18 15:19:39 +08:00
ryan 4433ab5af4 [功能] 添加应用日志分页查询和清理功能,优化日志管理逻辑 2026-03-18 14:34:25 +08:00
ryan 0ab0145f8d [修复] 精简access-logs-page和performance-page组件的导入和属性设置 2026-03-18 14:05:17 +08:00
ryan 7a22167997 [功能] 添加OpenRestyResolvers支持,优化DNS解析器配置和验证逻辑 2026-03-18 13:52:58 +08:00
ryan e2202d1456 [功能] 添加对应用结果的警告支持,优化配置激活和回滚逻辑 2026-03-18 13:09:28 +08:00
ryan 2ece13d08e [功能] 重构Lua和证书文件管理逻辑,优化文件同步和清理机制 2026-03-18 12:28:41 +08:00
ryan 4c4f7f9ced [功能] 添加OpenResty解析器指令支持,增强配置模板和运行时解析能力 2026-03-18 11:16:56 +08:00
ryan bb284c2f37 [功能] 优化DockerExecutor的Reload方法,添加挂载源验证并支持在运行中的容器内重载 2026-03-18 10:46:19 +08:00
ryan 915be62ca1 [修复] 添加对Docker挂载源的验证,确保配置文件和目录的有效性 2026-03-18 10:38:45 +08:00
ryan 29a6fedbe9 [功能] 添加访问日志折叠、IP汇总和趋势查询功能,并实现日志清理功能 2026-03-18 10:33:35 +08:00
ryan 2e875f583b [功能] 调整访问日志分页大小为20,并更新相关组件以支持动态分页 2026-03-18 09:52:39 +08:00
ryan 70da7c772c 更新README 2026-03-17 19:48:41 +08:00
ryan f1d469c18f 更新README 2026-03-17 19:23:21 +08:00
ryan 244a43ba77 更新README 2026-03-17 19:22:36 +08:00
ryan 5e8007da17 更新README 2026-03-17 15:46:48 +08:00
ryan b60ebf231c [功能] 添加 Docker Compose 配置以支持 PostgreSQL 数据库和 Openflare 服务 2026-03-17 14:06:47 +08:00
ryan cfd7c3d7ca [功能] 支持 PostgreSQL 数据库,添加数据库迁移逻辑并更新相关文档 2026-03-17 10:24:12 +08:00
ryan 2c17f3289b [重构] 将多个 API 接口的请求方法从 PUT 和 DELETE 更改为 POST,并更新相关路由 2026-03-17 09:57:38 +08:00
ryan 2cdb844010 [功能] 添加可选版本输入以支持自定义 Docker 镜像和发布版本 2026-03-16 12:53:01 +08:00
ryan 5e2503ca50 [修复] 更新代理配置以支持 SSL 服务器名称和主机头覆盖 2026-03-16 12:39:34 +08:00
ryan 4daf681eff [功能] 添加 origin_host 字段以覆盖回源请求的 Host 头 2026-03-16 12:20:02 +08:00
100 changed files with 12756 additions and 2291 deletions
+39 -2
View File
@@ -2,6 +2,11 @@ name: Docker image builds
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
@@ -35,9 +40,25 @@ jobs:
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
echo "VERSION=$(git describe --tags)" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -99,9 +120,25 @@ jobs:
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
echo "VERSION=$(git describe --tags)" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v4
+36 -20
View File
@@ -2,10 +2,15 @@ name: Release
permissions:
contents: write
on:
workflow_dispatch:
push:
tags: ["v*"]
on:
workflow_dispatch:
inputs:
version:
description: "Release version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
jobs:
prepare:
@@ -20,22 +25,33 @@ jobs:
with:
fetch-depth: 0
- name: Resolve version metadata
id: version
run: |
SHOULD_RUN=true
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
if [[ "${GITHUB_REF}" == refs/heads/main ]] && [[ -n "$POINTED_TAG" ]]; then
SHOULD_RUN=true
VERSION="$POINTED_TAG"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
else
VERSION="$(git describe --tags)"
fi
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
- name: Resolve version metadata
id: version
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
SHOULD_RUN=true
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
echo "main branch release requires the current commit to be tagged" >&2
exit 1
else
echo "unable to resolve release version from the current ref" >&2
exit 1
fi
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
+4 -2
View File
@@ -1,4 +1,5 @@
.cache
.gocache*
.idea
.vscode
upload
@@ -45,4 +46,5 @@ go.work.sum
*.log
.DS_Store
.codex-cache
.codex-cache
/.gomodcache/
+4 -8
View File
@@ -11,16 +11,13 @@
3. [docs/development-plan.md](./docs/development-plan.md)
作用:理解当前开发阶段、实施顺序、阶段目标和验收标准。
4. [docs/frontend-revamp-plan.md](./docs/frontend-revamp-plan.md)
作用:理解当前前端从 CRA + Semantic UI 迁移到 Next.js + Tailwind CSS + NextUI 的专项改造目标、实施阶段和风险边界。
5. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
作用:理解新版前端的技术选型、目录分层、组件规范、请求层、状态管理、样式和测试约束。
6. [docs/deployment.md](./docs/deployment.md)
5. [docs/deployment.md](./docs/deployment.md)
作用:理解当前的部署方式和联调步骤,确保开发过程中产出的功能能够成功部署和验证。
7. [docs/app-config.md](./docs/app-config.md)
6. [docs/app-config.md](./docs/app-config.md)
作用:系统启动时支持的环境变量和配置项说明,确保开发过程中新增的配置项能够正确使用和文档化。
@@ -29,7 +26,7 @@
* 如果实现内容超出 `docs/design.md` 的范围,先修改设计文档,再继续编码。
* 如果实现方式违反 `docs/development-guidelines.md`,应优先调整方案,而不是绕过规范。
* 如果需求与当前开发阶段冲突,优先遵守 `docs/development-plan.md` 的阶段顺序。
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-revamp-plan.md` 与 `docs/frontend-development-guidelines.md`。
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-development-guidelines.md`。
## 文档维护要求
@@ -40,6 +37,5 @@
* 产品范围或系统边界变化:更新 `docs/design.md`
* 开发约束、代码规范、接口约定变化:更新 `docs/development-guidelines.md`
* 阶段目标、顺序、验收标准变化:更新 `docs/development-plan.md`
* 前端技术栈、迁移阶段、页面范围变化:更新 `docs/frontend-revamp-plan.md`
* 前端目录分层、组件规范、样式体系、测试基线变化:更新 `docs/frontend-development-guidelines.md`
* 环境变量或配置项变化:更新 `docs/app-config.md`
-219
View File
@@ -1,219 +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 + Web UI)
|
| HTTP API / Config Pull
v
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
v
Local OpenResty or Docker OpenResty
|
v
Origin
```
Responsibilities:
* `openflare_server`: admin UI, management APIs, Agent APIs, rendering, rollout, and state storage
* `openflare_agent`: node registration, heartbeat, sync, local apply, validation, reload, rollback, and self-update
* `openflare_server/web`: the production admin frontend, exported statically and served by the Go server
## UI Preview
### Dashboard Overview
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### Node Detail and Install Command
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### Version Release Workflow
![OpenFlare version release](./docs/assets/readme/version-release.png)
## Quick Start
### 1. Start the Server
```yaml
services:
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-random-string
SQLITE_PATH: /data/openflare.db
GIN_MODE: release
LOG_LEVEL: info
PORT: "3000"
volumes:
- openflare-data:/data
volumes:
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
* `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'
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).
+41 -38
View File
@@ -3,7 +3,8 @@
</p>
<div align="center">
<img src="./openflare_server/web/public/logo.png" width="120" height="120" alt="OpenFlare logo">
[//]: # ( <img src="./openflare_server/web/public/logo.png" width="120" height="120" alt="OpenFlare logo">)
# OpenFlare
@@ -21,13 +22,8 @@
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
</a>
<a href="https://goreportcard.com/report/github.com/Rain-kl/OpenFlare">
<img src="https://goreportcard.com/badge/github.com/Rain-kl/OpenFlare" alt="GoReportCard">
</a>
</p>
OpenFlare `1.0.0` 是当前稳定基线。第六版开发工作已经完成,相关能力已并入正式版,仓库文档不再保留阶段性实施记录,而只维护当前有效的设计、约束和部署方式。
## 为什么存在
OpenFlare 解决的是一类朴素但高频的运维问题:
@@ -38,23 +34,18 @@ 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 的正式前端
## 系统架构
```text
OpenFlare Server (Gin + GORM + SQLite + Web UI)
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL + Web UI)
|
| HTTP API / Config Pull
v
@@ -79,11 +70,11 @@ Origin
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 节点详情与安装命令
### 节点详情
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### 配置发布与版本管理
### 配置新增
![OpenFlare version release](./docs/assets/readme/version-release.png)
@@ -93,21 +84,39 @@ Origin
```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:
```
@@ -124,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 -- \
@@ -153,7 +164,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
## 仓库结构
* `openflare_server`:Gin + GORM + SQLite 单体控制面
* `openflare_server`:Gin + GORM + SQLite/PostgreSQL 单体控制面
* `openflare_server/web`:Next.js 15 App Router 管理端前端
* `openflare_agent`:Go 单体 Agent
* `scripts`:安装脚本与辅助脚本
@@ -167,6 +178,9 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
cd openflare_server
export SESSION_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
# 可选:设置 DSN 或 SQL_DSN 后切换到 PostgreSQL。
# 如果 PostgreSQL 为空且 ./openflare.db 存在,启动时会自动迁移 SQLite 数据。
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
go run .
```
@@ -174,9 +188,8 @@ go run .
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
pnpm dev
```
### Agent
@@ -186,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 ./...
```
## 文档导航
建议按以下顺序阅读:
@@ -205,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)
## 管理端与接口
@@ -226,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) 开源。
本项目采用 [Apache License 2.0](./LICENSE) 开源。
+18 -2
View File
@@ -33,14 +33,17 @@ go run . --port 3000 --log-dir ./logs
| `LOG_LEVEL` | 日志等级 | `info` |
| `SESSION_SECRET` | Session 签名密钥 | 启动时随机生成 |
| `SQLITE_PATH` | SQLite 数据库文件路径 | `openflare.db` |
| `SQL_DSN` | MySQL DSN,设置后优先于 SQLite | 空 |
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
| `REDIS_CONN_STRING` | Redis 连接串 | 空 |
| `UPLOAD_PATH` | 上传目录 | `upload` |
| `AGENT_TOKEN` | 兼容旧部署的全局 Agent Token | 空 |
说明:
* `SQL_DSN` 与 `SQLITE_PATH` 同时存在时优先使用 `SQL_DSN`
* `DSN` 与 `SQL_DSN` 同时存在时优先使用 `DSN`
* `DSN` 或 `SQL_DSN` 与 `SQLITE_PATH` 同时存在时优先使用 PostgreSQL
* 当目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在时,Server 启动阶段会自动迁移 SQLite 数据,并在日志中输出按表迁移进度
* `SESSION_SECRET` 生产环境必须显式配置
* `REDIS_CONN_STRING` 未配置时,相关能力回退为进程内实现
@@ -54,12 +57,19 @@ go run . --port 3000 --log-dir ./logs
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
说明:
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1;管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录
### 1.4 OpenResty 参数
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
@@ -79,6 +89,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 前端构建环境变量
| 环境变量 | 作用 | 默认值 |
+25 -2
View File
@@ -8,7 +8,7 @@
* Go 1.24+
* Node.js 18+
* 可写 SQLite 文件目录
* 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例
### 1.2 Agent
@@ -37,6 +37,9 @@ cd openflare_server
export SESSION_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
# 可选:设置后优先使用 PostgreSQL。
# 如果 PostgreSQL 为空且本地 SQLite 文件存在,启动时会自动迁移数据。
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
go run .
```
@@ -46,21 +49,41 @@ go run .
```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
container_name: openflare
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
volumes:
- openflare-data:/data
volumes:
postgres-data:
openflare-data:
```
@@ -84,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
```
+8 -4
View File
@@ -53,7 +53,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
* Gin
* GORM
* SQLite
* SQLite / PostgreSQL
* 现有登录与 Session 体系
* 托管 `openflare_server/web` 静态构建产物
@@ -79,7 +79,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
## 4. 总体架构
```text
OpenFlare Server (Gin + SQLite + Web UI)
OpenFlare Server (Gin + SQLite/PostgreSQL + Web UI)
|
| HTTP API / Config Pull
v
@@ -119,9 +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`
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
@@ -170,6 +173,7 @@ Origin
* 主配置、路由配置、证书与 Lua 资源写入
* 执行 `openresty -t` / `openresty -s reload`
* 失败回滚
* 对已失败并回退的目标版本做本地熔断,直到控制面出现新的激活版本
* 节点观测采集与结果上报
### 7.3 `openflare_server/web`
+21 -5
View File
@@ -13,7 +13,7 @@
* Go 1.24+
* Gin
* GORM
* SQLite
* SQLite / PostgreSQL
* 现有登录体系
### 1.2 Agent
@@ -115,7 +115,9 @@
通用约束:
* 不新增平台化对象,除非设计文档明确要求
* `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` 必须保存完整快照与渲染结果
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
@@ -124,6 +126,18 @@
* 快照与聚合结果采用追加式模型,不覆盖历史
* 原始访问明细必须有受控保留策略
### 3.1 数据库版本与迁移
* 任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号
* 数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库
* 每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法
* 迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录
* 新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本
* 空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本
* 数据库版本元数据属于内部控制信息,必须保存在独立内部表中,不能混入业务配置表
* 如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录
* 涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试
## 4. API 与鉴权规范
### 4.1 API
@@ -132,6 +146,7 @@
* 成功与失败都必须返回清晰 `message`
* Agent API 固定放在 `/api/agent/*`
* 总览与节点详情优先使用专用聚合接口
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`
统一响应结构:
@@ -186,9 +201,10 @@ Agent 必须满足:
* 常规同步优先依据 heartbeat 返回的版本摘要判断
* 发现新版本时先备份旧文件
* 写入主配置、路由配置与必要证书文件
* 先执行 `openresty -t`
* 成功后执行 `openresty -s reload`
* 失败时自动回滚并上报最终结果
* 写入新配置后以运行态恢复为目标执行激活,Docker 模式优先重建容器并确认容器保持运行
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty
* 回滚后 OpenResty 恢复正常时上报警告;回滚后仍无法恢复运行时上报失败
* 某个目标 `version + checksum` 一旦应用失败并回退,Agent 必须在本地状态中阻断该目标的重复应用;只有远端激活版本或 checksum 发生变化时,才允许再次尝试
## 6. 测试与交付要求
+1 -1
View File
@@ -1,6 +1,6 @@
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "89d0efbf7bcc8fa53fe48889dce4d045",
"agent_token": "2380de64b00e99093e16590beb91e1a0",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
+1
View File
@@ -73,6 +73,7 @@ func main() {
NginxLuaDir: cfg.OpenrestyLuaDir,
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
OpenrestyResolverDirective: "",
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath,
DockerBinary: cfg.DockerBinary,
+27
View File
@@ -33,6 +33,7 @@ type Config struct {
AgentVersion string `json:"-"`
NginxVersion string `json:"-"`
OpenrestyPath string `json:"openresty_path"`
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
OpenrestyContainerName string `json:"openresty_container_name"`
OpenrestyDockerImage string `json:"openresty_docker_image"`
DockerBinary string `json:"docker_binary"`
@@ -59,6 +60,7 @@ type configFile struct {
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
OpenrestyPath string `json:"openresty_path"`
OpenrestyResolvers []string `json:"openresty_resolvers"`
OpenrestyContainerName string `json:"openresty_container_name"`
OpenrestyDockerImage string `json:"openresty_docker_image"`
DockerBinary string `json:"docker_binary"`
@@ -93,6 +95,7 @@ func Load(path string) (*Config, error) {
NodeName: file.NodeName,
NodeIP: file.NodeIP,
OpenrestyPath: file.OpenrestyPath,
OpenrestyResolvers: append([]string{}, file.OpenrestyResolvers...),
OpenrestyContainerName: file.OpenrestyContainerName,
OpenrestyDockerImage: file.OpenrestyDockerImage,
DockerBinary: file.DockerBinary,
@@ -121,6 +124,7 @@ func Load(path string) (*Config, error) {
func applyDefaults(cfg *Config, baseDir string) {
baseDir = filepath.Clean(baseDir)
cfg.AgentVersion = AgentVersion
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
if cfg.OpenrestyContainerName == "" {
cfg.OpenrestyContainerName = "openflare-openresty"
}
@@ -290,6 +294,29 @@ func detectHostname() string {
return strings.TrimSpace(host)
}
func normalizeResolverList(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -120,6 +120,40 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
}
}
func TestLoadNormalizesExplicitResolvers(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
"openresty_resolvers": []string{" 10.0.0.2 ", "10.0.0.2", "", "1.1.1.1"},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
expected := []string{"10.0.0.2", "1.1.1.1"}
if len(cfg.OpenrestyResolvers) != len(expected) {
t.Fatalf("unexpected resolver count: %#v", cfg.OpenrestyResolvers)
}
for index, value := range expected {
if cfg.OpenrestyResolvers[index] != value {
t.Fatalf("unexpected resolver at %d: got %q want %q", index, cfg.OpenrestyResolvers[index], value)
}
}
}
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
@@ -209,6 +243,7 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
cfg.NginxVersion = "1.27.1.2"
cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second)
cfg.RequestTimeout = MillisecondDuration(7 * time.Second)
cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"}
if err = cfg.Save(); err != nil {
t.Fatalf("Save failed: %v", err)
@@ -234,6 +269,10 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
if decoded["request_timeout"] != float64(7000) {
t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"])
}
resolvers, ok := decoded["openresty_resolvers"].([]any)
if !ok || len(resolvers) != 2 || resolvers[0] != "10.0.0.2" || resolvers[1] != "1.1.1.1" {
t.Fatalf("unexpected resolvers: %#v", decoded["openresty_resolvers"])
}
if decoded["openresty_observability_port"] != float64(defaultOpenRestyObservabilityPort) {
t.Fatalf("unexpected observability port: %#v", decoded["openresty_observability_port"])
}
+422 -68
View File
@@ -8,6 +8,8 @@ import (
"fmt"
"io/fs"
"log/slog"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -24,6 +26,7 @@ const AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__"
const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/openflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/openflare_access.log"
@@ -116,6 +119,9 @@ type DockerExecutor struct {
func (e *DockerExecutor) Test(ctx context.Context) error {
slog.Debug("running docker openresty test", "container", e.ContainerName, "image", e.Image)
if err := e.validateMountSources(); err != nil {
return err
}
output, err := e.runEphemeralRuntimeCommand(ctx, "-t")
if err != nil {
return fmt.Errorf("docker %s -t failed: %w: %s", dockerRuntimeCommand, err, string(output))
@@ -125,7 +131,25 @@ func (e *DockerExecutor) Test(ctx context.Context) error {
}
func (e *DockerExecutor) Reload(ctx context.Context) error {
return e.EnsureRuntime(ctx, true)
if err := e.validateMountSources(); err != nil {
return err
}
output, err := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "{{.State.Running}}", e.ContainerName)
if err != nil || strings.TrimSpace(string(output)) != "true" {
return e.EnsureRuntime(ctx, false)
}
output, err = e.Runner.Run(ctx, e.DockerBinary, "exec", e.ContainerName, dockerRuntimeCommand, "-s", "reload")
if err != nil {
if e.shouldRecreateAfterReloadFailure(string(output)) {
slog.Warn("docker openresty reload failed due to missing mounted files, recreating container", "container", e.ContainerName)
if recreateErr := e.EnsureRuntime(ctx, true); recreateErr != nil {
return fmt.Errorf("docker exec %s reload failed: %w: %s; recreate failed: %v", dockerRuntimeCommand, err, string(output), recreateErr)
}
return nil
}
return fmt.Errorf("docker exec %s reload failed: %w: %s", dockerRuntimeCommand, err, string(output))
}
return nil
}
func (e *DockerExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
@@ -157,7 +181,7 @@ func (e *DockerExecutor) CheckHealth(ctx context.Context) error {
return fmt.Errorf("docker inspect openresty failed: %w: %s", err, string(output))
}
if strings.TrimSpace(string(output)) != "true" {
return errors.New("docker openresty container is not running")
return e.containerNotRunningError(ctx)
}
return nil
}
@@ -182,6 +206,9 @@ func (e *DockerExecutor) removeContainer(ctx context.Context) error {
func (e *DockerExecutor) runContainer(ctx context.Context) error {
slog.Info("starting docker openresty container", "container", e.ContainerName, "image", e.Image)
if err := e.validateMountSources(); err != nil {
return err
}
runArgs := []string{
"run", "-d",
"--name", e.ContainerName,
@@ -198,10 +225,127 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
if runErr != nil {
return fmt.Errorf("docker run openresty failed: %w: %s", runErr, string(runOutput))
}
if err := e.CheckHealth(ctx); err != nil {
return err
}
slog.Info("docker openresty container started", "container", e.ContainerName)
return nil
}
func (e *DockerExecutor) validateMountSources() error {
if err := ensureRegularFile(e.MainConfigPath, "openresty main config"); err != nil {
return err
}
if err := ensureDirectory(e.RouteConfigDir, "openresty route config dir"); err != nil {
return err
}
if err := ensureDirectory(e.CertDir, "openresty cert dir"); err != nil {
return err
}
if err := ensureDirectory(e.LuaDir, "openresty lua dir"); err != nil {
return err
}
return nil
}
func (e *DockerExecutor) shouldRecreateAfterReloadFailure(output string) bool {
text := strings.ToLower(strings.TrimSpace(output))
if text == "" {
return false
}
if !strings.Contains(text, "no such file") && !strings.Contains(text, "cannot load certificate") {
return false
}
paths := []string{
strings.ToLower(e.NginxCertDir),
strings.ToLower(e.NginxLuaDir),
strings.ToLower(DockerMainConfigPath),
strings.ToLower("/etc/nginx/conf.d"),
}
for _, path := range paths {
if strings.TrimSpace(path) != "" && strings.Contains(text, path) {
return true
}
}
return false
}
func (e *DockerExecutor) containerNotRunningError(ctx context.Context) error {
inspectSummary := ""
inspectOutput, inspectErr := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "status={{.State.Status}} exit_code={{.State.ExitCode}} error={{printf \"%q\" .State.Error}} oom_killed={{.State.OOMKilled}} finished_at={{.State.FinishedAt}}", e.ContainerName)
if inspectErr == nil {
inspectSummary = strings.TrimSpace(string(inspectOutput))
}
logTail := ""
logOutput, logErr := e.Runner.Run(ctx, e.DockerBinary, "logs", "--tail", "50", e.ContainerName)
if logErr == nil {
logTail = strings.TrimSpace(string(logOutput))
}
message := "docker openresty container is not running"
if inspectSummary != "" {
message += ": " + inspectSummary
}
if logTail != "" {
message += "; recent logs: " + compactDiagnosticText(logTail)
}
return errors.New(message)
}
func compactDiagnosticText(text string) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
lines := strings.Split(trimmed, "\n")
if len(lines) > 8 {
lines = lines[len(lines)-8:]
}
joined := strings.Join(lines, " | ")
joined = strings.Join(strings.Fields(joined), " ")
if len(joined) > 800 {
return joined[len(joined)-800:]
}
return joined
}
func ensureRegularFile(path string, label string) error {
cleanPath := strings.TrimSpace(path)
if cleanPath == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s %q does not exist; run a config apply first so Docker does not create a directory mount source", label, cleanPath)
}
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
}
if info.IsDir() {
return fmt.Errorf("%s %q is a directory; expected a file for Docker bind mount", label, cleanPath)
}
return nil
}
func ensureDirectory(path string, label string) error {
cleanPath := strings.TrimSpace(path)
if cleanPath == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s %q does not exist; expected a directory for Docker bind mount", label, cleanPath)
}
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
}
if !info.IsDir() {
return fmt.Errorf("%s %q is not a directory; expected a directory for Docker bind mount", label, cleanPath)
}
return nil
}
type Manager struct {
MainConfigPath string
RouteConfigPath string
@@ -212,74 +356,117 @@ type Manager struct {
NginxLuaDir string
OpenrestyObservabilityListen string
OpenrestyObservabilityPort int
OpenrestyResolverDirective string
Executor Executor
}
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
type ApplyStatus string
const (
ApplyStatusSuccess ApplyStatus = "success"
ApplyStatusWarning ApplyStatus = "warning"
ApplyStatusFatal ApplyStatus = "fatal"
)
type ApplyOutcome struct {
Status ApplyStatus
Message string
}
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome {
slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles))
backup, err := m.backup()
if err != nil {
return fatalApplyOutcome(fmt.Errorf("backup openresty config failed: %w", err))
}
if err = m.writeTargetFiles(mainConfig, routeConfig, supportFiles); err != nil {
return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("write openresty config failed: %w", err))
}
if err = m.activateConfig(ctx); err != nil {
return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("activate openresty runtime failed: %w", err))
}
slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath)
return ApplyOutcome{Status: ApplyStatusSuccess}
}
func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
if err := m.EnsureLuaAssets(); err != nil {
return err
}
if err = m.EnsureLuaAssets(); err != nil {
slog.Error("writing lua assets failed, restoring backup", "error", err)
_ = m.restore(backup)
if err := m.writeCertFiles(supportFiles); err != nil {
return err
}
if err = m.writeCertFiles(supportFiles); err != nil {
slog.Error("writing cert files failed, restoring backup", "error", err)
_ = m.restore(backup)
return err
if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") {
slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured")
}
renderedMainConfig := m.renderMainConfig(mainConfig)
if err = os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil {
slog.Error("writing openresty main config failed, restoring backup", "error", err)
_ = m.restore(backup)
if err := os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil {
return err
}
renderedRouteConfig := m.renderRouteConfig(routeConfig)
if err = os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil {
slog.Error("writing openresty route config failed, restoring backup", "error", err)
_ = m.restore(backup)
if err := os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil {
return err
}
if err = m.Executor.Test(ctx); err != nil {
slog.Error("openresty test failed after config write, restoring backup", "error", err)
_ = m.restore(backup)
return err
}
if err = m.Executor.Reload(ctx); err != nil {
slog.Error("openresty reload failed after config write, restoring backup", "error", err)
_ = m.restore(backup)
return err
}
slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath)
return nil
}
func (m *Manager) activateConfig(ctx context.Context) error {
if m.Executor == nil {
return errors.New("executor 未配置")
}
if _, ok := m.Executor.(*DockerExecutor); ok {
return m.Executor.EnsureRuntime(ctx, true)
}
return m.Executor.Reload(ctx)
}
func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupState, applyErr error) ApplyOutcome {
slog.Warn("openresty apply failed, restoring previous config", "error", applyErr)
if err := m.restore(backup); err != nil {
return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %v: %w", applyErr, err))
}
if err := m.activateConfig(ctx); err != nil {
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err))
}
message := fmt.Sprintf("apply failed, rolled back to previous config: %v", applyErr)
slog.Warn("openresty apply rolled back successfully", "message", message)
return ApplyOutcome{
Status: ApplyStatusWarning,
Message: message,
}
}
func fatalApplyOutcome(err error) ApplyOutcome {
if err == nil {
return ApplyOutcome{Status: ApplyStatusFatal}
}
return ApplyOutcome{
Status: ApplyStatusFatal,
Message: strings.TrimSpace(err.Error()),
}
}
func (m *Manager) EnsureLuaAssets() error {
if strings.TrimSpace(m.LuaDir) == "" {
return nil
}
if err := os.RemoveAll(m.LuaDir); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(m.LuaDir, 0o755); err != nil {
return err
}
files := make([]managedFile, 0, len(ManagedObservabilityLuaFiles()))
for _, file := range ManagedObservabilityLuaFiles() {
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, []byte(file.Content), 0o644); err != nil {
relativePath, err := filepath.Rel(m.LuaDir, targetPath)
if err != nil {
return err
}
files = append(files, managedFile{
Path: filepath.ToSlash(relativePath),
Content: []byte(file.Content),
Mode: 0o644,
})
}
return nil
return syncManagedFiles(m.LuaDir, files)
}
func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
@@ -342,6 +529,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
if m.OpenrestyObservabilityPort > 0 {
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), ObservabilityPortPlaceholder)
}
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder)
}
normalizedRoute := string(data)
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
@@ -505,6 +695,12 @@ type backupState struct {
Files []protocol.SupportFile
}
type managedFile struct {
Path string
Content []byte
Mode fs.FileMode
}
func (m *Manager) backup() (*backupState, error) {
if m.MainConfigPath == "" {
return nil, errors.New("main config path 不能为空")
@@ -569,50 +765,34 @@ func (m *Manager) restore(state *backupState) error {
if m.CertDir == "" {
return nil
}
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
return err
}
for _, file := range state.Files {
targetPath, err := m.certFileTargetPath(file.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, []byte(file.Content), certFileMode(file.Path)); err != nil {
return err
}
}
return nil
return m.writeManagedCertFiles(state.Files)
}
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
if m.CertDir == "" {
return nil
}
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
return err
}
return m.writeManagedCertFiles(certFiles)
}
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
targetPath, err := m.certFileTargetPath(file.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, []byte(file.Content), certFileMode(file.Path)); err != nil {
relativePath, err := filepath.Rel(m.CertDir, targetPath)
if err != nil {
return err
}
files = append(files, managedFile{
Path: filepath.ToSlash(relativePath),
Content: []byte(file.Content),
Mode: certFileMode(file.Path),
})
}
return nil
return syncManagedFiles(m.CertDir, files)
}
func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
@@ -719,6 +899,94 @@ func luaFileTargetPath(baseDir string, relativePath string) (string, error) {
return targetPath, nil
}
func syncManagedFiles(baseDir string, files []managedFile) error {
if strings.TrimSpace(baseDir) == "" {
return errors.New("managed dir cannot be empty")
}
if info, err := os.Stat(baseDir); err == nil && !info.IsDir() {
return fmt.Errorf("managed dir %q is not a directory", baseDir)
} else if err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return err
}
desired := make(map[string]managedFile, len(files))
for _, file := range files {
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimSpace(file.Path)))
if cleanPath == "." || cleanPath == "" {
return errors.New("managed file path cannot be empty")
}
desired[cleanPath] = managedFile{
Path: cleanPath,
Content: file.Content,
Mode: file.Mode,
}
}
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relativePath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
if _, ok := desired[filepath.Clean(relativePath)]; ok {
return nil
}
return os.Remove(path)
}); err != nil {
return err
}
for _, file := range desired {
targetPath := filepath.Join(baseDir, file.Path)
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(targetPath, file.Content, file.Mode); err != nil {
return err
}
}
return removeEmptyManagedDirs(baseDir)
}
func removeEmptyManagedDirs(baseDir string) error {
dirs := make([]string, 0)
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && path != baseDir {
dirs = append(dirs, path)
}
return nil
}); err != nil {
return err
}
sort.Slice(dirs, func(i int, j int) bool {
return len(dirs[i]) > len(dirs[j])
})
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
if len(entries) == 0 {
if err := os.Remove(dir); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
func (m *Manager) renderRouteConfig(content string) string {
if m.NginxCertDir == "" {
return content
@@ -743,6 +1011,9 @@ func (m *Manager) renderMainConfig(content string) string {
if m.OpenrestyObservabilityPort > 0 {
rendered = strings.ReplaceAll(rendered, ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
}
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective)
}
return rendered
}
@@ -756,6 +1027,89 @@ func ObservabilityListenAddress(openrestyPath string, port int) string {
return fmt.Sprintf("%d", port)
}
func ResolverDirective(openrestyPath string, explicitResolvers []string) string {
resolvers := resolverAddresses(openrestyPath, explicitResolvers)
if len(resolvers) == 0 {
return ""
}
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
}
func resolverAddresses(openrestyPath string, explicitResolvers []string) []string {
if resolvers := normalizeResolverAddresses(explicitResolvers); len(resolvers) > 0 {
return resolvers
}
data, err := os.ReadFile("/etc/resolv.conf")
if err != nil {
return nil
}
return parseResolverAddresses(string(data), strings.TrimSpace(openrestyPath) == "")
}
func parseResolverAddresses(content string, dockerMode bool) []string {
lines := strings.Split(content, "\n")
resolvers := make([]string, 0, 2)
seen := make(map[string]struct{})
for _, line := range lines {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 2 || fields[0] != "nameserver" {
continue
}
addr := strings.TrimSpace(fields[1])
if addr == "" {
continue
}
if dockerMode && !isUsableDockerResolver(addr) {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
resolvers = append(resolvers, addr)
}
return resolvers
}
func isUsableDockerResolver(addr string) bool {
ip := net.ParseIP(addr)
if ip == nil {
return false
}
return !ip.IsLoopback() && !ip.IsUnspecified()
}
func normalizeResolverAddresses(values []string) []string {
if len(values) == 0 {
return nil
}
resolvers := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
addr := strings.TrimSpace(value)
if addr == "" {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
resolvers = append(resolvers, addr)
}
if len(resolvers) == 0 {
return nil
}
return resolvers
}
func RequiresRuntimeResolver(originURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(originURL))
if err != nil || parsed.Hostname() == "" {
return false
}
return net.ParseIP(parsed.Hostname()) == nil
}
func (m *Manager) routeConfigIncludePath() string {
if strings.TrimSpace(m.RuntimeRouteConfigPath) != "" {
return strings.TrimSpace(m.RuntimeRouteConfigPath)
+433 -45
View File
@@ -28,6 +28,11 @@ type fakeExecutor struct {
reloadErr error
}
type scriptedExecutor struct {
reloadErrors []error
reloadCalls int
}
func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)})
if r.runFn != nil {
@@ -56,6 +61,31 @@ func (e *fakeExecutor) Restart(ctx context.Context) error {
return e.reloadErr
}
func (e *scriptedExecutor) Test(ctx context.Context) error {
return nil
}
func (e *scriptedExecutor) Reload(ctx context.Context) error {
index := e.reloadCalls
e.reloadCalls++
if index >= len(e.reloadErrors) {
return nil
}
return e.reloadErrors[index]
}
func (e *scriptedExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
return nil
}
func (e *scriptedExecutor) CheckHealth(ctx context.Context) error {
return nil
}
func (e *scriptedExecutor) Restart(ctx context.Context) error {
return nil
}
func TestPathExecutorCommands(t *testing.T) {
runner := &fakeRunner{}
executor := &PathExecutor{
@@ -113,6 +143,15 @@ func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 4 && args[0] == "inspect" && args[2] == "{{.State.Running}}" {
return []byte("false"), nil
}
if len(args) >= 4 && args[0] == "inspect" {
return []byte("status=exited exit_code=1 error=\"\" oom_killed=false finished_at=2026-03-18T10:08:30Z"), nil
}
if len(args) >= 1 && args[0] == "logs" {
return []byte("nginx: [emerg] host not found in upstream \"c1\" in /etc/nginx/conf.d/openflare_routes.conf:30"), nil
}
return []byte("false"), nil
},
}
@@ -130,10 +169,38 @@ func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
}
if err := executor.CheckHealth(context.Background()); err == nil {
t.Fatal("expected CheckHealth to fail when container is not running")
} else {
text := err.Error()
if !strings.Contains(text, "exit_code=1") {
t.Fatalf("expected exit code in health error, got %v", err)
}
if !strings.Contains(text, "host not found in upstream") {
t.Fatalf("expected recent docker logs in health error, got %v", err)
}
}
}
func prepareDockerMountSources(t *testing.T) (string, string, string, string) {
t.Helper()
tempDir := t.TempDir()
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
if err := os.WriteFile(mainConfigPath, []byte("events {}\nhttp {}\n"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
return mainConfigPath, routeConfigDir, certDir, luaDir
}
func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
@@ -146,11 +213,11 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"),
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Clean("/tmp/lua"),
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
@@ -171,10 +238,16 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
}
func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
inspectCalls := 0
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 2 && args[0] == "inspect" {
return []byte("false"), nil
inspectCalls++
if inspectCalls < 3 {
return []byte("false"), nil
}
return []byte("true"), nil
}
return []byte("ok"), nil
},
@@ -183,11 +256,11 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"),
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Clean("/tmp/lua"),
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
@@ -196,26 +269,111 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
t.Fatalf("Reload failed: %v", err)
}
if len(runner.calls) != 3 {
t.Fatalf("expected 3 calls, got %d", len(runner.calls))
if len(runner.calls) != 5 {
t.Fatalf("expected 5 calls, got %d", len(runner.calls))
}
if runner.calls[0].args[0] != "inspect" {
t.Fatalf("expected docker inspect on first call, got %#v", runner.calls[0])
}
if runner.calls[1].args[0] != "rm" {
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
if runner.calls[1].args[0] != "inspect" {
t.Fatalf("expected docker inspect on second call, got %#v", runner.calls[1])
}
if runner.calls[2].args[0] != "run" {
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
if runner.calls[2].args[0] != "rm" {
t.Fatalf("expected docker rm on third call, got %#v", runner.calls[2])
}
if runner.calls[3].args[0] != "run" {
t.Fatalf("expected docker run on fourth call, got %#v", runner.calls[3])
}
if runner.calls[4].args[0] != "inspect" {
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[4])
}
}
func TestDockerExecutorReloadsRunningContainerInPlace(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
if err := executor.Reload(context.Background()); err != nil {
t.Fatalf("Reload failed: %v", err)
}
expected := []runCall{
{name: "docker", args: []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}},
{name: "docker", args: []string{"exec", "openflare-openresty", "openresty", "-s", "reload"}},
}
if !reflect.DeepEqual(runner.calls, expected) {
t.Fatalf("unexpected calls: %#v", runner.calls)
}
}
func TestDockerExecutorReloadRecreatesContainerWhenMountedCertMissing(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
if len(args) >= 2 && args[0] == "exec" {
return []byte(`nginx: [emerg] cannot load certificate "/etc/nginx/openflare-certs/1.crt": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory)`), errors.New("exit status 1")
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
Runner: runner,
}
if err := executor.Reload(context.Background()); err != nil {
t.Fatalf("Reload failed: %v", err)
}
if len(runner.calls) != 6 {
t.Fatalf("expected 6 calls, got %d", len(runner.calls))
}
if runner.calls[2].args[0] != "inspect" || runner.calls[3].args[0] != "rm" || runner.calls[4].args[0] != "run" || runner.calls[5].args[0] != "inspect" {
t.Fatalf("expected recreate after reload failure, got %#v", runner.calls)
}
}
func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
mainConfigPath := filepath.Clean("/tmp/managed/nginx.conf")
routeConfigDir := filepath.Clean("/tmp/managed/conf.d")
certDir := filepath.Clean("/tmp/managed/certs")
luaDir := filepath.Clean("/tmp/managed/lua")
runner := &fakeRunner{}
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
@@ -234,8 +392,8 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
t.Fatalf("runContainer failed: %v", err)
}
if len(runner.calls) != 1 {
t.Fatalf("expected one docker run call, got %d", len(runner.calls))
if len(runner.calls) != 2 {
t.Fatalf("expected docker run plus health check, got %d calls", len(runner.calls))
}
expectedArgs := []string{
@@ -253,9 +411,13 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
t.Fatalf("unexpected docker run args: %#v", runner.calls[0].args)
}
if !reflect.DeepEqual(runner.calls[1].args, []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}) {
t.Fatalf("unexpected docker health check args: %#v", runner.calls[1].args)
}
}
func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
@@ -268,11 +430,11 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"),
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Clean("/tmp/lua"),
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
Runner: runner,
@@ -281,8 +443,8 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
t.Fatalf("EnsureRuntime failed: %v", err)
}
if len(runner.calls) != 3 {
t.Fatalf("expected 3 calls, got %d", len(runner.calls))
if len(runner.calls) != 4 {
t.Fatalf("expected 4 calls, got %d", len(runner.calls))
}
if runner.calls[1].args[0] != "rm" {
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
@@ -290,6 +452,76 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
if runner.calls[2].args[0] != "run" {
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
}
if runner.calls[3].args[0] != "inspect" {
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[3])
}
}
func TestDockerExecutorRunContainerRejectsMissingMainConfigFile(t *testing.T) {
tempDir := t.TempDir()
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: &fakeRunner{},
}
err := executor.runContainer(context.Background())
if err == nil {
t.Fatal("expected missing main config file to be rejected")
}
if !strings.Contains(err.Error(), "run a config apply first") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDockerExecutorRunContainerRejectsMainConfigDirectory(t *testing.T) {
tempDir := t.TempDir()
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
for _, dir := range []string{mainConfigPath, routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: &fakeRunner{},
}
err := executor.runContainer(context.Background())
if err == nil {
t.Fatal("expected main config directory to be rejected")
}
if !strings.Contains(err.Error(), "expected a file") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
@@ -355,21 +587,21 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
Executor: &fakeExecutor{},
}
err := manager.Apply(
outcome := manager.Apply(
context.Background(),
"include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;\n",
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
)
if err != nil {
t.Fatalf("Apply failed: %v", err)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
expectedMain := "include " + routePath + ";\naccess_log " + filepath.Join(filepath.Dir(routePath), "openflare_access.log") + " openflare_json;\n"
expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(filepath.Join(filepath.Dir(routePath), "openflare_access.log")) + " openflare_json;\n"
if string(mainData) != expectedMain {
t.Fatalf("unexpected main config: %s", string(mainData))
}
@@ -411,8 +643,8 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
Executor: &fakeExecutor{},
}
if err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); err != nil {
t.Fatalf("Apply failed: %v", err)
if outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
@@ -485,15 +717,16 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityListen: "18081",
OpenrestyResolverDirective: " resolver 127.0.0.11 valid=30s ipv6=off;\n resolver_timeout 5s;\n",
Executor: &fakeExecutor{},
}
err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\nserver { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{
outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\n__OPENFLARE_RESOLVER_DIRECTIVE__server { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{
{Path: "1.crt", Content: "cert-data"},
{Path: "1.key", Content: "key-data"},
})
if err != nil {
t.Fatalf("Apply failed: %v", err)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
routeData, err := os.ReadFile(manager.RouteConfigPath)
@@ -510,6 +743,9 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if !strings.Contains(string(mainData), "listen 18081;") {
t.Fatalf("expected observability listen placeholder replacement in main config, got %s", string(mainData))
}
if !strings.Contains(string(mainData), "resolver 127.0.0.11 valid=30s ipv6=off;") {
t.Fatalf("expected resolver directive placeholder replacement in main config, got %s", string(mainData))
}
certData, err := os.ReadFile(filepath.Join(manager.CertDir, "1.crt"))
if err != nil {
t.Fatalf("failed to read cert file: %v", err)
@@ -521,11 +757,118 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
if luaInfo.Mode().Perm() != 0o644 {
if runtime.GOOS != "windows" && luaInfo.Mode().Perm() != 0o644 {
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
}
}
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") {
t.Fatalf("expected explicit resolver directive, got %q", got)
}
}
func TestParseResolverAddressesFiltersLoopbackForDocker(t *testing.T) {
content := strings.Join([]string{
"nameserver 127.0.0.53",
"nameserver 10.0.0.2",
"nameserver ::1",
"nameserver 1.1.1.1",
}, "\n")
got := parseResolverAddresses(content, true)
expected := []string{"10.0.0.2", "1.1.1.1"}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("unexpected docker resolvers: got %#v want %#v", got, expected)
}
}
func TestParseResolverAddressesKeepsLoopbackForLocalBinary(t *testing.T) {
content := strings.Join([]string{
"nameserver 127.0.0.53",
"nameserver 10.0.0.2",
}, "\n")
got := parseResolverAddresses(content, false)
expected := []string{"127.0.0.53", "10.0.0.2"}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("unexpected local resolvers: got %#v want %#v", got, expected)
}
}
func TestRequiresRuntimeResolver(t *testing.T) {
testCases := []struct {
name string
originURL string
want bool
}{
{name: "hostname", originURL: "https://origin.internal", want: true},
{name: "ipv4", originURL: "https://10.0.0.8", want: false},
{name: "ipv6", originURL: "https://[2001:db8::1]", want: false},
{name: "invalid", originURL: "://bad", want: false},
}
for _, testCase := range testCases {
if got := RequiresRuntimeResolver(testCase.originURL); got != testCase.want {
t.Fatalf("%s: got %v want %v", testCase.name, got, testCase.want)
}
}
}
func TestWriteCertFilesKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
tempDir := t.TempDir()
certDir := filepath.Join(tempDir, "certs")
if err := os.MkdirAll(filepath.Join(certDir, "stale"), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(filepath.Join(certDir, "stale", "old.crt"), []byte("old"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{CertDir: certDir}
if err := manager.writeCertFiles([]protocol.SupportFile{
{Path: "1.crt", Content: "cert"},
{Path: "1.key", Content: "key"},
}); err != nil {
t.Fatalf("writeCertFiles failed: %v", err)
}
if _, err := os.Stat(certDir); err != nil {
t.Fatalf("expected cert dir to persist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(certDir, "stale", "old.crt")); !os.IsNotExist(err) {
t.Fatalf("expected stale cert file to be removed, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(certDir, "1.crt")); err != nil {
t.Fatalf("expected new cert file to exist, stat err = %v", err)
}
}
func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
tempDir := t.TempDir()
luaDir := filepath.Join(tempDir, "lua")
if err := os.MkdirAll(filepath.Join(luaDir, "stale"), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(filepath.Join(luaDir, "stale", "old.lua"), []byte("old"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{LuaDir: luaDir}
if err := manager.EnsureLuaAssets(); err != nil {
t.Fatalf("EnsureLuaAssets failed: %v", err)
}
if _, err := os.Stat(luaDir); err != nil {
t.Fatalf("expected lua dir to persist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "stale", "old.lua")); !os.IsNotExist(err) {
t.Fatalf("expected stale lua file to be removed, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
}
func TestCertFileMode(t *testing.T) {
testCases := []struct {
path string
@@ -590,15 +933,15 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) {
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &fakeExecutor{
testErr: errors.New("openresty test failed"),
reloadErr: errors.New("openresty reload failed"),
},
}
err := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
{Path: "1.crt", Content: "new-cert"},
})
if err == nil {
t.Fatal("expected Apply to fail")
if outcome.Status != ApplyStatusFatal {
t.Fatalf("expected fatal apply outcome, got %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
@@ -624,6 +967,51 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) {
}
}
func TestManagerApplyReturnsWarningWhenRollbackRecoversRuntime(t *testing.T) {
tempDir := t.TempDir()
routePath := filepath.Join(tempDir, "routes.conf")
mainPath := filepath.Join(tempDir, "nginx.conf")
certDir := filepath.Join(tempDir, "certs")
if err := os.MkdirAll(certDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(mainPath, []byte("old-main"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
if err := os.WriteFile(filepath.Join(certDir, "1.crt"), []byte("old-cert"), 0o600); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &scriptedExecutor{
reloadErrors: []error{errors.New("target config failed"), nil},
},
}
outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
{Path: "1.crt", Content: "new-cert"},
})
if outcome.Status != ApplyStatusWarning {
t.Fatalf("expected warning apply outcome, got %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
if string(mainData) != "old-main" {
t.Fatalf("expected main rollback, got %s", string(mainData))
}
}
func TestManagerCertFileTargetPathRejectsEscapes(t *testing.T) {
manager := &Manager{CertDir: filepath.Join(t.TempDir(), "certs")}
if err := os.MkdirAll(manager.CertDir, 0o755); err != nil {
@@ -675,11 +1063,11 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
Executor: &fakeExecutor{},
}
err := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
{Path: "../escape.crt", Content: "bad"},
})
if err == nil {
t.Fatal("expected Apply to reject traversal path")
if outcome.Status != ApplyStatusWarning {
t.Fatalf("expected warning apply outcome, got %#v", outcome)
}
if _, statErr := os.Stat(filepath.Join(tempDir, "escape.crt")); !os.IsNotExist(statErr) {
@@ -327,18 +327,28 @@ type trafficCountItem struct {
value int64
}
const accessLogPathMaxRunes = 100
func normalizeAccessLogPath(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
return truncateAccessLogPath(trimmed)
}
if strings.HasPrefix(trimmed, "/") {
return trimmed
return truncateAccessLogPath(trimmed)
}
return "/" + trimmed
return truncateAccessLogPath("/" + trimmed)
}
func truncateAccessLogPath(value string) string {
runes := []rune(value)
if len(runes) <= accessLogPathMaxRunes {
return value
}
return string(runes[:accessLogPathMaxRunes])
}
func topCounts(values map[string]int64, limit int) map[string]int64 {
@@ -3,6 +3,7 @@ package observability
import (
"os"
"path/filepath"
"strings"
"testing"
"openflare-agent/internal/config"
@@ -108,6 +109,31 @@ func TestBuildTrafficObservabilityReturnsAccessLogs(t *testing.T) {
}
}
func TestBuildTrafficObservabilityTruncatesLongAccessLogPath(t *testing.T) {
tempDir := t.TempDir()
routeConfigPath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
logPath := filepath.Join(filepath.Dir(routeConfigPath), "openflare_access.log")
longPath := "/" + strings.Repeat("a", 140)
content := []byte(
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"path\":\"" + longPath + "\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n",
)
if err := os.WriteFile(logPath, content, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
_, accessLogs, _ := BuildTrafficObservability(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
if len(accessLogs) != 1 {
t.Fatalf("expected one access log, got %+v", accessLogs)
}
if got := len([]rune(accessLogs[0].Path)); got != accessLogPathMaxRunes {
t.Fatalf("expected truncated path length %d, got %d (%q)", accessLogPathMaxRunes, got, accessLogs[0].Path)
}
}
func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
tempDir := t.TempDir()
routeConfigPath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
+3
View File
@@ -13,6 +13,9 @@ type Snapshot struct {
NodeID string `json:"node_id"`
CurrentVersion string `json:"current_version"`
CurrentChecksum string `json:"current_checksum"`
BlockedVersion string `json:"blocked_version"`
BlockedChecksum string `json:"blocked_checksum"`
BlockedReason string `json:"blocked_reason"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"`
OpenrestyMessage string `json:"openresty_message"`
+142 -34
View File
@@ -4,15 +4,18 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"strings"
"openflare-agent/internal/nginx"
"openflare-agent/internal/protocol"
"openflare-agent/internal/state"
)
const (
ApplyResultSuccess = "success"
ApplyResultWarning = "warning"
ApplyResultFailed = "failed"
)
@@ -22,7 +25,7 @@ type ConfigClient interface {
}
type NginxManager interface {
Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error
Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome
EnsureRuntime(ctx context.Context, recreate bool) error
CurrentChecksum() (string, error)
}
@@ -102,13 +105,27 @@ func (s *Service) sync(ctx context.Context, startup bool, target *protocol.Activ
}
snapshot.CurrentVersion = target.Version
snapshot.CurrentChecksum = target.Checksum
clearBlockedTarget(snapshot)
snapshot.LastError = ""
slog.Debug("sync finished without changes", "mode", mode, "version", target.Version)
return s.stateStore.Save(snapshot)
}
if isBlockedTarget(snapshot, target.Version, target.Checksum) {
slog.Warn("skipping blocked config version after previous failed apply", "mode", mode, "version", target.Version, "checksum", target.Checksum)
if startup {
if err = s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
return err
}
return s.stateStore.Save(snapshot)
}
return nil
}
if hasBlockedTarget(snapshot) {
clearBlockedTarget(snapshot)
}
if snapshot.CurrentVersion == target.Version && snapshot.CurrentChecksum == target.Checksum && !startup {
slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum)
return nil
return s.stateStore.Save(snapshot)
}
config, err := s.client.GetActiveConfig(ctx)
@@ -136,6 +153,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
}
snapshot.CurrentVersion = config.Version
snapshot.CurrentChecksum = config.Checksum
clearBlockedTarget(snapshot)
snapshot.LastError = ""
slog.Debug("sync finished without changes", "mode", mode, "version", config.Version)
return s.stateStore.Save(snapshot)
@@ -143,9 +161,22 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
if target != nil && (target.Version != config.Version || target.Checksum != config.Checksum) {
slog.Warn("active config changed between heartbeat and fetch", "heartbeat_version", target.Version, "heartbeat_checksum", target.Checksum, "fetched_version", config.Version, "fetched_checksum", config.Checksum)
}
if isBlockedTarget(snapshot, config.Version, config.Checksum) {
slog.Warn("skipping blocked config after fetch because the same version previously failed", "mode", mode, "version", config.Version, "checksum", config.Checksum)
if startup {
if err := s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
return err
}
return s.stateStore.Save(snapshot)
}
return nil
}
if hasBlockedTarget(snapshot) {
clearBlockedTarget(snapshot)
}
if snapshot.CurrentVersion == config.Version && snapshot.CurrentChecksum == config.Checksum && !startup {
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
return nil
return s.stateStore.Save(snapshot)
}
routeConfig := config.RouteConfig
if routeConfig == "" {
@@ -154,55 +185,132 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
mainConfigChecksum := checksumString(config.MainConfig)
routeConfigChecksum := checksumString(routeConfig)
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
if err := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles); err != nil {
slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "error", err)
snapshot.LastError = err.Error()
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = err.Error()
_ = s.stateStore.Save(snapshot)
reportErr := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
NodeID: snapshot.NodeID,
Version: config.Version,
Result: ApplyResultFailed,
Message: err.Error(),
Checksum: config.Checksum,
MainConfigChecksum: mainConfigChecksum,
RouteConfigChecksum: routeConfigChecksum,
SupportFileCount: len(config.SupportFiles),
})
if reportErr != nil {
slog.Error("report failed apply log failed", "version", config.Version, "error", reportErr)
return reportErr
outcome := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles)
message := strings.TrimSpace(outcome.Message)
if outcome.Status == "" {
outcome.Status = nginx.ApplyStatusFatal
if message == "" {
message = "openresty apply returned empty outcome"
}
slog.Warn("failed apply log reported", "version", config.Version)
return err
}
slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version)
snapshot.CurrentVersion = config.Version
snapshot.CurrentChecksum = config.Checksum
snapshot.LastError = ""
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = ""
reportResult := ApplyResultFailed
switch outcome.Status {
case nginx.ApplyStatusSuccess:
slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version)
snapshot.CurrentVersion = config.Version
snapshot.CurrentChecksum = config.Checksum
clearBlockedTarget(snapshot)
snapshot.LastError = ""
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = ""
reportResult = ApplyResultSuccess
if message == "" {
message = "apply success"
}
case nginx.ApplyStatusWarning:
if message == "" {
message = "apply rolled back to previous config"
}
slog.Warn("openresty config apply rolled back", "mode", mode, "version", config.Version, "message", message)
markBlockedTarget(snapshot, config.Version, config.Checksum, message)
snapshot.LastError = message
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = message
reportResult = ApplyResultWarning
default:
if message == "" {
message = "openresty apply failed"
}
slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "message", message)
markBlockedTarget(snapshot, config.Version, config.Checksum, message)
snapshot.LastError = message
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = message
}
if err := s.stateStore.Save(snapshot); err != nil {
return err
}
if err := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
NodeID: snapshot.NodeID,
Version: config.Version,
Result: ApplyResultSuccess,
Message: "apply success",
Result: reportResult,
Message: message,
Checksum: config.Checksum,
MainConfigChecksum: mainConfigChecksum,
RouteConfigChecksum: routeConfigChecksum,
SupportFileCount: len(config.SupportFiles),
}); err != nil {
slog.Error("report successful apply log failed", "version", config.Version, "error", err)
slog.Error("report apply log failed", "version", config.Version, "result", reportResult, "error", err)
return err
}
slog.Debug("successful apply log reported", "version", config.Version)
if reportResult == ApplyResultFailed {
slog.Warn("failed apply log reported", "version", config.Version)
return outcomeError(config.Version, message)
}
slog.Debug("apply log reported", "version", config.Version, "result", reportResult)
return nil
}
func outcomeError(version string, message string) error {
trimmed := strings.TrimSpace(message)
if trimmed == "" {
trimmed = "openresty apply failed"
}
return fmt.Errorf("apply version %s failed: %s", version, trimmed)
}
func (s *Service) ensureRuntimeForCurrentConfig(ctx context.Context, mode string, snapshot *state.Snapshot, currentChecksum string) error {
if strings.TrimSpace(currentChecksum) == "" {
slog.Warn("blocked config cannot be retried and no local checksum is available for runtime recovery", "mode", mode, "blocked_version", snapshot.BlockedVersion)
return nil
}
slog.Info("ensuring runtime with current local config while active target remains blocked", "mode", mode, "current_version", snapshot.CurrentVersion, "current_checksum", currentChecksum, "blocked_version", snapshot.BlockedVersion)
if err := s.nginxManager.EnsureRuntime(ctx, true); err != nil {
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = err.Error()
_ = s.stateStore.Save(snapshot)
return err
}
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
if strings.TrimSpace(snapshot.OpenrestyMessage) == strings.TrimSpace(snapshot.BlockedReason) {
snapshot.OpenrestyMessage = ""
}
return nil
}
func markBlockedTarget(snapshot *state.Snapshot, version string, checksum string, reason string) {
if snapshot == nil {
return
}
snapshot.BlockedVersion = strings.TrimSpace(version)
snapshot.BlockedChecksum = strings.TrimSpace(checksum)
snapshot.BlockedReason = strings.TrimSpace(reason)
}
func clearBlockedTarget(snapshot *state.Snapshot) {
if snapshot == nil {
return
}
snapshot.BlockedVersion = ""
snapshot.BlockedChecksum = ""
snapshot.BlockedReason = ""
}
func hasBlockedTarget(snapshot *state.Snapshot) bool {
return snapshot != nil && (strings.TrimSpace(snapshot.BlockedVersion) != "" || strings.TrimSpace(snapshot.BlockedChecksum) != "")
}
func isBlockedTarget(snapshot *state.Snapshot, version string, checksum string) bool {
if snapshot == nil {
return false
}
return strings.TrimSpace(snapshot.BlockedVersion) == strings.TrimSpace(version) &&
strings.TrimSpace(snapshot.BlockedChecksum) == strings.TrimSpace(checksum) &&
(strings.TrimSpace(version) != "" || strings.TrimSpace(checksum) != "")
}
func checksumString(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
+238 -35
View File
@@ -24,7 +24,7 @@ type fakeClient struct {
}
type fakeManager struct {
applyErr error
applyOutcome nginx.ApplyOutcome
currentChecksum string
currentChecksumErr error
ensureErr error
@@ -64,11 +64,14 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
return nil
}
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome {
m.applyMainContents = append(m.applyMainContents, mainConfig)
m.applyRouteContents = append(m.applyRouteContents, routeConfig)
m.applyFiles = append(m.applyFiles, append([]protocol.SupportFile(nil), supportFiles...))
return m.applyErr
if m.applyOutcome.Status == "" {
return nginx.ApplyOutcome{Status: nginx.ApplyStatusSuccess}
}
return m.applyOutcome
}
func (m *fakeManager) EnsureRuntime(ctx context.Context, recreate bool) error {
@@ -166,17 +169,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
},
}
tempDir := t.TempDir()
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "routes.conf")
if err := os.WriteFile(mainPath, []byte("worker_processes auto;"), 0o644); err != nil {
t.Fatalf("failed to seed main file: %v", err)
}
if err := os.WriteFile(routePath, []byte("server { listen 80; }"), 0o644); err != nil {
t.Fatalf("failed to seed route file: %v", err)
}
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
@@ -189,11 +182,10 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
t.Fatalf("failed to seed state: %v", err)
}
service := New(client, &nginx.Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
Executor: &fakeExecutor{
testErr: context.DeadlineExceeded,
service := New(client, &fakeManager{
applyOutcome: nginx.ApplyOutcome{
Status: nginx.ApplyStatusFatal,
Message: "openresty failed after rollback",
},
}, stateStore)
@@ -202,22 +194,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
Checksum: client.config.Checksum,
})
if err == nil {
t.Fatal("expected SyncOnce to fail when nginx test fails")
}
data, readErr := os.ReadFile(routePath)
if readErr != nil {
t.Fatalf("failed to read route file after rollback: %v", readErr)
}
if string(data) != "server { listen 80; }" {
t.Fatal("expected original route config to be restored after rollback")
}
mainData, readErr := os.ReadFile(mainPath)
if readErr != nil {
t.Fatalf("failed to read main file after rollback: %v", readErr)
}
if string(mainData) != "worker_processes auto;" {
t.Fatal("expected original main config to be restored after rollback")
t.Fatal("expected SyncOnce to fail when apply outcome is fatal")
}
snapshot, loadErr := stateStore.Load()
if loadErr != nil {
@@ -226,6 +203,12 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
if snapshot.CurrentVersion != "20260309-001" {
t.Fatal("expected failed sync not to overwrite current version")
}
if snapshot.BlockedVersion != "20260309-002" || snapshot.BlockedChecksum != "checksum-2" {
t.Fatalf("expected failed target version to be blocked, got %+v", snapshot)
}
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusUnhealthy {
t.Fatalf("expected unhealthy openresty status, got %q", snapshot.OpenrestyStatus)
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultFailed {
t.Fatal("expected failed apply report to be sent")
}
@@ -240,6 +223,67 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
}
}
func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-002",
Checksum: "checksum-2",
MainConfig: "worker_processes 2;",
RouteConfig: "server { listen 81; }",
RenderedConfig: "server { listen 81; }",
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-001",
CurrentChecksum: "checksum-1",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
service := New(client, &fakeManager{
applyOutcome: nginx.ApplyOutcome{
Status: nginx.ApplyStatusWarning,
Message: "apply failed, rolled back to previous config",
},
}, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: client.config.Version,
Checksum: client.config.Checksum,
}); err != nil {
t.Fatalf("expected warning outcome to keep sync successful, got %v", err)
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.CurrentVersion != "20260309-001" || snapshot.CurrentChecksum != "checksum-1" {
t.Fatal("expected warning apply to keep previous version state")
}
if snapshot.BlockedVersion != "20260309-002" || snapshot.BlockedChecksum != "checksum-2" {
t.Fatalf("expected rolled-back target version to be blocked, got %+v", snapshot)
}
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy {
t.Fatalf("expected healthy openresty after rollback, got %q", snapshot.OpenrestyStatus)
}
if snapshot.LastError == "" {
t.Fatal("expected rollback warning to be recorded")
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultWarning {
t.Fatal("expected warning apply report to be sent")
}
}
func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
@@ -330,6 +374,165 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
}
}
func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-006",
Checksum: "checksum-6",
MainConfig: "worker_processes 6;",
RouteConfig: "server { listen 86; }",
RenderedConfig: "server { listen 86; }",
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-005",
CurrentChecksum: "checksum-5",
BlockedVersion: "20260309-006",
BlockedChecksum: "checksum-6",
BlockedReason: "apply failed, rolled back to previous config",
LastError: "apply failed, rolled back to previous config",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
manager := &fakeManager{currentChecksum: "checksum-5"}
service := New(client, manager, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: "20260309-006",
Checksum: "checksum-6",
}); err != nil {
t.Fatalf("expected blocked version to be skipped, got %v", err)
}
if client.fetchCalls != 0 {
t.Fatalf("expected blocked version to skip fetch, got %d", client.fetchCalls)
}
if len(manager.applyMainContents) != 0 {
t.Fatal("expected blocked version to skip apply")
}
if len(client.reports) != 0 {
t.Fatal("expected blocked version to skip reporting duplicate apply result")
}
}
func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-007",
Checksum: "checksum-7",
MainConfig: "worker_processes 7;",
RouteConfig: "server { listen 87; }",
RenderedConfig: "server { listen 87; }",
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-005",
CurrentChecksum: "checksum-5",
BlockedVersion: "20260309-007",
BlockedChecksum: "checksum-7",
BlockedReason: "apply failed, rolled back to previous config",
OpenrestyStatus: protocol.OpenrestyStatusUnhealthy,
OpenrestyMessage: "apply failed, rolled back to previous config",
LastError: "apply failed, rolled back to previous config",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
manager := &fakeManager{currentChecksum: "checksum-5"}
service := New(client, manager, stateStore)
if err = service.SyncOnStartup(context.Background(), &protocol.ActiveConfigMeta{
Version: "20260309-007",
Checksum: "checksum-7",
}); err != nil {
t.Fatalf("expected blocked startup target to be skipped, got %v", err)
}
if len(manager.ensureCalls) != 1 || !manager.ensureCalls[0] {
t.Fatal("expected startup skip to ensure runtime with current local config")
}
if client.fetchCalls != 0 {
t.Fatalf("expected blocked startup target to skip fetch, got %d", client.fetchCalls)
}
if len(client.reports) != 0 {
t.Fatal("expected blocked startup target to skip duplicate apply report")
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.BlockedVersion != "20260309-007" || snapshot.BlockedChecksum != "checksum-7" {
t.Fatalf("expected blocked target to remain recorded, got %+v", snapshot)
}
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy {
t.Fatalf("expected startup runtime recovery to mark openresty healthy, got %q", snapshot.OpenrestyStatus)
}
}
func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-008",
Checksum: "checksum-8",
MainConfig: "worker_processes 8;",
RouteConfig: "server { listen 88; }",
RenderedConfig: "server { listen 88; }",
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: "20260309-005",
CurrentChecksum: "checksum-5",
BlockedVersion: "20260309-007",
BlockedChecksum: "checksum-7",
BlockedReason: "apply failed, rolled back to previous config",
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
manager := &fakeManager{}
service := New(client, manager, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: "20260309-008",
Checksum: "checksum-8",
}); err != nil {
t.Fatalf("expected new target version to be applied, got %v", err)
}
if client.fetchCalls != 1 {
t.Fatalf("expected new target to trigger fetch, got %d", client.fetchCalls)
}
if len(manager.applyMainContents) != 1 {
t.Fatal("expected new target to trigger apply")
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.BlockedVersion != "" || snapshot.BlockedChecksum != "" {
t.Fatalf("expected blocked target to be cleared after new version succeeds, got %+v", snapshot)
}
if snapshot.CurrentVersion != "20260309-008" || snapshot.CurrentChecksum != "checksum-8" {
t.Fatalf("expected current version to move to new target, got %+v", snapshot)
}
}
func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
+10
View File
@@ -0,0 +1,10 @@
web/node_modules
web/.next
web/build
web/out
upload
logs
.git
.github
+9 -6
View File
@@ -18,6 +18,7 @@ var HomePageLink = ""
var SessionSecret = uuid.New().String()
var SQLitePath = "openflare.db"
var SQLDSN = ""
var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
@@ -54,21 +55,23 @@ var NodeOfflineThreshold = 2 * time.Minute
var AgentHeartbeatInterval = 10000 // milliseconds
var AgentUpdateRepo = "Rain-kl/OpenFlare"
var GeoIPProvider = "ipinfo"
var DatabaseAutoCleanupEnabled = false
var DatabaseAutoCleanupRetentionDays = 30
// V5 OpenResty performance settings (hot-reloadable via Option table)
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
@@ -85,7 +88,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"
@@ -101,7 +104,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;
+6
View File
@@ -48,6 +48,12 @@ func init() {
if os.Getenv("SQLITE_PATH") != "" {
SQLitePath = os.Getenv("SQLITE_PATH")
}
if os.Getenv("SQL_DSN") != "" {
SQLDSN = os.Getenv("SQL_DSN")
}
if os.Getenv("DSN") != "" {
SQLDSN = os.Getenv("DSN")
}
if os.Getenv("UPLOAD_PATH") != "" {
UploadPath = os.Getenv("UPLOAD_PATH")
}
+136 -3
View File
@@ -1,6 +1,7 @@
package controller
import (
"net/http"
"openflare/service"
"strconv"
@@ -13,17 +14,149 @@ import (
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param path query string false "Path"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ [get]
func GetAccessLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("p", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "0"))
logs, err := service.ListAccessLogs(c.Query("node_id"), page, pageSize)
logs, err := service.ListAccessLogs(readAccessLogQuery(c))
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, logs)
}
// GetFoldedAccessLogs godoc
// @Summary List folded access logs
// @Tags AccessLogs
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param path query string false "Path"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Param fold_minutes query int false "Fold minutes"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/folds [get]
func GetFoldedAccessLogs(c *gin.Context) {
query := readAccessLogQuery(c)
query.FoldMinutes = readQueryInt(c, "fold_minutes")
logs, err := service.ListFoldedAccessLogs(query)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, logs)
}
// GetAccessLogIPSummaries godoc
// @Summary List access log IP summaries
// @Tags AccessLogs
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ip-summary [get]
func GetAccessLogIPSummaries(c *gin.Context) {
result, err := service.ListAccessLogIPSummaries(service.AccessLogIPSummaryQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
// GetAccessLogIPTrend godoc
// @Summary Get access log IP trend
// @Tags AccessLogs
// @Produce json
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string true "Remote address"
// @Param host query string false "Host"
// @Param hours query int false "Hours"
// @Param bucket_minutes query int false "Bucket minutes"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ip-summary/trend [get]
func GetAccessLogIPTrend(c *gin.Context) {
result, err := service.GetAccessLogIPTrend(service.AccessLogIPTrendQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Hours: readQueryInt(c, "hours"),
BucketMinutes: readQueryInt(c, "bucket_minutes"),
})
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
// CleanupAccessLogs godoc
// @Summary Cleanup access logs by retention days
// @Tags AccessLogs
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/cleanup [post]
func CleanupAccessLogs(c *gin.Context) {
var input service.AccessLogCleanupInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "参数错误",
"error": err.Error(),
})
return
}
result, err := service.CleanupAccessLogs(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
func readAccessLogQuery(c *gin.Context) service.AccessLogQuery {
return service.AccessLogQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Path: c.Query("path"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
}
}
func readQueryInt(c *gin.Context, key string) int {
value, _ := strconv.Atoi(c.DefaultQuery(key, "0"))
return value
}
+37 -1
View File
@@ -3,6 +3,7 @@ package controller
import (
"openflare/model"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
@@ -144,10 +145,45 @@ func GetNodes(c *gin.Context) {
// @Success 200 {object} map[string]interface{}
// @Router /api/apply-logs/ [get]
func GetApplyLogs(c *gin.Context) {
logs, err := service.ListApplyLogs(c.Query("node_id"))
logs, err := service.ListApplyLogsPage(service.ApplyLogListQuery{
NodeID: c.Query("node_id"),
PageNo: readIntQueryFallback(c, "pageNo", "page_no"),
PageSize: readIntQueryFallback(c, "pageSize", "page_size"),
})
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, logs)
}
// CleanupApplyLogs godoc
// @Summary Cleanup apply logs
// @Tags ApplyLogs
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/apply-logs/cleanup [post]
func CleanupApplyLogs(c *gin.Context) {
var input service.ApplyLogCleanupInput
if err := c.ShouldBindJSON(&input); err != nil {
respondBadRequest(c, "")
return
}
result, err := service.CleanupApplyLogs(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
func readIntQueryFallback(c *gin.Context, primary string, secondary string) int {
value := c.Query(primary)
if value == "" {
value = c.Query(secondary)
}
parsed, _ := strconv.Atoi(value)
return parsed
}
+37 -3
View File
@@ -1,10 +1,11 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
// GetConfigVersions godoc
@@ -30,6 +31,39 @@ func GetConfigVersions(c *gin.Context) {
})
}
// GetConfigVersion godoc
// @Summary Get config version detail
// @Tags ConfigVersions
// @Produce json
// @Security BearerAuth
// @Param id path int true "Version ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id} [get]
func GetConfigVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid id",
})
return
}
version, err := service.GetConfigVersionDetail(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": version,
})
}
// GetActiveConfigVersion godoc
// @Summary Get active config version
// @Tags ConfigVersions
@@ -131,13 +165,13 @@ func PublishConfigVersion(c *gin.Context) {
// @Param id path int true "Version ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id}/activate [put]
// @Router /api/config-versions/{id}/activate [post]
func ActivateConfigVersion(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": "无效的参数",
"message": "invalid id",
})
return
}
+149 -1
View File
@@ -6,6 +6,29 @@ import (
"github.com/gin-gonic/gin"
)
type dashboardOverviewPayload struct {
GeneratedAt any `json:"generated_at"`
Summary service.DashboardSummary `json:"summary"`
Traffic service.DashboardTraffic `json:"traffic"`
Capacity service.DashboardCapacity `json:"capacity"`
Distributions dashboardDistributionsPayload `json:"distributions"`
Trends dashboardTrendsPayload `json:"trends"`
Nodes [][]any `json:"nodes"`
}
type dashboardDistributionsPayload struct {
StatusCodes [][]any `json:"status_codes"`
TopDomains [][]any `json:"top_domains"`
SourceCountries [][]any `json:"source_countries"`
}
type dashboardTrendsPayload struct {
Traffic24h [][]any `json:"traffic_24h"`
Capacity24h [][]any `json:"capacity_24h"`
Network24h [][]any `json:"network_24h"`
DiskIO24h [][]any `json:"disk_io_24h"`
}
// GetDashboardOverview godoc
// @Summary Get dashboard overview
// @Tags Dashboard
@@ -20,5 +43,130 @@ func GetDashboardOverview(c *gin.Context) {
respondFailure(c, err.Error())
return
}
respondSuccess(c, view)
respondSuccess(c, compressDashboardOverview(view))
}
func compressDashboardOverview(view *service.DashboardOverviewView) *dashboardOverviewPayload {
if view == nil {
return &dashboardOverviewPayload{
Distributions: dashboardDistributionsPayload{
StatusCodes: [][]any{},
TopDomains: [][]any{},
SourceCountries: [][]any{},
},
Trends: dashboardTrendsPayload{
Traffic24h: [][]any{},
Capacity24h: [][]any{},
Network24h: [][]any{},
DiskIO24h: [][]any{},
},
Nodes: [][]any{},
}
}
return &dashboardOverviewPayload{
GeneratedAt: view.GeneratedAt,
Summary: view.Summary,
Traffic: view.Traffic,
Capacity: view.Capacity,
Distributions: dashboardDistributionsPayload{
StatusCodes: compressDistributionItems(view.Distributions.StatusCodes),
TopDomains: compressDistributionItems(view.Distributions.TopDomains),
SourceCountries: compressDistributionItems(view.Distributions.SourceCountries),
},
Trends: dashboardTrendsPayload{
Traffic24h: compressTrafficTrendPoints(view.Trends.Traffic24h),
Capacity24h: compressCapacityTrendPoints(view.Trends.Capacity24h),
Network24h: compressNetworkTrendPoints(view.Trends.Network24h),
DiskIO24h: compressDiskIOTrendPoints(view.Trends.DiskIO24h),
},
Nodes: compressDashboardNodes(view.Nodes),
}
}
func compressDistributionItems(items []service.DistributionItem) [][]any {
rows := make([][]any, 0, len(items))
for _, item := range items {
rows = append(rows, []any{item.Key, item.Value})
}
return rows
}
func compressTrafficTrendPoints(points []service.TrafficTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.RequestCount,
point.ErrorCount,
point.UniqueVisitorCount,
})
}
return rows
}
func compressCapacityTrendPoints(points []service.CapacityTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.AverageCPUUsagePercent,
point.AverageMemoryUsagePercent,
point.ReportedNodes,
})
}
return rows
}
func compressNetworkTrendPoints(points []service.NetworkTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.NetworkRxBytes,
point.NetworkTxBytes,
point.OpenrestyRxBytes,
point.OpenrestyTxBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDiskIOTrendPoints(points []service.DiskIOTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.DiskReadBytes,
point.DiskWriteBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDashboardNodes(nodes []service.DashboardNodeHealth) [][]any {
rows := make([][]any, 0, len(nodes))
for _, node := range nodes {
rows = append(rows, []any{
node.ID,
node.NodeID,
node.Name,
node.GeoName,
node.GeoLatitude,
node.GeoLongitude,
node.Status,
node.OpenrestyStatus,
node.CurrentVersion,
node.LastSeenAt,
node.ActiveEventCount,
node.CPUUsagePercent,
node.MemoryUsagePercent,
node.StorageUsagePercent,
node.RequestCount,
node.ErrorCount,
node.UniqueVisitorCount,
})
}
return rows
}
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"net/http"
"openflare/service"
"github.com/gin-gonic/gin"
)
// CleanupDatabaseObservability godoc
// @Summary Cleanup observability tables
// @Tags Options
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/option/database/cleanup [post]
func CleanupDatabaseObservability(c *gin.Context) {
var input service.DatabaseCleanupInput
if err := decodeOptionalJSONBody(c.Request.Body, &input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "参数错误",
"error": err.Error(),
})
return
}
result, err := service.CleanupDatabaseObservability(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
@@ -76,7 +76,7 @@ func CreateManagedDomain(c *gin.Context) {
// @Param payload body service.ManagedDomainInput true "Managed domain payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/managed-domains/{id} [put]
// @Router /api/managed-domains/{id}/update [post]
func UpdateManagedDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -117,7 +117,7 @@ func UpdateManagedDomain(c *gin.Context) {
// @Param id path int true "Managed domain ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/managed-domains/{id} [delete]
// @Router /api/managed-domains/{id}/delete [post]
func DeleteManagedDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
+26 -2
View File
@@ -84,7 +84,7 @@ func RotateNodeBootstrapToken(c *gin.Context) {
// @Param payload body service.NodeInput true "Node payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id} [put]
// @Router /api/nodes/{id}/update [post]
func UpdateNode(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -114,7 +114,7 @@ func UpdateNode(c *gin.Context) {
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id} [delete]
// @Router /api/nodes/{id}/delete [post]
func DeleteNode(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -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)
}
+25 -1
View File
@@ -73,6 +73,21 @@ func validateGeoIPOption(key string, value string) error {
return nil
}
func validateDatabaseCleanupOption(key string, value string) error {
switch key {
case "DatabaseAutoCleanupEnabled":
return validateBooleanOption(key, value)
case "DatabaseAutoCleanupRetentionDays":
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 1 {
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
}
return nil
default:
return nil
}
}
func validateOpenRestyOption(key string, value string) error {
trimmed := strings.TrimSpace(value)
@@ -113,6 +128,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",
@@ -211,7 +228,7 @@ func GetOptions(c *gin.Context) {
// @Param payload body model.Option true "Option payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/option/ [put]
// @Router /api/option/update [post]
func UpdateOption(c *gin.Context) {
var option model.Option
err := json.NewDecoder(c.Request.Body).Decode(&option)
@@ -269,6 +286,13 @@ func UpdateOption(c *gin.Context) {
})
return
}
if err = validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
err = model.UpdateOption(option.Key, option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -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"},
+2 -2
View File
@@ -75,7 +75,7 @@ func CreateProxyRoute(c *gin.Context) {
// @Param payload body service.ProxyRouteInput true "Proxy route payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/{id} [put]
// @Router /api/proxy-routes/{id}/update [post]
func UpdateProxyRoute(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -116,7 +116,7 @@ func UpdateProxyRoute(c *gin.Context) {
// @Param id path int true "Route ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/{id} [delete]
// @Router /api/proxy-routes/{id}/delete [post]
func DeleteProxyRoute(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -143,7 +143,7 @@ func CreateTLSCertificate(c *gin.Context) {
// @Param payload body service.TLSCertificateInput true "TLS certificate payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id} [put]
// @Router /api/tls-certificates/{id}/update [post]
func UpdateTLSCertificate(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -233,7 +233,7 @@ func ImportTLSCertificateFile(c *gin.Context) {
// @Param id path int true "Certificate ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id} [delete]
// @Router /api/tls-certificates/{id}/delete [post]
func DeleteTLSCertificate(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
+35
View File
@@ -0,0 +1,35 @@
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:
build:
dockerfile: Dockerfile
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
volumes:
- ./openflare-data:/data
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+692 -68
View File
@@ -1,5 +1,12 @@
basePath: /
definitions:
controller.geoIPLookupRequest:
properties:
ip:
type: string
provider:
type: string
type: object
model.Option:
properties:
key:
@@ -7,12 +14,94 @@ definitions:
value:
type: string
type: object
service.AgentBufferedObservabilityRecord:
properties:
access_logs:
items:
$ref: '#/definitions/service.AgentNodeAccessLog'
type: array
snapshot:
$ref: '#/definitions/service.AgentNodeMetricSnapshot'
traffic_report:
$ref: '#/definitions/service.AgentNodeTrafficReport'
window_started_at_unix:
type: integer
type: object
service.AgentNodeAccessLog:
properties:
host:
type: string
logged_at_unix:
type: integer
path:
type: string
remote_addr:
type: string
status_code:
type: integer
type: object
service.AgentNodeHealthEvent:
properties:
event_type:
type: string
message:
type: string
metadata:
additionalProperties:
type: string
type: object
severity:
type: string
triggered_at_unix:
type: integer
type: object
service.AgentNodeMetricSnapshot:
properties:
captured_at_unix:
type: integer
cpu_usage_percent:
type: number
disk_read_bytes:
type: integer
disk_write_bytes:
type: integer
memory_total_bytes:
type: integer
memory_used_bytes:
type: integer
network_rx_bytes:
type: integer
network_tx_bytes:
type: integer
openresty_connections:
type: integer
openresty_rx_bytes:
type: integer
openresty_tx_bytes:
type: integer
storage_total_bytes:
type: integer
storage_used_bytes:
type: integer
type: object
service.AgentNodePayload:
properties:
access_logs:
items:
$ref: '#/definitions/service.AgentNodeAccessLog'
type: array
agent_version:
type: string
buffered_observability:
items:
$ref: '#/definitions/service.AgentBufferedObservabilityRecord'
type: array
current_version:
type: string
health_events:
items:
$ref: '#/definitions/service.AgentNodeHealthEvent'
type: array
ip:
type: string
last_error:
@@ -27,15 +116,82 @@ definitions:
type: string
openresty_status:
type: string
profile:
$ref: '#/definitions/service.AgentNodeSystemProfile'
snapshot:
$ref: '#/definitions/service.AgentNodeMetricSnapshot'
traffic_report:
$ref: '#/definitions/service.AgentNodeTrafficReport'
type: object
service.AgentNodeSystemProfile:
properties:
architecture:
type: string
cpu_cores:
type: integer
cpu_model:
type: string
hostname:
type: string
kernel_version:
type: string
os_name:
type: string
os_version:
type: string
reported_at_unix:
type: integer
total_disk_bytes:
type: integer
total_memory_bytes:
type: integer
uptime_seconds:
type: integer
type: object
service.AgentNodeTrafficReport:
properties:
error_count:
type: integer
request_count:
type: integer
source_countries:
additionalProperties:
format: int64
type: integer
type: object
status_codes:
additionalProperties:
format: int64
type: integer
type: object
top_domains:
additionalProperties:
format: int64
type: integer
type: object
unique_visitor_count:
type: integer
window_ended_at_unix:
type: integer
window_started_at_unix:
type: integer
type: object
service.ApplyLogPayload:
properties:
checksum:
type: string
main_config_checksum:
type: string
message:
type: string
node_id:
type: string
result:
type: string
route_config_checksum:
type: string
support_file_count:
type: integer
version:
type: string
type: object
@@ -54,6 +210,16 @@ definitions:
properties:
auto_update_enabled:
type: boolean
geo_latitude:
type: number
geo_longitude:
type: number
geo_manual_override:
type: boolean
geo_name:
type: string
ip:
type: string
name:
type: string
type: object
@@ -66,6 +232,14 @@ definitions:
type: object
service.ProxyRouteInput:
properties:
cache_enabled:
type: boolean
cache_policy:
type: string
cache_rules:
items:
type: string
type: array
cert_id:
type: integer
custom_headers:
@@ -78,12 +252,18 @@ definitions:
type: boolean
enabled:
type: boolean
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:
@@ -102,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:
@@ -224,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:
@@ -239,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
@@ -325,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:
@@ -368,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
@@ -394,7 +835,8 @@ paths:
summary: Delete managed domain
tags:
- ManagedDomains
put:
/api/managed-domains/{id}/update:
post:
consumes:
- application/json
parameters:
@@ -491,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:
@@ -606,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:
@@ -632,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:
@@ -675,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:
@@ -744,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
@@ -770,7 +1301,8 @@ paths:
summary: Delete proxy route
tags:
- ProxyRoutes
put:
/api/proxy-routes/{id}/update:
post:
consumes:
- application/json
parameters:
@@ -860,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
@@ -885,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:
@@ -940,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:
+14 -6
View File
@@ -13,12 +13,14 @@ require (
github.com/go-playground/validator/v10 v10.19.0
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.3.0
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
gorm.io/driver/mysql v1.4.3
gorm.io/gorm v1.25.7
golang.org/x/net v0.47.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.25.10
)
require (
@@ -26,6 +28,7 @@ require (
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/bytedance/sonic v1.11.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
@@ -41,35 +44,40 @@ require (
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gomodule/redigo v2.0.0+incompatible // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/sharding v0.6.2 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
+30 -75
View File
@@ -6,18 +6,15 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
@@ -30,18 +27,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg=
github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -53,7 +48,6 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
@@ -74,31 +68,22 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -113,9 +98,16 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -124,13 +116,10 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -138,19 +127,15 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE=
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -168,20 +153,16 @@ github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -192,72 +173,54 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -269,21 +232,14 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -291,7 +247,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -300,14 +255,14 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k=
gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+10 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"embed"
"fmt"
"github.com/gin-contrib/sessions"
@@ -13,6 +14,7 @@ import (
"openflare/middleware"
"openflare/model"
"openflare/router"
"openflare/service"
"openflare/utils/geoip"
"os"
"strconv"
@@ -67,6 +69,9 @@ func main() {
// Initialize options
model.InitOptionMap()
geoip.InitGeoIP()
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
defer cancelBackgroundTasks()
service.StartDatabaseAutoCleanupScheduler(backgroundCtx)
// Initialize HTTP server
server := gin.Default()
@@ -88,7 +93,11 @@ func main() {
if port == "" {
port = strconv.Itoa(*common.Port)
}
slog.Info("server config", "port", port, "gin_mode", gin.Mode(), "log_level", common.GetLogLevel(), "sqlite_path", common.SQLitePath, "redis_enabled", common.RedisEnabled, "upload_path", common.UploadPath, "log_dir", valueOrDefault(*common.LogDir, "stdout"), "agent_token_configured", common.AgentToken != "", "node_offline_threshold", common.NodeOfflineThreshold)
dbBackend := "sqlite"
if common.SQLDSN != "" {
dbBackend = "postgres"
}
slog.Info("server config", "port", port, "gin_mode", gin.Mode(), "log_level", common.GetLogLevel(), "db_backend", dbBackend, "sqlite_path", common.SQLitePath, "redis_enabled", common.RedisEnabled, "upload_path", common.UploadPath, "log_dir", valueOrDefault(*common.LogDir, "stdout"), "agent_token_configured", common.AgentToken != "", "node_offline_threshold", common.NodeOfflineThreshold)
slog.Info("server listening", "address", fmt.Sprintf(":%s", port))
err = server.Run(":" + port)
if err != nil {
+42 -6
View File
@@ -1,13 +1,23 @@
package model
import "time"
import (
"time"
"gorm.io/gorm"
)
type ApplyLogQuery struct {
NodeID string
PageNo int
PageSize int
}
type ApplyLog struct {
ID uint `json:"id" gorm:"primaryKey"`
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
Version string `json:"version" gorm:"size:32;not null"`
Result string `json:"result" gorm:"size:32;not null"`
Message string `json:"message" gorm:"size:1024"`
Message string `json:"message" gorm:"type:text"`
Checksum string `json:"checksum" gorm:"size:64;not null;default:''"`
MainConfigChecksum string `json:"main_config_checksum" gorm:"size:64;not null;default:''"`
RouteConfigChecksum string `json:"route_config_checksum" gorm:"size:64;not null;default:''"`
@@ -15,13 +25,29 @@ type ApplyLog struct {
CreatedAt time.Time `json:"created_at"`
}
func ListApplyLogs(nodeID string) (logs []*ApplyLog, err error) {
query := DB.Order("id desc")
func ListApplyLogs(query ApplyLogQuery) (logs []*ApplyLog, err error) {
db := DB.Order("id desc")
if query.NodeID != "" {
db = db.Where("node_id = ?", query.NodeID)
}
if query.PageSize > 0 {
offset := 0
if query.PageNo > 1 {
offset = (query.PageNo - 1) * query.PageSize
}
db = db.Limit(query.PageSize).Offset(offset)
}
err = db.Find(&logs).Error
return logs, err
}
func CountApplyLogs(nodeID string) (total int64, err error) {
query := DB.Model(&ApplyLog{})
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
err = query.Find(&logs).Error
return logs, err
err = query.Count(&total).Error
return total, err
}
func GetLatestApplyLog(nodeID string) (*ApplyLog, error) {
@@ -49,3 +75,13 @@ func GetLatestApplyLogsByNodeIDs(nodeIDs []string) (map[string]*ApplyLog, error)
}
return result, nil
}
func DeleteAllApplyLogs() (deleted int64, err error) {
result := DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ApplyLog{})
return result.RowsAffected, result.Error
}
func DeleteApplyLogsBefore(before time.Time) (deleted int64, err error) {
result := DB.Where("created_at < ?", before).Delete(&ApplyLog{})
return result.RowsAffected, result.Error
}
+14 -2
View File
@@ -2,6 +2,15 @@ package model
import "time"
type ConfigVersionSummary struct {
ID uint `json:"id"`
Version string `json:"version"`
Checksum string `json:"checksum"`
IsActive bool `json:"is_active"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
type ConfigVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
@@ -15,8 +24,11 @@ type ConfigVersion struct {
CreatedAt time.Time `json:"created_at"`
}
func ListConfigVersions() (versions []*ConfigVersion, err error) {
err = DB.Order("id desc").Find(&versions).Error
func ListConfigVersionSummaries() (versions []*ConfigVersionSummary, err error) {
err = DB.Model(&ConfigVersion{}).
Select("id", "version", "checksum", "is_active", "created_by", "created_at").
Order("id desc").
Find(&versions).Error
return versions, err
}
@@ -0,0 +1,19 @@
package model
import "time"
const (
legacyDatabaseSchemaVersion = 1
currentDatabaseSchemaVersion = 3
databaseSchemaVersionRowID = 1
)
type DatabaseSchemaVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
Version int `json:"version" gorm:"not null"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DatabaseSchemaVersion) TableName() string {
return "database_schema_versions"
}
+765 -73
View File
@@ -1,17 +1,81 @@
package model
import (
"encoding/json"
"errors"
"fmt"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"log/slog"
"openflare/common"
"openflare/utils/security"
"os"
"reflect"
"sync"
)
var DB *gorm.DB
type dbModel struct {
value any
tableName string
hasIDPK bool
}
type databaseSchemaMigration struct {
fromVersion int
toVersion int
migrate func(db *gorm.DB, backend string) error
validate func(db *gorm.DB, backend string) error
}
func registeredModels() []any {
return []any{
&File{},
&User{},
&Option{},
&ProxyRoute{},
&ConfigVersion{},
&Node{},
&NodeSystemProfile{},
&ApplyLog{},
&NodeMetricSnapshot{},
&NodeRequestReport{},
&NodeAccessLog{},
&NodeHealthEvent{},
&TLSCertificate{},
&ManagedDomain{},
}
}
func schemaMetadataModels() []any {
return []any{
&DatabaseSchemaVersion{},
}
}
func buildDBModels() ([]dbModel, error) {
models := registeredModels()
result := make([]dbModel, 0, len(models))
namer := schema.NamingStrategy{}
cache := &sync.Map{}
for _, item := range models {
parsed, err := schema.Parse(item, cache, namer)
if err != nil {
return nil, err
}
hasIDPK := len(parsed.PrimaryFields) == 1 && parsed.PrimaryFields[0].DBName == "id"
result = append(result, dbModel{
value: item,
tableName: parsed.Table,
hasIDPK: hasIDPK,
})
}
return result, nil
}
func migrateProxyRouteEnableHTTPSColumn(db *gorm.DB) error {
if !db.Migrator().HasTable(&ProxyRoute{}) {
return nil
@@ -48,88 +112,716 @@ func CountTable(tableName string) (num int64) {
return
}
func InitDB() (err error) {
var db *gorm.DB
if os.Getenv("SQL_DSN") != "" {
// Use MySQL
db, err = gorm.Open(mysql.Open(os.Getenv("SQL_DSN")), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
} else {
// Use SQLite
db, err = gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
slog.Info("SQL_DSN not set, using SQLite as database")
func openDatabase() (*gorm.DB, string, error) {
if common.SQLDSN != "" {
db, err := gorm.Open(postgres.Open(common.SQLDSN), &gorm.Config{})
if err != nil {
return nil, "", err
}
return db, "postgres", nil
}
if err == nil {
DB = db
if err = migrateProxyRouteEnableHTTPSColumn(db); err != nil {
db, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{})
if err != nil {
return nil, "", err
}
slog.Info("database DSN not set, using SQLite as database", "sqlite_path", common.SQLitePath)
return db, "sqlite", nil
}
func autoMigrateAll(db *gorm.DB) error {
for _, item := range registeredModels() {
if err := db.AutoMigrate(item); err != nil {
return err
}
err := db.AutoMigrate(&File{})
}
return nil
}
func autoMigrateSchemaMetadata(db *gorm.DB) error {
for _, item := range schemaMetadataModels() {
if err := db.AutoMigrate(item); err != nil {
return err
}
}
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 migrateObservabilityLegacyColumns(db *gorm.DB) error {
if db == nil {
return nil
}
if !db.Migrator().HasTable(&NodeHealthEvent{}) || !db.Migrator().HasColumn(&NodeHealthEvent{}, "raw_json") {
return nil
}
type legacyHealthEventRaw struct {
ID uint
RawJSON string
MetadataJSON string
}
type legacyHealthEventPayload struct {
Metadata map[string]string `json:"metadata"`
}
var rows []legacyHealthEventRaw
if err := db.Model(&NodeHealthEvent{}).
Select("id, raw_json, metadata_json").
Where("raw_json <> '' AND (metadata_json IS NULL OR metadata_json = '')").
Find(&rows).Error; err != nil {
return fmt.Errorf("query legacy node health event raw_json failed: %w", err)
}
for _, row := range rows {
var payload legacyHealthEventPayload
if err := json.Unmarshal([]byte(row.RawJSON), &payload); err != nil {
continue
}
if len(payload.Metadata) == 0 {
continue
}
metadataJSON, err := json.Marshal(payload.Metadata)
if err != nil {
return err
continue
}
err = db.AutoMigrate(&User{})
if err != nil {
return err
if err := db.Model(&NodeHealthEvent{}).
Where("id = ?", row.ID).
Update("metadata_json", string(metadataJSON)).Error; err != nil {
return fmt.Errorf("migrate node health event metadata_json failed: %w", err)
}
err = db.AutoMigrate(&Option{})
if err != nil {
return err
}
err = db.AutoMigrate(&ProxyRoute{})
if err != nil {
return err
}
err = db.AutoMigrate(&ConfigVersion{})
if err != nil {
return err
}
err = db.AutoMigrate(&Node{})
if err != nil {
return err
}
err = db.AutoMigrate(&NodeSystemProfile{})
if err != nil {
return err
}
err = db.AutoMigrate(&ApplyLog{})
if err != nil {
return err
}
err = db.AutoMigrate(&NodeMetricSnapshot{})
if err != nil {
return err
}
err = db.AutoMigrate(&NodeRequestReport{})
if err != nil {
return err
}
err = db.AutoMigrate(&NodeAccessLog{})
if err != nil {
return err
}
err = db.AutoMigrate(&NodeHealthEvent{})
if err != nil {
return err
}
err = db.AutoMigrate(&TLSCertificate{})
if err != nil {
return err
}
err = db.AutoMigrate(&ManagedDomain{})
if err != nil {
return err
}
err = createRootAccountIfNeed()
}
return nil
}
func applyCurrentSchema(db *gorm.DB, backend string) error {
if err := autoMigrateSchemaMetadata(db); err != nil {
return err
} else {
}
if err := migrateProxyRouteEnableHTTPSColumn(db); err != nil {
return err
}
if err := autoMigrateAll(db); err != nil {
return err
}
if err := migrateTextColumns(db, backend); err != nil {
return err
}
if err := migrateObservabilityLegacyColumns(db); err != nil {
return err
}
return nil
}
func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
if db == nil {
return 0, false, nil
}
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
return 0, false, nil
}
var state DatabaseSchemaVersion
err := db.Where("id = ?", databaseSchemaVersionRowID).First(&state).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return state.Version, true, nil
}
func saveDatabaseSchemaVersion(db *gorm.DB, version int) error {
return db.Save(&DatabaseSchemaVersion{
ID: databaseSchemaVersionRowID,
Version: version,
}).Error
}
func validateDatabaseSchemaV2(db *gorm.DB, backend string) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
return fmt.Errorf("table %s is missing", (&DatabaseSchemaVersion{}).TableName())
}
models, err := buildDBModels()
if err != nil {
return err
}
for _, item := range models {
if isShardedObservabilityTable(item.tableName) {
for _, table := range observabilityShardTables(item.tableName) {
if !db.Migrator().HasTable(table) {
return fmt.Errorf("sharded table %s is missing", table)
}
}
continue
}
if !db.Migrator().HasTable(item.value) {
return fmt.Errorf("table %s is missing", item.tableName)
}
}
if !db.Migrator().HasColumn(&NodeHealthEvent{}, "metadata_json") {
return fmt.Errorf("column node_health_events.metadata_json is missing")
}
_ = backend
return nil
}
func validateDatabaseSchemaV3(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV2(db, backend); err != nil {
return err
}
for _, baseTable := range shardedObservabilityBaseTables() {
for _, table := range observabilityShardTables(baseTable) {
legacyTable := legacyObservabilityShardTableName(table)
if db.Migrator().HasTable(legacyTable) {
return fmt.Errorf("legacy sharded table %s still exists", legacyTable)
}
}
}
return nil
}
func renameLegacyObservabilityShardTables(db *gorm.DB) error {
for _, baseTable := range shardedObservabilityBaseTables() {
for _, table := range observabilityShardTables(baseTable) {
legacyTable := legacyObservabilityShardTableName(table)
if db.Migrator().HasTable(legacyTable) {
return fmt.Errorf("legacy sharded table %s already exists", legacyTable)
}
if !db.Migrator().HasTable(table) {
continue
}
if err := db.Migrator().RenameTable(table, legacyTable); err != nil {
return fmt.Errorf("rename sharded table %s to %s failed: %w", table, legacyTable, err)
}
if err := dropLegacyObservabilitySecondaryIndexes(db, legacyTable); err != nil {
return err
}
}
}
return nil
}
func dropLegacyObservabilitySecondaryIndexes(db *gorm.DB, table string) error {
db = sessionIgnoringSharding(db)
if db == nil {
return fmt.Errorf("database handle is nil")
}
backend := baseDialector(db).Name()
indexes := make([]string, 0)
switch backend {
case "sqlite":
if err := db.Raw(
`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name LIKE 'idx_%'`,
table,
).Scan(&indexes).Error; err != nil {
return fmt.Errorf("list indexes for %s failed: %w", table, err)
}
case "postgres":
if err := db.Raw(
`SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND tablename = ? AND indexname LIKE 'idx_%'`,
table,
).Scan(&indexes).Error; err != nil {
return fmt.Errorf("list indexes for %s failed: %w", table, err)
}
default:
return fmt.Errorf("unsupported database backend %s", backend)
}
for _, indexName := range indexes {
if err := db.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, indexName)).Error; err != nil {
return fmt.Errorf("drop legacy index %s failed: %w", indexName, err)
}
}
return nil
}
func autoMigrateObservabilityShardTables(db *gorm.DB) error {
db = sessionIgnoringSharding(db)
if db == nil {
return fmt.Errorf("database handle is nil")
}
dialector := baseDialector(db)
if dialector == nil {
return fmt.Errorf("database dialector is nil")
}
type shardedTable struct {
model any
base string
}
tables := []shardedTable{
{model: &NodeMetricSnapshot{}, base: "node_metric_snapshots"},
{model: &NodeRequestReport{}, base: "node_request_reports"},
{model: &NodeAccessLog{}, base: "node_access_logs"},
}
for _, item := range tables {
for _, table := range observabilityShardTables(item.base) {
tx := db.Table(table)
if err := dialector.Migrator(tx).AutoMigrate(item.model); err != nil {
return fmt.Errorf("auto migrate sharded table %s failed: %w", table, err)
}
}
}
return nil
}
func dropLegacyObservabilityShardTables(db *gorm.DB) error {
db = sessionIgnoringSharding(db)
if db == nil {
return fmt.Errorf("database handle is nil")
}
for _, baseTable := range shardedObservabilityBaseTables() {
for _, table := range observabilityShardTables(baseTable) {
legacyTable := legacyObservabilityShardTableName(table)
if !db.Migrator().HasTable(legacyTable) {
continue
}
if err := db.Exec(fmt.Sprintf(`DROP TABLE IF EXISTS "%s"`, legacyTable)).Error; err != nil {
return fmt.Errorf("drop legacy sharded table %s failed: %w", legacyTable, err)
}
}
}
return nil
}
func migrateLegacyNodeMetricSnapshots(db *gorm.DB) error {
for _, table := range observabilityShardTables("node_metric_snapshots") {
legacyTable := legacyObservabilityShardTableName(table)
if !db.Migrator().HasTable(legacyTable) {
continue
}
var lastSeenID uint
for {
var rows []NodeMetricSnapshot
query := db.Table(legacyTable).Order("id ASC").Limit(500)
if lastSeenID > 0 {
query = query.Where("id > ?", lastSeenID)
}
if err := query.Find(&rows).Error; err != nil {
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
}
if len(rows) == 0 {
break
}
lastSeenID = rows[len(rows)-1].ID
grouped := make(map[string][]NodeMetricSnapshot, observabilityShardCount)
for index := range rows {
rows[index].ID = 0
if err := assignObservabilityID(&rows[index].ID); err != nil {
return err
}
targetTable := observabilityShardTableForID("node_metric_snapshots", rows[index].ID)
grouped[targetTable] = append(grouped[targetTable], rows[index])
}
for targetTable, batch := range grouped {
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
}
}
}
}
return nil
}
func migrateLegacyNodeRequestReports(db *gorm.DB) error {
for _, table := range observabilityShardTables("node_request_reports") {
legacyTable := legacyObservabilityShardTableName(table)
if !db.Migrator().HasTable(legacyTable) {
continue
}
var lastSeenID uint
for {
var rows []NodeRequestReport
query := db.Table(legacyTable).Order("id ASC").Limit(500)
if lastSeenID > 0 {
query = query.Where("id > ?", lastSeenID)
}
if err := query.Find(&rows).Error; err != nil {
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
}
if len(rows) == 0 {
break
}
lastSeenID = rows[len(rows)-1].ID
grouped := make(map[string][]NodeRequestReport, observabilityShardCount)
for index := range rows {
rows[index].ID = 0
if err := assignObservabilityID(&rows[index].ID); err != nil {
return err
}
targetTable := observabilityShardTableForID("node_request_reports", rows[index].ID)
grouped[targetTable] = append(grouped[targetTable], rows[index])
}
for targetTable, batch := range grouped {
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
}
}
}
}
return nil
}
func migrateLegacyNodeAccessLogs(db *gorm.DB) error {
for _, table := range observabilityShardTables("node_access_logs") {
legacyTable := legacyObservabilityShardTableName(table)
if !db.Migrator().HasTable(legacyTable) {
continue
}
var lastSeenID uint
for {
var rows []NodeAccessLog
query := db.Table(legacyTable).Order("id ASC").Limit(500)
if lastSeenID > 0 {
query = query.Where("id > ?", lastSeenID)
}
if err := query.Find(&rows).Error; err != nil {
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
}
if len(rows) == 0 {
break
}
lastSeenID = rows[len(rows)-1].ID
grouped := make(map[string][]NodeAccessLog, observabilityShardCount)
for index := range rows {
rows[index].ID = 0
if err := assignObservabilityID(&rows[index].ID); err != nil {
return err
}
targetTable := observabilityShardTableForID("node_access_logs", rows[index].ID)
grouped[targetTable] = append(grouped[targetTable], rows[index])
}
for targetTable, batch := range grouped {
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
}
}
}
}
return nil
}
func migrateObservabilityShardsToID(db *gorm.DB, backend string) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
_ = backend
if err := renameLegacyObservabilityShardTables(db); err != nil {
return err
}
if err := autoMigrateObservabilityShardTables(db); err != nil {
return err
}
if err := migrateLegacyNodeMetricSnapshots(db); err != nil {
return err
}
if err := migrateLegacyNodeRequestReports(db); err != nil {
return err
}
if err := migrateLegacyNodeAccessLogs(db); err != nil {
return err
}
return dropLegacyObservabilityShardTables(db)
}
func databaseSchemaMigrations() []databaseSchemaMigration {
return []databaseSchemaMigration{
{
fromVersion: 1,
toVersion: 2,
migrate: applyCurrentSchema,
validate: validateDatabaseSchemaV2,
},
{
fromVersion: 2,
toVersion: 3,
migrate: migrateObservabilityShardsToID,
validate: validateDatabaseSchemaV3,
},
}
}
func databaseSchemaMigrationMap() map[int]databaseSchemaMigration {
migrations := make(map[int]databaseSchemaMigration, len(databaseSchemaMigrations()))
for _, item := range databaseSchemaMigrations() {
migrations[item.fromVersion] = item
}
return migrations
}
func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseSchemaMigration) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migration.migrate(tx, backend); err != nil {
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
}
if err := migration.validate(tx, backend); err != nil {
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
}
if err := saveDatabaseSchemaVersion(tx, migration.toVersion); err != nil {
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
}
return nil
})
}
func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error {
if version > currentDatabaseSchemaVersion {
return fmt.Errorf("database schema version %d is newer than application version %d", version, currentDatabaseSchemaVersion)
}
if version == currentDatabaseSchemaVersion {
return nil
}
migrationMap := databaseSchemaMigrationMap()
for version < currentDatabaseSchemaVersion {
migration, ok := migrationMap[version]
if !ok {
return fmt.Errorf("database schema migration from v%d is not defined", version)
}
if err := runDatabaseSchemaMigration(db, backend, migration); err != nil {
return err
}
version = migration.toVersion
}
return nil
}
func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := migrateSQLiteDataIfNeeded(db, backend); err != nil {
return err
}
if err := validateDatabaseSchemaV3(db, backend); err != nil {
return err
}
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
}
func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
return err
}
if exists {
return upgradeDatabaseSchema(db, backend, version)
}
empty, err := isDatabaseEmpty(db)
if err != nil {
return err
}
if empty {
return initializeFreshDatabaseSchema(db, backend)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
return err
}
return upgradeDatabaseSchema(db, backend, legacyDatabaseSchemaVersion)
}
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
models, err := buildDBModels()
if err != nil {
return false, err
}
for _, item := range models {
if isShardedObservabilityTable(item.tableName) {
for _, table := range observabilityShardTables(item.tableName) {
if !db.Migrator().HasTable(table) {
continue
}
var count int64
if err := db.Table(table).Limit(1).Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return false, nil
}
}
continue
}
if !db.Migrator().HasTable(item.value) {
continue
}
var count int64
if err := db.Model(item.value).Limit(1).Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return false, nil
}
}
return true, nil
}
func sqliteSourceExists() bool {
info, err := os.Stat(common.SQLitePath)
if err != nil {
return false
}
return !info.IsDir()
}
func migrateSQLiteDataIfNeeded(target *gorm.DB, backend string) error {
if backend != "postgres" {
return nil
}
empty, err := isDatabaseEmpty(target)
if err != nil {
return err
}
if !empty {
slog.Info("skip sqlite migration because target database already has data", "backend", backend)
return nil
}
if !sqliteSourceExists() {
slog.Info("skip sqlite migration because sqlite source file was not found", "sqlite_path", common.SQLitePath)
return nil
}
source, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
PrepareStmt: true,
})
if err != nil {
return fmt.Errorf("open sqlite source database failed: %w", err)
}
sourceSQLDB, err := source.DB()
if err != nil {
return fmt.Errorf("get sqlite source database handle failed: %w", err)
}
defer func() {
_ = sourceSQLDB.Close()
}()
models, err := buildDBModels()
if err != nil {
return err
}
slog.Info("starting sqlite to postgres database migration", "sqlite_path", common.SQLitePath)
err = target.Transaction(func(tx *gorm.DB) error {
for _, item := range models {
if err := migrateTableData(source, tx, item); err != nil {
return err
}
if item.hasIDPK {
if err := resetPostgresSequence(tx, item.tableName); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return err
}
slog.Info("sqlite to postgres database migration completed", "sqlite_path", common.SQLitePath)
return nil
}
func migrateTableData(source *gorm.DB, target *gorm.DB, item dbModel) error {
if !source.Migrator().HasTable(item.value) {
slog.Info("database migration progress", "table", item.tableName, "migrated", 0, "total", 0, "status", "skipped_missing_source_table")
return nil
}
var total int64
if err := source.Model(item.value).Count(&total).Error; err != nil {
return fmt.Errorf("count sqlite table %s failed: %w", item.tableName, err)
}
slog.Info("database migration progress", "table", item.tableName, "migrated", 0, "total", total, "status", "starting")
if total == 0 {
slog.Info("database migration progress", "table", item.tableName, "migrated", 0, "total", total, "status", "completed")
return nil
}
modelType := reflect.TypeOf(item.value).Elem()
sliceType := reflect.SliceOf(modelType)
migrated := int64(0)
offset := 0
const batchSize = 200
for {
batchPtr := reflect.New(sliceType)
query := source.Model(item.value).Limit(batchSize).Offset(offset)
if item.hasIDPK {
query = query.Order("id ASC")
}
if err := query.Find(batchPtr.Interface()).Error; err != nil {
return fmt.Errorf("read sqlite table %s failed: %w", item.tableName, err)
}
batchLen := batchPtr.Elem().Len()
if batchLen == 0 {
break
}
if isShardedObservabilityTable(item.tableName) {
for index := 0; index < batchLen; index++ {
record := batchPtr.Elem().Index(index)
if err := target.Create(record.Addr().Interface()).Error; err != nil {
return fmt.Errorf("write target sharded table %s failed: %w", item.tableName, err)
}
}
} else {
if err := target.Create(batchPtr.Interface()).Error; err != nil {
return fmt.Errorf("write target table %s failed: %w", item.tableName, err)
}
}
migrated += int64(batchLen)
offset += batchLen
slog.Info("database migration progress", "table", item.tableName, "migrated", migrated, "total", total, "status", "running")
}
slog.Info("database migration progress", "table", item.tableName, "migrated", migrated, "total", total, "status", "completed")
return nil
}
func resetPostgresSequence(db *gorm.DB, tableName string) error {
sql := fmt.Sprintf(
"SELECT setval(pg_get_serial_sequence('%s', 'id'), COALESCE(MAX(id), 1), MAX(id) IS NOT NULL) FROM \"%s\"",
tableName,
tableName,
)
return db.Exec(sql).Error
}
func InitDB() (err error) {
db, backend, err := openDatabase()
if err != nil {
slog.Error("open database failed", "error", err)
os.Exit(1)
}
return err
DB = db
if err = registerSharding(db, backend); err != nil {
return err
}
if err = ensureDatabaseSchemaUpToDate(db, backend); err != nil {
return err
}
return createRootAccountIfNeed()
}
func CloseDB() error {
+436
View File
@@ -0,0 +1,436 @@
package model
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func openBareTestSQLiteDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql db: %v", err)
}
t.Cleanup(func() {
_ = sqlDB.Close()
})
return db
}
func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db := openBareTestSQLiteDB(t, name)
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
return db
}
func findDBModelByTableName(t *testing.T, tableName string) dbModel {
t.Helper()
models, err := buildDBModels()
if err != nil {
t.Fatalf("build db models: %v", err)
}
for _, item := range models {
if item.tableName == tableName {
return item
}
}
t.Fatalf("db model not found for table %s", tableName)
return dbModel{}
}
func TestIsDatabaseEmpty(t *testing.T) {
db := openTestSQLiteDB(t, "empty.db")
empty, err := isDatabaseEmpty(db)
if err != nil {
t.Fatalf("isDatabaseEmpty returned error: %v", err)
}
if !empty {
t.Fatal("expected database to be empty")
}
if err := db.Create(&User{
Username: "alice",
Password: "secret",
DisplayName: "Alice",
Role: 1,
Status: 1,
}).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
empty, err = isDatabaseEmpty(db)
if err != nil {
t.Fatalf("isDatabaseEmpty after seed returned error: %v", err)
}
if empty {
t.Fatal("expected database to be non-empty")
}
}
func TestMigrateTableDataCopiesRows(t *testing.T) {
source := openTestSQLiteDB(t, "source.db")
target := openTestSQLiteDB(t, "target.db")
user := User{
Id: 1,
Username: "root",
Password: "hashed",
DisplayName: "Root User",
Role: 100,
Status: 1,
}
option := Option{
Key: "AgentHeartbeatInterval",
Value: "10000",
}
if err := source.Create(&user).Error; err != nil {
t.Fatalf("seed source user: %v", err)
}
if err := source.Create(&option).Error; err != nil {
t.Fatalf("seed source option: %v", err)
}
if err := migrateTableData(source, target, findDBModelByTableName(t, "users")); err != nil {
t.Fatalf("migrate users: %v", err)
}
if err := migrateTableData(source, target, findDBModelByTableName(t, "options")); err != nil {
t.Fatalf("migrate options: %v", err)
}
var gotUser User
if err := target.First(&gotUser, 1).Error; err != nil {
t.Fatalf("query migrated user: %v", err)
}
if gotUser.Username != user.Username || gotUser.DisplayName != user.DisplayName {
t.Fatalf("unexpected migrated user: %+v", gotUser)
}
var gotOption Option
if err := target.First(&gotOption, "key = ?", option.Key).Error; err != nil {
t.Fatalf("query migrated option: %v", err)
}
if gotOption.Value != option.Value {
t.Fatalf("unexpected migrated option value: %s", gotOption.Value)
}
}
func TestRegisterShardingAutoMigratesShardTables(t *testing.T) {
db := openBareTestSQLiteDB(t, "sharded.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
for _, table := range []string{
"node_metric_snapshots_00",
"node_metric_snapshots_09",
"node_request_reports_00",
"node_request_reports_09",
"node_access_logs_00",
"node_access_logs_09",
} {
if !db.Migrator().HasTable(table) {
t.Fatalf("expected sharded table %s to exist", table)
}
}
}
func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testing.T) {
db := openTestSQLiteDB(t, "legacy-health-events.db")
if err := db.Exec("ALTER TABLE node_health_events ADD COLUMN raw_json TEXT").Error; err != nil {
t.Fatalf("add raw_json column: %v", err)
}
rawJSON, err := json.Marshal(map[string]any{
"event_type": "sync_error",
"metadata": map[string]string{
"reason": "checksum_mismatch",
"scope": "routes",
},
})
if err != nil {
t.Fatalf("marshal raw json: %v", err)
}
event := &NodeHealthEvent{
NodeID: "node-legacy",
EventType: "sync_error",
Severity: "warning",
Status: "active",
Message: "checksum mismatch",
FirstTriggeredAt: time.Now().Add(-time.Minute),
LastTriggeredAt: time.Now(),
ReportedAt: time.Now(),
}
if err := db.Create(event).Error; err != nil {
t.Fatalf("create health event: %v", err)
}
if err := db.Exec("UPDATE node_health_events SET raw_json = ? WHERE id = ?", string(rawJSON), event.ID).Error; err != nil {
t.Fatalf("seed legacy raw_json: %v", err)
}
if err := migrateObservabilityLegacyColumns(db); err != nil {
t.Fatalf("migrateObservabilityLegacyColumns: %v", err)
}
var got NodeHealthEvent
if err := db.First(&got, event.ID).Error; err != nil {
t.Fatalf("query health event: %v", err)
}
if got.MetadataJSON == "" {
t.Fatal("expected metadata_json to be backfilled")
}
}
func TestEnsureDatabaseSchemaUpToDateInitializesFreshDatabase(t *testing.T) {
db := openBareTestSQLiteDB(t, "fresh-schema.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
}
if !exists {
t.Fatal("expected database schema version to be recorded")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
}
}
func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-schema.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
if err := db.Create(&User{
Username: "legacy",
Password: "secret",
DisplayName: "Legacy User",
Role: 1,
Status: 1,
}).Error; err != nil {
t.Fatalf("seed legacy user: %v", err)
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
}
if !exists {
t.Fatal("expected legacy database to gain a schema version record")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
}
}
func TestEnsureDatabaseSchemaUpToDateMigratesObservabilityShardsToID(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-observability-shards.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
now := time.Now().UTC()
if err := db.Table("node_metric_snapshots_00").Create(&NodeMetricSnapshot{
ID: 1,
NodeID: "node-a",
CapturedAt: now.Add(-2 * time.Minute),
CPUUsagePercent: 22,
MemoryUsedBytes: 2,
MemoryTotalBytes: 8,
}).Error; err != nil {
t.Fatalf("seed metric snapshot shard 00: %v", err)
}
if err := db.Table("node_metric_snapshots_01").Create(&NodeMetricSnapshot{
ID: 1,
NodeID: "node-b",
CapturedAt: now.Add(-time.Minute),
CPUUsagePercent: 44,
MemoryUsedBytes: 4,
MemoryTotalBytes: 8,
}).Error; err != nil {
t.Fatalf("seed metric snapshot shard 01: %v", err)
}
if err := db.Table("node_request_reports_00").Create(&NodeRequestReport{
ID: 1,
NodeID: "node-a",
WindowStartedAt: now.Add(-3 * time.Minute),
WindowEndedAt: now.Add(-2 * time.Minute),
RequestCount: 12,
ErrorCount: 1,
UniqueVisitorCount: 6,
}).Error; err != nil {
t.Fatalf("seed request report shard 00: %v", err)
}
if err := db.Table("node_request_reports_01").Create(&NodeRequestReport{
ID: 1,
NodeID: "node-b",
WindowStartedAt: now.Add(-2 * time.Minute),
WindowEndedAt: now.Add(-time.Minute),
RequestCount: 21,
ErrorCount: 2,
UniqueVisitorCount: 9,
}).Error; err != nil {
t.Fatalf("seed request report shard 01: %v", err)
}
if err := db.Table("node_access_logs_00").Create(&NodeAccessLog{
ID: 1,
NodeID: "node-a",
LoggedAt: now.Add(-90 * time.Second),
RemoteAddr: "203.0.113.10",
Host: "a.example.com",
Path: "/alpha",
StatusCode: 200,
}).Error; err != nil {
t.Fatalf("seed access log shard 00: %v", err)
}
if err := db.Table("node_access_logs_01").Create(&NodeAccessLog{
ID: 1,
NodeID: "node-b",
LoggedAt: now.Add(-60 * time.Second),
RemoteAddr: "203.0.113.11",
Host: "b.example.com",
Path: "/beta",
StatusCode: 502,
}).Error; err != nil {
t.Fatalf("seed access log shard 01: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 2); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
}
if !exists {
t.Fatal("expected migrated database to keep schema version record")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
}
for _, baseTable := range shardedObservabilityBaseTables() {
for _, table := range observabilityShardTables(baseTable) {
legacyTable := legacyObservabilityShardTableName(table)
if db.Migrator().HasTable(legacyTable) {
t.Fatalf("expected legacy shard table %s to be removed", legacyTable)
}
}
}
snapshots, err := ListMetricSnapshotsSince(time.Time{})
if err != nil {
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
}
if len(snapshots) != 2 {
t.Fatalf("expected 2 migrated metric snapshots, got %+v", snapshots)
}
reports, err := ListRequestReportsSince(time.Time{})
if err != nil {
t.Fatalf("ListRequestReportsSince failed: %v", err)
}
if len(reports) != 2 {
t.Fatalf("expected 2 migrated request reports, got %+v", reports)
}
logs, err := ListNodeAccessLogs(NodeAccessLogQuery{Page: 0, PageSize: 10})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(logs) != 2 {
t.Fatalf("expected 2 migrated access logs, got %+v", logs)
}
seenSnapshotIDs := make(map[uint]struct{}, len(snapshots))
for _, item := range snapshots {
if item == nil || item.ID == 0 {
t.Fatalf("expected migrated metric snapshot to have a new non-zero id: %+v", item)
}
if _, exists := seenSnapshotIDs[item.ID]; exists {
t.Fatalf("expected migrated metric snapshot ids to be unique, got duplicate %d", item.ID)
}
seenSnapshotIDs[item.ID] = struct{}{}
targetTable := observabilityShardTableForID("node_metric_snapshots", item.ID)
var count int64
if err := db.Table(targetTable).Where("id = ?", item.ID).Count(&count).Error; err != nil {
t.Fatalf("count migrated metric snapshot in target shard: %v", err)
}
if count != 1 {
t.Fatalf("expected migrated metric snapshot id %d to be stored in %s", item.ID, targetTable)
}
}
}
func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) {
db := openBareTestSQLiteDB(t, "failed-validation.db")
err := runDatabaseSchemaMigration(db, "sqlite", databaseSchemaMigration{
fromVersion: legacyDatabaseSchemaVersion,
toVersion: currentDatabaseSchemaVersion,
migrate: func(tx *gorm.DB, backend string) error {
return autoMigrateSchemaMetadata(tx)
},
validate: func(tx *gorm.DB, backend string) error {
return gorm.ErrInvalidDB
},
})
if err == nil {
t.Fatal("expected migration validation to fail")
}
_, exists, loadErr := loadDatabaseSchemaVersion(db)
if loadErr != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", loadErr)
}
if exists {
t.Fatal("expected schema version to remain unset after failed validation")
}
}
+10 -2
View File
@@ -20,11 +20,11 @@ type Node struct {
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
NginxVersion string `json:"nginx_version" gorm:"size:64"`
OpenrestyStatus string `json:"openresty_status" gorm:"size:16;not null;default:'unknown'"`
OpenrestyMessage string `json:"openresty_message" gorm:"size:2048"`
OpenrestyMessage string `json:"openresty_message" gorm:"type:text"`
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
CurrentVersion string `json:"current_version" gorm:"size:32"`
LastSeenAt time.Time `json:"last_seen_at"`
LastError string `json:"last_error" gorm:"size:1024"`
LastError string `json:"last_error" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -34,6 +34,14 @@ func ListNodes() (nodes []*Node, err error) {
return nodes, err
}
func ListNodesByNodeIDs(nodeIDs []string) (nodes []*Node, err error) {
if len(nodeIDs) == 0 {
return []*Node{}, nil
}
err = DB.Where("node_id IN ?", nodeIDs).Find(&nodes).Error
return nodes, err
}
func GetNodeByNodeID(nodeID string) (*Node, error) {
node := &Node{}
err := DB.Where("node_id = ?", nodeID).First(node).Error
+647 -48
View File
@@ -1,17 +1,23 @@
package model
import "time"
import (
"fmt"
"sort"
"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:,composite:node_logged_at,priority:1;size:64;not null"`
LoggedAt time.Time `json:"logged_at" gorm:"index;index:,composite:node_logged_at,priority:2"`
RemoteAddr string `json:"remote_addr" gorm:"index;size:128"`
Region string `json:"region" gorm:"size:128"`
Host string `json:"host" gorm:"size:255"`
Host string `json:"host" gorm:"index;size:255"`
Path string `json:"path" gorm:"size:2048"`
StatusCode int `json:"status_code"`
RawJSON string `json:"raw_json" gorm:"type:text"`
StatusCode int `json:"status_code" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
}
@@ -20,58 +26,651 @@ 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
return logs, err
type NodeAccessLogQuery struct {
NodeID string
RemoteAddr string
Host string
Path string
Since time.Time
Page int
PageSize int
SortBy string
SortOrder string
}
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)
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 (log *NodeAccessLog) BeforeCreate(tx *gorm.DB) error {
return assignObservabilityID(&log.ID)
}
func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err error) {
all, err := listNodeAccessLogsAcrossShards(query)
if err != nil {
return nil, err
}
if !since.IsZero() {
query = query.Where("logged_at >= ?", since)
start, end := paginateBounds(len(all), query.Page, query.PageSize)
if start >= len(all) {
return []*NodeAccessLog{}, nil
}
if err = query.Count(&totalRecords).Error; err != nil {
return all[start:end], nil
}
func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) {
all, err := listNodeAccessLogsAcrossShards(query)
if err != nil {
return 0, 0, err
}
if err = query.
Where("remote_addr <> ''").
Distinct("remote_addr").
Count(&totalIPs).Error; err != nil {
return 0, 0, err
ips := make(map[string]struct{}, len(all))
for _, item := range all {
if item == nil {
continue
}
trimmed := strings.TrimSpace(item.RemoteAddr)
if trimmed != "" {
ips[trimmed] = struct{}{}
}
}
return totalRecords, totalIPs, nil
return int64(len(all)), int64(len(ips)), nil
}
func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (items []*NodeAccessLogRegionCount, err error) {
query := DB.Model(&NodeAccessLog{}).
Select("region as region, count(*) as count").
Where("region <> ''")
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: nodeID,
Since: since,
})
if err != nil {
return nil, err
}
if !since.IsZero() {
query = query.Where("logged_at >= ?", since)
counts := make(map[string]int64)
for _, item := range logs {
if item == nil {
continue
}
region := strings.TrimSpace(item.Region)
if region == "" {
continue
}
counts[region]++
}
query = query.Group("region").Order("count desc, region asc")
if limit > 0 {
query = query.Limit(limit)
items = make([]*NodeAccessLogRegionCount, 0, len(counts))
for region, count := range counts {
items = append(items, &NodeAccessLogRegionCount{
Region: region,
Count: count,
})
}
err = query.Scan(&items).Error
return items, err
sort.Slice(items, func(i int, j int) bool {
if items[i].Count == items[j].Count {
return items[i].Region < items[j].Region
}
return items[i].Count > items[j].Count
})
if limit > 0 && len(items) > limit {
items = items[:limit]
}
return items, nil
}
func ListNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (items []*NodeAccessLogBucketRow, err error) {
rows, err := buildNodeAccessLogBucketRows(query)
if err != nil {
return nil, err
}
start, end := paginateBounds(len(rows), query.Page, query.PageSize)
if start >= len(rows) {
return []*NodeAccessLogBucketRow{}, nil
}
return rows[start:end], nil
}
func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err error) {
rows, err := buildNodeAccessLogBucketRows(query)
if err != nil {
return 0, err
}
return int64(len(rows)), nil
}
func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) {
rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince)
if err != nil {
return nil, err
}
start, end := paginateBounds(len(rows), query.Page, query.PageSize)
if start >= len(rows) {
return []*NodeAccessLogIPSummaryRow{}, nil
}
return rows[start:end], nil
}
func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int64, err error) {
rows, err := buildNodeAccessLogIPSummaryRows(query, time.Time{})
if err != nil {
return 0, err
}
return int64(len(rows)), nil
}
func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAccessLogTrendPointRow, err error) {
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
})
if err != nil {
return nil, err
}
remoteAddr := strings.TrimSpace(query.RemoteAddr)
if remoteAddr == "" {
return []*NodeAccessLogTrendPointRow{}, nil
}
buckets := make(map[int64]int64)
for _, item := range logs {
if item == nil || strings.TrimSpace(item.RemoteAddr) != remoteAddr {
continue
}
bucketEpoch := bucketEpochForTime(item.LoggedAt, query.BucketMinutes)
buckets[bucketEpoch]++
}
items = make([]*NodeAccessLogTrendPointRow, 0, len(buckets))
for bucketEpoch, requestCount := range buckets {
items = append(items, &NodeAccessLogTrendPointRow{
BucketEpoch: bucketEpoch,
RequestCount: requestCount,
})
}
sort.Slice(items, func(i int, j int) bool {
return items[i].BucketEpoch < items[j].BucketEpoch
})
return items, nil
}
func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) {
return deleteAcrossShards(DB, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("logged_at < ?", before)
})
}
func DeleteAllNodeAccessLogs(db *gorm.DB) (deleted int64, err error) {
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, nil)
}
func NodeAccessLogExists(db *gorm.DB, record *NodeAccessLog) (bool, error) {
if record == nil {
return false, nil
}
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables("node_access_logs") {
var count int64
if err := db.Table(table).
Where(
"node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?",
record.NodeID,
record.LoggedAt,
record.RemoteAddr,
record.Host,
record.Path,
record.StatusCode,
).
Limit(1).
Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func DeleteNodeAccessLogsByNodeBefore(db *gorm.DB, nodeID string, before time.Time) (deleted int64, err error) {
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("node_id = ? AND logged_at < ?", nodeID, before)
})
}
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{})
}
return applyNodeAccessLogFilters(db, query)
}
func applyNodeAccessLogFilters(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
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 listNodeAccessLogsAcrossShards(query NodeAccessLogQuery) ([]*NodeAccessLog, error) {
items, err := queryAcrossShards("node_access_logs", func(tx *gorm.DB) ([]*NodeAccessLog, error) {
var shardRows []*NodeAccessLog
if err := applyNodeAccessLogFilters(tx, query).Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
sortNodeAccessLogs(items, query.SortBy, query.SortOrder)
return items, nil
}
func buildNodeAccessLogBucketRows(query NodeAccessLogBucketQuery) ([]*NodeAccessLogBucketRow, error) {
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Path: query.Path,
Since: query.Since,
})
if err != nil {
return nil, err
}
type bucketAccumulator struct {
requestCount int64
uniqueIPs map[string]struct{}
uniqueHosts map[string]struct{}
successCount int64
clientErrorCount int64
serverErrorCount int64
}
accumulators := make(map[int64]*bucketAccumulator)
for _, item := range logs {
if item == nil {
continue
}
bucketEpoch := bucketEpochForTime(item.LoggedAt, query.FoldMinutes)
accumulator := accumulators[bucketEpoch]
if accumulator == nil {
accumulator = &bucketAccumulator{
uniqueIPs: make(map[string]struct{}),
uniqueHosts: make(map[string]struct{}),
}
accumulators[bucketEpoch] = accumulator
}
accumulator.requestCount++
if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" {
accumulator.uniqueIPs[trimmed] = struct{}{}
}
if trimmed := strings.TrimSpace(item.Host); trimmed != "" {
accumulator.uniqueHosts[trimmed] = struct{}{}
}
switch {
case item.StatusCode < 400:
accumulator.successCount++
case item.StatusCode < 500:
accumulator.clientErrorCount++
default:
accumulator.serverErrorCount++
}
}
rows := make([]*NodeAccessLogBucketRow, 0, len(accumulators))
for bucketEpoch, accumulator := range accumulators {
rows = append(rows, &NodeAccessLogBucketRow{
BucketEpoch: bucketEpoch,
RequestCount: accumulator.requestCount,
UniqueIPCount: int64(len(accumulator.uniqueIPs)),
UniqueHostCount: int64(len(accumulator.uniqueHosts)),
SuccessCount: accumulator.successCount,
ClientErrorCount: accumulator.clientErrorCount,
ServerErrorCount: accumulator.serverErrorCount,
})
}
sortNodeAccessLogBucketRows(rows, query.SortBy, query.SortOrder)
return rows, nil
}
func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) {
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
})
if err != nil {
return nil, err
}
type accumulator struct {
totalRequests int64
recentRequests int64
lastSeenAt time.Time
}
accumulators := make(map[string]*accumulator)
for _, item := range logs {
if item == nil {
continue
}
remoteAddr := strings.TrimSpace(item.RemoteAddr)
if remoteAddr == "" {
continue
}
acc := accumulators[remoteAddr]
if acc == nil {
acc = &accumulator{}
accumulators[remoteAddr] = acc
}
acc.totalRequests++
if !recentSince.IsZero() && !item.LoggedAt.Before(recentSince) {
acc.recentRequests++
}
if item.LoggedAt.After(acc.lastSeenAt) {
acc.lastSeenAt = item.LoggedAt
}
}
rows := make([]*NodeAccessLogIPSummaryRow, 0, len(accumulators))
for remoteAddr, acc := range accumulators {
rows = append(rows, &NodeAccessLogIPSummaryRow{
RemoteAddr: remoteAddr,
TotalRequests: acc.totalRequests,
RecentRequests: acc.recentRequests,
LastSeenEpoch: acc.lastSeenAt.Unix(),
})
}
sortNodeAccessLogIPSummaryRows(rows, query.SortBy, query.SortOrder)
return rows, nil
}
func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) {
desc := normalizeSortOrder(sortOrder) != "asc"
sort.Slice(items, func(i int, j int) bool {
left := items[i]
right := items[j]
if left == nil || right == nil {
return left != nil
}
var compare int
switch strings.TrimSpace(sortBy) {
case "status_code":
compare = compareInt(left.StatusCode, right.StatusCode)
case "remote_addr":
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
case "host":
compare = strings.Compare(left.Host, right.Host)
case "path":
compare = strings.Compare(left.Path, right.Path)
default:
compare = compareTime(left.LoggedAt, right.LoggedAt)
}
if compare == 0 {
compare = compareTime(left.LoggedAt, right.LoggedAt)
}
if compare == 0 {
compare = compareUint(left.ID, right.ID)
}
if desc {
return compare > 0
}
return compare < 0
})
}
func sortNodeAccessLogBucketRows(items []*NodeAccessLogBucketRow, sortBy string, sortOrder string) {
desc := normalizeSortOrder(sortOrder) != "asc"
sort.Slice(items, func(i int, j int) bool {
left := items[i]
right := items[j]
if left == nil || right == nil {
return left != nil
}
var compare int
switch strings.TrimSpace(sortBy) {
case "request_count":
compare = compareInt64(left.RequestCount, right.RequestCount)
default:
compare = compareInt64(left.BucketEpoch, right.BucketEpoch)
}
if compare == 0 {
compare = compareInt64(left.BucketEpoch, right.BucketEpoch)
}
if desc {
return compare > 0
}
return compare < 0
})
}
func sortNodeAccessLogIPSummaryRows(items []*NodeAccessLogIPSummaryRow, sortBy string, sortOrder string) {
desc := normalizeSortOrder(sortOrder) != "asc"
sort.Slice(items, func(i int, j int) bool {
left := items[i]
right := items[j]
if left == nil || right == nil {
return left != nil
}
var compare int
switch strings.TrimSpace(sortBy) {
case "recent_requests":
compare = compareInt64(left.RecentRequests, right.RecentRequests)
case "last_seen_at":
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
case "remote_addr":
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
default:
compare = compareInt64(left.TotalRequests, right.TotalRequests)
}
if compare == 0 {
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
}
if compare == 0 {
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
}
if desc {
return compare > 0
}
return compare < 0
})
}
func paginateBounds(total int, page int, pageSize int) (int, int) {
if page < 0 {
page = 0
}
if pageSize <= 0 {
return 0, total
}
start := page * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
return start, end
}
func bucketEpochForTime(value time.Time, bucketMinutes int) int64 {
bucketSeconds := int64(bucketMinutes * 60)
if bucketSeconds <= 0 {
bucketSeconds = 180
}
return (value.UTC().Unix() / bucketSeconds) * bucketSeconds
}
func compareTime(left time.Time, right time.Time) int {
switch {
case left.After(right):
return 1
case left.Before(right):
return -1
default:
return 0
}
}
func compareInt(left int, right int) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func compareInt64(left int64, right int64) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func compareUint(left uint, right uint) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
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"
}
+7 -2
View File
@@ -8,12 +8,12 @@ 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"`
ResolvedAt *time.Time `json:"resolved_at" gorm:"index"`
RawJSON string `json:"raw_json" gorm:"type:text"`
MetadataJSON string `json:"metadata_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -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
}
+82 -14
View File
@@ -1,6 +1,11 @@
package model
import "time"
import (
"sort"
"time"
"gorm.io/gorm"
)
type NodeMetricSnapshot struct {
ID uint `json:"id" gorm:"primaryKey"`
@@ -18,31 +23,94 @@ type NodeMetricSnapshot struct {
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
OpenrestyConnections int64 `json:"openresty_connections"`
RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
}
func (snapshot *NodeMetricSnapshot) BeforeCreate(tx *gorm.DB) error {
return assignObservabilityID(&snapshot.ID)
}
func (snapshot *NodeMetricSnapshot) Insert() error {
return DB.Create(snapshot).Error
}
func ListNodeMetricSnapshots(nodeID string, since time.Time, limit int) (snapshots []*NodeMetricSnapshot, err error) {
query := DB.Where("node_id = ?", nodeID).Order("captured_at desc")
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) {
var shardRows []*NodeMetricSnapshot
query := tx.Order("captured_at desc, id desc")
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
if limit > 0 {
query = query.Limit(limit)
sort.Slice(rows, func(i int, j int) bool {
if rows[i].CapturedAt.Equal(rows[j].CapturedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].CapturedAt.After(rows[j].CapturedAt)
})
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
}
err = query.Find(&snapshots).Error
return snapshots, err
return rows, nil
}
func ListMetricSnapshotsSince(since time.Time) (snapshots []*NodeMetricSnapshot, err error) {
query := DB.Order("captured_at desc")
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) {
var shardRows []*NodeMetricSnapshot
query := tx.Order("captured_at desc")
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
err = query.Find(&snapshots).Error
return snapshots, err
sort.Slice(rows, func(i int, j int) bool {
if rows[i].CapturedAt.Equal(rows[j].CapturedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].CapturedAt.After(rows[j].CapturedAt)
})
return rows, nil
}
func NodeMetricSnapshotExists(db *gorm.DB, nodeID string, capturedAt time.Time) (bool, error) {
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables("node_metric_snapshots") {
var count int64
if err := db.Table(table).
Where("node_id = ? AND captured_at = ?", nodeID, capturedAt).
Limit(1).
Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func DeleteNodeMetricSnapshotsBefore(db *gorm.DB, before time.Time) (int64, error) {
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("captured_at < ?", before)
})
}
func DeleteAllNodeMetricSnapshots(db *gorm.DB) (int64, error) {
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, nil)
}
+82 -14
View File
@@ -1,6 +1,11 @@
package model
import "time"
import (
"sort"
"time"
"gorm.io/gorm"
)
type NodeRequestReport struct {
ID uint `json:"id" gorm:"primaryKey"`
@@ -13,31 +18,94 @@ type NodeRequestReport struct {
StatusCodesJSON string `json:"status_codes_json" gorm:"type:text"`
TopDomainsJSON string `json:"top_domains_json" gorm:"type:text"`
SourceCountriesJSON string `json:"source_countries_json" gorm:"type:text"`
RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
}
func (report *NodeRequestReport) BeforeCreate(tx *gorm.DB) error {
return assignObservabilityID(&report.ID)
}
func (report *NodeRequestReport) Insert() error {
return DB.Create(report).Error
}
func ListNodeRequestReports(nodeID string, since time.Time, limit int) (reports []*NodeRequestReport, err error) {
query := DB.Where("node_id = ?", nodeID).Order("window_ended_at desc")
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) {
var shardRows []*NodeRequestReport
query := tx.Order("window_ended_at desc, id desc")
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
if limit > 0 {
query = query.Limit(limit)
sort.Slice(rows, func(i int, j int) bool {
if rows[i].WindowEndedAt.Equal(rows[j].WindowEndedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].WindowEndedAt.After(rows[j].WindowEndedAt)
})
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
}
err = query.Find(&reports).Error
return reports, err
return rows, nil
}
func ListRequestReportsSince(since time.Time) (reports []*NodeRequestReport, err error) {
query := DB.Order("window_ended_at desc")
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) {
var shardRows []*NodeRequestReport
query := tx.Order("window_ended_at desc")
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
err = query.Find(&reports).Error
return reports, err
sort.Slice(rows, func(i int, j int) bool {
if rows[i].WindowEndedAt.Equal(rows[j].WindowEndedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].WindowEndedAt.After(rows[j].WindowEndedAt)
})
return rows, nil
}
func NodeRequestReportExists(db *gorm.DB, nodeID string, windowStartedAt time.Time, windowEndedAt time.Time) (bool, error) {
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables("node_request_reports") {
var count int64
if err := db.Table(table).
Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, windowStartedAt, windowEndedAt).
Limit(1).
Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func DeleteNodeRequestReportsBefore(db *gorm.DB, before time.Time) (int64, error) {
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("window_ended_at < ?", before)
})
}
func DeleteAllNodeRequestReports(db *gorm.DB) (int64, error) {
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, nil)
}
@@ -20,7 +20,6 @@ type NodeSystemProfile struct {
TotalDiskBytes int64 `json:"total_disk_bytes"`
UptimeSeconds int64 `json:"uptime_seconds"`
ReportedAt time.Time `json:"reported_at" gorm:"index"`
RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -49,7 +48,6 @@ func UpsertNodeSystemProfile(profile *NodeSystemProfile) error {
"total_disk_bytes",
"uptime_seconds",
"reported_at",
"raw_json",
"updated_at",
}),
}).Create(profile).Error
+13
View File
@@ -56,6 +56,8 @@ func InitOptionMap() {
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled)
common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays)
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
common.OptionMap["OpenRestyWorkerConnections"] = strconv.Itoa(common.OpenRestyWorkerConnections)
common.OptionMap["OpenRestyWorkerRlimitNofile"] = strconv.Itoa(common.OpenRestyWorkerRlimitNofile)
@@ -131,6 +133,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 {
@@ -214,6 +221,12 @@ func updateOptionMap(key string, value string) {
common.GeoIPProvider = value
shouldRefreshGeoIP = true
}
case "DatabaseAutoCleanupEnabled":
common.DatabaseAutoCleanupEnabled = value == "true"
case "DatabaseAutoCleanupRetentionDays":
if v, err := strconv.Atoi(value); err == nil && v >= 1 {
common.DatabaseAutoCleanupRetentionDays = v
}
case "OpenRestyWorkerProcesses":
if strings.TrimSpace(value) != "" {
common.OpenRestyWorkerProcesses = value
+10
View File
@@ -6,10 +6,15 @@ type ProxyRoute struct {
ID uint `json:"id" gorm:"primaryKey"`
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"`
@@ -40,10 +45,15 @@ func (route *ProxyRoute) Update() error {
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
"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
+231
View File
@@ -0,0 +1,231 @@
package model
import (
"fmt"
"sort"
"strconv"
"strings"
"sync"
"github.com/bwmarrin/snowflake"
"gorm.io/gorm"
"gorm.io/sharding"
)
const observabilityShardCount = 10
var (
observabilityIDNode *snowflake.Node
observabilityIDNodeErr error
observabilityIDNodeOnce sync.Once
)
func registerSharding(db *gorm.DB, backend string) error {
if db == nil {
return nil
}
_ = backend
if err := db.Use(sharding.Register(sharding.Config{
ShardingKey: "id",
NumberOfShards: observabilityShardCount,
ShardingAlgorithm: func(value any) (string, error) {
return observabilityShardSuffixForValue(value)
},
ShardingAlgorithmByPrimaryKey: func(id int64) string {
return observabilityShardSuffixForInt64(id)
},
PrimaryKeyGenerator: sharding.PKCustom,
PrimaryKeyGeneratorFn: func(tableIdx int64) int64 {
return 0
},
}, shardedObservabilityTables()...)); err != nil {
return fmt.Errorf("register observability sharding failed: %w", err)
}
return nil
}
func shardedObservabilityTables() []any {
return []any{
&NodeMetricSnapshot{},
&NodeRequestReport{},
&NodeAccessLog{},
}
}
func shardedObservabilityBaseTables() []string {
return []string{
"node_metric_snapshots",
"node_request_reports",
"node_access_logs",
}
}
func isShardedObservabilityTable(tableName string) bool {
switch strings.TrimSpace(tableName) {
case "node_metric_snapshots", "node_request_reports", "node_access_logs":
return true
default:
return false
}
}
func observabilityShardTables(baseTable string) []string {
tables := make([]string, 0, observabilityShardCount)
for _, suffix := range observabilityShardSuffixes() {
tables = append(tables, baseTable+suffix)
}
return tables
}
func observabilityShardSuffixes() []string {
suffixes := make([]string, 0, observabilityShardCount)
for index := 0; index < observabilityShardCount; index++ {
suffixes = append(suffixes, fmt.Sprintf("_%02d", index))
}
return suffixes
}
func observabilityShardSuffixForID(id uint) string {
return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount))
}
func observabilityShardSuffixForInt64(id int64) string {
if id < 0 {
id = -id
}
return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount))
}
func observabilityShardSuffixForValue(value any) (string, error) {
switch typed := value.(type) {
case int:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int8:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int16:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int32:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int64:
return observabilityShardSuffixForInt64(typed), nil
case uint:
return observabilityShardSuffixForID(typed), nil
case uint8:
return observabilityShardSuffixForID(uint(typed)), nil
case uint16:
return observabilityShardSuffixForID(uint(typed)), nil
case uint32:
return observabilityShardSuffixForID(uint(typed)), nil
case uint64:
return fmt.Sprintf("_%02d", typed%uint64(observabilityShardCount)), nil
case string:
id, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", fmt.Errorf("invalid sharding id %q", typed)
}
return fmt.Sprintf("_%02d", id%uint64(observabilityShardCount)), nil
default:
return "", fmt.Errorf("unsupported observability sharding value type %T", value)
}
}
func observabilityShardTableForID(baseTable string, id uint) string {
return baseTable + observabilityShardSuffixForID(id)
}
func legacyObservabilityShardTableName(tableName string) string {
return tableName + "_legacy_v2_to_v3"
}
func normalizeShardedDB(db *gorm.DB) *gorm.DB {
if db != nil {
return db
}
return DB
}
func sessionIgnoringSharding(db *gorm.DB) *gorm.DB {
db = normalizeShardedDB(db)
if db == nil {
return nil
}
return db.Session(&gorm.Session{}).Set(sharding.ShardingIgnoreStoreKey, true)
}
func baseDialector(db *gorm.DB) gorm.Dialector {
if db == nil {
return nil
}
if dialector, ok := db.Dialector.(sharding.ShardingDialector); ok {
return dialector.Dialector
}
return db.Dialector
}
func nextObservabilityID() (uint, error) {
observabilityIDNodeOnce.Do(func() {
observabilityIDNode, observabilityIDNodeErr = snowflake.NewNode(0)
})
if observabilityIDNodeErr != nil {
return 0, observabilityIDNodeErr
}
id := observabilityIDNode.Generate().Int64()
if id <= 0 {
return 0, fmt.Errorf("generated invalid observability id %d", id)
}
return uint(id), nil
}
func assignObservabilityID(id *uint) error {
if id == nil || *id != 0 {
return nil
}
generated, err := nextObservabilityID()
if err != nil {
return err
}
*id = generated
return nil
}
func queryAcrossShards[T any](baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) {
return queryAcrossShardsWithDB(DB, baseTable, query)
}
func queryAcrossShardsWithDB[T any](db *gorm.DB, baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) {
items := make([]T, 0)
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables(baseTable) {
rows, err := query(db.Table(table))
if err != nil {
return nil, err
}
items = append(items, rows...)
}
return items, nil
}
func deleteAcrossShards(db *gorm.DB, baseTable string, model any, apply func(tx *gorm.DB) *gorm.DB) (int64, error) {
db = normalizeShardedDB(db)
var deleted int64
for _, table := range observabilityShardTables(baseTable) {
tx := db.Table(table)
if apply != nil {
tx = apply(tx)
} else {
tx = tx.Session(&gorm.Session{AllowGlobalUpdate: true})
}
result := tx.Delete(model)
if result.Error != nil {
return deleted, result.Error
}
deleted += result.RowsAffected
}
return deleted, nil
}
func sortShardRows[T any](items []T, less func(left T, right T) bool) {
sort.Slice(items, func(i int, j int) bool {
return less(items[i], items[j])
})
}
+23 -15
View File
@@ -32,8 +32,8 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
{
selfRoute.GET("/self", controller.GetSelf)
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.POST("/self/update", controller.UpdateSelf)
selfRoute.POST("/self/delete", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateToken)
}
@@ -45,16 +45,17 @@ func SetApiRouter(router *gin.Engine) {
adminRoute.GET("/:id", controller.GetUser)
adminRoute.POST("/", controller.CreateUser)
adminRoute.POST("/manage", controller.ManageUser)
adminRoute.PUT("/", controller.UpdateUser)
adminRoute.DELETE("/:id", controller.DeleteUser)
adminRoute.POST("/update", controller.UpdateUser)
adminRoute.POST("/:id/delete", controller.DeleteUser)
}
}
optionRoute := apiRouter.Group("/option")
optionRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.POST("/update", controller.UpdateOption)
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
}
updateRoute := apiRouter.Group("/update")
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
@@ -71,15 +72,15 @@ func SetApiRouter(router *gin.Engine) {
fileRoute.GET("/", controller.GetAllFiles)
fileRoute.GET("/search", controller.SearchFiles)
fileRoute.POST("/", middleware.UploadRateLimit(), controller.UploadFile)
fileRoute.DELETE("/:id", controller.DeleteFile)
fileRoute.POST("/:id/delete", controller.DeleteFile)
}
proxyRoute := apiRouter.Group("/proxy-routes")
proxyRoute.Use(middleware.AdminAuth())
{
proxyRoute.GET("/", controller.GetProxyRoutes)
proxyRoute.POST("/", controller.CreateProxyRoute)
proxyRoute.PUT("/:id", controller.UpdateProxyRoute)
proxyRoute.DELETE("/:id", controller.DeleteProxyRoute)
proxyRoute.POST("/:id/update", controller.UpdateProxyRoute)
proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute)
}
managedDomainRoute := apiRouter.Group("/managed-domains")
managedDomainRoute.Use(middleware.AdminAuth())
@@ -87,8 +88,8 @@ func SetApiRouter(router *gin.Engine) {
managedDomainRoute.GET("/", controller.GetManagedDomains)
managedDomainRoute.GET("/match", controller.MatchManagedDomainCertificate)
managedDomainRoute.POST("/", controller.CreateManagedDomain)
managedDomainRoute.PUT("/:id", controller.UpdateManagedDomain)
managedDomainRoute.DELETE("/:id", controller.DeleteManagedDomain)
managedDomainRoute.POST("/:id/update", controller.UpdateManagedDomain)
managedDomainRoute.POST("/:id/delete", controller.DeleteManagedDomain)
}
tlsCertificateRoute := apiRouter.Group("/tls-certificates")
tlsCertificateRoute.Use(middleware.AdminAuth())
@@ -97,9 +98,9 @@ func SetApiRouter(router *gin.Engine) {
tlsCertificateRoute.GET("/:id", controller.GetTLSCertificate)
tlsCertificateRoute.GET("/:id/content", controller.GetTLSCertificateContent)
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
tlsCertificateRoute.PUT("/:id", controller.UpdateTLSCertificate)
tlsCertificateRoute.POST("/:id/update", controller.UpdateTLSCertificate)
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
tlsCertificateRoute.DELETE("/:id", controller.DeleteTLSCertificate)
tlsCertificateRoute.POST("/:id/delete", controller.DeleteTLSCertificate)
}
configVersionRoute := apiRouter.Group("/config-versions")
configVersionRoute.Use(middleware.AdminAuth())
@@ -108,8 +109,9 @@ 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.PUT("/:id/activate", controller.ActivateConfigVersion)
configVersionRoute.POST("/:id/activate", controller.ActivateConfigVersion)
}
dashboardRoute := apiRouter.Group("/dashboard")
dashboardRoute.Use(middleware.AdminAuth())
@@ -125,20 +127,26 @@ 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.PUT("/:id", controller.UpdateNode)
nodeRoute.DELETE("/:id", controller.DeleteNode)
nodeRoute.POST("/:id/update", controller.UpdateNode)
nodeRoute.POST("/:id/delete", controller.DeleteNode)
}
applyLogRoute := apiRouter.Group("/apply-logs")
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")
{
+73 -17
View File
@@ -44,10 +44,15 @@ func TestPhase1PublishLifecycle(t *testing.T) {
token := prepareRootToken(t)
createBody := map[string]any{
"domain": "app.example.com",
"origin_url": "https://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
@@ -55,6 +60,18 @@ func TestPhase1PublishLifecycle(t *testing.T) {
if createdRoute.Domain != "app.example.com" {
t.Fatalf("unexpected created route domain: %s", createdRoute.Domain)
}
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
@@ -99,17 +116,31 @@ func TestPhase1PublishLifecycle(t *testing.T) {
initialRendered := version1.RenderedConfig
updateBody := map[string]any{
"domain": "app.example.com",
"origin_url": "https://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.MethodPut, routePath, updateBody)
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
@@ -119,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
@@ -133,7 +186,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
}
activatePath := "/api/config-versions/" + toString(version1.ID) + "/activate"
resp = performJSONRequest(t, engine, token, http.MethodPut, activatePath, nil)
resp = performJSONRequest(t, engine, token, http.MethodPost, activatePath, nil)
decodeResponseData(t, resp, &activeVersion)
if activeVersion.ID != version1.ID || !activeVersion.IsActive {
t.Fatal("expected version1 to become active after rollback activation")
@@ -154,7 +207,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
}
deletePath := "/api/proxy-routes/" + toString(createdRoute.ID)
resp = performJSONRequest(t, engine, token, http.MethodDelete, deletePath, nil)
resp = performJSONRequest(t, engine, token, http.MethodPost, deletePath+"/delete", nil)
if !resp.Success {
t.Fatalf("expected delete route success, got: %s", resp.Message)
}
@@ -202,7 +255,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
}
updatedCertPEM, updatedKeyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com", "www.secure.example.com"})
updateCertificateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/tls-certificates/"+toString(manualCertificate.ID), map[string]any{
updateCertificateResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(manualCertificate.ID)+"/update", map[string]any{
"name": "secure-example-updated",
"cert_pem": updatedCertPEM,
"key_pem": updatedKeyPEM,
@@ -242,7 +295,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
t.Fatal("expected route to persist https certificate binding")
}
updateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(route.ID), map[string]any{
updateResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/"+toString(route.ID)+"/update", map[string]any{
"domain": "secure.example.com",
"origin_url": "http://origin-secure.internal",
"enabled": true,
@@ -256,7 +309,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
t.Fatalf("expected route to disable https flags, got %+v", route)
}
updateResp = performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(route.ID), map[string]any{
updateResp = performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/"+toString(route.ID)+"/update", map[string]any{
"domain": "secure.example.com",
"origin_url": "https://origin-secure.internal",
"enabled": true,
@@ -297,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")
@@ -84,7 +84,7 @@ func TestPhase2ManagedDomainLifecycle(t *testing.T) {
t.Fatalf("expected exact certificate id %d, got %#v", exactID, candidate["certificate_id"])
}
updateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/managed-domains/"+toString(uint(exactDomain["id"].(float64))), map[string]any{
updateResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/"+toString(uint(exactDomain["id"].(float64)))+"/update", map[string]any{
"domain": "api.example.com",
"cert_id": exactID,
"enabled": false,
@@ -105,7 +105,7 @@ func TestPhase2ManagedDomainLifecycle(t *testing.T) {
t.Fatalf("expected wildcard certificate id %d, got %#v", wildcardID, candidate["certificate_id"])
}
deleteResp := performJSONRequest(t, engine, token, http.MethodDelete, "/api/managed-domains/"+toString(uint(wildcardDomain["id"].(float64))), nil)
deleteResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/"+toString(uint(wildcardDomain["id"].(float64)))+"/delete", nil)
if !deleteResp.Success {
t.Fatalf("expected delete success, got %s", deleteResp.Message)
}
+101 -18
View File
@@ -41,19 +41,19 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
loginCookie := loginAsRoot(t, engine)
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "GlobalApiRateLimitNum",
"value": "450",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "GlobalApiRateLimitDuration",
"value": "240",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "CriticalRateLimitNum",
"value": "150",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "CriticalRateLimitDuration",
"value": "900",
})
@@ -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,14 +354,52 @@ 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.MethodPut, "/api/nodes/"+toString(createdNode.ID), map[string]any{
updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/update", map[string]any{
"name": "shanghai-edge-1-renamed",
"geo_manual_override": true,
"geo_name": "Tokyo",
@@ -355,7 +424,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
t.Fatal("expected node to be shown as offline after timeout")
}
deleteResp := performJSONRequest(t, engine, adminToken, http.MethodDelete, "/api/nodes/"+toString(createdNode.ID), nil)
deleteResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/delete", nil)
if !deleteResp.Success {
t.Fatalf("expected delete node success, got %s", deleteResp.Message)
}
@@ -382,9 +451,10 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
token := prepareRootToken(t)
createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{
"domain": "preview.example.com",
"origin_url": "https://origin-a.internal",
"enabled": true,
"domain": "preview.example.com",
"origin_url": "https://origin-a.internal",
"origin_host": "preview-origin.internal",
"enabled": true,
"custom_headers": []map[string]any{
{"key": "X-Trace-Id", "value": "$request_id"},
},
@@ -394,13 +464,17 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
if !strings.Contains(createdRoute.CustomHeaders, "X-Trace-Id") {
t.Fatalf("expected custom headers to be stored as json, got %s", createdRoute.CustomHeaders)
}
if createdRoute.OriginHost != "preview-origin.internal" {
t.Fatalf("expected origin_host to be stored, got %s", createdRoute.OriginHost)
}
performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(createdRoute.ID), map[string]any{
"domain": "preview.example.com",
"origin_url": "https://origin-b.internal",
"enabled": true,
performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/"+toString(createdRoute.ID)+"/update", map[string]any{
"domain": "preview.example.com",
"origin_url": "https://origin-b.internal",
"origin_host": "preview-upstream.internal",
"enabled": true,
"custom_headers": []map[string]any{
{"key": "X-Trace-Id", "value": "$request_id"},
{"key": "X-Release", "value": "candidate"},
@@ -419,6 +493,15 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
if !strings.Contains(renderedConfig, `proxy_set_header X-Release "candidate";`) {
t.Fatalf("expected preview endpoint to return custom header, got %s", renderedConfig)
}
if !strings.Contains(renderedConfig, `proxy_set_header Host "preview-upstream.internal";`) {
t.Fatalf("expected preview endpoint to return overridden host header, got %s", renderedConfig)
}
if !strings.Contains(renderedConfig, "proxy_ssl_server_name on;") {
t.Fatalf("expected preview endpoint to enable proxy ssl server name, got %s", renderedConfig)
}
if !strings.Contains(renderedConfig, `proxy_ssl_name "preview-upstream.internal";`) {
t.Fatalf("expected preview endpoint to return proxy ssl name, got %s", renderedConfig)
}
diffResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/diff", nil)
var diff map[string]any
+411 -30
View File
@@ -1,16 +1,36 @@
package service
import (
"errors"
"openflare/model"
"strings"
"time"
)
const (
defaultAccessLogPageSize = 50
maxAccessLogPageSize = 200
defaultAccessLogPageSize = 20
maxAccessLogPageSize = 200
defaultAccessLogSortBy = "logged_at"
defaultAccessLogSortOrder = "desc"
defaultAccessLogFoldMinute = 3
defaultIPTrendHours = 24
defaultIPTrendBucketMinute = 30
maxIPTrendHours = 168
nodeAccessLogRetentionDays = 90
)
type AccessLogQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
FoldMinutes int `json:"fold_minutes"`
}
type AccessLogView struct {
ID uint `json:"id"`
NodeID string `json:"node_id"`
@@ -32,40 +52,99 @@ type AccessLogList struct {
TotalIP int64 `json:"total_ip"`
}
func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, error) {
normalizedPage := normalizeAccessLogPage(page)
normalizedPageSize := normalizeAccessLogPageSize(pageSize)
offset := normalizedPage * normalizedPageSize
trimmedNodeID := strings.TrimSpace(nodeID)
since := time.Now().Add(-nodeAccessLogRetentionWindow)
logs, err := model.ListNodeAccessLogs(
trimmedNodeID,
since,
offset,
normalizedPageSize+1,
)
type FoldedAccessLogView struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
UniqueIPCount int64 `json:"unique_ip_count"`
UniqueHostCount int64 `json:"unique_host_count"`
SuccessCount int64 `json:"success_count"`
ClientErrorCount int64 `json:"client_error_count"`
ServerErrorCount int64 `json:"server_error_count"`
}
type FoldedAccessLogList struct {
Items []FoldedAccessLogView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalBucket int64 `json:"total_bucket"`
TotalRecord int64 `json:"total_record"`
TotalIP int64 `json:"total_ip"`
FoldMinutes int `json:"fold_minutes"`
}
type AccessLogIPSummaryQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
type AccessLogIPSummaryView struct {
RemoteAddr string `json:"remote_addr"`
TotalRequests int64 `json:"total_requests"`
RecentRequests int64 `json:"recent_requests"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type AccessLogIPSummaryList struct {
Items []AccessLogIPSummaryView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalIP int64 `json:"total_ip"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
type AccessLogIPTrendQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Hours int `json:"hours"`
BucketMinutes int `json:"bucket_minutes"`
}
type AccessLogIPTrendPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
}
type AccessLogIPTrendView struct {
RemoteAddr string `json:"remote_addr"`
Hours int `json:"hours"`
BucketMinutes int `json:"bucket_minutes"`
Points []AccessLogIPTrendPoint `json:"points"`
}
type AccessLogCleanupInput struct {
RetentionDays int `json:"retention_days"`
}
type AccessLogCleanupResult struct {
RetentionDays int `json:"retention_days"`
DeletedCount int64 `json:"deleted_count"`
Cutoff time.Time `json:"cutoff"`
}
func ListAccessLogs(input AccessLogQuery) (*AccessLogList, error) {
normalized := normalizeAccessLogQuery(input)
modelQuery := buildModelAccessLogQuery(normalized)
logs, err := model.ListNodeAccessLogs(modelQuery)
if err != nil {
return nil, err
}
totalRecords, totalIPs, err := model.CountNodeAccessLogs(trimmedNodeID, since)
totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery)
if err != nil {
return nil, err
}
nodes, err := model.ListNodes()
nodeNames, err := listNodeNameMap(logs)
if err != nil {
return nil, err
}
nodeNames := make(map[string]string, len(nodes))
for _, node := range nodes {
if node == nil {
continue
}
nodeNames[node.NodeID] = node.Name
}
hasMore := len(logs) > normalizedPageSize
if hasMore {
logs = logs[:normalizedPageSize]
}
views := make([]AccessLogView, 0, len(logs))
for _, item := range logs {
if item == nil {
@@ -85,14 +164,270 @@ func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, erro
}
return &AccessLogList{
Items: views,
Page: normalizedPage,
PageSize: normalizedPageSize,
HasMore: hasMore,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalRecords,
TotalRecord: totalRecords,
TotalIP: totalIPs,
}, nil
}
func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) {
normalized := normalizeAccessLogQuery(input)
foldMinutes, err := normalizeFoldMinutes(normalized.FoldMinutes)
if err != nil {
return nil, err
}
modelQuery := buildModelAccessLogQuery(normalized)
bucketQuery := model.NodeAccessLogBucketQuery{
NodeID: modelQuery.NodeID,
RemoteAddr: modelQuery.RemoteAddr,
Host: modelQuery.Host,
Path: modelQuery.Path,
Since: modelQuery.Since,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalizeFoldSortBy(normalized.SortBy),
SortOrder: normalized.SortOrder,
FoldMinutes: foldMinutes,
}
items, err := model.ListNodeAccessLogBuckets(bucketQuery)
if err != nil {
return nil, err
}
totalBuckets, err := model.CountNodeAccessLogBuckets(bucketQuery)
if err != nil {
return nil, err
}
totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery)
if err != nil {
return nil, err
}
views := make([]FoldedAccessLogView, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
views = append(views, FoldedAccessLogView{
BucketStartedAt: time.Unix(item.BucketEpoch, 0).UTC(),
RequestCount: item.RequestCount,
UniqueIPCount: item.UniqueIPCount,
UniqueHostCount: item.UniqueHostCount,
SuccessCount: item.SuccessCount,
ClientErrorCount: item.ClientErrorCount,
ServerErrorCount: item.ServerErrorCount,
})
}
return &FoldedAccessLogList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalBuckets,
TotalBucket: totalBuckets,
TotalRecord: totalRecords,
TotalIP: totalIPs,
FoldMinutes: foldMinutes,
}, nil
}
func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
normalized := normalizeAccessLogIPSummaryQuery(input)
since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow)
recentSince := time.Now().UTC().Add(-3 * time.Hour)
query := model.NodeAccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(normalized.NodeID),
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
Host: strings.TrimSpace(normalized.Host),
Since: since,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}
items, err := model.ListNodeAccessLogIPSummaries(query, recentSince)
if err != nil {
return nil, err
}
totalIP, err := model.CountNodeAccessLogIPSummaries(query)
if err != nil {
return nil, err
}
views := make([]AccessLogIPSummaryView, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
views = append(views, AccessLogIPSummaryView{
RemoteAddr: item.RemoteAddr,
TotalRequests: item.TotalRequests,
RecentRequests: item.RecentRequests,
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
})
}
return &AccessLogIPSummaryList{
Items: views,
Page: normalized.Page,
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP,
TotalIP: totalIP,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}, nil
}
func GetAccessLogIPTrend(input AccessLogIPTrendQuery) (*AccessLogIPTrendView, error) {
normalized, err := normalizeAccessLogIPTrendQuery(input)
if err != nil {
return nil, err
}
points, err := model.ListNodeAccessLogIPTrend(model.NodeAccessLogIPTrendQuery{
NodeID: strings.TrimSpace(normalized.NodeID),
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
Host: strings.TrimSpace(normalized.Host),
Since: time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour),
BucketMinutes: normalized.BucketMinutes,
})
if err != nil {
return nil, err
}
pointMap := make(map[int64]int64, len(points))
for _, item := range points {
if item == nil {
continue
}
pointMap[item.BucketEpoch] = item.RequestCount
}
bucketDuration := time.Duration(normalized.BucketMinutes) * time.Minute
start := time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour).Truncate(bucketDuration)
end := time.Now().UTC().Truncate(bucketDuration)
views := make([]AccessLogIPTrendPoint, 0, int(end.Sub(start)/bucketDuration)+1)
for cursor := start; !cursor.After(end); cursor = cursor.Add(bucketDuration) {
views = append(views, AccessLogIPTrendPoint{
BucketStartedAt: cursor,
RequestCount: pointMap[cursor.Unix()],
})
}
return &AccessLogIPTrendView{
RemoteAddr: normalized.RemoteAddr,
Hours: normalized.Hours,
BucketMinutes: normalized.BucketMinutes,
Points: views,
}, nil
}
func CleanupAccessLogs(input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
return nil, errors.New("retention_days 必须在 1 到 90 之间")
}
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
deleted, err := model.DeleteNodeAccessLogsBefore(cutoff)
if err != nil {
return nil, err
}
return &AccessLogCleanupResult{
RetentionDays: input.RetentionDays,
DeletedCount: deleted,
Cutoff: cutoff,
}, nil
}
func buildModelAccessLogQuery(input AccessLogQuery) model.NodeAccessLogQuery {
return model.NodeAccessLogQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path),
Since: time.Now().UTC().Add(-nodeAccessLogRetentionWindow),
Page: input.Page,
PageSize: input.PageSize,
SortBy: input.SortBy,
SortOrder: input.SortOrder,
}
}
func listNodeNameMap(logs []*model.NodeAccessLog) (map[string]string, error) {
nodeIDs := make([]string, 0, len(logs))
seen := make(map[string]struct{}, len(logs))
for _, item := range logs {
if item == nil || item.NodeID == "" {
continue
}
if _, exists := seen[item.NodeID]; exists {
continue
}
seen[item.NodeID] = struct{}{}
nodeIDs = append(nodeIDs, item.NodeID)
}
nodes, err := model.ListNodesByNodeIDs(nodeIDs)
if err != nil {
return nil, err
}
result := make(map[string]string, len(nodes))
for _, node := range nodes {
if node == nil {
continue
}
result[node.NodeID] = node.Name
}
return result, nil
}
func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
return AccessLogQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path),
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeAccessLogSortBy(input.SortBy),
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
FoldMinutes: input.FoldMinutes,
}
}
func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIPSummaryQuery {
return AccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeIPSummarySortBy(input.SortBy),
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
}
}
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" {
return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空")
}
hours := input.Hours
if hours <= 0 {
hours = defaultIPTrendHours
}
if hours > maxIPTrendHours {
hours = maxIPTrendHours
}
bucketMinutes := input.BucketMinutes
if bucketMinutes <= 0 {
bucketMinutes = defaultIPTrendBucketMinute
}
switch bucketMinutes {
case 5, 10, 15, 30, 60:
default:
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60")
}
return AccessLogIPTrendQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: remoteAddr,
Host: strings.TrimSpace(input.Host),
Hours: hours,
BucketMinutes: bucketMinutes,
}, nil
}
func normalizeAccessLogPage(page int) int {
if page < 0 {
return 0
@@ -109,3 +444,49 @@ func normalizeAccessLogPageSize(pageSize int) int {
}
return pageSize
}
func normalizeAccessLogSortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "status_code", "remote_addr", "host", "path":
return strings.TrimSpace(sortBy)
default:
return defaultAccessLogSortBy
}
}
func normalizeAccessLogSortOrder(sortOrder string) string {
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
return "asc"
}
return defaultAccessLogSortOrder
}
func normalizeFoldSortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "request_count":
return "request_count"
default:
return "bucket_started_at"
}
}
func normalizeIPSummarySortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
case "recent_requests", "last_seen_at", "remote_addr":
return strings.TrimSpace(sortBy)
default:
return "total_requests"
}
}
func normalizeFoldMinutes(value int) (int, error) {
if value <= 0 {
return defaultAccessLogFoldMinute, nil
}
switch value {
case 3, 5:
return value, nil
default:
return 0, errors.New("fold_minutes 仅支持 3 或 5")
}
}
+200 -5
View File
@@ -2,6 +2,7 @@ package service
import (
"openflare/model"
"strings"
"testing"
"time"
)
@@ -60,11 +61,9 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
StatusCode: 200,
},
}
if err := model.DB.Create(&logs).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
seedNodeAccessLogs(t, logs)
result, err := ListAccessLogs("", 0, 2)
result, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 2})
if err != nil {
t.Fatalf("ListAccessLogs failed: %v", err)
}
@@ -84,7 +83,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 +97,199 @@ 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,
})
}
seedNodeAccessLogs(t, logs)
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.Date(2026, 3, 19, 8, 12, 30, 0, time.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,
},
}
seedNodeAccessLogs(t, logs)
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()
seedNodeAccessLogs(t, []*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,
},
})
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)
}
}
func TestPersistNodeAccessLogsTruncatesLongPath(t *testing.T) {
setupServiceTestDB(t)
longPath := "/" + strings.Repeat("a", 140)
reportedAt := time.Now().UTC()
if err := persistNodeAccessLogs(model.DB, "node-truncate", []AgentNodeAccessLog{
{
LoggedAtUnix: reportedAt.Unix(),
RemoteAddr: "203.0.113.10",
Host: "truncate.example.com",
Path: longPath,
StatusCode: 200,
},
}, reportedAt); err != nil {
t.Fatalf("persistNodeAccessLogs failed: %v", err)
}
logs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{
NodeID: "node-truncate",
Page: 0,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(logs) != 1 {
t.Fatalf("expected one stored log, got %+v", logs)
}
if got := len([]rune(logs[0].Path)); got != nodeAccessLogPathMaxLength {
t.Fatalf("expected truncated path length %d, got %d (%q)", nodeAccessLogPathMaxLength, got, logs[0].Path)
}
}
func seedNodeAccessLogs(t *testing.T, logs []*model.NodeAccessLog) {
t.Helper()
for _, item := range logs {
if err := model.DB.Create(item).Error; err != nil {
t.Fatalf("failed to seed access log: %v", err)
}
}
}
+117 -4
View File
@@ -17,6 +17,7 @@ const (
NodeStatusOffline = "offline"
NodeStatusPending = "pending"
ApplyResultOK = "success"
ApplyResultWarning = "warning"
ApplyResultFailed = "failed"
OpenrestyStatusHealthy = "healthy"
OpenrestyStatusUnhealthy = "unhealthy"
@@ -52,6 +53,31 @@ type ApplyLogPayload struct {
SupportFileCount int `json:"support_file_count"`
}
type ApplyLogListQuery struct {
NodeID string `json:"node_id"`
PageNo int `json:"pageNo"`
PageSize int `json:"pageSize"`
}
type ApplyLogListResult struct {
Rows []*model.ApplyLog `json:"rows"`
Current int `json:"current"`
Total int `json:"total"`
TotalPage int `json:"totalPage"`
}
type ApplyLogCleanupInput struct {
DeleteAll bool `json:"delete_all"`
RetentionDays int `json:"retention_days"`
}
type ApplyLogCleanupResult struct {
DeleteAll bool `json:"delete_all"`
RetentionDays int `json:"retention_days"`
DeletedCount int64 `json:"deleted_count"`
Cutoff *time.Time `json:"cutoff,omitempty"`
}
type AgentConfigResponse struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
@@ -228,14 +254,15 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
payload.Checksum = strings.TrimSpace(payload.Checksum)
payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum)
payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum)
payload.Message = truncateForDatabase(payload.Message, 16000)
if payload.NodeID == "" {
return nil, errors.New("node_id 不能为空")
}
if payload.Version == "" {
return nil, errors.New("version 不能为空")
}
if payload.Result != ApplyResultOK && payload.Result != ApplyResultFailed {
return nil, errors.New("result 仅支持 success 或 failed")
if payload.Result != ApplyResultOK && payload.Result != ApplyResultWarning && payload.Result != ApplyResultFailed {
return nil, errors.New("result 仅支持 success、warning 或 failed")
}
slog.Debug("agent apply log received", "node_id", payload.NodeID, "version", payload.Version, "result", payload.Result)
@@ -273,6 +300,8 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
}
if payload.Result == ApplyResultOK {
slog.Debug("agent apply reported success", "node_id", payload.NodeID, "version", payload.Version)
} else if payload.Result == ApplyResultWarning {
slog.Warn("agent apply reported warning", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message)
} else {
slog.Error("agent apply reported failure", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message)
}
@@ -311,8 +340,92 @@ func ListNodeViews() ([]*NodeView, error) {
return views, nil
}
func ListApplyLogs(nodeID string) ([]*model.ApplyLog, error) {
return model.ListApplyLogs(strings.TrimSpace(nodeID))
func truncateForDatabase(value string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
const (
defaultApplyLogPageSize = 20
maxApplyLogPageSize = 200
maxApplyLogRetentionDays = 3650
)
func ListApplyLogsPage(input ApplyLogListQuery) (*ApplyLogListResult, error) {
pageNo := normalizeApplyLogPageNo(input.PageNo)
pageSize := normalizeApplyLogPageSize(input.PageSize)
nodeID := strings.TrimSpace(input.NodeID)
rows, err := model.ListApplyLogs(model.ApplyLogQuery{
NodeID: nodeID,
PageNo: pageNo,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
total, err := model.CountApplyLogs(nodeID)
if err != nil {
return nil, err
}
totalPage := 0
if total > 0 {
totalPage = int((total + int64(pageSize) - 1) / int64(pageSize))
}
return &ApplyLogListResult{
Rows: rows,
Current: pageNo,
Total: int(total),
TotalPage: totalPage,
}, nil
}
func CleanupApplyLogs(input ApplyLogCleanupInput) (*ApplyLogCleanupResult, error) {
if input.DeleteAll {
deleted, err := model.DeleteAllApplyLogs()
if err != nil {
return nil, err
}
return &ApplyLogCleanupResult{
DeleteAll: true,
DeletedCount: deleted,
}, nil
}
if input.RetentionDays <= 0 || input.RetentionDays > maxApplyLogRetentionDays {
return nil, errors.New("retention_days 必须在 1 到 3650 之间")
}
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
deleted, err := model.DeleteApplyLogsBefore(cutoff)
if err != nil {
return nil, err
}
return &ApplyLogCleanupResult{
RetentionDays: input.RetentionDays,
DeletedCount: deleted,
Cutoff: &cutoff,
}, nil
}
func normalizeApplyLogPageNo(pageNo int) int {
if pageNo <= 0 {
return 1
}
return pageNo
}
func normalizeApplyLogPageSize(pageSize int) int {
if pageSize <= 0 {
return defaultApplyLogPageSize
}
if pageSize > maxApplyLogPageSize {
return maxApplyLogPageSize
}
return pageSize
}
func upsertNode(payload AgentNodePayload) (*model.Node, error) {
+308 -22
View File
@@ -6,8 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"openflare/common"
"openflare/model"
"regexp"
"sort"
"strings"
"time"
@@ -35,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"`
@@ -54,14 +60,33 @@ type ConfigOptionDiffItem struct {
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"`
@@ -128,6 +153,8 @@ var requiredMainConfigTemplatePlaceholders = []string{
"{{OpenRestyWorkerProcesses}}",
"{{OpenRestyWorkerConnections}}",
"{{OpenRestyWorkerRlimitNofile}}",
"{{OpenRestyConnectionUpgradeMap}}",
"{{OpenRestyDefaultServerBlock}}",
"{{OpenRestyAccessLogPath}}",
"{{OpenRestyEventsUseDirective}}",
"{{OpenRestyEventsMultiAcceptDirective}}",
@@ -153,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()
}
@@ -351,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
}
@@ -376,13 +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,
})
@@ -419,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.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
}
@@ -583,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)
@@ -592,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, customHeaders))
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
continue
}
if route.CertID == nil || *route.CertID == 0 {
@@ -610,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, customHeaders))
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
}
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, certificate.ID, customHeaders))
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, upstreamConfig, cfg))
}
return builder.String(), dedupeSupportFiles(supportFiles), nil
}
@@ -647,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;"),
@@ -668,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,
)
@@ -746,45 +836,241 @@ func nextVersionNumber(now time.Time) (string, error) {
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
}
func renderHTTPProxyServer(domain string, originURL 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(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, 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(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(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
builder.WriteString(" proxy_set_header Host $host;\n")
if strings.TrimSpace(originHost) != "" {
builder.WriteString(fmt.Sprintf(" proxy_set_header Host %s;\n", quoteNginxHeaderValue(originHost)))
} else {
builder.WriteString(" proxy_set_header Host $host;\n")
}
if upstreamServerName := resolveUpstreamServerName(originURL, originHost); upstreamServerName != "" {
builder.WriteString(" proxy_ssl_server_name on;\n")
builder.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", quoteNginxHeaderValue(upstreamServerName)))
}
builder.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
builder.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
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()
}
func resolveUpstreamServerName(originURL string, originHost string) string {
parsed, err := url.Parse(originURL)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") {
return ""
}
if strings.TrimSpace(originHost) != "" {
parsedHost, err := url.Parse("//" + originHost)
if err == nil && parsedHost.Hostname() != "" {
return parsedHost.Hostname()
}
return originHost
}
return parsed.Hostname()
}
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)
@@ -0,0 +1,182 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"openflare/common"
"openflare/model"
"strings"
"time"
)
const (
DatabaseCleanupTargetAccessLogs = "node_access_logs"
DatabaseCleanupTargetMetricSnapshots = "node_metric_snapshots"
DatabaseCleanupTargetRequestReports = "node_request_reports"
)
var databaseCleanupTargets = map[string]string{
DatabaseCleanupTargetAccessLogs: "访问日志",
DatabaseCleanupTargetMetricSnapshots: "性能快照",
DatabaseCleanupTargetRequestReports: "请求聚合",
}
type DatabaseCleanupInput struct {
Target string `json:"target"`
RetentionDays *int `json:"retention_days"`
}
type DatabaseCleanupResult struct {
Target string `json:"target"`
TargetLabel string `json:"target_label"`
DeletedCount int64 `json:"deleted_count"`
DeleteAll bool `json:"delete_all"`
RetentionDays *int `json:"retention_days,omitempty"`
Cutoff *time.Time `json:"cutoff,omitempty"`
}
type DatabaseAutoCleanupSummary struct {
RetentionDays int `json:"retention_days"`
ExecutedAt time.Time `json:"executed_at"`
Results []DatabaseCleanupResult `json:"results"`
}
func CleanupDatabaseObservability(input DatabaseCleanupInput) (*DatabaseCleanupResult, error) {
target := strings.TrimSpace(input.Target)
targetLabel, ok := databaseCleanupTargets[target]
if !ok {
return nil, errors.New("unsupported cleanup target")
}
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
return nil, errors.New("retention_days 必须为大于 0 的整数")
}
result := &DatabaseCleanupResult{
Target: target,
TargetLabel: targetLabel,
DeleteAll: input.RetentionDays == nil,
}
if input.RetentionDays == nil {
deleted, err := deleteAllObservabilityRows(target)
if err != nil {
return nil, err
}
result.DeletedCount = deleted
return result, nil
}
retentionDays := *input.RetentionDays
cutoff := time.Now().UTC().Add(-time.Duration(retentionDays) * 24 * time.Hour)
deleted, err := deleteObservabilityRowsBefore(target, cutoff)
if err != nil {
return nil, err
}
result.DeletedCount = deleted
result.RetentionDays = &retentionDays
result.Cutoff = &cutoff
return result, nil
}
func RunDatabaseAutoCleanupOnce(now time.Time) (*DatabaseAutoCleanupSummary, error) {
if !common.DatabaseAutoCleanupEnabled {
return nil, nil
}
if common.DatabaseAutoCleanupRetentionDays < 1 {
return nil, fmt.Errorf("database auto cleanup retention_days must be at least 1")
}
retentionDays := common.DatabaseAutoCleanupRetentionDays
results := make([]DatabaseCleanupResult, 0, len(databaseCleanupTargets))
for _, target := range []string{
DatabaseCleanupTargetAccessLogs,
DatabaseCleanupTargetMetricSnapshots,
DatabaseCleanupTargetRequestReports,
} {
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
Target: target,
RetentionDays: &retentionDays,
})
if err != nil {
return nil, err
}
results = append(results, *result)
}
return &DatabaseAutoCleanupSummary{
RetentionDays: retentionDays,
ExecutedAt: now.UTC(),
Results: results,
}, nil
}
func StartDatabaseAutoCleanupScheduler(ctx context.Context) {
go func() {
for {
wait := time.Until(nextDatabaseAutoCleanupTime(time.Now()))
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
summary, err := RunDatabaseAutoCleanupOnce(time.Now())
if err != nil {
slog.Error("database auto cleanup failed", "error", err)
continue
}
if summary == nil {
continue
}
totalDeleted := int64(0)
for _, item := range summary.Results {
totalDeleted += item.DeletedCount
}
slog.Info(
"database auto cleanup completed",
"retention_days",
summary.RetentionDays,
"deleted_count",
totalDeleted,
)
}
}()
}
func nextDatabaseAutoCleanupTime(now time.Time) time.Time {
next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location())
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next
}
func deleteAllObservabilityRows(target string) (int64, error) {
switch target {
case DatabaseCleanupTargetAccessLogs:
return model.DeleteAllNodeAccessLogs(nil)
case DatabaseCleanupTargetMetricSnapshots:
return model.DeleteAllNodeMetricSnapshots(nil)
case DatabaseCleanupTargetRequestReports:
return model.DeleteAllNodeRequestReports(nil)
default:
return 0, errors.New("unsupported cleanup target")
}
}
func deleteObservabilityRowsBefore(target string, cutoff time.Time) (int64, error) {
switch target {
case DatabaseCleanupTargetAccessLogs:
return model.DeleteNodeAccessLogsBefore(cutoff)
case DatabaseCleanupTargetMetricSnapshots:
return model.DeleteNodeMetricSnapshotsBefore(nil, cutoff)
case DatabaseCleanupTargetRequestReports:
return model.DeleteNodeRequestReportsBefore(nil, cutoff)
default:
return 0, errors.New("unsupported cleanup target")
}
}
@@ -0,0 +1,165 @@
package service
import (
"openflare/common"
"openflare/model"
"testing"
"time"
)
func TestCleanupDatabaseObservabilityDeletesTargetedRows(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create(&model.NodeMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-10 * 24 * time.Hour),
CPUUsagePercent: 10,
}).Error; err != nil {
t.Fatalf("seed old metric snapshot: %v", err)
}
if err := model.DB.Create(&model.NodeMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-12 * time.Hour),
CPUUsagePercent: 20,
}).Error; err != nil {
t.Fatalf("seed recent metric snapshot: %v", err)
}
retentionDays := 7
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
Target: DatabaseCleanupTargetMetricSnapshots,
RetentionDays: &retentionDays,
})
if err != nil {
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
}
if result.DeleteAll {
t.Fatal("expected retention cleanup instead of delete_all")
}
if result.DeletedCount != 1 {
t.Fatalf("expected 1 deleted row, got %+v", result)
}
rows, err := model.ListMetricSnapshotsSince(time.Time{})
if err != nil {
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
}
if len(rows) != 1 || rows[0].CPUUsagePercent != 20 {
t.Fatalf("unexpected remaining metric snapshots: %+v", rows)
}
}
func TestCleanupDatabaseObservabilityDeletesAllRowsWhenRetentionMissing(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create(&model.NodeAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-3 * time.Hour),
RemoteAddr: "203.0.113.1",
Host: "example.com",
Path: "/one",
StatusCode: 200,
}).Error; err != nil {
t.Fatalf("seed first access log: %v", err)
}
if err := model.DB.Create(&model.NodeAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-2 * time.Hour),
RemoteAddr: "203.0.113.2",
Host: "example.com",
Path: "/two",
StatusCode: 502,
}).Error; err != nil {
t.Fatalf("seed second access log: %v", err)
}
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
Target: DatabaseCleanupTargetAccessLogs,
})
if err != nil {
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
}
if !result.DeleteAll || result.DeletedCount != 2 {
t.Fatalf("unexpected delete-all result: %+v", result)
}
rows, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected all access logs deleted, got %+v", rows)
}
}
func TestRunDatabaseAutoCleanupOnceDeletesAllObservabilityTargets(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create(&model.NodeAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-48 * time.Hour),
RemoteAddr: "203.0.113.10",
Host: "example.com",
Path: "/access",
StatusCode: 200,
}).Error; err != nil {
t.Fatalf("seed access log: %v", err)
}
if err := model.DB.Create(&model.NodeMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-48 * time.Hour),
CPUUsagePercent: 10,
}).Error; err != nil {
t.Fatalf("seed metric snapshot: %v", err)
}
if err := model.DB.Create(&model.NodeRequestReport{
NodeID: "node-a",
WindowStartedAt: now.Add(-49 * time.Hour),
WindowEndedAt: now.Add(-48 * time.Hour),
RequestCount: 15,
}).Error; err != nil {
t.Fatalf("seed request report: %v", err)
}
previousEnabled := common.DatabaseAutoCleanupEnabled
previousRetentionDays := common.DatabaseAutoCleanupRetentionDays
common.DatabaseAutoCleanupEnabled = true
common.DatabaseAutoCleanupRetentionDays = 1
t.Cleanup(func() {
common.DatabaseAutoCleanupEnabled = previousEnabled
common.DatabaseAutoCleanupRetentionDays = previousRetentionDays
})
summary, err := RunDatabaseAutoCleanupOnce(now)
if err != nil {
t.Fatalf("RunDatabaseAutoCleanupOnce failed: %v", err)
}
if summary == nil || len(summary.Results) != 3 {
t.Fatalf("unexpected auto cleanup summary: %+v", summary)
}
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(accessLogs) != 0 {
t.Fatalf("expected auto cleanup to delete access logs, got %+v", accessLogs)
}
metricSnapshots, err := model.ListMetricSnapshotsSince(time.Time{})
if err != nil {
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
}
if len(metricSnapshots) != 0 {
t.Fatalf("expected auto cleanup to delete metric snapshots, got %+v", metricSnapshots)
}
requestReports, err := model.ListRequestReportsSince(time.Time{})
if err != nil {
t.Fatalf("ListRequestReportsSince failed: %v", err)
}
if len(requestReports) != 0 {
t.Fatalf("expected auto cleanup to delete request reports, got %+v", requestReports)
}
}
+345 -16
View File
@@ -63,14 +63,32 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
if !strings.Contains(result.Version.MainConfig, "listen __OPENFLARE_OBSERVABILITY_LISTEN__;") {
t.Fatal("expected main config to include managed openresty observability listen placeholder")
}
if strings.Contains(result.Version.MainConfig, "resolver ") {
t.Fatal("expected main config to omit resolver directive when no resolvers are configured")
}
if !strings.Contains(result.Version.MainConfig, "use epoll;") {
t.Fatal("expected main config to default to epoll event model")
}
if !strings.Contains(result.Version.MainConfig, "multi_accept on;") {
t.Fatal("expected main config to default multi_accept to on")
}
if !strings.Contains(result.Version.MainConfig, "keepalive_timeout 20;") {
t.Fatal("expected main config to default keepalive_timeout to 20")
}
if !strings.Contains(result.Version.MainConfig, "proxy_connect_timeout 3;") {
t.Fatal("expected main config to default proxy_connect_timeout to 3")
}
if strings.Contains(result.Version.MainConfig, "allow 127.0.0.1;") {
t.Fatal("expected main config to avoid hard-coded allow rules on observability server")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
t.Fatal("expected rendered config to include https server block")
t.Fatal("expected rendered config to include https ssl listener")
}
if !strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) {
t.Fatal("expected rendered config to reject unmatched host headers with 404")
if !strings.Contains(result.Version.RenderedConfig, "http2 on;") {
t.Fatal("expected rendered config to enable http2 with dedicated directive")
}
if strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) {
t.Fatal("expected rendered config to avoid per-route host guard")
}
if !strings.Contains(result.Version.RenderedConfig, "return 301 https://$host$request_uri;") {
t.Fatal("expected rendered config to include http redirect")
@@ -135,14 +153,304 @@ 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")
}
}
func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "git.arctel.de",
OriginURL: "https://git.arctel.net",
OriginHost: "git.arctel.net",
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, `proxy_set_header Host "git.arctel.net";`) {
t.Fatal("expected rendered config to override host header for origin routing")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_ssl_server_name on;") {
t.Fatal("expected rendered config to enable proxy ssl server name for https origin")
}
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 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",
@@ -159,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) {
@@ -314,7 +622,7 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
}
}
func TestRenderConfigRejectsUnknownSubdomainHosts(t *testing.T) {
func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
@@ -334,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")
}
}
@@ -386,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,
@@ -404,6 +724,15 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
t.Fatal("expected template without managed access log placeholder to fail validation")
}
invalidTemplate = strings.ReplaceAll(
common.OpenRestyMainConfigTemplate,
"{{OpenRestyConnectionUpgradeMap}}",
"",
)
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
t.Fatal("expected template without managed websocket upgrade map placeholder to fail validation")
}
}
func TestOpenRestyCommonRequestOptionsRender(t *testing.T) {
+4 -4
View File
@@ -418,9 +418,9 @@ func normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload {
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.LastError = strings.TrimSpace(payload.LastError)
payload.LastError = truncateForDatabase(payload.LastError, 16000)
payload.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
payload.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
payload.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
return payload
}
@@ -444,11 +444,11 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b
node.AgentVersion = strings.TrimSpace(payload.AgentVersion)
node.NginxVersion = strings.TrimSpace(payload.NginxVersion)
node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
node.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
node.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
node.Status = NodeStatusOnline
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
node.LastSeenAt = time.Now()
node.LastError = strings.TrimSpace(payload.LastError)
node.LastError = truncateForDatabase(payload.LastError, 16000)
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
}
@@ -42,6 +42,11 @@ type NodeObservabilityTrends struct {
DiskIO24h []DiskIOTrendPoint `json:"disk_io_24h"`
}
type NodeHealthEventCleanupResult struct {
NodeID string `json:"node_id"`
DeletedCount int64 `json:"deleted_count"`
}
func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) {
now := time.Now()
node, err := model.GetNodeByID(id)
@@ -105,6 +110,21 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
}, nil
}
func CleanupNodeHealthEvents(id uint) (*NodeHealthEventCleanupResult, error) {
node, err := model.GetNodeByID(id)
if err != nil {
return nil, err
}
deletedCount, err := model.DeleteNodeHealthEvents(node.NodeID)
if err != nil {
return nil, err
}
return &NodeHealthEventCleanupResult{
NodeID: node.NodeID,
DeletedCount: deletedCount,
}, nil
}
func latestMetricSnapshot(snapshots []*model.NodeMetricSnapshot) *model.NodeMetricSnapshot {
for _, snapshot := range snapshots {
if snapshot != nil {
+127 -10
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"io"
"net"
"net/http"
@@ -677,6 +678,9 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
Severity: NodeHealthSeverityCritical,
Message: "reload failed",
TriggeredAtUnix: time.Now().Add(-2 * time.Minute).Unix(),
Metadata: map[string]string{
"source": "runtime",
},
},
},
})
@@ -708,7 +712,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)
}
@@ -726,6 +735,16 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
if len(events) != 1 || events[0].EventType != "openresty_unhealthy" {
t.Fatalf("unexpected active health events: %+v", events)
}
if events[0].MetadataJSON == "" {
t.Fatal("expected metadata_json to persist")
}
var metadata map[string]string
if err := json.Unmarshal([]byte(events[0].MetadataJSON), &metadata); err != nil {
t.Fatalf("expected metadata_json to be valid json: %v", err)
}
if metadata["source"] != "runtime" {
t.Fatalf("unexpected metadata json: %+v", metadata)
}
}
func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
@@ -822,7 +841,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)
}
@@ -858,6 +882,15 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
TopDomains: map[string]int64{"edge.example.com": 40},
SourceCountries: map[string]int64{"CN": 20},
},
AccessLogs: []AgentNodeAccessLog{
{
LoggedAtUnix: now.Add(-110 * time.Second).Unix(),
RemoteAddr: "203.0.113.21",
Host: "edge.example.com",
Path: "/buffered",
StatusCode: 200,
},
},
},
},
})
@@ -879,6 +912,18 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
if len(reports) != 2 {
t.Fatalf("expected replay dedupe to keep report count stable, got %+v", reports)
}
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 after replay: %v", err)
}
if len(accessLogs) != 1 {
t.Fatalf("expected replay dedupe to keep access log count stable, got %+v", accessLogs)
}
}
func TestListAccessLogsUsesPagination(t *testing.T) {
@@ -898,7 +943,7 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
}
now := time.Now().UTC()
if err := model.DB.Create([]*model.NodeAccessLog{
for _, item := range []*model.NodeAccessLog{
{
NodeID: node.NodeID,
LoggedAt: now.Add(-10 * time.Second),
@@ -926,11 +971,17 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
Path: "/three",
StatusCode: 502,
},
}).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
} {
if err := model.DB.Create(item).Error; err != nil {
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 +995,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 +1238,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)
@@ -1326,7 +1441,7 @@ func TestGetDashboardOverview(t *testing.T) {
}).Insert(); err != nil {
t.Fatalf("failed to insert node b traffic report: %v", err)
}
if err := model.DB.Create([]*model.NodeAccessLog{
for _, item := range []*model.NodeAccessLog{
{
NodeID: "node-dashboard-a",
LoggedAt: now.Add(-30 * time.Minute),
@@ -1354,8 +1469,10 @@ func TestGetDashboardOverview(t *testing.T) {
Path: "/edge",
StatusCode: 502,
},
}).Error; err != nil {
t.Fatalf("failed to seed dashboard access logs: %v", err)
} {
if err := model.DB.Create(item).Error; err != nil {
t.Fatalf("failed to seed dashboard access logs: %v", err)
}
}
if err := model.DB.Create(&model.NodeHealthEvent{
+37 -22
View File
@@ -17,7 +17,8 @@ const (
NodeHealthSeverityInfo = "info"
NodeHealthSeverityWarning = "warning"
NodeHealthSeverityCritical = "critical"
nodeAccessLogRetentionWindow = 24 * time.Hour
nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
nodeAccessLogPathMaxLength = 100
)
type AgentNodeSystemProfile struct {
@@ -151,7 +152,6 @@ func persistNodeSystemProfile(tx *gorm.DB, nodeID string, profile *AgentNodeSyst
TotalDiskBytes: profile.TotalDiskBytes,
UptimeSeconds: profile.UptimeSeconds,
ReportedAt: timeFromUnix(profile.ReportedAtUnix, reportedAt),
RawJSON: marshalJSON(profile),
}
return tx.Model(&model.NodeSystemProfile{}).Where("node_id = ?", nodeID).Assign(record).FirstOrCreate(record).Error
}
@@ -175,9 +175,15 @@ func persistNodeMetricSnapshot(tx *gorm.DB, nodeID string, snapshot *AgentNodeMe
OpenrestyRxBytes: snapshot.OpenrestyRxBytes,
OpenrestyTxBytes: snapshot.OpenrestyTxBytes,
OpenrestyConnections: snapshot.OpenrestyConnections,
RawJSON: marshalJSON(snapshot),
}
return tx.Where("node_id = ? AND captured_at = ?", nodeID, record.CapturedAt).Assign(record).FirstOrCreate(record).Error
exists, err := model.NodeMetricSnapshotExists(tx, nodeID, record.CapturedAt)
if err != nil {
return err
}
if exists {
return nil
}
return tx.Create(record).Error
}
func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTrafficReport, reportedAt time.Time) error {
@@ -197,9 +203,15 @@ func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTraff
StatusCodesJSON: marshalJSON(report.StatusCodes),
TopDomainsJSON: marshalJSON(report.TopDomains),
SourceCountriesJSON: marshalJSON(report.SourceCountries),
RawJSON: marshalJSON(report),
}
return tx.Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, record.WindowStartedAt, record.WindowEndedAt).Assign(record).FirstOrCreate(record).Error
exists, err := model.NodeRequestReportExists(tx, nodeID, record.WindowStartedAt, record.WindowEndedAt)
if err != nil {
return err
}
if exists {
return nil
}
return tx.Create(record).Error
}
func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog, reportedAt time.Time) error {
@@ -220,26 +232,25 @@ func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog
RemoteAddr: strings.TrimSpace(item.RemoteAddr),
Region: "",
Host: strings.TrimSpace(item.Host),
Path: strings.TrimSpace(item.Path),
Path: truncateForDatabase(strings.TrimSpace(item.Path), nodeAccessLogPathMaxLength),
StatusCode: item.StatusCode,
RawJSON: marshalJSON(item),
}
if resolver != nil {
record.Region = resolver.Resolve(record.RemoteAddr)
}
if err := tx.Where(
"node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?",
nodeID,
record.LoggedAt,
record.RemoteAddr,
record.Host,
record.Path,
record.StatusCode,
).Assign(record).FirstOrCreate(record).Error; err != nil {
exists, err := model.NodeAccessLogExists(tx, record)
if err != nil {
return err
}
if exists {
continue
}
if err := tx.Create(record).Error; err != nil {
return err
}
}
return tx.Where("node_id = ? AND logged_at < ?", nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow)).Delete(&model.NodeAccessLog{}).Error
_, err = model.DeleteNodeAccessLogsByNodeBefore(tx, nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow))
return err
}
func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHealthEvent, reportedAt time.Time) error {
@@ -271,10 +282,10 @@ 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)
existing.MetadataJSON = marshalJSON(event.Metadata)
existing.ResolvedAt = nil
if err := tx.Save(existing).Error; err != nil {
return err
@@ -286,11 +297,11 @@ 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,
RawJSON: marshalJSON(event),
MetadataJSON: marshalJSON(event.Metadata),
}
if err := tx.Create(record).Error; err != nil {
return err
@@ -330,6 +341,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
+221 -2
View File
@@ -11,6 +11,13 @@ import (
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
const (
proxyRouteCachePolicyURL = "url"
proxyRouteCachePolicySuffix = "suffix"
proxyRouteCachePolicyPathPrefix = "path_prefix"
proxyRouteCachePolicyPathExact = "path_exact"
)
type ProxyRouteCustomHeaderInput struct {
Key string `json:"key"`
Value string `json:"value"`
@@ -19,10 +26,15 @@ type ProxyRouteCustomHeaderInput struct {
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"`
}
@@ -74,11 +86,29 @@ func DeleteProxyRoute(id uint) error {
func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) {
domain := strings.ToLower(strings.TrimSpace(input.Domain))
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
@@ -89,7 +119,7 @@ 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 {
if err := validateOriginHost(originHost); err != nil {
return nil, err
}
if !input.EnableHTTPS {
@@ -111,11 +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
@@ -149,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 == "" {
@@ -161,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("源站地址不能为空")
@@ -178,6 +380,23 @@ func validateOriginURL(raw string) error {
return nil
}
func validateOriginHost(raw string) error {
if raw == "" {
return nil
}
if strings.ContainsAny(raw, "/\\ \t\r\n") || strings.Contains(raw, "://") {
return errors.New("回源主机名格式不合法")
}
parsed, err := url.Parse("//" + raw)
if err != nil || parsed.Host == "" || parsed.Host != raw {
return errors.New("回源主机名格式不合法")
}
if parsed.Hostname() == "" {
return errors.New("回源主机名格式不合法")
}
return nil
}
func isUniqueConstraintError(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique")
}
@@ -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`, {
method: 'PUT',
});
}
export function publishConfigVersion() {
return apiRequest<ConfigVersionDetail>('/config-versions/publish', {
method: 'POST',
});
}
export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
method: 'POST',
});
}
@@ -1,20 +1,41 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import type { ConfigVersionItem } from '@/features/config-versions/types';
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
import type { ConfigVersionSummary } from '@/features/config-versions/types';
import {
CodeBlock,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function ConfigVersionSnapshotModal({
version,
onClose,
}: {
version: ConfigVersionItem | null;
version: ConfigVersionSummary | null;
onClose: () => void;
}) {
const versionDetailQuery = useQuery({
queryKey: ['config-versions', 'detail', version?.id ?? 0],
queryFn: () => {
if (!version) {
throw new Error('missing config version');
}
return getConfigVersion(version.id);
},
enabled: Boolean(version?.id),
});
const versionDetail = versionDetailQuery.data ?? null;
return (
<AppModal
isOpen={Boolean(version)}
@@ -30,7 +51,14 @@ export function ConfigVersionSnapshotModal({
</div>
}
>
{version ? (
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
<LoadingState />
) : versionDetailQuery.isError ? (
<ErrorState
title="配置版本详情加载失败"
description={getErrorMessage(versionDetailQuery.error)}
/>
) : versionDetail ? (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -38,7 +66,7 @@ export function ConfigVersionSnapshotModal({
Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{version.checksum}
{versionDetail.checksum}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -46,7 +74,7 @@ export function ConfigVersionSnapshotModal({
创建人
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{version.created_by || '系统'}
{versionDetail.created_by || '系统'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -54,7 +82,7 @@ export function ConfigVersionSnapshotModal({
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(version.created_at)}
{formatDateTime(versionDetail.created_at)}
</p>
</div>
</div>
@@ -64,23 +92,25 @@ export function ConfigVersionSnapshotModal({
快照 JSON
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json}
{versionDetail.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.main_config}
{versionDetail.main_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config}
{versionDetail.rendered_config}
</CodeBlock>
</div>
</div>
@@ -12,6 +12,7 @@ import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
activateConfigVersion,
getActiveConfigVersion,
getConfigVersionDiff,
getConfigVersionPreview,
getConfigVersions,
@@ -22,7 +23,8 @@ import type {
ConfigOptionDiffItem,
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
ConfigVersionDetail,
ConfigVersionSummary,
SupportFile,
} from '@/features/config-versions/types';
import {
@@ -168,14 +170,20 @@ function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
function PublishPreviewCard({
preview,
diff,
activeVersion,
activeVersionMeta,
activeVersionDetail,
isActiveVersionDetailLoading,
activeVersionDetailError,
isPublishing,
onConfirm,
onCancel,
}: {
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
activeVersion: ConfigVersionItem | null;
activeVersionMeta: ConfigVersionSummary | null;
activeVersionDetail: ConfigVersionDetail | null;
isActiveVersionDetailLoading: boolean;
activeVersionDetailError: string | null;
isPublishing: boolean;
onConfirm: () => void;
onCancel: () => void;
@@ -263,18 +271,29 @@ function PublishPreviewCard({
<OptionDiffTable items={diff.changed_option_details} />
</div>
{diff.main_config_changed && activeVersion ? (
{diff.main_config_changed && activeVersionMeta ? (
<div className="grid gap-5 xl:grid-cols-2">
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Current Active Main Config
</p>
<StatusBadge label={activeVersion.version} variant="info" />
<StatusBadge label={activeVersionMeta.version} variant="info" />
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersion.main_config}
</CodeBlock>
{isActiveVersionDetailLoading ? (
<LoadingState />
) : activeVersionDetailError ? (
<InlineMessage tone="danger" message={activeVersionDetailError} />
) : activeVersionDetail ? (
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersionDetail.main_config}
</CodeBlock>
) : (
<InlineMessage
tone="info"
message="当前激活版本详情暂不可用。"
/>
)}
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
@@ -406,6 +425,14 @@ export function ConfigVersionsPage() {
() => versions.find((item) => item.id === selectedVersionId) ?? null,
[selectedVersionId, versions],
);
const shouldLoadActiveVersionDetail = Boolean(
activeVersion?.id && publishPreview?.diff.main_config_changed,
);
const activeVersionDetailQuery = useQuery({
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
queryFn: getActiveConfigVersion,
enabled: shouldLoadActiveVersionDetail,
});
const publishMutation = useMutation({
mutationFn: publishConfigVersion,
@@ -455,7 +482,7 @@ export function ConfigVersionsPage() {
}
};
const handleActivate = (version: ConfigVersionItem) => {
const handleActivate = (version: ConfigVersionSummary) => {
if (version.is_active) {
return;
}
@@ -493,7 +520,14 @@ export function ConfigVersionsPage() {
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
activeVersion={activeVersion}
activeVersionMeta={activeVersion}
activeVersionDetail={activeVersionDetailQuery.data ?? null}
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
activeVersionDetailError={
activeVersionDetailQuery.isError
? getErrorMessage(activeVersionDetailQuery.error)
: null
}
isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)}
@@ -1,16 +1,19 @@
export interface ConfigVersionItem {
export interface ConfigVersionSummary {
id: number;
version: string;
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
checksum: string;
is_active: boolean;
created_by: string;
created_at: string;
}
export interface ConfigVersionDetail extends ConfigVersionSummary {
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
}
export interface SupportFile {
path: string;
content: string;
@@ -1,38 +1,229 @@
import { apiRequest } from '@/lib/api/client';
import type { DashboardOverview } from '@/features/dashboard/types';
import type {
CompactCapacityTrendPoint,
CompactDashboardNodeHealth,
CompactDiskIOTrendPoint,
CompactDistributionItem,
CompactNetworkTrendPoint,
CompactTrafficTrendPoint,
DashboardCapacity,
DashboardNodeHealth,
DashboardOverview,
DashboardOverviewCompact,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/features/dashboard/types';
function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : [];
}
function isCompactDistributionItem(
value: DistributionItem | CompactDistributionItem,
): value is CompactDistributionItem {
return Array.isArray(value);
}
function isCompactTrafficTrendPoint(
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint,
): value is CompactTrafficTrendPoint {
return Array.isArray(value);
}
function isCompactCapacityTrendPoint(
value:
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint,
): value is CompactCapacityTrendPoint {
return Array.isArray(value);
}
function isCompactNetworkTrendPoint(
value:
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint,
): value is CompactNetworkTrendPoint {
return Array.isArray(value);
}
function isCompactDiskIOTrendPoint(
value:
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint,
): value is CompactDiskIOTrendPoint {
return Array.isArray(value);
}
function isCompactDashboardNode(
value: DashboardNodeHealth | CompactDashboardNodeHealth,
): value is CompactDashboardNodeHealth {
return Array.isArray(value);
}
function normalizeDistributionItems(
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
): DistributionItem[] {
return arrayOrEmpty(items).map((item) =>
isCompactDistributionItem(item)
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
: item,
);
}
function normalizeTrafficTrendPoints(
items:
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactTrafficTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
request_count: Number(item[1] ?? 0),
error_count: Number(item[2] ?? 0),
unique_visitor_count: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeCapacityTrendPoints(
items:
| Array<
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactCapacityTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
average_cpu_usage_percent: Number(item[1] ?? 0),
average_memory_usage_percent: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeNetworkTrendPoints(
items:
| Array<
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactNetworkTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
network_rx_bytes: Number(item[1] ?? 0),
network_tx_bytes: Number(item[2] ?? 0),
openresty_rx_bytes: Number(item[3] ?? 0),
openresty_tx_bytes: Number(item[4] ?? 0),
reported_nodes: Number(item[5] ?? 0),
}
: item,
);
}
function normalizeDiskIOTrendPoints(
items:
| Array<
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactDiskIOTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
disk_read_bytes: Number(item[1] ?? 0),
disk_write_bytes: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeDashboardNodes(
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item)
? {
id: Number(item[0] ?? 0),
node_id: String(item[1] ?? ''),
name: String(item[2] ?? ''),
geo_name: String(item[3] ?? ''),
geo_latitude:
item[4] === null || item[4] === undefined ? null : Number(item[4]),
geo_longitude:
item[5] === null || item[5] === undefined ? null : Number(item[5]),
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
openresty_status: (item[7] ??
'unknown') as DashboardNodeHealth['openresty_status'],
current_version: String(item[8] ?? ''),
last_seen_at: String(item[9] ?? ''),
active_event_count: Number(item[10] ?? 0),
cpu_usage_percent: Number(item[11] ?? 0),
memory_usage_percent: Number(item[12] ?? 0),
storage_usage_percent: Number(item[13] ?? 0),
request_count: Number(item[14] ?? 0),
error_count: Number(item[15] ?? 0),
unique_visitor_count: Number(item[16] ?? 0),
}
: item,
);
}
function normalizeDashboardOverview(
overview: DashboardOverview | null | undefined,
overview:
| DashboardOverview
| DashboardOverviewCompact
| null
| undefined,
): DashboardOverview | null {
if (!overview) {
return null;
}
const summary = (overview.summary ?? {}) as DashboardSummary;
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
return {
...overview,
nodes: arrayOrEmpty(overview.nodes),
generated_at: String(overview.generated_at ?? ''),
summary,
traffic,
capacity,
nodes: normalizeDashboardNodes(overview.nodes),
distributions: {
...overview.distributions,
source_countries: arrayOrEmpty(overview.distributions?.source_countries),
status_codes: arrayOrEmpty(overview.distributions?.status_codes),
top_domains: arrayOrEmpty(overview.distributions?.top_domains),
source_countries: normalizeDistributionItems(
overview.distributions?.source_countries,
),
status_codes: normalizeDistributionItems(overview.distributions?.status_codes),
top_domains: normalizeDistributionItems(overview.distributions?.top_domains),
},
trends: {
...overview.trends,
traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h),
capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h),
network_24h: arrayOrEmpty(overview.trends?.network_24h),
disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h),
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
},
};
}
export async function getDashboardOverview() {
const overview = await apiRequest<DashboardOverview>('/dashboard/overview');
const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>(
'/dashboard/overview',
);
return normalizeDashboardOverview(overview);
}
@@ -27,6 +27,8 @@ export interface DistributionItem {
value: number;
}
export type CompactDistributionItem = [string, number];
export interface TrafficTrendPoint {
bucket_started_at: string;
request_count: number;
@@ -99,3 +101,53 @@ export interface DashboardOverview {
trends: DashboardTrends;
nodes: DashboardNodeHealth[];
}
export type CompactTrafficTrendPoint = [string, number, number, number];
export type CompactCapacityTrendPoint = [string, number, number, number];
export type CompactNetworkTrendPoint = [
string,
number,
number,
number,
number,
number,
];
export type CompactDiskIOTrendPoint = [string, number, number, number];
export type CompactDashboardNodeHealth = [
number,
string,
string,
string,
number | null,
number | null,
DashboardNodeHealth['status'],
DashboardNodeHealth['openresty_status'],
string,
string,
number,
number,
number,
number,
number,
number,
number,
];
export interface DashboardOverviewCompact {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: {
status_codes: CompactDistributionItem[];
top_domains: CompactDistributionItem[];
source_countries: CompactDistributionItem[];
};
trends: {
traffic_24h: CompactTrafficTrendPoint[];
capacity_24h: CompactCapacityTrendPoint[];
network_24h: CompactNetworkTrendPoint[];
disk_io_24h: CompactDiskIOTrendPoint[];
};
nodes: CompactDashboardNodeHealth[];
}
@@ -18,15 +18,15 @@ export function createManagedDomain(payload: ManagedDomainMutationPayload) {
}
export function updateManagedDomain(id: number, payload: ManagedDomainMutationPayload) {
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}`, {
method: 'PUT',
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteManagedDomain(id: number) {
return apiRequest<void>(`/managed-domains/${id}`, {
method: 'DELETE',
return apiRequest<void>(`/managed-domains/${id}/delete`, {
method: 'POST',
});
}
@@ -22,15 +22,15 @@ export function createNode(payload: NodeMutationPayload) {
}
export function updateNode(id: number, payload: NodeMutationPayload) {
return apiRequest<NodeItem>(`/nodes/${id}`, {
method: 'PUT',
return apiRequest<NodeItem>(`/nodes/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteNode(id: number) {
return apiRequest<void>(`/nodes/${id}`, {
method: 'DELETE',
return apiRequest<void>(`/nodes/${id}/delete`, {
method: 'POST',
});
}
@@ -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)}
+196 -195
View File
@@ -1,205 +1,206 @@
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[];
}
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;
metadata_json?: 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;
}
export interface NodeObservability {
node_id: string;
profile: NodeSystemProfile | null;
metric_snapshots: NodeMetricSnapshot[];
traffic_reports: NodeTrafficReport[];
health_events: NodeHealthEvent[];
analytics: NodeObservabilityAnalytics;
trends: NodeObservabilityTrends;
}
+169 -161
View File
@@ -1,161 +1,169 @@
import type { NodeItem } from '@/features/nodes/types';
export function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
if (node.update_channel === 'preview') {
return { label: '等待预览更新', variant: 'warning' as const };
}
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动', variant: 'success' as const };
}
return { label: '手动', variant: 'info' as const };
}
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
function parseVersionParts(version: string) {
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') {
return null;
}
return normalized.split('.').map((segment) => {
const matched = segment.trim().match(/^\d+/);
return matched ? Number.parseInt(matched[0], 10) : 0;
});
}
function isOlderVersion(current: string, target: string) {
const currentParts = parseVersionParts(current);
const targetParts = parseVersionParts(target);
if (!currentParts || !targetParts) {
return false;
}
const maxLength = Math.max(currentParts.length, targetParts.length);
for (let index = 0; index < maxLength; index += 1) {
const currentPart = currentParts[index] ?? 0;
const targetPart = targetParts[index] ?? 0;
if (currentPart < targetPart) {
return true;
}
if (currentPart > targetPart) {
return false;
}
}
return false;
}
export function shouldShowManualUpdate(
agentVersion: string,
serverVersion: string,
) {
const normalizedServerVersion = serverVersion.trim();
const normalizedAgentVersion = agentVersion.trim();
if (
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' ||
!normalizedAgentVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown'
) {
return false;
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
import type { NodeItem } from '@/features/nodes/types';
export function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'warning') {
return 'warning';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'warning') {
return '警告';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
if (node.update_channel === 'preview') {
return { label: '等待预览更新', variant: 'warning' as const };
}
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动', variant: 'success' as const };
}
return { label: '手动', variant: 'info' as const };
}
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
function parseVersionParts(version: string) {
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') {
return null;
}
return normalized.split('.').map((segment) => {
const matched = segment.trim().match(/^\d+/);
return matched ? Number.parseInt(matched[0], 10) : 0;
});
}
function isOlderVersion(current: string, target: string) {
const currentParts = parseVersionParts(current);
const targetParts = parseVersionParts(target);
if (!currentParts || !targetParts) {
return false;
}
const maxLength = Math.max(currentParts.length, targetParts.length);
for (let index = 0; index < maxLength; index += 1) {
const currentPart = currentParts[index] ?? 0;
const targetPart = targetParts[index] ?? 0;
if (currentPart < targetPart) {
return true;
}
if (currentPart > targetPart) {
return false;
}
}
return false;
}
export function shouldShowManualUpdate(
agentVersion: string,
serverVersion: string,
) {
const normalizedServerVersion = serverVersion.trim();
const normalizedAgentVersion = agentVersion.trim();
if (
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' ||
!normalizedAgentVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown'
) {
return false;
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
@@ -34,16 +34,16 @@ const defaultPerformanceFields = {
OpenRestyWorkerProcesses: 'auto',
OpenRestyWorkerConnections: '4096',
OpenRestyWorkerRlimitNofile: '65535',
OpenRestyEventsUse: '',
OpenRestyEventsMultiAcceptEnabled: false,
OpenRestyKeepaliveTimeout: '65',
OpenRestyEventsUse: 'epoll',
OpenRestyEventsMultiAcceptEnabled: true,
OpenRestyKeepaliveTimeout: '20',
OpenRestyKeepaliveRequests: '1000',
OpenRestyClientHeaderTimeout: '15',
OpenRestyClientBodyTimeout: '15',
OpenRestyClientMaxBodySize: '64m',
OpenRestyLargeClientHeaderBuffers: '4 16k',
OpenRestySendTimeout: '30',
OpenRestyProxyConnectTimeout: '5',
OpenRestyProxyConnectTimeout: '3',
OpenRestyProxySendTimeout: '60',
OpenRestyProxyReadTimeout: '60',
OpenRestyWebsocketEnabled: true,
@@ -60,7 +60,7 @@ const defaultPerformanceFields = {
OpenRestyCacheLevels: '1:2',
OpenRestyCacheInactive: '30m',
OpenRestyCacheMaxSize: '1g',
OpenRestyCacheKeyTemplate: '$scheme$proxy_host$request_uri',
OpenRestyCacheKeyTemplate: '$scheme$host$request_uri',
OpenRestyCacheLockEnabled: true,
OpenRestyCacheLockTimeout: '5s',
OpenRestyCacheUseStale:
@@ -75,9 +75,9 @@ const performanceFieldTooltips: Record<string, string> = {
worker_rlimit_nofile:
'提升 worker 可打开的文件描述符上限,避免高并发下连接或文件句柄不足。',
events_use:
'指定事件驱动模型。Linux 常见是 epoll,留空时由 OpenResty 自动选择。',
'指定事件驱动模型。默认使用 epoll,Linux 高并发场景通常优先选择它。',
multi_accept:
'开启后,worker 会尽可能一次接受多个新连接,适合高吞吐接入场景。',
'默认开启。worker 会尽可能一次接受多个新连接,适合高吞吐接入场景。',
keepalive_timeout: '客户端 Keep-Alive 空闲保持时间,单位秒。',
keepalive_requests: '单个长连接允许复用的最大请求数。',
client_header_timeout: '读取客户端请求头的超时时间,单位秒。',
@@ -203,12 +203,12 @@ export function PerformancePage() {
optionMap.OpenRestyWorkerConnections ?? '4096',
OpenRestyWorkerRlimitNofile:
optionMap.OpenRestyWorkerRlimitNofile ?? '65535',
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? '',
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? 'epoll',
OpenRestyEventsMultiAcceptEnabled: toBoolean(
optionMap.OpenRestyEventsMultiAcceptEnabled,
false,
true,
),
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '65',
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '20',
OpenRestyKeepaliveRequests:
optionMap.OpenRestyKeepaliveRequests ?? '1000',
OpenRestyClientHeaderTimeout:
@@ -219,7 +219,7 @@ export function PerformancePage() {
optionMap.OpenRestyLargeClientHeaderBuffers ?? '4 16k',
OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30',
OpenRestyProxyConnectTimeout:
optionMap.OpenRestyProxyConnectTimeout ?? '5',
optionMap.OpenRestyProxyConnectTimeout ?? '3',
OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60',
OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60',
OpenRestyWebsocketEnabled: toBoolean(
@@ -247,7 +247,7 @@ export function PerformancePage() {
OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? '30m',
OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? '1g',
OpenRestyCacheKeyTemplate:
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$proxy_host$request_uri',
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$host$request_uri',
OpenRestyCacheLockEnabled: toBoolean(
optionMap.OpenRestyCacheLockEnabled,
true,
@@ -18,18 +18,18 @@ export function createProxyRoute(payload: ProxyRouteMutationPayload) {
});
}
export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteProxyRoute(id: number) {
return apiRequest<void>(`/proxy-routes/${id}`, {
method: 'DELETE',
});
}
export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteProxyRoute(id: number) {
return apiRequest<void>(`/proxy-routes/${id}/delete`, {
method: 'POST',
});
}
export function getTlsCertificates() {
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
@@ -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, '请输入域名'),
@@ -69,10 +71,32 @@ const proxyRouteSchema = z
return false;
}
}, '请输入合法的源站地址'),
origin_host: z
.string()
.trim()
.refine(
(value) =>
!value ||
(!/[\/\\\s]/.test(value) &&
!value.includes('://') &&
(() => {
try {
const parsed = new URL(`http://${value}`);
return parsed.host === value && Boolean(parsed.hostname);
} catch {
return false;
}
})()),
'请输入合法的回源主机名',
),
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 个字符'),
})
@@ -85,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();
@@ -129,10 +173,15 @@ type FeedbackState = {
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: '',
};
@@ -176,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)})`
@@ -186,11 +300,18 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
return {
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),
@@ -200,14 +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 || '',
};
@@ -267,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({
@@ -493,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>
@@ -503,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">
@@ -510,7 +649,15 @@ export function ProxyRoutesPage() {
{route.domain}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{route.origin_url}
<div className="space-y-1">
<p>{route.origin_url}</p>
<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">
{route.enable_https ? (
@@ -528,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={
@@ -633,6 +797,27 @@ export function ProxyRoutesPage() {
</ResourceField>
</div>
<ResourceField
label="回源主机名"
hint="可选。填写后将覆盖回源请求的 Host,留空则默认使用访问域名 $host"
error={form.formState.errors.origin_host?.message}
>
<ResourceInput
{...form.register('origin_host')}
/>
</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="启用规则"
@@ -644,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, {
@@ -703,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,30 +3,40 @@ export interface ProxyRouteCustomHeader {
value: string;
}
export interface ProxyRouteItem {
id: number;
domain: string;
origin_url: 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;
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;
@@ -2,6 +2,8 @@ import { apiRequest } from '@/lib/api/client';
import type {
BootstrapTokenPayload,
DatabaseCleanupPayload,
DatabaseCleanupResult,
GeoIPLookupResult,
OptionItem,
SettingsProfile,
@@ -13,8 +15,8 @@ export function getOptions() {
}
export function updateOption(key: string, value: string) {
return apiRequest<void>('/option/', {
method: 'PUT',
return apiRequest<void>('/option/update', {
method: 'POST',
body: JSON.stringify({ key, value }),
});
}
@@ -26,6 +28,13 @@ export function lookupGeoIP(provider: string, ip: string) {
});
}
export function cleanupDatabaseObservability(payload: DatabaseCleanupPayload) {
return apiRequest<DatabaseCleanupResult>('/option/database/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function getBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
}
@@ -41,8 +50,8 @@ export function getSettingsProfile() {
}
export function updateSelf(payload: UpdateSelfPayload) {
return apiRequest<void>('/user/self', {
method: 'PUT',
return apiRequest<void>('/user/self/update', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -7,6 +7,7 @@ import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { useAuth } from '@/components/providers/auth-provider';
import { PageHeader } from '@/components/layout/page-header';
@@ -17,6 +18,7 @@ import { getPublicStatus } from '@/features/auth/api/public';
import {
bindEmail,
bindWeChat,
cleanupDatabaseObservability,
generateAccessToken,
getBootstrapToken,
getOptions,
@@ -28,12 +30,15 @@ import {
} from '@/features/settings/api/settings';
import type {
BootstrapTokenPayload,
DatabaseCleanupResult,
DatabaseCleanupTarget,
GeoIPLookupResult,
OptionItem,
UpdateSelfPayload,
} from '@/features/settings/types';
import {
CodeBlock,
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
@@ -78,14 +83,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 +105,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:
@@ -126,6 +131,11 @@ const defaultOtherFields = {
Footer: '',
};
const defaultDatabaseFields = {
DatabaseAutoCleanupEnabled: false,
DatabaseAutoCleanupRetentionDays: '30',
};
const defaultProfileFields: UpdateSelfPayload = {
username: '',
display_name: '',
@@ -137,7 +147,17 @@ type FeedbackState = {
message: string;
};
type SettingsTab = 'personal' | 'operation' | 'system' | 'other';
type CleanupModalState = {
target: DatabaseCleanupTarget;
label: string;
};
type SettingsTab =
| 'personal'
| 'operation'
| 'database'
| 'system'
| 'other';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
@@ -227,12 +247,16 @@ export function SettingsPage() {
defaultOperationFields,
);
const [otherFields, setOtherFields] = useState(defaultOtherFields);
const [databaseFields, setDatabaseFields] = useState(defaultDatabaseFields);
const [accessToken, setAccessToken] = useState('');
const [wechatCode, setWeChatCode] = useState('');
const [emailAddress, setEmailAddress] = useState('');
const [emailCode, setEmailCode] = useState('');
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
const [cleanupModalState, setCleanupModalState] =
useState<CleanupModalState | null>(null);
const [cleanupRetentionDays, setCleanupRetentionDays] = useState('');
const isRoot = (user?.role ?? 0) >= 100;
@@ -348,12 +372,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 +385,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 +405,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,
@@ -410,6 +434,14 @@ export function SettingsPage() {
About: optionMap.About ?? '',
Footer: optionMap.Footer ?? '',
});
setDatabaseFields({
DatabaseAutoCleanupEnabled: toBoolean(
optionMap.DatabaseAutoCleanupEnabled,
false,
),
DatabaseAutoCleanupRetentionDays:
optionMap.DatabaseAutoCleanupRetentionDays ?? '30',
});
}, [optionsQuery.data, publicStatusQuery.data?.server_address]);
const rotateTokenMutation = useMutation({
@@ -451,6 +483,23 @@ export function SettingsPage() {
lookupGeoIP(provider, ip),
});
const databaseCleanupMutation = useMutation({
mutationFn: cleanupDatabaseObservability,
onSuccess: (result: DatabaseCleanupResult) => {
setCleanupModalState(null);
setCleanupRetentionDays('');
setFeedback({
tone: 'success',
message: result.delete_all
? `已清空${result.target_label}数据,共删除 ${result.deleted_count} 条。`
: `已清理${result.target_label}中超出保留期的数据,共删除 ${result.deleted_count} 条。`,
});
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
const discoveryCommand =
isRoot && operationFields.ServerAddress && discoveryToken
@@ -476,6 +525,11 @@ export function SettingsPage() {
label: '系统设置',
description: '登录注册、SMTP、OAuth、限流与风控开关。',
},
{
key: 'database' as const,
label: '数据库',
description: '观测数据清理与每日自动保留策略。',
},
{
key: 'other' as const,
label: '其他设置',
@@ -1270,6 +1324,168 @@ export function SettingsPage() {
);
}
if (activeTab === 'database') {
return (
<div className="grid gap-6 xl:grid-cols-2 xl:items-start">
<AppCard
title="自动数据清理"
description="每天凌晨 3 点自动清理超出保留期的观测数据,统一作用于访问日志、性能快照和请求聚合。"
action={
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction('database-auto-cleanup', async () => {
const retentionDays = Number.parseInt(
databaseFields.DatabaseAutoCleanupRetentionDays,
10,
);
if (Number.isNaN(retentionDays) || retentionDays < 1) {
throw new Error('自动清理保留天数至少为 1 天。');
}
await saveOptionEntries(
[
[
'DatabaseAutoCleanupEnabled',
String(databaseFields.DatabaseAutoCleanupEnabled),
],
[
'DatabaseAutoCleanupRetentionDays',
String(retentionDays),
],
],
'数据库自动清理设置已保存。',
);
})
}
disabled={busyKey === 'database-auto-cleanup'}
>
{busyKey === 'database-auto-cleanup'
? '保存中...'
: '保存自动清理'}
</PrimaryButton>
}
>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="space-y-5">
<ToggleField
label="启用每日自动清理"
description="开启后,服务端每天自动删除保留天数之外的观测数据。"
checked={databaseFields.DatabaseAutoCleanupEnabled}
onChange={(checked) =>
setDatabaseFields((previous) => ({
...previous,
DatabaseAutoCleanupEnabled: checked,
}))
}
/>
<div className="border-t border-[var(--border-default)] pt-5">
<ResourceField
label="自动清理保留天数"
hint="必须至少保留 1 天,服务端不允许配置为 24 小时以内。"
>
<ResourceInput
type="number"
min={1}
value={databaseFields.DatabaseAutoCleanupRetentionDays}
onChange={(event) =>
setDatabaseFields((previous) => ({
...previous,
DatabaseAutoCleanupRetentionDays: event.target.value,
}))
}
placeholder="例如 30"
/>
</ResourceField>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
触发频率
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
每天一次
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
默认执行时间
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
凌晨 3:00
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
生效范围
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
三类观测表
</p>
</div>
</div>
</div>
</div>
</div>
</AppCard>
<AppCard
title="数据清理"
description="用于手动清理单类观测数据。保留天数留空时会直接删除该类数据的全部历史记录。"
>
<div className="grid gap-5 xl:grid-cols-3">
{[
{
target: 'node_access_logs' as const,
label: '访问日志',
description:
'清理 node_access_logs,影响访问明细、IP 汇总与相关趋势查询。',
},
{
target: 'node_metric_snapshots' as const,
label: '性能快照',
description:
'清理 node_metric_snapshots,影响节点资源趋势和总览资源统计。',
},
{
target: 'node_request_reports' as const,
label: '请求聚合',
description:
'清理 node_request_reports,影响请求量、错误量与来源聚合展示。',
},
].map((item) => (
<div
key={item.target}
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<div className="space-y-3">
<div>
<p className="text-lg font-semibold text-[var(--foreground-primary)]">
{item.label}
</p>
<p className="mt-2 text-sm leading-6 text-[var(--foreground-secondary)]">
{item.description}
</p>
</div>
<DangerButton
type="button"
onClick={() => {
setCleanupRetentionDays('');
setCleanupModalState({
target: item.target,
label: item.label,
});
}}
>
清理数据
</DangerButton>
</div>
</div>
))}
</div>
</AppCard>
</div>
);
}
if (activeTab === 'system') {
return (
<div className="space-y-6">
@@ -2032,6 +2248,83 @@ export function SettingsPage() {
</div>
{renderTabContent()}
<AppModal
isOpen={cleanupModalState !== null}
title={`清理${cleanupModalState?.label ?? ''}`}
description="输入保留天数后,将只保留该天数范围内的数据;如果留空,则会直接删除该类数据的全部历史记录。"
onClose={() => {
if (databaseCleanupMutation.isPending) {
return;
}
setCleanupModalState(null);
}}
footer={
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton
type="button"
onClick={() => setCleanupModalState(null)}
disabled={databaseCleanupMutation.isPending}
>
取消
</SecondaryButton>
<DangerButton
type="button"
disabled={databaseCleanupMutation.isPending}
onClick={() => {
if (!cleanupModalState) {
return;
}
const trimmed = cleanupRetentionDays.trim();
if (trimmed !== '') {
const retentionDays = Number.parseInt(trimmed, 10);
if (Number.isNaN(retentionDays) || retentionDays < 1) {
setFeedback({
tone: 'danger',
message: '手动清理保留天数至少为 1 天。',
});
return;
}
databaseCleanupMutation.mutate({
target: cleanupModalState.target,
retention_days: retentionDays,
});
return;
}
databaseCleanupMutation.mutate({
target: cleanupModalState.target,
});
}}
>
{databaseCleanupMutation.isPending ? '清理中...' : '确认清理'}
</DangerButton>
</div>
}
>
<div className="space-y-5">
<div className="rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-4 py-4 text-sm leading-6 text-[var(--status-danger-foreground)]">
该操作会直接删除数据库中的历史观测数据,删除后无法恢复,请确认当前选择的数据类型和保留范围无误。
</div>
<ResourceField
label="保留天数"
hint="留空表示全部删除;填写时必须为大于等于 1 的整数。"
>
<ResourceInput
type="number"
min={1}
value={cleanupRetentionDays}
onChange={(event) => setCleanupRetentionDays(event.target.value)}
placeholder="例如 30;留空则全部删除"
/>
</ResourceField>
{databaseCleanupMutation.isError ? (
<ErrorState
title="数据库清理失败"
description={getErrorMessage(databaseCleanupMutation.error)}
/>
) : null}
</div>
</AppModal>
</div>
);
}
@@ -18,6 +18,25 @@ export interface GeoIPLookupResult {
longitude?: number | null;
}
export type DatabaseCleanupTarget =
| 'node_access_logs'
| 'node_metric_snapshots'
| 'node_request_reports';
export interface DatabaseCleanupPayload {
target: DatabaseCleanupTarget;
retention_days?: number;
}
export interface DatabaseCleanupResult {
target: DatabaseCleanupTarget;
target_label: string;
deleted_count: number;
delete_all: boolean;
retention_days?: number;
cutoff?: string;
}
export interface UpdateSelfPayload {
username: string;
display_name: string;
@@ -31,8 +31,8 @@ export function updateTlsCertificate(
id: number,
payload: TlsCertificateMutationPayload,
) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}`, {
method: 'PUT',
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -51,7 +51,7 @@ export function importTlsCertificateFiles(payload: TlsCertificateFileImportPaylo
}
export function deleteTlsCertificate(id: number) {
return apiRequest<void>(`/tls-certificates/${id}`, {
method: 'DELETE',
return apiRequest<void>(`/tls-certificates/${id}/delete`, {
method: 'POST',
});
}
@@ -26,12 +26,12 @@ export function createUser(payload: UserMutationPayload) {
});
}
export function updateUser(payload: UserMutationPayload & { id: number }) {
return apiRequest<void>('/user/', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function updateUser(payload: UserMutationPayload & { id: number }) {
return apiRequest<void>('/user/update', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function manageUser(username: string, action: ManageUserAction) {
return apiRequest<ManageUserResult>('/user/manage', {
@@ -397,7 +397,12 @@ export function WebsiteDetailPage({websiteId}: { websiteId: string }) {
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="max-w-72 break-all">{route.origin_url}</p>
<div className="max-w-72 space-y-1 break-all">
<p>{route.origin_url}</p>
<p className="text-xs text-[var(--foreground-muted)]">
回源主机名: {route.origin_host || '$host'}
</p>
</div>
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
@@ -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();