mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-13 13:54:22 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed38aa1d79 | |||
| 9ced0eb6f0 | |||
| 4433ab5af4 | |||
| 0ab0145f8d | |||
| 7a22167997 | |||
| e2202d1456 | |||
| 2ece13d08e | |||
| 4c4f7f9ced | |||
| bb284c2f37 | |||
| 915be62ca1 | |||
| 29a6fedbe9 | |||
| 2e875f583b | |||
| 70da7c772c | |||
| f1d469c18f | |||
| 244a43ba77 | |||
| 5e8007da17 | |||
| b60ebf231c | |||
| cfd7c3d7ca | |||
| 2c17f3289b |
+2
-1
@@ -1,4 +1,5 @@
|
||||
|
||||
.cache
|
||||
.gocache*
|
||||
.idea
|
||||
.vscode
|
||||
upload
|
||||
|
||||
-219
@@ -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
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
@@ -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 ./...
|
||||
```
|
||||
|
||||
## 文档导航
|
||||
|
||||
建议按以下顺序阅读:
|
||||
@@ -228,4 +229,4 @@ GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
|
||||
+7
-2
@@ -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` 未配置时,相关能力回退为进程内实现
|
||||
|
||||
@@ -73,12 +76,14 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
* `OpenRestyProxyReadTimeout`
|
||||
* `OpenRestyProxyBufferingEnabled`
|
||||
* `OpenRestyGzipEnabled`
|
||||
* `OpenRestyResolvers`
|
||||
* `OpenRestyCacheEnabled`
|
||||
* `OpenRestyCachePath`
|
||||
* `OpenRestyCacheMaxSize`
|
||||
|
||||
这类参数必须以结构化方式校验、保存并参与版本渲染。
|
||||
|
||||
* `OpenRestyResolvers` 由管理端性能页面维护,支持填写多个 DNS 服务器 IP;留空时不额外生成 `resolver` 指令。
|
||||
### 1.5 前端构建环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
|
||||
+24
-1
@@ -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:
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Go 1.24+
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录体系
|
||||
|
||||
### 1.2 Agent
|
||||
@@ -133,6 +133,7 @@
|
||||
* 成功与失败都必须返回清晰 `message`
|
||||
* Agent API 固定放在 `/api/agent/*`
|
||||
* 总览与节点详情优先使用专用聚合接口
|
||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`
|
||||
|
||||
统一响应结构:
|
||||
|
||||
@@ -187,9 +188,9 @@ Agent 必须满足:
|
||||
* 常规同步优先依据 heartbeat 返回的版本摘要判断
|
||||
* 发现新版本时先备份旧文件
|
||||
* 写入主配置、路由配置与必要证书文件
|
||||
* 先执行 `openresty -t`
|
||||
* 成功后执行 `openresty -s reload`
|
||||
* 失败时自动回滚并上报最终结果
|
||||
* 写入新配置后以运行态恢复为目标执行激活,Docker 模式优先重建容器并确认容器保持运行
|
||||
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty
|
||||
* 回滚后 OpenResty 恢复正常时上报警告;回滚后仍无法恢复运行时上报失败
|
||||
|
||||
## 6. 测试与交付要求
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "89d0efbf7bcc8fa53fe48889dce4d045",
|
||||
"agent_token": "2380de64b00e99093e16590beb91e1a0",
|
||||
"data_dir": "./data",
|
||||
"openresty_container_name": "openflare-openresty",
|
||||
"openresty_docker_image": "openresty/openresty:alpine",
|
||||
|
||||
@@ -73,6 +73,7 @@ func main() {
|
||||
NginxLuaDir: cfg.OpenrestyLuaDir,
|
||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
|
||||
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
|
||||
OpenrestyResolverDirective: "",
|
||||
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
|
||||
NginxPath: cfg.OpenrestyPath,
|
||||
DockerBinary: cfg.DockerBinary,
|
||||
|
||||
@@ -33,6 +33,7 @@ type Config struct {
|
||||
AgentVersion string `json:"-"`
|
||||
NginxVersion string `json:"-"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
|
||||
OpenrestyContainerName string `json:"openresty_container_name"`
|
||||
OpenrestyDockerImage string `json:"openresty_docker_image"`
|
||||
DockerBinary string `json:"docker_binary"`
|
||||
@@ -59,6 +60,7 @@ type configFile struct {
|
||||
NodeName string `json:"node_name"`
|
||||
NodeIP string `json:"node_ip"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers"`
|
||||
OpenrestyContainerName string `json:"openresty_container_name"`
|
||||
OpenrestyDockerImage string `json:"openresty_docker_image"`
|
||||
DockerBinary string `json:"docker_binary"`
|
||||
@@ -93,6 +95,7 @@ func Load(path string) (*Config, error) {
|
||||
NodeName: file.NodeName,
|
||||
NodeIP: file.NodeIP,
|
||||
OpenrestyPath: file.OpenrestyPath,
|
||||
OpenrestyResolvers: append([]string{}, file.OpenrestyResolvers...),
|
||||
OpenrestyContainerName: file.OpenrestyContainerName,
|
||||
OpenrestyDockerImage: file.OpenrestyDockerImage,
|
||||
DockerBinary: file.DockerBinary,
|
||||
@@ -121,6 +124,7 @@ func Load(path string) (*Config, error) {
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
cfg.AgentVersion = AgentVersion
|
||||
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
|
||||
if cfg.OpenrestyContainerName == "" {
|
||||
cfg.OpenrestyContainerName = "openflare-openresty"
|
||||
}
|
||||
@@ -290,6 +294,29 @@ func detectHostname() string {
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
|
||||
func normalizeResolverList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
|
||||
@@ -120,6 +120,40 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNormalizesExplicitResolvers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
"openresty_resolvers": []string{" 10.0.0.2 ", "10.0.0.2", "", "1.1.1.1"},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
expected := []string{"10.0.0.2", "1.1.1.1"}
|
||||
if len(cfg.OpenrestyResolvers) != len(expected) {
|
||||
t.Fatalf("unexpected resolver count: %#v", cfg.OpenrestyResolvers)
|
||||
}
|
||||
for index, value := range expected {
|
||||
if cfg.OpenrestyResolvers[index] != value {
|
||||
t.Fatalf("unexpected resolver at %d: got %q want %q", index, cfg.OpenrestyResolvers[index], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
@@ -209,6 +243,7 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
|
||||
cfg.NginxVersion = "1.27.1.2"
|
||||
cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second)
|
||||
cfg.RequestTimeout = MillisecondDuration(7 * time.Second)
|
||||
cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"}
|
||||
|
||||
if err = cfg.Save(); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
@@ -234,6 +269,10 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
|
||||
if decoded["request_timeout"] != float64(7000) {
|
||||
t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"])
|
||||
}
|
||||
resolvers, ok := decoded["openresty_resolvers"].([]any)
|
||||
if !ok || len(resolvers) != 2 || resolvers[0] != "10.0.0.2" || resolvers[1] != "1.1.1.1" {
|
||||
t.Fatalf("unexpected resolvers: %#v", decoded["openresty_resolvers"])
|
||||
}
|
||||
if decoded["openresty_observability_port"] != float64(defaultOpenRestyObservabilityPort) {
|
||||
t.Fatalf("unexpected observability port: %#v", decoded["openresty_observability_port"])
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -24,6 +26,7 @@ const AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__"
|
||||
const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
|
||||
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
|
||||
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
||||
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
|
||||
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
|
||||
const DockerRouteConfigPath = "/etc/nginx/conf.d/openflare_routes.conf"
|
||||
const DockerAccessLogPath = "/etc/nginx/conf.d/openflare_access.log"
|
||||
@@ -116,6 +119,9 @@ type DockerExecutor struct {
|
||||
|
||||
func (e *DockerExecutor) Test(ctx context.Context) error {
|
||||
slog.Debug("running docker openresty test", "container", e.ContainerName, "image", e.Image)
|
||||
if err := e.validateMountSources(); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := e.runEphemeralRuntimeCommand(ctx, "-t")
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker %s -t failed: %w: %s", dockerRuntimeCommand, err, string(output))
|
||||
@@ -125,7 +131,25 @@ func (e *DockerExecutor) Test(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (e *DockerExecutor) Reload(ctx context.Context) error {
|
||||
return e.EnsureRuntime(ctx, true)
|
||||
if err := e.validateMountSources(); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "{{.State.Running}}", e.ContainerName)
|
||||
if err != nil || strings.TrimSpace(string(output)) != "true" {
|
||||
return e.EnsureRuntime(ctx, false)
|
||||
}
|
||||
output, err = e.Runner.Run(ctx, e.DockerBinary, "exec", e.ContainerName, dockerRuntimeCommand, "-s", "reload")
|
||||
if err != nil {
|
||||
if e.shouldRecreateAfterReloadFailure(string(output)) {
|
||||
slog.Warn("docker openresty reload failed due to missing mounted files, recreating container", "container", e.ContainerName)
|
||||
if recreateErr := e.EnsureRuntime(ctx, true); recreateErr != nil {
|
||||
return fmt.Errorf("docker exec %s reload failed: %w: %s; recreate failed: %v", dockerRuntimeCommand, err, string(output), recreateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("docker exec %s reload failed: %w: %s", dockerRuntimeCommand, err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DockerExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
@@ -157,7 +181,7 @@ func (e *DockerExecutor) CheckHealth(ctx context.Context) error {
|
||||
return fmt.Errorf("docker inspect openresty failed: %w: %s", err, string(output))
|
||||
}
|
||||
if strings.TrimSpace(string(output)) != "true" {
|
||||
return errors.New("docker openresty container is not running")
|
||||
return e.containerNotRunningError(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -182,6 +206,9 @@ func (e *DockerExecutor) removeContainer(ctx context.Context) error {
|
||||
|
||||
func (e *DockerExecutor) runContainer(ctx context.Context) error {
|
||||
slog.Info("starting docker openresty container", "container", e.ContainerName, "image", e.Image)
|
||||
if err := e.validateMountSources(); err != nil {
|
||||
return err
|
||||
}
|
||||
runArgs := []string{
|
||||
"run", "-d",
|
||||
"--name", e.ContainerName,
|
||||
@@ -198,10 +225,127 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
|
||||
if runErr != nil {
|
||||
return fmt.Errorf("docker run openresty failed: %w: %s", runErr, string(runOutput))
|
||||
}
|
||||
if err := e.CheckHealth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("docker openresty container started", "container", e.ContainerName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DockerExecutor) validateMountSources() error {
|
||||
if err := ensureRegularFile(e.MainConfigPath, "openresty main config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDirectory(e.RouteConfigDir, "openresty route config dir"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDirectory(e.CertDir, "openresty cert dir"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDirectory(e.LuaDir, "openresty lua dir"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DockerExecutor) shouldRecreateAfterReloadFailure(output string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(output))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(text, "no such file") && !strings.Contains(text, "cannot load certificate") {
|
||||
return false
|
||||
}
|
||||
paths := []string{
|
||||
strings.ToLower(e.NginxCertDir),
|
||||
strings.ToLower(e.NginxLuaDir),
|
||||
strings.ToLower(DockerMainConfigPath),
|
||||
strings.ToLower("/etc/nginx/conf.d"),
|
||||
}
|
||||
for _, path := range paths {
|
||||
if strings.TrimSpace(path) != "" && strings.Contains(text, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *DockerExecutor) containerNotRunningError(ctx context.Context) error {
|
||||
inspectSummary := ""
|
||||
inspectOutput, inspectErr := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "status={{.State.Status}} exit_code={{.State.ExitCode}} error={{printf \"%q\" .State.Error}} oom_killed={{.State.OOMKilled}} finished_at={{.State.FinishedAt}}", e.ContainerName)
|
||||
if inspectErr == nil {
|
||||
inspectSummary = strings.TrimSpace(string(inspectOutput))
|
||||
}
|
||||
|
||||
logTail := ""
|
||||
logOutput, logErr := e.Runner.Run(ctx, e.DockerBinary, "logs", "--tail", "50", e.ContainerName)
|
||||
if logErr == nil {
|
||||
logTail = strings.TrimSpace(string(logOutput))
|
||||
}
|
||||
|
||||
message := "docker openresty container is not running"
|
||||
if inspectSummary != "" {
|
||||
message += ": " + inspectSummary
|
||||
}
|
||||
if logTail != "" {
|
||||
message += "; recent logs: " + compactDiagnosticText(logTail)
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
func compactDiagnosticText(text string) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
if len(lines) > 8 {
|
||||
lines = lines[len(lines)-8:]
|
||||
}
|
||||
joined := strings.Join(lines, " | ")
|
||||
joined = strings.Join(strings.Fields(joined), " ")
|
||||
if len(joined) > 800 {
|
||||
return joined[len(joined)-800:]
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
func ensureRegularFile(path string, label string) error {
|
||||
cleanPath := strings.TrimSpace(path)
|
||||
if cleanPath == "" {
|
||||
return fmt.Errorf("%s path is empty", label)
|
||||
}
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%s %q does not exist; run a config apply first so Docker does not create a directory mount source", label, cleanPath)
|
||||
}
|
||||
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s %q is a directory; expected a file for Docker bind mount", label, cleanPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDirectory(path string, label string) error {
|
||||
cleanPath := strings.TrimSpace(path)
|
||||
if cleanPath == "" {
|
||||
return fmt.Errorf("%s path is empty", label)
|
||||
}
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%s %q does not exist; expected a directory for Docker bind mount", label, cleanPath)
|
||||
}
|
||||
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s %q is not a directory; expected a directory for Docker bind mount", label, cleanPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
MainConfigPath string
|
||||
RouteConfigPath string
|
||||
@@ -212,74 +356,117 @@ type Manager struct {
|
||||
NginxLuaDir string
|
||||
OpenrestyObservabilityListen string
|
||||
OpenrestyObservabilityPort int
|
||||
OpenrestyResolverDirective string
|
||||
Executor Executor
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
|
||||
type ApplyStatus string
|
||||
|
||||
const (
|
||||
ApplyStatusSuccess ApplyStatus = "success"
|
||||
ApplyStatusWarning ApplyStatus = "warning"
|
||||
ApplyStatusFatal ApplyStatus = "fatal"
|
||||
)
|
||||
|
||||
type ApplyOutcome struct {
|
||||
Status ApplyStatus
|
||||
Message string
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome {
|
||||
slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles))
|
||||
backup, err := m.backup()
|
||||
if err != nil {
|
||||
return fatalApplyOutcome(fmt.Errorf("backup openresty config failed: %w", err))
|
||||
}
|
||||
if err = m.writeTargetFiles(mainConfig, routeConfig, supportFiles); err != nil {
|
||||
return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("write openresty config failed: %w", err))
|
||||
}
|
||||
if err = m.activateConfig(ctx); err != nil {
|
||||
return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("activate openresty runtime failed: %w", err))
|
||||
}
|
||||
slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath)
|
||||
return ApplyOutcome{Status: ApplyStatusSuccess}
|
||||
}
|
||||
|
||||
func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
|
||||
if err := m.EnsureLuaAssets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = m.EnsureLuaAssets(); err != nil {
|
||||
slog.Error("writing lua assets failed, restoring backup", "error", err)
|
||||
_ = m.restore(backup)
|
||||
if err := m.writeCertFiles(supportFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = m.writeCertFiles(supportFiles); err != nil {
|
||||
slog.Error("writing cert files failed, restoring backup", "error", err)
|
||||
_ = m.restore(backup)
|
||||
return err
|
||||
if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") {
|
||||
slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured")
|
||||
}
|
||||
renderedMainConfig := m.renderMainConfig(mainConfig)
|
||||
if err = os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil {
|
||||
slog.Error("writing openresty main config failed, restoring backup", "error", err)
|
||||
_ = m.restore(backup)
|
||||
if err := os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
renderedRouteConfig := m.renderRouteConfig(routeConfig)
|
||||
if err = os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil {
|
||||
slog.Error("writing openresty route config failed, restoring backup", "error", err)
|
||||
_ = m.restore(backup)
|
||||
if err := os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = m.Executor.Test(ctx); err != nil {
|
||||
slog.Error("openresty test failed after config write, restoring backup", "error", err)
|
||||
_ = m.restore(backup)
|
||||
return err
|
||||
}
|
||||
if err = m.Executor.Reload(ctx); err != nil {
|
||||
slog.Error("openresty reload failed after config write, restoring backup", "error", err)
|
||||
_ = m.restore(backup)
|
||||
return err
|
||||
}
|
||||
slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) activateConfig(ctx context.Context) error {
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
if _, ok := m.Executor.(*DockerExecutor); ok {
|
||||
return m.Executor.EnsureRuntime(ctx, true)
|
||||
}
|
||||
return m.Executor.Reload(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupState, applyErr error) ApplyOutcome {
|
||||
slog.Warn("openresty apply failed, restoring previous config", "error", applyErr)
|
||||
if err := m.restore(backup); err != nil {
|
||||
return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %v: %w", applyErr, err))
|
||||
}
|
||||
if err := m.activateConfig(ctx); err != nil {
|
||||
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err))
|
||||
}
|
||||
message := fmt.Sprintf("apply failed, rolled back to previous config: %v", applyErr)
|
||||
slog.Warn("openresty apply rolled back successfully", "message", message)
|
||||
return ApplyOutcome{
|
||||
Status: ApplyStatusWarning,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func fatalApplyOutcome(err error) ApplyOutcome {
|
||||
if err == nil {
|
||||
return ApplyOutcome{Status: ApplyStatusFatal}
|
||||
}
|
||||
return ApplyOutcome{
|
||||
Status: ApplyStatusFatal,
|
||||
Message: strings.TrimSpace(err.Error()),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) EnsureLuaAssets() error {
|
||||
if strings.TrimSpace(m.LuaDir) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(m.LuaDir); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(m.LuaDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
files := make([]managedFile, 0, len(ManagedObservabilityLuaFiles()))
|
||||
for _, file := range ManagedObservabilityLuaFiles() {
|
||||
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte(file.Content), 0o644); err != nil {
|
||||
relativePath, err := filepath.Rel(m.LuaDir, targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, managedFile{
|
||||
Path: filepath.ToSlash(relativePath),
|
||||
Content: []byte(file.Content),
|
||||
Mode: 0o644,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
return syncManagedFiles(m.LuaDir, files)
|
||||
}
|
||||
|
||||
func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
@@ -342,6 +529,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), ObservabilityPortPlaceholder)
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder)
|
||||
}
|
||||
normalizedRoute := string(data)
|
||||
if m.NginxCertDir != "" {
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
|
||||
@@ -505,6 +695,12 @@ type backupState struct {
|
||||
Files []protocol.SupportFile
|
||||
}
|
||||
|
||||
type managedFile struct {
|
||||
Path string
|
||||
Content []byte
|
||||
Mode fs.FileMode
|
||||
}
|
||||
|
||||
func (m *Manager) backup() (*backupState, error) {
|
||||
if m.MainConfigPath == "" {
|
||||
return nil, errors.New("main config path 不能为空")
|
||||
@@ -569,50 +765,34 @@ func (m *Manager) restore(state *backupState) error {
|
||||
if m.CertDir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range state.Files {
|
||||
targetPath, err := m.certFileTargetPath(file.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte(file.Content), certFileMode(file.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return m.writeManagedCertFiles(state.Files)
|
||||
}
|
||||
|
||||
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
|
||||
if m.CertDir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.writeManagedCertFiles(certFiles)
|
||||
}
|
||||
|
||||
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
|
||||
files := make([]managedFile, 0, len(certFiles))
|
||||
for _, file := range certFiles {
|
||||
targetPath, err := m.certFileTargetPath(file.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte(file.Content), certFileMode(file.Path)); err != nil {
|
||||
relativePath, err := filepath.Rel(m.CertDir, targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, managedFile{
|
||||
Path: filepath.ToSlash(relativePath),
|
||||
Content: []byte(file.Content),
|
||||
Mode: certFileMode(file.Path),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
return syncManagedFiles(m.CertDir, files)
|
||||
}
|
||||
|
||||
func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
|
||||
@@ -719,6 +899,94 @@ func luaFileTargetPath(baseDir string, relativePath string) (string, error) {
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
func syncManagedFiles(baseDir string, files []managedFile) error {
|
||||
if strings.TrimSpace(baseDir) == "" {
|
||||
return errors.New("managed dir cannot be empty")
|
||||
}
|
||||
if info, err := os.Stat(baseDir); err == nil && !info.IsDir() {
|
||||
return fmt.Errorf("managed dir %q is not a directory", baseDir)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
desired := make(map[string]managedFile, len(files))
|
||||
for _, file := range files {
|
||||
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimSpace(file.Path)))
|
||||
if cleanPath == "." || cleanPath == "" {
|
||||
return errors.New("managed file path cannot be empty")
|
||||
}
|
||||
desired[cleanPath] = managedFile{
|
||||
Path: cleanPath,
|
||||
Content: file.Content,
|
||||
Mode: file.Mode,
|
||||
}
|
||||
}
|
||||
|
||||
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relativePath, err := filepath.Rel(baseDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := desired[filepath.Clean(relativePath)]; ok {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range desired {
|
||||
targetPath := filepath.Join(baseDir, file.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(targetPath, file.Content, file.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return removeEmptyManagedDirs(baseDir)
|
||||
}
|
||||
|
||||
func removeEmptyManagedDirs(baseDir string) error {
|
||||
dirs := make([]string, 0)
|
||||
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() && path != baseDir {
|
||||
dirs = append(dirs, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(dirs, func(i int, j int) bool {
|
||||
return len(dirs[i]) > len(dirs[j])
|
||||
})
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
if err := os.Remove(dir); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) renderRouteConfig(content string) string {
|
||||
if m.NginxCertDir == "" {
|
||||
return content
|
||||
@@ -743,6 +1011,9 @@ func (m *Manager) renderMainConfig(content string) string {
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
rendered = strings.ReplaceAll(rendered, ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective)
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
@@ -756,6 +1027,89 @@ func ObservabilityListenAddress(openrestyPath string, port int) string {
|
||||
return fmt.Sprintf("%d", port)
|
||||
}
|
||||
|
||||
func ResolverDirective(openrestyPath string, explicitResolvers []string) string {
|
||||
resolvers := resolverAddresses(openrestyPath, explicitResolvers)
|
||||
if len(resolvers) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
|
||||
}
|
||||
|
||||
func resolverAddresses(openrestyPath string, explicitResolvers []string) []string {
|
||||
if resolvers := normalizeResolverAddresses(explicitResolvers); len(resolvers) > 0 {
|
||||
return resolvers
|
||||
}
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseResolverAddresses(string(data), strings.TrimSpace(openrestyPath) == "")
|
||||
}
|
||||
|
||||
func parseResolverAddresses(content string, dockerMode bool) []string {
|
||||
lines := strings.Split(content, "\n")
|
||||
resolvers := make([]string, 0, 2)
|
||||
seen := make(map[string]struct{})
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) < 2 || fields[0] != "nameserver" {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(fields[1])
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if dockerMode && !isUsableDockerResolver(addr) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[addr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
resolvers = append(resolvers, addr)
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
func isUsableDockerResolver(addr string) bool {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return !ip.IsLoopback() && !ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func normalizeResolverAddresses(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
resolvers := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
addr := strings.TrimSpace(value)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[addr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
resolvers = append(resolvers, addr)
|
||||
}
|
||||
if len(resolvers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
func RequiresRuntimeResolver(originURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(originURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
return net.ParseIP(parsed.Hostname()) == nil
|
||||
}
|
||||
|
||||
func (m *Manager) routeConfigIncludePath() string {
|
||||
if strings.TrimSpace(m.RuntimeRouteConfigPath) != "" {
|
||||
return strings.TrimSpace(m.RuntimeRouteConfigPath)
|
||||
|
||||
@@ -28,6 +28,11 @@ type fakeExecutor struct {
|
||||
reloadErr error
|
||||
}
|
||||
|
||||
type scriptedExecutor struct {
|
||||
reloadErrors []error
|
||||
reloadCalls int
|
||||
}
|
||||
|
||||
func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)})
|
||||
if r.runFn != nil {
|
||||
@@ -56,6 +61,31 @@ func (e *fakeExecutor) Restart(ctx context.Context) error {
|
||||
return e.reloadErr
|
||||
}
|
||||
|
||||
func (e *scriptedExecutor) Test(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *scriptedExecutor) Reload(ctx context.Context) error {
|
||||
index := e.reloadCalls
|
||||
e.reloadCalls++
|
||||
if index >= len(e.reloadErrors) {
|
||||
return nil
|
||||
}
|
||||
return e.reloadErrors[index]
|
||||
}
|
||||
|
||||
func (e *scriptedExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *scriptedExecutor) CheckHealth(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *scriptedExecutor) Restart(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPathExecutorCommands(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
@@ -113,6 +143,15 @@ func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
|
||||
func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 4 && args[0] == "inspect" && args[2] == "{{.State.Running}}" {
|
||||
return []byte("false"), nil
|
||||
}
|
||||
if len(args) >= 4 && args[0] == "inspect" {
|
||||
return []byte("status=exited exit_code=1 error=\"\" oom_killed=false finished_at=2026-03-18T10:08:30Z"), nil
|
||||
}
|
||||
if len(args) >= 1 && args[0] == "logs" {
|
||||
return []byte("nginx: [emerg] host not found in upstream \"c1\" in /etc/nginx/conf.d/openflare_routes.conf:30"), nil
|
||||
}
|
||||
return []byte("false"), nil
|
||||
},
|
||||
}
|
||||
@@ -130,10 +169,38 @@ func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
|
||||
}
|
||||
if err := executor.CheckHealth(context.Background()); err == nil {
|
||||
t.Fatal("expected CheckHealth to fail when container is not running")
|
||||
} else {
|
||||
text := err.Error()
|
||||
if !strings.Contains(text, "exit_code=1") {
|
||||
t.Fatalf("expected exit code in health error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "host not found in upstream") {
|
||||
t.Fatalf("expected recent docker logs in health error, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareDockerMountSources(t *testing.T) (string, string, string, string) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routeConfigDir := filepath.Join(tempDir, "conf.d")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
|
||||
if err := os.WriteFile(mainConfigPath, []byte("events {}\nhttp {}\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
}
|
||||
return mainConfigPath, routeConfigDir, certDir, luaDir
|
||||
}
|
||||
|
||||
func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
@@ -146,11 +213,11 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
|
||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||
CertDir: filepath.Clean("/tmp/certs"),
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Clean("/tmp/lua"),
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: runner,
|
||||
}
|
||||
@@ -171,10 +238,16 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
inspectCalls := 0
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 2 && args[0] == "inspect" {
|
||||
return []byte("false"), nil
|
||||
inspectCalls++
|
||||
if inspectCalls < 3 {
|
||||
return []byte("false"), nil
|
||||
}
|
||||
return []byte("true"), nil
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
@@ -183,11 +256,11 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
|
||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||
CertDir: filepath.Clean("/tmp/certs"),
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Clean("/tmp/lua"),
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: runner,
|
||||
}
|
||||
@@ -196,26 +269,111 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
|
||||
if len(runner.calls) != 3 {
|
||||
t.Fatalf("expected 3 calls, got %d", len(runner.calls))
|
||||
if len(runner.calls) != 5 {
|
||||
t.Fatalf("expected 5 calls, got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[0].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect on first call, got %#v", runner.calls[0])
|
||||
}
|
||||
if runner.calls[1].args[0] != "rm" {
|
||||
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
|
||||
if runner.calls[1].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect on second call, got %#v", runner.calls[1])
|
||||
}
|
||||
if runner.calls[2].args[0] != "run" {
|
||||
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
|
||||
if runner.calls[2].args[0] != "rm" {
|
||||
t.Fatalf("expected docker rm on third call, got %#v", runner.calls[2])
|
||||
}
|
||||
if runner.calls[3].args[0] != "run" {
|
||||
t.Fatalf("expected docker run on fourth call, got %#v", runner.calls[3])
|
||||
}
|
||||
if runner.calls[4].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorReloadsRunningContainerInPlace(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte("true"), nil
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
|
||||
expected := []runCall{
|
||||
{name: "docker", args: []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}},
|
||||
{name: "docker", args: []string{"exec", "openflare-openresty", "openresty", "-s", "reload"}},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls, expected) {
|
||||
t.Fatalf("unexpected calls: %#v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorReloadRecreatesContainerWhenMountedCertMissing(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte("true"), nil
|
||||
}
|
||||
if len(args) >= 2 && args[0] == "exec" {
|
||||
return []byte(`nginx: [emerg] cannot load certificate "/etc/nginx/openflare-certs/1.crt": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory)`), errors.New("exit status 1")
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
OpenrestyObservabilityPort: 18081,
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
|
||||
if len(runner.calls) != 6 {
|
||||
t.Fatalf("expected 6 calls, got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[2].args[0] != "inspect" || runner.calls[3].args[0] != "rm" || runner.calls[4].args[0] != "run" || runner.calls[5].args[0] != "inspect" {
|
||||
t.Fatalf("expected recreate after reload failure, got %#v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
|
||||
mainConfigPath := filepath.Clean("/tmp/managed/nginx.conf")
|
||||
routeConfigDir := filepath.Clean("/tmp/managed/conf.d")
|
||||
certDir := filepath.Clean("/tmp/managed/certs")
|
||||
luaDir := filepath.Clean("/tmp/managed/lua")
|
||||
runner := &fakeRunner{}
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte("true"), nil
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
@@ -234,8 +392,8 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
|
||||
t.Fatalf("runContainer failed: %v", err)
|
||||
}
|
||||
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected one docker run call, got %d", len(runner.calls))
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("expected docker run plus health check, got %d calls", len(runner.calls))
|
||||
}
|
||||
|
||||
expectedArgs := []string{
|
||||
@@ -253,9 +411,13 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
|
||||
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
|
||||
t.Fatalf("unexpected docker run args: %#v", runner.calls[0].args)
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls[1].args, []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}) {
|
||||
t.Fatalf("unexpected docker health check args: %#v", runner.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
@@ -268,11 +430,11 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
|
||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||
CertDir: filepath.Clean("/tmp/certs"),
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Clean("/tmp/lua"),
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
OpenrestyObservabilityPort: 18081,
|
||||
Runner: runner,
|
||||
@@ -281,8 +443,8 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
|
||||
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
|
||||
t.Fatalf("EnsureRuntime failed: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 3 {
|
||||
t.Fatalf("expected 3 calls, got %d", len(runner.calls))
|
||||
if len(runner.calls) != 4 {
|
||||
t.Fatalf("expected 4 calls, got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[1].args[0] != "rm" {
|
||||
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
|
||||
@@ -290,6 +452,76 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
|
||||
if runner.calls[2].args[0] != "run" {
|
||||
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
|
||||
}
|
||||
if runner.calls[3].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRunContainerRejectsMissingMainConfigFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routeConfigDir := filepath.Join(tempDir, "conf.d")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: &fakeRunner{},
|
||||
}
|
||||
|
||||
err := executor.runContainer(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing main config file to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "run a config apply first") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRunContainerRejectsMainConfigDirectory(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routeConfigDir := filepath.Join(tempDir, "conf.d")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
for _, dir := range []string{mainConfigPath, routeConfigDir, certDir, luaDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: &fakeRunner{},
|
||||
}
|
||||
|
||||
err := executor.runContainer(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected main config directory to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "expected a file") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
|
||||
@@ -355,21 +587,21 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
err := manager.Apply(
|
||||
outcome := manager.Apply(
|
||||
context.Background(),
|
||||
"include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
|
||||
"ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;\n",
|
||||
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply failed: %v", err)
|
||||
if outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
|
||||
mainData, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
}
|
||||
expectedMain := "include " + routePath + ";\naccess_log " + filepath.Join(filepath.Dir(routePath), "openflare_access.log") + " openflare_json;\n"
|
||||
expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(filepath.Join(filepath.Dir(routePath), "openflare_access.log")) + " openflare_json;\n"
|
||||
if string(mainData) != expectedMain {
|
||||
t.Fatalf("unexpected main config: %s", string(mainData))
|
||||
}
|
||||
@@ -411,8 +643,8 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
if err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); err != nil {
|
||||
t.Fatalf("Apply failed: %v", err)
|
||||
if outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
|
||||
mainData, err := os.ReadFile(mainPath)
|
||||
@@ -485,15 +717,16 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
OpenrestyObservabilityListen: "18081",
|
||||
OpenrestyResolverDirective: " resolver 127.0.0.11 valid=30s ipv6=off;\n resolver_timeout 5s;\n",
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\nserver { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{
|
||||
outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\n__OPENFLARE_RESOLVER_DIRECTIVE__server { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{
|
||||
{Path: "1.crt", Content: "cert-data"},
|
||||
{Path: "1.key", Content: "key-data"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply failed: %v", err)
|
||||
if outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
|
||||
routeData, err := os.ReadFile(manager.RouteConfigPath)
|
||||
@@ -510,6 +743,9 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
if !strings.Contains(string(mainData), "listen 18081;") {
|
||||
t.Fatalf("expected observability listen placeholder replacement in main config, got %s", string(mainData))
|
||||
}
|
||||
if !strings.Contains(string(mainData), "resolver 127.0.0.11 valid=30s ipv6=off;") {
|
||||
t.Fatalf("expected resolver directive placeholder replacement in main config, got %s", string(mainData))
|
||||
}
|
||||
certData, err := os.ReadFile(filepath.Join(manager.CertDir, "1.crt"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
@@ -521,11 +757,118 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
|
||||
}
|
||||
if luaInfo.Mode().Perm() != 0o644 {
|
||||
if runtime.GOOS != "windows" && luaInfo.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
|
||||
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
|
||||
if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") {
|
||||
t.Fatalf("expected explicit resolver directive, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolverAddressesFiltersLoopbackForDocker(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"nameserver 127.0.0.53",
|
||||
"nameserver 10.0.0.2",
|
||||
"nameserver ::1",
|
||||
"nameserver 1.1.1.1",
|
||||
}, "\n")
|
||||
got := parseResolverAddresses(content, true)
|
||||
expected := []string{"10.0.0.2", "1.1.1.1"}
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Fatalf("unexpected docker resolvers: got %#v want %#v", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolverAddressesKeepsLoopbackForLocalBinary(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"nameserver 127.0.0.53",
|
||||
"nameserver 10.0.0.2",
|
||||
}, "\n")
|
||||
got := parseResolverAddresses(content, false)
|
||||
expected := []string{"127.0.0.53", "10.0.0.2"}
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Fatalf("unexpected local resolvers: got %#v want %#v", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiresRuntimeResolver(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
originURL string
|
||||
want bool
|
||||
}{
|
||||
{name: "hostname", originURL: "https://origin.internal", want: true},
|
||||
{name: "ipv4", originURL: "https://10.0.0.8", want: false},
|
||||
{name: "ipv6", originURL: "https://[2001:db8::1]", want: false},
|
||||
{name: "invalid", originURL: "://bad", want: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
if got := RequiresRuntimeResolver(testCase.originURL); got != testCase.want {
|
||||
t.Fatalf("%s: got %v want %v", testCase.name, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCertFilesKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
if err := os.MkdirAll(filepath.Join(certDir, "stale"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(certDir, "stale", "old.crt"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
manager := &Manager{CertDir: certDir}
|
||||
|
||||
if err := manager.writeCertFiles([]protocol.SupportFile{
|
||||
{Path: "1.crt", Content: "cert"},
|
||||
{Path: "1.key", Content: "key"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeCertFiles failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(certDir); err != nil {
|
||||
t.Fatalf("expected cert dir to persist, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(certDir, "stale", "old.crt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected stale cert file to be removed, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(certDir, "1.crt")); err != nil {
|
||||
t.Fatalf("expected new cert file to exist, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
if err := os.MkdirAll(filepath.Join(luaDir, "stale"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(luaDir, "stale", "old.lua"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
manager := &Manager{LuaDir: luaDir}
|
||||
|
||||
if err := manager.EnsureLuaAssets(); err != nil {
|
||||
t.Fatalf("EnsureLuaAssets failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(luaDir); err != nil {
|
||||
t.Fatalf("expected lua dir to persist, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(luaDir, "stale", "old.lua")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected stale lua file to be removed, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
|
||||
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertFileMode(t *testing.T) {
|
||||
testCases := []struct {
|
||||
path string
|
||||
@@ -590,15 +933,15 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) {
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Executor: &fakeExecutor{
|
||||
testErr: errors.New("openresty test failed"),
|
||||
reloadErr: errors.New("openresty reload failed"),
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
|
||||
outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
|
||||
{Path: "1.crt", Content: "new-cert"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Apply to fail")
|
||||
if outcome.Status != ApplyStatusFatal {
|
||||
t.Fatalf("expected fatal apply outcome, got %#v", outcome)
|
||||
}
|
||||
|
||||
mainData, err := os.ReadFile(mainPath)
|
||||
@@ -624,6 +967,51 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerApplyReturnsWarningWhenRollbackRecoversRuntime(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routePath := filepath.Join(tempDir, "routes.conf")
|
||||
mainPath := filepath.Join(tempDir, "nginx.conf")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
if err := os.MkdirAll(certDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(mainPath, []byte("old-main"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(certDir, "1.crt"), []byte("old-cert"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
manager := &Manager{
|
||||
MainConfigPath: mainPath,
|
||||
RouteConfigPath: routePath,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Executor: &scriptedExecutor{
|
||||
reloadErrors: []error{errors.New("target config failed"), nil},
|
||||
},
|
||||
}
|
||||
|
||||
outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{
|
||||
{Path: "1.crt", Content: "new-cert"},
|
||||
})
|
||||
if outcome.Status != ApplyStatusWarning {
|
||||
t.Fatalf("expected warning apply outcome, got %#v", outcome)
|
||||
}
|
||||
|
||||
mainData, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
}
|
||||
if string(mainData) != "old-main" {
|
||||
t.Fatalf("expected main rollback, got %s", string(mainData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCertFileTargetPathRejectsEscapes(t *testing.T) {
|
||||
manager := &Manager{CertDir: filepath.Join(t.TempDir(), "certs")}
|
||||
if err := os.MkdirAll(manager.CertDir, 0o755); err != nil {
|
||||
@@ -675,11 +1063,11 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
err := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
|
||||
outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
|
||||
{Path: "../escape.crt", Content: "bad"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Apply to reject traversal path")
|
||||
if outcome.Status != ApplyStatusWarning {
|
||||
t.Fatalf("expected warning apply outcome, got %#v", outcome)
|
||||
}
|
||||
|
||||
if _, statErr := os.Stat(filepath.Join(tempDir, "escape.crt")); !os.IsNotExist(statErr) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -154,55 +157,79 @@ 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
|
||||
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)
|
||||
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)
|
||||
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 checksumString(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -24,7 +24,7 @@ type fakeClient struct {
|
||||
}
|
||||
|
||||
type fakeManager struct {
|
||||
applyErr error
|
||||
applyOutcome nginx.ApplyOutcome
|
||||
currentChecksum string
|
||||
currentChecksumErr error
|
||||
ensureErr error
|
||||
@@ -64,11 +64,14 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error {
|
||||
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome {
|
||||
m.applyMainContents = append(m.applyMainContents, mainConfig)
|
||||
m.applyRouteContents = append(m.applyRouteContents, routeConfig)
|
||||
m.applyFiles = append(m.applyFiles, append([]protocol.SupportFile(nil), supportFiles...))
|
||||
return m.applyErr
|
||||
if m.applyOutcome.Status == "" {
|
||||
return nginx.ApplyOutcome{Status: nginx.ApplyStatusSuccess}
|
||||
}
|
||||
return m.applyOutcome
|
||||
}
|
||||
|
||||
func (m *fakeManager) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
@@ -166,17 +169,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
mainPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routePath := filepath.Join(tempDir, "routes.conf")
|
||||
if err := os.WriteFile(mainPath, []byte("worker_processes auto;"), 0o644); err != nil {
|
||||
t.Fatalf("failed to seed main file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(routePath, []byte("server { listen 80; }"), 0o644); err != nil {
|
||||
t.Fatalf("failed to seed route file: %v", err)
|
||||
}
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
nodeID, err := stateStore.EnsureNodeID()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||
@@ -189,11 +182,10 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
t.Fatalf("failed to seed state: %v", err)
|
||||
}
|
||||
|
||||
service := New(client, &nginx.Manager{
|
||||
MainConfigPath: mainPath,
|
||||
RouteConfigPath: routePath,
|
||||
Executor: &fakeExecutor{
|
||||
testErr: context.DeadlineExceeded,
|
||||
service := New(client, &fakeManager{
|
||||
applyOutcome: nginx.ApplyOutcome{
|
||||
Status: nginx.ApplyStatusFatal,
|
||||
Message: "openresty failed after rollback",
|
||||
},
|
||||
}, stateStore)
|
||||
|
||||
@@ -202,22 +194,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
Checksum: client.config.Checksum,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected SyncOnce to fail when nginx test fails")
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(routePath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("failed to read route file after rollback: %v", readErr)
|
||||
}
|
||||
if string(data) != "server { listen 80; }" {
|
||||
t.Fatal("expected original route config to be restored after rollback")
|
||||
}
|
||||
mainData, readErr := os.ReadFile(mainPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("failed to read main file after rollback: %v", readErr)
|
||||
}
|
||||
if string(mainData) != "worker_processes auto;" {
|
||||
t.Fatal("expected original main config to be restored after rollback")
|
||||
t.Fatal("expected SyncOnce to fail when apply outcome is fatal")
|
||||
}
|
||||
snapshot, loadErr := stateStore.Load()
|
||||
if loadErr != nil {
|
||||
@@ -226,6 +203,9 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
if snapshot.CurrentVersion != "20260309-001" {
|
||||
t.Fatal("expected failed sync not to overwrite current version")
|
||||
}
|
||||
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 +220,64 @@ 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.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{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
web/node_modules
|
||||
web/.next
|
||||
web/build
|
||||
web/out
|
||||
|
||||
upload
|
||||
logs
|
||||
|
||||
.git
|
||||
.github
|
||||
@@ -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
|
||||
@@ -80,6 +81,7 @@ var OpenRestyProxyBusyBuffersSize = "64k"
|
||||
var OpenRestyGzipEnabled = true
|
||||
var OpenRestyGzipMinLength = 1024
|
||||
var OpenRestyGzipCompLevel = 5
|
||||
var OpenRestyResolvers = ""
|
||||
var OpenRestyCacheEnabled = false
|
||||
var OpenRestyCachePath = ""
|
||||
var OpenRestyCacheLevels = "1:2"
|
||||
@@ -124,7 +126,7 @@ http {
|
||||
gzip {{OpenRestyGzip}};
|
||||
gzip_min_length {{OpenRestyGzipMinLength}};
|
||||
gzip_comp_level {{OpenRestyGzipCompLevel}};
|
||||
{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
|
||||
{{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
@@ -13,17 +14,149 @@ import (
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
// @Param path query string false "Path"
|
||||
// @Param p query int false "Page index"
|
||||
// @Param page_size query int false "Page size"
|
||||
// @Param sort_by query string false "Sort by"
|
||||
// @Param sort_order query string false "Sort order"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/access-logs/ [get]
|
||||
func GetAccessLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("p", "0"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "0"))
|
||||
logs, err := service.ListAccessLogs(c.Query("node_id"), page, pageSize)
|
||||
logs, err := service.ListAccessLogs(readAccessLogQuery(c))
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, logs)
|
||||
}
|
||||
|
||||
// GetFoldedAccessLogs godoc
|
||||
// @Summary List folded access logs
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
// @Param path query string false "Path"
|
||||
// @Param p query int false "Page index"
|
||||
// @Param page_size query int false "Page size"
|
||||
// @Param sort_by query string false "Sort by"
|
||||
// @Param sort_order query string false "Sort order"
|
||||
// @Param fold_minutes query int false "Fold minutes"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/access-logs/folds [get]
|
||||
func GetFoldedAccessLogs(c *gin.Context) {
|
||||
query := readAccessLogQuery(c)
|
||||
query.FoldMinutes = readQueryInt(c, "fold_minutes")
|
||||
logs, err := service.ListFoldedAccessLogs(query)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, logs)
|
||||
}
|
||||
|
||||
// GetAccessLogIPSummaries godoc
|
||||
// @Summary List access log IP summaries
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
// @Param p query int false "Page index"
|
||||
// @Param page_size query int false "Page size"
|
||||
// @Param sort_by query string false "Sort by"
|
||||
// @Param sort_order query string false "Sort order"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/access-logs/ip-summary [get]
|
||||
func GetAccessLogIPSummaries(c *gin.Context) {
|
||||
result, err := service.ListAccessLogIPSummaries(service.AccessLogIPSummaryQuery{
|
||||
NodeID: c.Query("node_id"),
|
||||
RemoteAddr: c.Query("remote_addr"),
|
||||
Host: c.Query("host"),
|
||||
Page: readQueryInt(c, "p"),
|
||||
PageSize: readQueryInt(c, "page_size"),
|
||||
SortBy: c.Query("sort_by"),
|
||||
SortOrder: c.Query("sort_order"),
|
||||
})
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
// GetAccessLogIPTrend godoc
|
||||
// @Summary Get access log IP trend
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string true "Remote address"
|
||||
// @Param host query string false "Host"
|
||||
// @Param hours query int false "Hours"
|
||||
// @Param bucket_minutes query int false "Bucket minutes"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/access-logs/ip-summary/trend [get]
|
||||
func GetAccessLogIPTrend(c *gin.Context) {
|
||||
result, err := service.GetAccessLogIPTrend(service.AccessLogIPTrendQuery{
|
||||
NodeID: c.Query("node_id"),
|
||||
RemoteAddr: c.Query("remote_addr"),
|
||||
Host: c.Query("host"),
|
||||
Hours: readQueryInt(c, "hours"),
|
||||
BucketMinutes: readQueryInt(c, "bucket_minutes"),
|
||||
})
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
// CleanupAccessLogs godoc
|
||||
// @Summary Cleanup access logs by retention days
|
||||
// @Tags AccessLogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/access-logs/cleanup [post]
|
||||
func CleanupAccessLogs(c *gin.Context) {
|
||||
var input service.AccessLogCleanupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
result, err := service.CleanupAccessLogs(input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
func readAccessLogQuery(c *gin.Context) service.AccessLogQuery {
|
||||
return service.AccessLogQuery{
|
||||
NodeID: c.Query("node_id"),
|
||||
RemoteAddr: c.Query("remote_addr"),
|
||||
Host: c.Query("host"),
|
||||
Path: c.Query("path"),
|
||||
Page: readQueryInt(c, "p"),
|
||||
PageSize: readQueryInt(c, "page_size"),
|
||||
SortBy: c.Query("sort_by"),
|
||||
SortOrder: c.Query("sort_order"),
|
||||
}
|
||||
}
|
||||
|
||||
func readQueryInt(c *gin.Context, key string) int {
|
||||
value, _ := strconv.Atoi(c.DefaultQuery(key, "0"))
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -144,10 +145,45 @@ func GetNodes(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/apply-logs/ [get]
|
||||
func GetApplyLogs(c *gin.Context) {
|
||||
logs, err := service.ListApplyLogs(c.Query("node_id"))
|
||||
logs, err := service.ListApplyLogsPage(service.ApplyLogListQuery{
|
||||
NodeID: c.Query("node_id"),
|
||||
PageNo: readIntQueryFallback(c, "pageNo", "page_no"),
|
||||
PageSize: readIntQueryFallback(c, "pageSize", "page_size"),
|
||||
})
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, logs)
|
||||
}
|
||||
|
||||
// CleanupApplyLogs godoc
|
||||
// @Summary Cleanup apply logs
|
||||
// @Tags ApplyLogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/apply-logs/cleanup [post]
|
||||
func CleanupApplyLogs(c *gin.Context) {
|
||||
var input service.ApplyLogCleanupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
result, err := service.CleanupApplyLogs(input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
func readIntQueryFallback(c *gin.Context, primary string, secondary string) int {
|
||||
value := c.Query(primary)
|
||||
if value == "" {
|
||||
value = c.Query(secondary)
|
||||
}
|
||||
parsed, _ := strconv.Atoi(value)
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetConfigVersions godoc
|
||||
@@ -30,6 +31,39 @@ func GetConfigVersions(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetConfigVersion godoc
|
||||
// @Summary Get config version detail
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Version ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/{id} [get]
|
||||
func GetConfigVersion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid id",
|
||||
})
|
||||
return
|
||||
}
|
||||
version, err := service.GetConfigVersionDetail(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": version,
|
||||
})
|
||||
}
|
||||
|
||||
// GetActiveConfigVersion godoc
|
||||
// @Summary Get active config version
|
||||
// @Tags ConfigVersions
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
@@ -113,6 +114,16 @@ func validateOpenRestyOption(key string, value string) error {
|
||||
default:
|
||||
return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key)
|
||||
}
|
||||
case "OpenRestyResolvers":
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
for _, token := range splitOpenRestyResolvers(trimmed) {
|
||||
if net.ParseIP(token) == nil {
|
||||
return fmt.Errorf("%s only supports IP resolver entries, invalid value %q", key, token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "OpenRestyEventsMultiAcceptEnabled",
|
||||
"OpenRestyWebsocketEnabled",
|
||||
"OpenRestyProxyRequestBufferingEnabled",
|
||||
@@ -176,6 +187,12 @@ func validateOpenRestyOption(key string, value string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func splitOpenRestyResolvers(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||
})
|
||||
}
|
||||
|
||||
// GetOptions godoc
|
||||
// @Summary List editable options
|
||||
// @Tags Options
|
||||
@@ -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)
|
||||
|
||||
@@ -32,6 +32,9 @@ func TestValidateOpenRestyOption(t *testing.T) {
|
||||
{name: "cache use stale invalid", key: "OpenRestyCacheUseStale", value: "error whatever", wantErr: true},
|
||||
{name: "gzip level valid", key: "OpenRestyGzipCompLevel", value: "9"},
|
||||
{name: "gzip level invalid", key: "OpenRestyGzipCompLevel", value: "10", wantErr: true},
|
||||
{name: "resolvers empty", key: "OpenRestyResolvers", value: ""},
|
||||
{name: "resolvers valid", key: "OpenRestyResolvers", value: "1.1.1.1, 8.8.8.8"},
|
||||
{name: "resolvers invalid", key: "OpenRestyResolvers", value: "dns.internal", wantErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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 (
|
||||
@@ -41,12 +43,15 @@ 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
|
||||
@@ -57,13 +62,12 @@ require (
|
||||
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/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
|
||||
|
||||
+18
-73
@@ -7,17 +7,12 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
|
||||
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/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 +25,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 +46,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 +66,21 @@ 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/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 +95,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 +113,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,9 +124,6 @@ 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/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
@@ -148,9 +131,6 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
|
||||
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,18 +148,13 @@ 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -192,11 +167,9 @@ 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=
|
||||
@@ -206,58 +179,40 @@ github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy
|
||||
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/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 +224,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 +239,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 +247,12 @@ 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=
|
||||
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=
|
||||
|
||||
@@ -88,7 +88,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 {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
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"`
|
||||
@@ -15,13 +25,29 @@ type ApplyLog struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ListApplyLogs(nodeID string) (logs []*ApplyLog, err error) {
|
||||
query := DB.Order("id desc")
|
||||
func ListApplyLogs(query ApplyLogQuery) (logs []*ApplyLog, err error) {
|
||||
db := DB.Order("id desc")
|
||||
if query.NodeID != "" {
|
||||
db = db.Where("node_id = ?", query.NodeID)
|
||||
}
|
||||
if query.PageSize > 0 {
|
||||
offset := 0
|
||||
if query.PageNo > 1 {
|
||||
offset = (query.PageNo - 1) * query.PageSize
|
||||
}
|
||||
db = db.Limit(query.PageSize).Offset(offset)
|
||||
}
|
||||
err = db.Find(&logs).Error
|
||||
return logs, err
|
||||
}
|
||||
|
||||
func CountApplyLogs(nodeID string) (total int64, err error) {
|
||||
query := DB.Model(&ApplyLog{})
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
err = query.Find(&logs).Error
|
||||
return logs, err
|
||||
err = query.Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
func GetLatestApplyLog(nodeID string) (*ApplyLog, error) {
|
||||
@@ -49,3 +75,13 @@ func GetLatestApplyLogsByNodeIDs(nodeIDs []string) (map[string]*ApplyLog, error)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DeleteAllApplyLogs() (deleted int64, err error) {
|
||||
result := DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ApplyLog{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func DeleteApplyLogsBefore(before time.Time) (deleted int64, err error) {
|
||||
result := DB.Where("created_at < ?", before).Delete(&ApplyLog{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ConfigVersionSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigVersion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
|
||||
@@ -15,8 +24,11 @@ type ConfigVersion struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ListConfigVersions() (versions []*ConfigVersion, err error) {
|
||||
err = DB.Order("id desc").Find(&versions).Error
|
||||
func ListConfigVersionSummaries() (versions []*ConfigVersionSummary, err error) {
|
||||
err = DB.Model(&ConfigVersion{}).
|
||||
Select("id", "version", "checksum", "is_active", "created_by", "created_at").
|
||||
Order("id desc").
|
||||
Find(&versions).Error
|
||||
return versions, err
|
||||
}
|
||||
|
||||
|
||||
+219
-73
@@ -1,17 +1,66 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
func registeredModels() []any {
|
||||
return []any{
|
||||
&File{},
|
||||
&User{},
|
||||
&Option{},
|
||||
&ProxyRoute{},
|
||||
&ConfigVersion{},
|
||||
&Node{},
|
||||
&NodeSystemProfile{},
|
||||
&ApplyLog{},
|
||||
&NodeMetricSnapshot{},
|
||||
&NodeRequestReport{},
|
||||
&NodeAccessLog{},
|
||||
&NodeHealthEvent{},
|
||||
&TLSCertificate{},
|
||||
&ManagedDomain{},
|
||||
}
|
||||
}
|
||||
|
||||
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 +97,185 @@ 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
|
||||
func openDatabase() (*gorm.DB, string, error) {
|
||||
if common.SQLDSN != "" {
|
||||
db, err := gorm.Open(postgres.Open(common.SQLDSN), &gorm.Config{
|
||||
PrepareStmt: true,
|
||||
})
|
||||
} 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")
|
||||
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{
|
||||
PrepareStmt: true,
|
||||
})
|
||||
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{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
|
||||
for _, item := range registeredModels() {
|
||||
var count int64
|
||||
if err := db.Model(item).Limit(1).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
err = db.AutoMigrate(&User{})
|
||||
if err != nil {
|
||||
return err
|
||||
if count > 0 {
|
||||
return false, nil
|
||||
}
|
||||
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 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
|
||||
} else {
|
||||
}
|
||||
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 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 = migrateProxyRouteEnableHTTPSColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = autoMigrateAll(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = migrateSQLiteDataIfNeeded(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return createRootAccountIfNeed()
|
||||
}
|
||||
|
||||
func CloseDB() error {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openTestSQLiteDB(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)
|
||||
}
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
t.Fatalf("auto migrate 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 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)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ func ListNodes() (nodes []*Node, err error) {
|
||||
return nodes, err
|
||||
}
|
||||
|
||||
func ListNodesByNodeIDs(nodeIDs []string) (nodes []*Node, err error) {
|
||||
if len(nodeIDs) == 0 {
|
||||
return []*Node{}, nil
|
||||
}
|
||||
err = DB.Where("node_id IN ?", nodeIDs).Find(&nodes).Error
|
||||
return nodes, err
|
||||
}
|
||||
|
||||
func GetNodeByNodeID(nodeID string) (*Node, error) {
|
||||
node := &Node{}
|
||||
err := DB.Where("node_id = ?", nodeID).First(node).Error
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NodeAccessLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
LoggedAt time.Time `json:"logged_at" gorm:"index"`
|
||||
RemoteAddr string `json:"remote_addr" gorm:"size:128"`
|
||||
NodeID string `json:"node_id" gorm:"index:idx_node_access_logs_node_logged_at,priority:1;size:64;not null"`
|
||||
LoggedAt time.Time `json:"logged_at" gorm:"index:idx_node_access_logs_logged_at;index:idx_node_access_logs_node_logged_at,priority:2"`
|
||||
RemoteAddr string `json:"remote_addr" gorm:"index:idx_node_access_logs_remote_addr;size:128"`
|
||||
Region string `json:"region" gorm:"size:128"`
|
||||
Host string `json:"host" gorm:"size:255"`
|
||||
Host string `json:"host" gorm:"index:idx_node_access_logs_host;size:255"`
|
||||
Path string `json:"path" gorm:"size:2048"`
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusCode int `json:"status_code" gorm:"index:idx_node_access_logs_status_code"`
|
||||
RawJSON string `json:"raw_json" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -20,39 +26,91 @@ type NodeAccessLogRegionCount struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
func ListNodeAccessLogs(nodeID string, since time.Time, offset int, limit int) (logs []*NodeAccessLog, err error) {
|
||||
query := DB.Order("logged_at desc, id desc")
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("logged_at >= ?", since)
|
||||
}
|
||||
if offset > 0 {
|
||||
query = query.Offset(offset)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err = query.Find(&logs).Error
|
||||
type NodeAccessLogQuery struct {
|
||||
NodeID string
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Path string
|
||||
Since time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
type NodeAccessLogBucketQuery struct {
|
||||
NodeID string
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Path string
|
||||
Since time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
FoldMinutes int
|
||||
}
|
||||
|
||||
type NodeAccessLogBucketRow struct {
|
||||
BucketEpoch int64 `json:"bucket_epoch"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
UniqueIPCount int64 `json:"unique_ip_count"`
|
||||
UniqueHostCount int64 `json:"unique_host_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
ClientErrorCount int64 `json:"client_error_count"`
|
||||
ServerErrorCount int64 `json:"server_error_count"`
|
||||
}
|
||||
|
||||
type NodeAccessLogIPSummaryQuery struct {
|
||||
NodeID string
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Since time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
type NodeAccessLogIPSummaryRow struct {
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
RecentRequests int64 `json:"recent_requests"`
|
||||
LastSeenEpoch int64 `json:"last_seen_epoch"`
|
||||
}
|
||||
|
||||
type NodeAccessLogIPTrendQuery struct {
|
||||
NodeID string
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Since time.Time
|
||||
BucketMinutes int
|
||||
}
|
||||
|
||||
type NodeAccessLogTrendPointRow struct {
|
||||
BucketEpoch int64 `json:"bucket_epoch"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
}
|
||||
|
||||
func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err error) {
|
||||
offset := query.Page * query.PageSize
|
||||
db := buildNodeAccessLogQuery(DB, query).
|
||||
Order(buildNodeAccessLogSortClause(query.SortBy, query.SortOrder)).
|
||||
Limit(query.PageSize).
|
||||
Offset(offset)
|
||||
err = db.Find(&logs).Error
|
||||
return logs, err
|
||||
}
|
||||
|
||||
func CountNodeAccessLogs(nodeID string, since time.Time) (totalRecords int64, totalIPs int64, err error) {
|
||||
query := DB.Model(&NodeAccessLog{})
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("logged_at >= ?", since)
|
||||
}
|
||||
if err = query.Count(&totalRecords).Error; err != nil {
|
||||
func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) {
|
||||
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query)
|
||||
if err = base.Count(&totalRecords).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err = query.
|
||||
distinctQuery := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query).
|
||||
Where("remote_addr <> ''").
|
||||
Distinct("remote_addr").
|
||||
Count(&totalIPs).Error; err != nil {
|
||||
Distinct("remote_addr")
|
||||
if err = distinctQuery.Count(&totalIPs).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return totalRecords, totalIPs, nil
|
||||
@@ -75,3 +133,203 @@ func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (i
|
||||
err = query.Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func ListNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (items []*NodeAccessLogBucketRow, err error) {
|
||||
offset := query.Page * query.PageSize
|
||||
bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes)
|
||||
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Path: query.Path,
|
||||
Since: query.Since,
|
||||
})
|
||||
err = base.Select(fmt.Sprintf(
|
||||
"%s as bucket_epoch, count(*) as request_count, count(distinct remote_addr) as unique_ip_count, count(distinct host) as unique_host_count, sum(case when status_code < 400 then 1 else 0 end) as success_count, sum(case when status_code >= 400 and status_code < 500 then 1 else 0 end) as client_error_count, sum(case when status_code >= 500 then 1 else 0 end) as server_error_count",
|
||||
bucketExpr,
|
||||
)).
|
||||
Group(bucketExpr).
|
||||
Order(buildNodeAccessLogBucketSortClause(query.SortBy, query.SortOrder)).
|
||||
Limit(query.PageSize).
|
||||
Offset(offset).
|
||||
Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err error) {
|
||||
bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes)
|
||||
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Path: query.Path,
|
||||
Since: query.Since,
|
||||
})
|
||||
rows := []struct {
|
||||
BucketEpoch int64 `gorm:"column:bucket_epoch"`
|
||||
}{}
|
||||
err = base.Select(fmt.Sprintf("%s as bucket_epoch", bucketExpr)).
|
||||
Group(bucketExpr).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(rows)), nil
|
||||
}
|
||||
|
||||
func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) {
|
||||
offset := query.Page * query.PageSize
|
||||
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Since: query.Since,
|
||||
}).Where("remote_addr <> ''")
|
||||
lastSeenExpr := accessLogEpochExpr("max(logged_at)")
|
||||
err = base.Select(
|
||||
"remote_addr as remote_addr, count(*) as total_requests, sum(case when logged_at >= ? then 1 else 0 end) as recent_requests, "+lastSeenExpr+" as last_seen_epoch",
|
||||
recentSince,
|
||||
).
|
||||
Group("remote_addr").
|
||||
Order(buildNodeAccessLogIPSummarySortClause(query.SortBy, query.SortOrder)).
|
||||
Limit(query.PageSize).
|
||||
Offset(offset).
|
||||
Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int64, err error) {
|
||||
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Since: query.Since,
|
||||
}).Where("remote_addr <> ''")
|
||||
rows := []struct {
|
||||
RemoteAddr string `gorm:"column:remote_addr"`
|
||||
}{}
|
||||
err = base.Select("remote_addr").
|
||||
Group("remote_addr").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(rows)), nil
|
||||
}
|
||||
|
||||
func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAccessLogTrendPointRow, err error) {
|
||||
bucketExpr := accessLogBucketEpochExpr(query.BucketMinutes)
|
||||
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Since: query.Since,
|
||||
}).Where("remote_addr = ?", strings.TrimSpace(query.RemoteAddr))
|
||||
err = base.Select(fmt.Sprintf("%s as bucket_epoch, count(*) as request_count", bucketExpr)).
|
||||
Group(bucketExpr).
|
||||
Order("bucket_epoch asc").
|
||||
Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) {
|
||||
result := DB.Where("logged_at < ?", before).Delete(&NodeAccessLog{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
||||
if db == nil {
|
||||
db = DB.Model(&NodeAccessLog{})
|
||||
}
|
||||
if db.Statement == nil || db.Statement.Model == nil {
|
||||
db = db.Model(&NodeAccessLog{})
|
||||
}
|
||||
if trimmed := strings.TrimSpace(query.NodeID); trimmed != "" {
|
||||
db = db.Where("node_id LIKE ?", "%"+trimmed+"%")
|
||||
}
|
||||
if trimmed := strings.TrimSpace(query.RemoteAddr); trimmed != "" {
|
||||
db = db.Where("remote_addr LIKE ?", "%"+trimmed+"%")
|
||||
}
|
||||
if trimmed := strings.TrimSpace(query.Host); trimmed != "" {
|
||||
db = db.Where("host LIKE ?", "%"+trimmed+"%")
|
||||
}
|
||||
if trimmed := strings.TrimSpace(query.Path); trimmed != "" {
|
||||
db = db.Where("path LIKE ?", "%"+trimmed+"%")
|
||||
}
|
||||
if !query.Since.IsZero() {
|
||||
db = db.Where("logged_at >= ?", query.Since)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func buildNodeAccessLogSortClause(sortBy string, sortOrder string) string {
|
||||
column := "logged_at"
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "status_code":
|
||||
column = "status_code"
|
||||
case "remote_addr":
|
||||
column = "remote_addr"
|
||||
case "host":
|
||||
column = "host"
|
||||
case "path":
|
||||
column = "path"
|
||||
}
|
||||
order := normalizeSortOrder(sortOrder)
|
||||
if column == "logged_at" {
|
||||
return fmt.Sprintf("%s %s, id %s", column, order, order)
|
||||
}
|
||||
return fmt.Sprintf("%s %s, logged_at desc, id desc", column, order)
|
||||
}
|
||||
|
||||
func buildNodeAccessLogBucketSortClause(sortBy string, sortOrder string) string {
|
||||
order := normalizeSortOrder(sortOrder)
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "request_count":
|
||||
return fmt.Sprintf("request_count %s, bucket_epoch desc", order)
|
||||
default:
|
||||
return fmt.Sprintf("bucket_epoch %s", order)
|
||||
}
|
||||
}
|
||||
|
||||
func buildNodeAccessLogIPSummarySortClause(sortBy string, sortOrder string) string {
|
||||
order := normalizeSortOrder(sortOrder)
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "recent_requests":
|
||||
return fmt.Sprintf("recent_requests %s, last_seen_epoch desc, remote_addr asc", order)
|
||||
case "last_seen_at":
|
||||
return fmt.Sprintf("last_seen_epoch %s, total_requests desc, remote_addr asc", order)
|
||||
case "remote_addr":
|
||||
return fmt.Sprintf("remote_addr %s", order)
|
||||
default:
|
||||
return fmt.Sprintf("total_requests %s, last_seen_epoch desc, remote_addr asc", order)
|
||||
}
|
||||
}
|
||||
|
||||
func accessLogBucketEpochExpr(bucketMinutes int) string {
|
||||
bucketSeconds := bucketMinutes * 60
|
||||
if bucketSeconds <= 0 {
|
||||
bucketSeconds = 180
|
||||
}
|
||||
switch DB.Dialector.Name() {
|
||||
case "postgres":
|
||||
return fmt.Sprintf("CAST(floor(extract(epoch from logged_at) / %d) * %d AS BIGINT)", bucketSeconds, bucketSeconds)
|
||||
default:
|
||||
return fmt.Sprintf("CAST((strftime('%%s', logged_at) / %d) * %d AS INTEGER)", bucketSeconds, bucketSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func accessLogEpochExpr(expression string) string {
|
||||
switch DB.Dialector.Name() {
|
||||
case "postgres":
|
||||
return fmt.Sprintf("CAST(extract(epoch from %s) AS BIGINT)", expression)
|
||||
default:
|
||||
return fmt.Sprintf("CAST(strftime('%%s', %s) AS INTEGER)", expression)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSortOrder(sortOrder string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
|
||||
return "asc"
|
||||
}
|
||||
return "desc"
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["OpenRestyGzipEnabled"] = strconv.FormatBool(common.OpenRestyGzipEnabled)
|
||||
common.OptionMap["OpenRestyGzipMinLength"] = strconv.Itoa(common.OpenRestyGzipMinLength)
|
||||
common.OptionMap["OpenRestyGzipCompLevel"] = strconv.Itoa(common.OpenRestyGzipCompLevel)
|
||||
common.OptionMap["OpenRestyResolvers"] = common.OpenRestyResolvers
|
||||
common.OptionMap["OpenRestyCacheEnabled"] = strconv.FormatBool(common.OpenRestyCacheEnabled)
|
||||
common.OptionMap["OpenRestyCachePath"] = common.OpenRestyCachePath
|
||||
common.OptionMap["OpenRestyCacheLevels"] = common.OpenRestyCacheLevels
|
||||
@@ -298,6 +299,8 @@ func updateOptionMap(key string, value string) {
|
||||
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
||||
common.OpenRestyGzipCompLevel = v
|
||||
}
|
||||
case "OpenRestyResolvers":
|
||||
common.OpenRestyResolvers = strings.TrimSpace(value)
|
||||
case "OpenRestyCacheEnabled":
|
||||
common.OpenRestyCacheEnabled = value == "true"
|
||||
case "OpenRestyCachePath":
|
||||
|
||||
@@ -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,15 +45,15 @@ 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)
|
||||
}
|
||||
updateRoute := apiRouter.Group("/update")
|
||||
@@ -71,15 +71,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 +87,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 +97,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 +108,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())
|
||||
@@ -127,18 +128,23 @@ func SetApiRouter(router *gin.Engine) {
|
||||
nodeRoute.GET("/:id/observability", controller.GetNodeObservability)
|
||||
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")
|
||||
{
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
"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" {
|
||||
t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL)
|
||||
@@ -127,11 +127,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
|
||||
@@ -141,7 +163,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")
|
||||
@@ -162,7 +184,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)
|
||||
}
|
||||
@@ -210,7 +232,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,
|
||||
@@ -250,7 +272,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,
|
||||
@@ -264,7 +286,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -323,14 +325,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 +395,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)
|
||||
}
|
||||
@@ -401,7 +441,7 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
|
||||
|
||||
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{
|
||||
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",
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"openflare/model"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAccessLogPageSize = 50
|
||||
maxAccessLogPageSize = 200
|
||||
defaultAccessLogPageSize = 20
|
||||
maxAccessLogPageSize = 200
|
||||
defaultAccessLogSortBy = "logged_at"
|
||||
defaultAccessLogSortOrder = "desc"
|
||||
defaultAccessLogFoldMinute = 3
|
||||
defaultIPTrendHours = 24
|
||||
defaultIPTrendBucketMinute = 30
|
||||
maxIPTrendHours = 168
|
||||
nodeAccessLogRetentionDays = 90
|
||||
)
|
||||
|
||||
type AccessLogQuery struct {
|
||||
NodeID string `json:"node_id"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
SortBy string `json:"sort_by"`
|
||||
SortOrder string `json:"sort_order"`
|
||||
FoldMinutes int `json:"fold_minutes"`
|
||||
}
|
||||
|
||||
type AccessLogView struct {
|
||||
ID uint `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
@@ -32,40 +52,99 @@ type AccessLogList struct {
|
||||
TotalIP int64 `json:"total_ip"`
|
||||
}
|
||||
|
||||
func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, error) {
|
||||
normalizedPage := normalizeAccessLogPage(page)
|
||||
normalizedPageSize := normalizeAccessLogPageSize(pageSize)
|
||||
offset := normalizedPage * normalizedPageSize
|
||||
trimmedNodeID := strings.TrimSpace(nodeID)
|
||||
since := time.Now().Add(-nodeAccessLogRetentionWindow)
|
||||
logs, err := model.ListNodeAccessLogs(
|
||||
trimmedNodeID,
|
||||
since,
|
||||
offset,
|
||||
normalizedPageSize+1,
|
||||
)
|
||||
type FoldedAccessLogView struct {
|
||||
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
UniqueIPCount int64 `json:"unique_ip_count"`
|
||||
UniqueHostCount int64 `json:"unique_host_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
ClientErrorCount int64 `json:"client_error_count"`
|
||||
ServerErrorCount int64 `json:"server_error_count"`
|
||||
}
|
||||
|
||||
type FoldedAccessLogList struct {
|
||||
Items []FoldedAccessLogView `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
TotalBucket int64 `json:"total_bucket"`
|
||||
TotalRecord int64 `json:"total_record"`
|
||||
TotalIP int64 `json:"total_ip"`
|
||||
FoldMinutes int `json:"fold_minutes"`
|
||||
}
|
||||
|
||||
type AccessLogIPSummaryQuery struct {
|
||||
NodeID string `json:"node_id"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Host string `json:"host"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
SortBy string `json:"sort_by"`
|
||||
SortOrder string `json:"sort_order"`
|
||||
}
|
||||
|
||||
type AccessLogIPSummaryView struct {
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
RecentRequests int64 `json:"recent_requests"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type AccessLogIPSummaryList struct {
|
||||
Items []AccessLogIPSummaryView `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
TotalIP int64 `json:"total_ip"`
|
||||
SortBy string `json:"sort_by"`
|
||||
SortOrder string `json:"sort_order"`
|
||||
}
|
||||
|
||||
type AccessLogIPTrendQuery struct {
|
||||
NodeID string `json:"node_id"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Host string `json:"host"`
|
||||
Hours int `json:"hours"`
|
||||
BucketMinutes int `json:"bucket_minutes"`
|
||||
}
|
||||
|
||||
type AccessLogIPTrendPoint struct {
|
||||
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
}
|
||||
|
||||
type AccessLogIPTrendView struct {
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Hours int `json:"hours"`
|
||||
BucketMinutes int `json:"bucket_minutes"`
|
||||
Points []AccessLogIPTrendPoint `json:"points"`
|
||||
}
|
||||
|
||||
type AccessLogCleanupInput struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
}
|
||||
|
||||
type AccessLogCleanupResult struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
DeletedCount int64 `json:"deleted_count"`
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
}
|
||||
|
||||
func ListAccessLogs(input AccessLogQuery) (*AccessLogList, error) {
|
||||
normalized := normalizeAccessLogQuery(input)
|
||||
modelQuery := buildModelAccessLogQuery(normalized)
|
||||
logs, err := model.ListNodeAccessLogs(modelQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalRecords, totalIPs, err := model.CountNodeAccessLogs(trimmedNodeID, since)
|
||||
totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := model.ListNodes()
|
||||
nodeNames, err := listNodeNameMap(logs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodeNames := make(map[string]string, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
nodeNames[node.NodeID] = node.Name
|
||||
}
|
||||
hasMore := len(logs) > normalizedPageSize
|
||||
if hasMore {
|
||||
logs = logs[:normalizedPageSize]
|
||||
}
|
||||
views := make([]AccessLogView, 0, len(logs))
|
||||
for _, item := range logs {
|
||||
if item == nil {
|
||||
@@ -85,14 +164,270 @@ func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, erro
|
||||
}
|
||||
return &AccessLogList{
|
||||
Items: views,
|
||||
Page: normalizedPage,
|
||||
PageSize: normalizedPageSize,
|
||||
HasMore: hasMore,
|
||||
Page: normalized.Page,
|
||||
PageSize: normalized.PageSize,
|
||||
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalRecords,
|
||||
TotalRecord: totalRecords,
|
||||
TotalIP: totalIPs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) {
|
||||
normalized := normalizeAccessLogQuery(input)
|
||||
foldMinutes, err := normalizeFoldMinutes(normalized.FoldMinutes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelQuery := buildModelAccessLogQuery(normalized)
|
||||
bucketQuery := model.NodeAccessLogBucketQuery{
|
||||
NodeID: modelQuery.NodeID,
|
||||
RemoteAddr: modelQuery.RemoteAddr,
|
||||
Host: modelQuery.Host,
|
||||
Path: modelQuery.Path,
|
||||
Since: modelQuery.Since,
|
||||
Page: normalized.Page,
|
||||
PageSize: normalized.PageSize,
|
||||
SortBy: normalizeFoldSortBy(normalized.SortBy),
|
||||
SortOrder: normalized.SortOrder,
|
||||
FoldMinutes: foldMinutes,
|
||||
}
|
||||
items, err := model.ListNodeAccessLogBuckets(bucketQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalBuckets, err := model.CountNodeAccessLogBuckets(bucketQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]FoldedAccessLogView, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, FoldedAccessLogView{
|
||||
BucketStartedAt: time.Unix(item.BucketEpoch, 0).UTC(),
|
||||
RequestCount: item.RequestCount,
|
||||
UniqueIPCount: item.UniqueIPCount,
|
||||
UniqueHostCount: item.UniqueHostCount,
|
||||
SuccessCount: item.SuccessCount,
|
||||
ClientErrorCount: item.ClientErrorCount,
|
||||
ServerErrorCount: item.ServerErrorCount,
|
||||
})
|
||||
}
|
||||
return &FoldedAccessLogList{
|
||||
Items: views,
|
||||
Page: normalized.Page,
|
||||
PageSize: normalized.PageSize,
|
||||
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalBuckets,
|
||||
TotalBucket: totalBuckets,
|
||||
TotalRecord: totalRecords,
|
||||
TotalIP: totalIPs,
|
||||
FoldMinutes: foldMinutes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
|
||||
normalized := normalizeAccessLogIPSummaryQuery(input)
|
||||
since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow)
|
||||
recentSince := time.Now().UTC().Add(-3 * time.Hour)
|
||||
query := model.NodeAccessLogIPSummaryQuery{
|
||||
NodeID: strings.TrimSpace(normalized.NodeID),
|
||||
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
|
||||
Host: strings.TrimSpace(normalized.Host),
|
||||
Since: since,
|
||||
Page: normalized.Page,
|
||||
PageSize: normalized.PageSize,
|
||||
SortBy: normalized.SortBy,
|
||||
SortOrder: normalized.SortOrder,
|
||||
}
|
||||
items, err := model.ListNodeAccessLogIPSummaries(query, recentSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalIP, err := model.CountNodeAccessLogIPSummaries(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]AccessLogIPSummaryView, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, AccessLogIPSummaryView{
|
||||
RemoteAddr: item.RemoteAddr,
|
||||
TotalRequests: item.TotalRequests,
|
||||
RecentRequests: item.RecentRequests,
|
||||
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
|
||||
})
|
||||
}
|
||||
return &AccessLogIPSummaryList{
|
||||
Items: views,
|
||||
Page: normalized.Page,
|
||||
PageSize: normalized.PageSize,
|
||||
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP,
|
||||
TotalIP: totalIP,
|
||||
SortBy: normalized.SortBy,
|
||||
SortOrder: normalized.SortOrder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetAccessLogIPTrend(input AccessLogIPTrendQuery) (*AccessLogIPTrendView, error) {
|
||||
normalized, err := normalizeAccessLogIPTrendQuery(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points, err := model.ListNodeAccessLogIPTrend(model.NodeAccessLogIPTrendQuery{
|
||||
NodeID: strings.TrimSpace(normalized.NodeID),
|
||||
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
|
||||
Host: strings.TrimSpace(normalized.Host),
|
||||
Since: time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour),
|
||||
BucketMinutes: normalized.BucketMinutes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pointMap := make(map[int64]int64, len(points))
|
||||
for _, item := range points {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
pointMap[item.BucketEpoch] = item.RequestCount
|
||||
}
|
||||
bucketDuration := time.Duration(normalized.BucketMinutes) * time.Minute
|
||||
start := time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour).Truncate(bucketDuration)
|
||||
end := time.Now().UTC().Truncate(bucketDuration)
|
||||
views := make([]AccessLogIPTrendPoint, 0, int(end.Sub(start)/bucketDuration)+1)
|
||||
for cursor := start; !cursor.After(end); cursor = cursor.Add(bucketDuration) {
|
||||
views = append(views, AccessLogIPTrendPoint{
|
||||
BucketStartedAt: cursor,
|
||||
RequestCount: pointMap[cursor.Unix()],
|
||||
})
|
||||
}
|
||||
return &AccessLogIPTrendView{
|
||||
RemoteAddr: normalized.RemoteAddr,
|
||||
Hours: normalized.Hours,
|
||||
BucketMinutes: normalized.BucketMinutes,
|
||||
Points: views,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func CleanupAccessLogs(input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
|
||||
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
|
||||
return nil, errors.New("retention_days 必须在 1 到 90 之间")
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
|
||||
deleted, err := model.DeleteNodeAccessLogsBefore(cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AccessLogCleanupResult{
|
||||
RetentionDays: input.RetentionDays,
|
||||
DeletedCount: deleted,
|
||||
Cutoff: cutoff,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildModelAccessLogQuery(input AccessLogQuery) model.NodeAccessLogQuery {
|
||||
return model.NodeAccessLogQuery{
|
||||
NodeID: strings.TrimSpace(input.NodeID),
|
||||
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
|
||||
Host: strings.TrimSpace(input.Host),
|
||||
Path: strings.TrimSpace(input.Path),
|
||||
Since: time.Now().UTC().Add(-nodeAccessLogRetentionWindow),
|
||||
Page: input.Page,
|
||||
PageSize: input.PageSize,
|
||||
SortBy: input.SortBy,
|
||||
SortOrder: input.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func listNodeNameMap(logs []*model.NodeAccessLog) (map[string]string, error) {
|
||||
nodeIDs := make([]string, 0, len(logs))
|
||||
seen := make(map[string]struct{}, len(logs))
|
||||
for _, item := range logs {
|
||||
if item == nil || item.NodeID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item.NodeID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item.NodeID] = struct{}{}
|
||||
nodeIDs = append(nodeIDs, item.NodeID)
|
||||
}
|
||||
nodes, err := model.ListNodesByNodeIDs(nodeIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
result[node.NodeID] = node.Name
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
|
||||
return AccessLogQuery{
|
||||
NodeID: strings.TrimSpace(input.NodeID),
|
||||
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
|
||||
Host: strings.TrimSpace(input.Host),
|
||||
Path: strings.TrimSpace(input.Path),
|
||||
Page: normalizeAccessLogPage(input.Page),
|
||||
PageSize: normalizeAccessLogPageSize(input.PageSize),
|
||||
SortBy: normalizeAccessLogSortBy(input.SortBy),
|
||||
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
|
||||
FoldMinutes: input.FoldMinutes,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIPSummaryQuery {
|
||||
return AccessLogIPSummaryQuery{
|
||||
NodeID: strings.TrimSpace(input.NodeID),
|
||||
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
|
||||
Host: strings.TrimSpace(input.Host),
|
||||
Page: normalizeAccessLogPage(input.Page),
|
||||
PageSize: normalizeAccessLogPageSize(input.PageSize),
|
||||
SortBy: normalizeIPSummarySortBy(input.SortBy),
|
||||
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
|
||||
remoteAddr := strings.TrimSpace(input.RemoteAddr)
|
||||
if remoteAddr == "" {
|
||||
return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空")
|
||||
}
|
||||
hours := input.Hours
|
||||
if hours <= 0 {
|
||||
hours = defaultIPTrendHours
|
||||
}
|
||||
if hours > maxIPTrendHours {
|
||||
hours = maxIPTrendHours
|
||||
}
|
||||
bucketMinutes := input.BucketMinutes
|
||||
if bucketMinutes <= 0 {
|
||||
bucketMinutes = defaultIPTrendBucketMinute
|
||||
}
|
||||
switch bucketMinutes {
|
||||
case 5, 10, 15, 30, 60:
|
||||
default:
|
||||
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60")
|
||||
}
|
||||
return AccessLogIPTrendQuery{
|
||||
NodeID: strings.TrimSpace(input.NodeID),
|
||||
RemoteAddr: remoteAddr,
|
||||
Host: strings.TrimSpace(input.Host),
|
||||
Hours: hours,
|
||||
BucketMinutes: bucketMinutes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAccessLogPage(page int) int {
|
||||
if page < 0 {
|
||||
return 0
|
||||
@@ -109,3 +444,49 @@ func normalizeAccessLogPageSize(pageSize int) int {
|
||||
}
|
||||
return pageSize
|
||||
}
|
||||
|
||||
func normalizeAccessLogSortBy(sortBy string) string {
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "status_code", "remote_addr", "host", "path":
|
||||
return strings.TrimSpace(sortBy)
|
||||
default:
|
||||
return defaultAccessLogSortBy
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAccessLogSortOrder(sortOrder string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
|
||||
return "asc"
|
||||
}
|
||||
return defaultAccessLogSortOrder
|
||||
}
|
||||
|
||||
func normalizeFoldSortBy(sortBy string) string {
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "request_count":
|
||||
return "request_count"
|
||||
default:
|
||||
return "bucket_started_at"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIPSummarySortBy(sortBy string) string {
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "recent_requests", "last_seen_at", "remote_addr":
|
||||
return strings.TrimSpace(sortBy)
|
||||
default:
|
||||
return "total_requests"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFoldMinutes(value int) (int, error) {
|
||||
if value <= 0 {
|
||||
return defaultAccessLogFoldMinute, nil
|
||||
}
|
||||
switch value {
|
||||
case 3, 5:
|
||||
return value, nil
|
||||
default:
|
||||
return 0, errors.New("fold_minutes 仅支持 3 或 5")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
|
||||
result, err := ListAccessLogs("", 0, 2)
|
||||
result, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessLogs failed: %v", err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
|
||||
t.Fatal("expected has_more to be true")
|
||||
}
|
||||
|
||||
filtered, err := ListAccessLogs("node-a", 0, 50)
|
||||
filtered, err := ListAccessLogs(AccessLogQuery{NodeID: "node-a", Page: 0, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessLogs filtered failed: %v", err)
|
||||
}
|
||||
@@ -98,3 +98,163 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
|
||||
t.Fatalf("expected filtered items=2, got %d", len(filtered.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccessLogsUsesDefaultPageSize(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
if err := model.DB.Create(&model.Node{
|
||||
NodeID: "node-default-page-size",
|
||||
Name: "edge-default-page-size",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed node: %v", err)
|
||||
}
|
||||
|
||||
logs := make([]*model.NodeAccessLog, 0, 25)
|
||||
for index := range 25 {
|
||||
logs = append(logs, &model.NodeAccessLog{
|
||||
NodeID: "node-default-page-size",
|
||||
LoggedAt: now.Add(-time.Duration(index) * time.Minute),
|
||||
RemoteAddr: "1.1.1.1",
|
||||
Host: "example.com",
|
||||
Path: "/default-page-size",
|
||||
StatusCode: 200,
|
||||
})
|
||||
}
|
||||
if err := model.DB.Create(&logs).Error; err != nil {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
|
||||
result, err := ListAccessLogs(AccessLogQuery{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessLogs failed: %v", err)
|
||||
}
|
||||
if result.PageSize != 20 {
|
||||
t.Fatalf("expected default page_size=20, got %d", result.PageSize)
|
||||
}
|
||||
if len(result.Items) != 20 {
|
||||
t.Fatalf("expected current page items=20, got %d", len(result.Items))
|
||||
}
|
||||
if !result.HasMore {
|
||||
t.Fatal("expected has_more to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := model.DB.Create(&model.Node{
|
||||
NodeID: "node-folded",
|
||||
Name: "edge-folded",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed node: %v", err)
|
||||
}
|
||||
logs := []*model.NodeAccessLog{
|
||||
{
|
||||
NodeID: "node-folded",
|
||||
LoggedAt: now.Add(-4 * time.Minute),
|
||||
RemoteAddr: "203.0.113.1",
|
||||
Host: "alpha.example.com",
|
||||
Path: "/first",
|
||||
StatusCode: 200,
|
||||
},
|
||||
{
|
||||
NodeID: "node-folded",
|
||||
LoggedAt: now.Add(-3 * time.Minute),
|
||||
RemoteAddr: "203.0.113.1",
|
||||
Host: "alpha.example.com",
|
||||
Path: "/second",
|
||||
StatusCode: 502,
|
||||
},
|
||||
{
|
||||
NodeID: "node-folded",
|
||||
LoggedAt: now.Add(-2 * time.Minute),
|
||||
RemoteAddr: "203.0.113.2",
|
||||
Host: "beta.example.com",
|
||||
Path: "/third",
|
||||
StatusCode: 404,
|
||||
},
|
||||
}
|
||||
if err := model.DB.Create(&logs).Error; err != nil {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
|
||||
folded, err := ListFoldedAccessLogs(AccessLogQuery{
|
||||
NodeID: "node-folded",
|
||||
Page: 0,
|
||||
PageSize: 10,
|
||||
FoldMinutes: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListFoldedAccessLogs failed: %v", err)
|
||||
}
|
||||
if len(folded.Items) != 2 {
|
||||
t.Fatalf("expected two folded buckets, got %+v", folded.Items)
|
||||
}
|
||||
if folded.TotalRecord != 3 || folded.TotalBucket != 2 {
|
||||
t.Fatalf("unexpected folded totals: %+v", folded)
|
||||
}
|
||||
if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 {
|
||||
t.Fatalf("unexpected folded request count sum: %+v", folded.Items)
|
||||
}
|
||||
|
||||
ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{
|
||||
NodeID: "node-folded",
|
||||
Page: 0,
|
||||
PageSize: 10,
|
||||
SortBy: "total_requests",
|
||||
SortOrder: "desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessLogIPSummaries failed: %v", err)
|
||||
}
|
||||
if len(ipSummaries.Items) != 2 {
|
||||
t.Fatalf("expected two ip summary rows, got %+v", ipSummaries.Items)
|
||||
}
|
||||
if ipSummaries.Items[0].RemoteAddr != "203.0.113.1" || ipSummaries.Items[0].TotalRequests != 2 {
|
||||
t.Fatalf("unexpected top ip summary row: %+v", ipSummaries.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := model.DB.Create([]*model.NodeAccessLog{
|
||||
{
|
||||
NodeID: "node-cleanup",
|
||||
LoggedAt: now.Add(-10 * 24 * time.Hour),
|
||||
RemoteAddr: "203.0.113.9",
|
||||
Host: "cleanup.example.com",
|
||||
Path: "/old",
|
||||
StatusCode: 200,
|
||||
},
|
||||
{
|
||||
NodeID: "node-cleanup",
|
||||
LoggedAt: now.Add(-2 * 24 * time.Hour),
|
||||
RemoteAddr: "203.0.113.10",
|
||||
Host: "cleanup.example.com",
|
||||
Path: "/recent",
|
||||
StatusCode: 200,
|
||||
},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed cleanup logs: %v", err)
|
||||
}
|
||||
|
||||
result, err := CleanupAccessLogs(AccessLogCleanupInput{RetentionDays: 7})
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupAccessLogs failed: %v", err)
|
||||
}
|
||||
if result.DeletedCount != 1 {
|
||||
t.Fatalf("expected 1 deleted record, got %+v", result)
|
||||
}
|
||||
|
||||
remaining, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 10, NodeID: "node-cleanup"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessLogs failed after cleanup: %v", err)
|
||||
}
|
||||
if len(remaining.Items) != 1 || remaining.Items[0].Path != "/recent" {
|
||||
t.Fatalf("unexpected remaining logs after cleanup: %+v", remaining.Items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
NodeStatusOffline = "offline"
|
||||
NodeStatusPending = "pending"
|
||||
ApplyResultOK = "success"
|
||||
ApplyResultWarning = "warning"
|
||||
ApplyResultFailed = "failed"
|
||||
OpenrestyStatusHealthy = "healthy"
|
||||
OpenrestyStatusUnhealthy = "unhealthy"
|
||||
@@ -52,6 +53,31 @@ type ApplyLogPayload struct {
|
||||
SupportFileCount int `json:"support_file_count"`
|
||||
}
|
||||
|
||||
type ApplyLogListQuery struct {
|
||||
NodeID string `json:"node_id"`
|
||||
PageNo int `json:"pageNo"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type ApplyLogListResult struct {
|
||||
Rows []*model.ApplyLog `json:"rows"`
|
||||
Current int `json:"current"`
|
||||
Total int `json:"total"`
|
||||
TotalPage int `json:"totalPage"`
|
||||
}
|
||||
|
||||
type ApplyLogCleanupInput struct {
|
||||
DeleteAll bool `json:"delete_all"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
}
|
||||
|
||||
type ApplyLogCleanupResult struct {
|
||||
DeleteAll bool `json:"delete_all"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
DeletedCount int64 `json:"deleted_count"`
|
||||
Cutoff *time.Time `json:"cutoff,omitempty"`
|
||||
}
|
||||
|
||||
type AgentConfigResponse struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
@@ -234,8 +260,8 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
|
||||
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 +299,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 +339,81 @@ func ListNodeViews() ([]*NodeView, error) {
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func ListApplyLogs(nodeID string) ([]*model.ApplyLog, error) {
|
||||
return model.ListApplyLogs(strings.TrimSpace(nodeID))
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
@@ -36,6 +37,10 @@ type ConfigPreviewResult struct {
|
||||
RouteCount int `json:"route_count"`
|
||||
}
|
||||
|
||||
type ConfigVersionSummary = model.ConfigVersionSummary
|
||||
|
||||
type ConfigVersionDetail = model.ConfigVersion
|
||||
|
||||
type ConfigDiffResult struct {
|
||||
ActiveVersion string `json:"active_version,omitempty"`
|
||||
AddedDomains []string `json:"added_domains"`
|
||||
@@ -89,6 +94,7 @@ type openRestyConfigSnapshot struct {
|
||||
GzipEnabled bool `json:"gzip_enabled"`
|
||||
GzipMinLength int `json:"gzip_min_length"`
|
||||
GzipCompLevel int `json:"gzip_comp_level"`
|
||||
Resolvers string `json:"resolvers,omitempty"`
|
||||
CacheEnabled bool `json:"cache_enabled"`
|
||||
CachePath string `json:"cache_path,omitempty"`
|
||||
CacheLevels string `json:"cache_levels"`
|
||||
@@ -151,15 +157,20 @@ var requiredMainConfigTemplatePlaceholders = []string{
|
||||
"{{OpenRestyGzip}}",
|
||||
"{{OpenRestyGzipMinLength}}",
|
||||
"{{OpenRestyGzipCompLevel}}",
|
||||
"{{OpenRestyResolverDirective}}",
|
||||
"{{OpenRestyCacheBlock}}",
|
||||
"{{OpenRestyRouteConfigInclude}}",
|
||||
}
|
||||
|
||||
func ListConfigVersions() ([]*model.ConfigVersion, error) {
|
||||
return model.ListConfigVersions()
|
||||
func ListConfigVersions() ([]*ConfigVersionSummary, error) {
|
||||
return model.ListConfigVersionSummaries()
|
||||
}
|
||||
|
||||
func GetActiveConfigVersion() (*model.ConfigVersion, error) {
|
||||
func GetConfigVersionDetail(id uint) (*ConfigVersionDetail, error) {
|
||||
return model.GetConfigVersionByID(id)
|
||||
}
|
||||
|
||||
func GetActiveConfigVersion() (*ConfigVersionDetail, error) {
|
||||
return model.GetActiveConfigVersion()
|
||||
}
|
||||
|
||||
@@ -353,7 +364,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
|
||||
}
|
||||
@@ -467,6 +478,7 @@ func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot {
|
||||
GzipEnabled: common.OpenRestyGzipEnabled,
|
||||
GzipMinLength: common.OpenRestyGzipMinLength,
|
||||
GzipCompLevel: common.OpenRestyGzipCompLevel,
|
||||
Resolvers: common.OpenRestyResolvers,
|
||||
CacheEnabled: common.OpenRestyCacheEnabled,
|
||||
CachePath: common.OpenRestyCachePath,
|
||||
CacheLevels: common.OpenRestyCacheLevels,
|
||||
@@ -528,6 +540,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
|
||||
appendIfChanged("OpenRestyGzipEnabled", fmt.Sprintf("%t", left.GzipEnabled), fmt.Sprintf("%t", right.GzipEnabled))
|
||||
appendIfChanged("OpenRestyGzipMinLength", fmt.Sprintf("%d", left.GzipMinLength), fmt.Sprintf("%d", right.GzipMinLength))
|
||||
appendIfChanged("OpenRestyGzipCompLevel", fmt.Sprintf("%d", left.GzipCompLevel), fmt.Sprintf("%d", right.GzipCompLevel))
|
||||
appendIfChanged("OpenRestyResolvers", left.Resolvers, right.Resolvers)
|
||||
appendIfChanged("OpenRestyCacheEnabled", fmt.Sprintf("%t", left.CacheEnabled), fmt.Sprintf("%t", right.CacheEnabled))
|
||||
appendIfChanged("OpenRestyCachePath", left.CachePath, right.CachePath)
|
||||
appendIfChanged("OpenRestyCacheLevels", left.CacheLevels, right.CacheLevels)
|
||||
@@ -574,6 +587,7 @@ func openRestyOptionKeys() []string {
|
||||
"OpenRestyGzipEnabled",
|
||||
"OpenRestyGzipMinLength",
|
||||
"OpenRestyGzipCompLevel",
|
||||
"OpenRestyResolvers",
|
||||
"OpenRestyCacheEnabled",
|
||||
"OpenRestyCachePath",
|
||||
"OpenRestyCacheLevels",
|
||||
@@ -586,7 +600,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)
|
||||
@@ -596,7 +610,7 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error
|
||||
return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
|
||||
}
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders))
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg))
|
||||
continue
|
||||
}
|
||||
if route.CertID == nil || *route.CertID == 0 {
|
||||
@@ -613,9 +627,9 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error
|
||||
if route.RedirectHTTP {
|
||||
builder.WriteString(renderHTTPRedirectServer(route.Domain))
|
||||
} else {
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders))
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg))
|
||||
}
|
||||
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders))
|
||||
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cfg))
|
||||
}
|
||||
return builder.String(), dedupeSupportFiles(supportFiles), nil
|
||||
}
|
||||
@@ -671,6 +685,7 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
|
||||
"{{OpenRestyGzip}}", onOff(cfg.GzipEnabled),
|
||||
"{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength),
|
||||
"{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel),
|
||||
"{{OpenRestyResolverDirective}}", renderResolverDirective(cfg.Resolvers),
|
||||
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg),
|
||||
"{{OpenRestyRouteConfigInclude}}", nginxRouteConfigPlaceholder,
|
||||
)
|
||||
@@ -749,18 +764,18 @@ func nextVersionNumber(now time.Time) (string, error) {
|
||||
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
|
||||
}
|
||||
|
||||
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), originURL)
|
||||
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cfg openRestyConfigSnapshot) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n location / {\n%s%s }\n}\n\n", domain, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL, cfg))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput) string {
|
||||
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cfg openRestyConfigSnapshot) string {
|
||||
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
|
||||
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), originURL)
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s%s }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL, cfg))
|
||||
}
|
||||
|
||||
func renderExactHostGuard(domain string) string {
|
||||
@@ -795,6 +810,65 @@ func renderProxyHeaderBlock(originURL string, originHost string, customHeaders [
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderProxyPassBlock(originURL string, cfg openRestyConfigSnapshot) string {
|
||||
parsed, err := url.Parse(originURL)
|
||||
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
|
||||
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
|
||||
}
|
||||
if !shouldUseRuntimeResolver(originURL, cfg.Resolvers) {
|
||||
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
|
||||
}
|
||||
upstreamURL := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
basePath := strings.TrimRight(parsed.EscapedPath(), "/")
|
||||
if basePath == "" || basePath == "." {
|
||||
basePath = ""
|
||||
}
|
||||
if parsed.RawQuery != "" {
|
||||
if basePath == "" {
|
||||
basePath = "/"
|
||||
}
|
||||
basePath += "?" + parsed.RawQuery
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf(" set $openflare_upstream %s;\n", quoteNginxStringLiteral(upstreamURL)))
|
||||
if basePath != "" {
|
||||
builder.WriteString(fmt.Sprintf(" set $openflare_upstream_base_path %s;\n", quoteNginxStringLiteral(basePath)))
|
||||
builder.WriteString(" proxy_pass $openflare_upstream$openflare_upstream_base_path$request_uri;\n")
|
||||
return builder.String()
|
||||
}
|
||||
builder.WriteString(" proxy_pass $openflare_upstream$request_uri;\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func shouldUseRuntimeResolver(originURL string, resolvers string) bool {
|
||||
if strings.TrimSpace(resolvers) == "" {
|
||||
return false
|
||||
}
|
||||
return requiresRuntimeResolver(originURL)
|
||||
}
|
||||
|
||||
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 renderResolverDirective(value string) string {
|
||||
resolvers := splitResolverList(value)
|
||||
if len(resolvers) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
|
||||
}
|
||||
|
||||
func splitResolverList(value string) []string {
|
||||
return strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||
})
|
||||
}
|
||||
|
||||
func resolveUpstreamServerName(originURL string, originHost string) string {
|
||||
parsed, err := url.Parse(originURL)
|
||||
if err != nil || !strings.EqualFold(parsed.Scheme, "https") {
|
||||
@@ -811,6 +885,10 @@ func resolveUpstreamServerName(originURL string, originHost string) string {
|
||||
}
|
||||
|
||||
func quoteNginxHeaderValue(value string) string {
|
||||
return quoteNginxStringLiteral(value)
|
||||
}
|
||||
|
||||
func quoteNginxStringLiteral(value string) string {
|
||||
escaped := strings.ReplaceAll(value, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return fmt.Sprintf(`"%s"`, escaped)
|
||||
|
||||
@@ -63,6 +63,9 @@ 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, "allow 127.0.0.1;") {
|
||||
t.Fatal("expected main config to avoid hard-coded allow rules on observability server")
|
||||
}
|
||||
@@ -138,6 +141,12 @@ func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) {
|
||||
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_pass https://origin.internal;") {
|
||||
t.Fatal("expected rendered config to keep direct proxy_pass when no resolvers are configured")
|
||||
}
|
||||
if strings.Contains(result.Version.RenderedConfig, "proxy_pass $openflare_upstream$request_uri;") {
|
||||
t.Fatal("expected rendered config to avoid runtime-resolved proxy_pass when no resolvers are configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) {
|
||||
@@ -166,11 +175,68 @@ func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) {
|
||||
if !strings.Contains(result.Version.RenderedConfig, `proxy_ssl_name "git.arctel.net";`) {
|
||||
t.Fatal("expected rendered config to set proxy ssl name from origin host override")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://git.arctel.net;") {
|
||||
t.Fatal("expected rendered config to keep direct proxy_pass for hostname origin when resolvers are blank")
|
||||
}
|
||||
if !strings.Contains(result.Version.SnapshotJSON, `"origin_host":"git.arctel.net"`) {
|
||||
t.Fatal("expected snapshot to include origin_host override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionUsesRuntimeResolverWhenConfigured(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
if err := model.UpdateOption("OpenRestyResolvers", "1.1.1.1, 8.8.8.8"); err != nil {
|
||||
t.Fatalf("UpdateOption OpenRestyResolvers failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "resolver.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.MainConfig, "resolver 1.1.1.1 8.8.8.8 valid=30s ipv6=off;") {
|
||||
t.Fatal("expected main config to render configured resolver directive")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "https://origin.internal";`) {
|
||||
t.Fatal("expected rendered config to use runtime upstream variable when resolvers are configured")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass $openflare_upstream$request_uri;") {
|
||||
t.Fatal("expected rendered config to proxy via runtime-resolved upstream variable when resolvers are configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionKeepsDirectProxyPassForIPOrigins(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, "proxy_pass http://10.0.0.8:8080;") {
|
||||
t.Fatal("expected rendered config to keep direct proxy_pass 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)
|
||||
|
||||
|
||||
@@ -708,7 +708,12 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
|
||||
t.Fatalf("unexpected request reports: %+v", reports)
|
||||
}
|
||||
|
||||
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10)
|
||||
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{
|
||||
NodeID: node.NodeID,
|
||||
Since: time.Time{},
|
||||
Page: 0,
|
||||
PageSize: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected node access logs query to succeed: %v", err)
|
||||
}
|
||||
@@ -822,7 +827,12 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
|
||||
t.Fatalf("expected current and buffered reports, got %+v", reports)
|
||||
}
|
||||
|
||||
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10)
|
||||
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{
|
||||
NodeID: node.NodeID,
|
||||
Since: time.Time{},
|
||||
Page: 0,
|
||||
PageSize: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected node access logs query to succeed: %v", err)
|
||||
}
|
||||
@@ -930,7 +940,11 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
|
||||
pageOne, err := ListAccessLogs(node.NodeID, 0, 2)
|
||||
pageOne, err := ListAccessLogs(AccessLogQuery{
|
||||
NodeID: node.NodeID,
|
||||
Page: 0,
|
||||
PageSize: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessLogs page 1 failed: %v", err)
|
||||
}
|
||||
@@ -944,7 +958,11 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
|
||||
t.Fatalf("expected paged access log region to be returned, got %+v", pageOne.Items[0])
|
||||
}
|
||||
|
||||
pageTwo, err := ListAccessLogs(node.NodeID, 1, 2)
|
||||
pageTwo, err := ListAccessLogs(AccessLogQuery{
|
||||
NodeID: node.NodeID,
|
||||
Page: 1,
|
||||
PageSize: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessLogs page 2 failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
NodeHealthSeverityInfo = "info"
|
||||
NodeHealthSeverityWarning = "warning"
|
||||
NodeHealthSeverityCritical = "critical"
|
||||
nodeAccessLogRetentionWindow = 24 * time.Hour
|
||||
nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
|
||||
)
|
||||
|
||||
type AgentNodeSystemProfile struct {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
+39
-9
@@ -1,20 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import type { ConfigVersionItem } from '@/features/config-versions/types';
|
||||
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
|
||||
import type { ConfigVersionSummary } from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function ConfigVersionSnapshotModal({
|
||||
version,
|
||||
onClose,
|
||||
}: {
|
||||
version: ConfigVersionItem | null;
|
||||
version: ConfigVersionSummary | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const versionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'detail', version?.id ?? 0],
|
||||
queryFn: () => {
|
||||
if (!version) {
|
||||
throw new Error('missing config version');
|
||||
}
|
||||
return getConfigVersion(version.id);
|
||||
},
|
||||
enabled: Boolean(version?.id),
|
||||
});
|
||||
const versionDetail = versionDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(version)}
|
||||
@@ -30,7 +51,14 @@ export function ConfigVersionSnapshotModal({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{version ? (
|
||||
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
|
||||
<LoadingState />
|
||||
) : versionDetailQuery.isError ? (
|
||||
<ErrorState
|
||||
title="配置版本详情加载失败"
|
||||
description={getErrorMessage(versionDetailQuery.error)}
|
||||
/>
|
||||
) : versionDetail ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -38,7 +66,7 @@ export function ConfigVersionSnapshotModal({
|
||||
Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{version.checksum}
|
||||
{versionDetail.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -46,7 +74,7 @@ export function ConfigVersionSnapshotModal({
|
||||
创建人
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{version.created_by || '系统'}
|
||||
{versionDetail.created_by || '系统'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -54,7 +82,7 @@ export function ConfigVersionSnapshotModal({
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(version.created_at)}
|
||||
{formatDateTime(versionDetail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,23 +92,25 @@ export function ConfigVersionSnapshotModal({
|
||||
快照 JSON
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.snapshot_json}
|
||||
{versionDetail.snapshot_json}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.main_config}
|
||||
{versionDetail.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{version.rendered_config}
|
||||
{versionDetail.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
activateConfigVersion,
|
||||
getActiveConfigVersion,
|
||||
getConfigVersionDiff,
|
||||
getConfigVersionPreview,
|
||||
getConfigVersions,
|
||||
@@ -22,7 +23,8 @@ import type {
|
||||
ConfigOptionDiffItem,
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionItem,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
SupportFile,
|
||||
} from '@/features/config-versions/types';
|
||||
import {
|
||||
@@ -168,14 +170,20 @@ function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
|
||||
function PublishPreviewCard({
|
||||
preview,
|
||||
diff,
|
||||
activeVersion,
|
||||
activeVersionMeta,
|
||||
activeVersionDetail,
|
||||
isActiveVersionDetailLoading,
|
||||
activeVersionDetailError,
|
||||
isPublishing,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
activeVersion: ConfigVersionItem | null;
|
||||
activeVersionMeta: ConfigVersionSummary | null;
|
||||
activeVersionDetail: ConfigVersionDetail | null;
|
||||
isActiveVersionDetailLoading: boolean;
|
||||
activeVersionDetailError: string | null;
|
||||
isPublishing: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -263,18 +271,29 @@ function PublishPreviewCard({
|
||||
<OptionDiffTable items={diff.changed_option_details} />
|
||||
</div>
|
||||
|
||||
{diff.main_config_changed && activeVersion ? (
|
||||
{diff.main_config_changed && activeVersionMeta ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Current Active Main Config
|
||||
</p>
|
||||
<StatusBadge label={activeVersion.version} variant="info" />
|
||||
<StatusBadge label={activeVersionMeta.version} variant="info" />
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersion.main_config}
|
||||
</CodeBlock>
|
||||
{isActiveVersionDetailLoading ? (
|
||||
<LoadingState />
|
||||
) : activeVersionDetailError ? (
|
||||
<InlineMessage tone="danger" message={activeVersionDetailError} />
|
||||
) : activeVersionDetail ? (
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersionDetail.main_config}
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前激活版本详情暂不可用。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -406,6 +425,14 @@ export function ConfigVersionsPage() {
|
||||
() => versions.find((item) => item.id === selectedVersionId) ?? null,
|
||||
[selectedVersionId, versions],
|
||||
);
|
||||
const shouldLoadActiveVersionDetail = Boolean(
|
||||
activeVersion?.id && publishPreview?.diff.main_config_changed,
|
||||
);
|
||||
const activeVersionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
|
||||
queryFn: getActiveConfigVersion,
|
||||
enabled: shouldLoadActiveVersionDetail,
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: publishConfigVersion,
|
||||
@@ -455,7 +482,7 @@ export function ConfigVersionsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (version: ConfigVersionItem) => {
|
||||
const handleActivate = (version: ConfigVersionSummary) => {
|
||||
if (version.is_active) {
|
||||
return;
|
||||
}
|
||||
@@ -493,7 +520,14 @@ export function ConfigVersionsPage() {
|
||||
<PublishPreviewCard
|
||||
preview={publishPreview.preview}
|
||||
diff={publishPreview.diff}
|
||||
activeVersion={activeVersion}
|
||||
activeVersionMeta={activeVersion}
|
||||
activeVersionDetail={activeVersionDetailQuery.data ?? null}
|
||||
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
|
||||
activeVersionDetailError={
|
||||
activeVersionDetailQuery.isError
|
||||
? getErrorMessage(activeVersionDetailQuery.error)
|
||||
: null
|
||||
}
|
||||
isPublishing={publishMutation.isPending}
|
||||
onConfirm={() => publishMutation.mutate()}
|
||||
onCancel={() => setPublishPreview(null)}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
export interface ConfigVersionItem {
|
||||
export interface ConfigVersionSummary {
|
||||
id: number;
|
||||
version: string;
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
rendered_config: string;
|
||||
support_files_json: string;
|
||||
checksum: string;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigVersionDetail extends ConfigVersionSummary {
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
rendered_config: string;
|
||||
support_files_json: string;
|
||||
}
|
||||
|
||||
export interface SupportFile {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
@@ -1,38 +1,229 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type { DashboardOverview } from '@/features/dashboard/types';
|
||||
import type {
|
||||
CompactCapacityTrendPoint,
|
||||
CompactDashboardNodeHealth,
|
||||
CompactDiskIOTrendPoint,
|
||||
CompactDistributionItem,
|
||||
CompactNetworkTrendPoint,
|
||||
CompactTrafficTrendPoint,
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
DashboardOverview,
|
||||
DashboardOverviewCompact,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
|
||||
function arrayOrEmpty<T>(value: T[] | null | undefined) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function isCompactDistributionItem(
|
||||
value: DistributionItem | CompactDistributionItem,
|
||||
): value is CompactDistributionItem {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactTrafficTrendPoint(
|
||||
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint,
|
||||
): value is CompactTrafficTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactCapacityTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint,
|
||||
): value is CompactCapacityTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactNetworkTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint,
|
||||
): value is CompactNetworkTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDiskIOTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint,
|
||||
): value is CompactDiskIOTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDashboardNode(
|
||||
value: DashboardNodeHealth | CompactDashboardNodeHealth,
|
||||
): value is CompactDashboardNodeHealth {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeDistributionItems(
|
||||
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
|
||||
): DistributionItem[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDistributionItem(item)
|
||||
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTrafficTrendPoints(
|
||||
items:
|
||||
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactTrafficTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
request_count: Number(item[1] ?? 0),
|
||||
error_count: Number(item[2] ?? 0),
|
||||
unique_visitor_count: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapacityTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactCapacityTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
average_cpu_usage_percent: Number(item[1] ?? 0),
|
||||
average_memory_usage_percent: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNetworkTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactNetworkTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
network_rx_bytes: Number(item[1] ?? 0),
|
||||
network_tx_bytes: Number(item[2] ?? 0),
|
||||
openresty_rx_bytes: Number(item[3] ?? 0),
|
||||
openresty_tx_bytes: Number(item[4] ?? 0),
|
||||
reported_nodes: Number(item[5] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiskIOTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDiskIOTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
disk_read_bytes: Number(item[1] ?? 0),
|
||||
disk_write_bytes: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardNodes(
|
||||
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
|
||||
): DashboardNodeHealth[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDashboardNode(item)
|
||||
? {
|
||||
id: Number(item[0] ?? 0),
|
||||
node_id: String(item[1] ?? ''),
|
||||
name: String(item[2] ?? ''),
|
||||
geo_name: String(item[3] ?? ''),
|
||||
geo_latitude:
|
||||
item[4] === null || item[4] === undefined ? null : Number(item[4]),
|
||||
geo_longitude:
|
||||
item[5] === null || item[5] === undefined ? null : Number(item[5]),
|
||||
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
|
||||
openresty_status: (item[7] ??
|
||||
'unknown') as DashboardNodeHealth['openresty_status'],
|
||||
current_version: String(item[8] ?? ''),
|
||||
last_seen_at: String(item[9] ?? ''),
|
||||
active_event_count: Number(item[10] ?? 0),
|
||||
cpu_usage_percent: Number(item[11] ?? 0),
|
||||
memory_usage_percent: Number(item[12] ?? 0),
|
||||
storage_usage_percent: Number(item[13] ?? 0),
|
||||
request_count: Number(item[14] ?? 0),
|
||||
error_count: Number(item[15] ?? 0),
|
||||
unique_visitor_count: Number(item[16] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardOverview(
|
||||
overview: DashboardOverview | null | undefined,
|
||||
overview:
|
||||
| DashboardOverview
|
||||
| DashboardOverviewCompact
|
||||
| null
|
||||
| undefined,
|
||||
): DashboardOverview | null {
|
||||
if (!overview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = (overview.summary ?? {}) as DashboardSummary;
|
||||
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
|
||||
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
|
||||
|
||||
return {
|
||||
...overview,
|
||||
nodes: arrayOrEmpty(overview.nodes),
|
||||
generated_at: String(overview.generated_at ?? ''),
|
||||
summary,
|
||||
traffic,
|
||||
capacity,
|
||||
nodes: normalizeDashboardNodes(overview.nodes),
|
||||
distributions: {
|
||||
...overview.distributions,
|
||||
source_countries: arrayOrEmpty(overview.distributions?.source_countries),
|
||||
status_codes: arrayOrEmpty(overview.distributions?.status_codes),
|
||||
top_domains: arrayOrEmpty(overview.distributions?.top_domains),
|
||||
source_countries: normalizeDistributionItems(
|
||||
overview.distributions?.source_countries,
|
||||
),
|
||||
status_codes: normalizeDistributionItems(overview.distributions?.status_codes),
|
||||
top_domains: normalizeDistributionItems(overview.distributions?.top_domains),
|
||||
},
|
||||
trends: {
|
||||
...overview.trends,
|
||||
traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h),
|
||||
capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h),
|
||||
network_24h: arrayOrEmpty(overview.trends?.network_24h),
|
||||
disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h),
|
||||
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
||||
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
|
||||
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
|
||||
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardOverview() {
|
||||
const overview = await apiRequest<DashboardOverview>('/dashboard/overview');
|
||||
const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>(
|
||||
'/dashboard/overview',
|
||||
);
|
||||
return normalizeDashboardOverview(overview);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface DistributionItem {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type CompactDistributionItem = [string, number];
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
@@ -99,3 +101,53 @@ export interface DashboardOverview {
|
||||
trends: DashboardTrends;
|
||||
nodes: DashboardNodeHealth[];
|
||||
}
|
||||
|
||||
export type CompactTrafficTrendPoint = [string, number, number, number];
|
||||
export type CompactCapacityTrendPoint = [string, number, number, number];
|
||||
export type CompactNetworkTrendPoint = [
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
export type CompactDiskIOTrendPoint = [string, number, number, number];
|
||||
export type CompactDashboardNodeHealth = [
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number | null,
|
||||
number | null,
|
||||
DashboardNodeHealth['status'],
|
||||
DashboardNodeHealth['openresty_status'],
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
export interface DashboardOverviewCompact {
|
||||
generated_at: string;
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
distributions: {
|
||||
status_codes: CompactDistributionItem[];
|
||||
top_domains: CompactDistributionItem[];
|
||||
source_countries: CompactDistributionItem[];
|
||||
};
|
||||
trends: {
|
||||
traffic_24h: CompactTrafficTrendPoint[];
|
||||
capacity_24h: CompactCapacityTrendPoint[];
|
||||
network_24h: CompactNetworkTrendPoint[];
|
||||
disk_io_24h: CompactDiskIOTrendPoint[];
|
||||
};
|
||||
nodes: CompactDashboardNodeHealth[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 {
|
||||
deleteNode,
|
||||
@@ -276,8 +276,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,
|
||||
});
|
||||
@@ -411,7 +416,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 +530,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 =
|
||||
@@ -1623,7 +1628,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}
|
||||
|
||||
@@ -1,205 +1,205 @@
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
agent_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
update_channel: ReleaseChannel;
|
||||
update_tag: string;
|
||||
restart_openresty_requested: boolean;
|
||||
agent_version: string;
|
||||
nginx_version: string;
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
openresty_message: string;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_checksum: string;
|
||||
latest_main_config_checksum: string;
|
||||
latest_route_config_checksum: string;
|
||||
latest_support_file_count: number;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NodeBootstrapToken {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
name: string;
|
||||
ip: string;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
}
|
||||
|
||||
export interface NodeAgentReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
channel: ReleaseChannel;
|
||||
prerelease: boolean;
|
||||
update_requested: boolean;
|
||||
requested_channel: ReleaseChannel;
|
||||
requested_tag: string;
|
||||
}
|
||||
|
||||
export interface NodeAgentUpdatePayload {
|
||||
channel?: ReleaseChannel;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
export interface NodeSystemProfile {
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
kernel_version: string;
|
||||
architecture: string;
|
||||
cpu_model: string;
|
||||
cpu_cores: number;
|
||||
total_memory_bytes: number;
|
||||
total_disk_bytes: number;
|
||||
uptime_seconds: number;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
export interface NodeMetricSnapshot {
|
||||
captured_at: string;
|
||||
cpu_usage_percent: number;
|
||||
memory_used_bytes: number;
|
||||
memory_total_bytes: number;
|
||||
storage_used_bytes: number;
|
||||
storage_total_bytes: number;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
openresty_connections: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficReport {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
status_codes_json: string;
|
||||
top_domains_json: string;
|
||||
source_countries_json: string;
|
||||
}
|
||||
|
||||
export interface NodeTrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface NodeCapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeNetworkTrendPoint {
|
||||
bucket_started_at: string;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDiskIOTrendPoint {
|
||||
bucket_started_at: string;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDistributionItem {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficDistributions {
|
||||
status_codes: NodeDistributionItem[];
|
||||
top_domains: NodeDistributionItem[];
|
||||
source_countries: NodeDistributionItem[];
|
||||
}
|
||||
|
||||
export interface NodeTrafficSummary {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
unique_visitor_count: number;
|
||||
error_count: number;
|
||||
estimated_qps: number;
|
||||
error_rate_percent: number;
|
||||
}
|
||||
|
||||
export interface NodeHealthSummary {
|
||||
active_alerts: number;
|
||||
critical_alerts: number;
|
||||
warning_alerts: number;
|
||||
info_alerts: number;
|
||||
resolved_alerts: number;
|
||||
has_capacity_risk: boolean;
|
||||
has_traffic_risk: boolean;
|
||||
has_runtime_risk: boolean;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityAnalytics {
|
||||
traffic: NodeTrafficSummary;
|
||||
distributions: NodeTrafficDistributions;
|
||||
health: NodeHealthSummary;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityTrends {
|
||||
traffic_24h: NodeTrafficTrendPoint[];
|
||||
capacity_24h: NodeCapacityTrendPoint[];
|
||||
network_24h: NodeNetworkTrendPoint[];
|
||||
disk_io_24h: NodeDiskIOTrendPoint[];
|
||||
}
|
||||
|
||||
export interface NodeHealthEvent {
|
||||
event_type: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
message: string;
|
||||
first_triggered_at: string;
|
||||
last_triggered_at: string;
|
||||
reported_at: string;
|
||||
resolved_at?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeObservability {
|
||||
node_id: string;
|
||||
profile: NodeSystemProfile | null;
|
||||
metric_snapshots: NodeMetricSnapshot[];
|
||||
traffic_reports: NodeTrafficReport[];
|
||||
health_events: NodeHealthEvent[];
|
||||
analytics: NodeObservabilityAnalytics;
|
||||
trends: NodeObservabilityTrends;
|
||||
}
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
agent_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
update_channel: ReleaseChannel;
|
||||
update_tag: string;
|
||||
restart_openresty_requested: boolean;
|
||||
agent_version: string;
|
||||
nginx_version: string;
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
openresty_message: string;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'warning' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_checksum: string;
|
||||
latest_main_config_checksum: string;
|
||||
latest_route_config_checksum: string;
|
||||
latest_support_file_count: number;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NodeBootstrapToken {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
name: string;
|
||||
ip: string;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
}
|
||||
|
||||
export interface NodeAgentReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
channel: ReleaseChannel;
|
||||
prerelease: boolean;
|
||||
update_requested: boolean;
|
||||
requested_channel: ReleaseChannel;
|
||||
requested_tag: string;
|
||||
}
|
||||
|
||||
export interface NodeAgentUpdatePayload {
|
||||
channel?: ReleaseChannel;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
export interface NodeSystemProfile {
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
kernel_version: string;
|
||||
architecture: string;
|
||||
cpu_model: string;
|
||||
cpu_cores: number;
|
||||
total_memory_bytes: number;
|
||||
total_disk_bytes: number;
|
||||
uptime_seconds: number;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
export interface NodeMetricSnapshot {
|
||||
captured_at: string;
|
||||
cpu_usage_percent: number;
|
||||
memory_used_bytes: number;
|
||||
memory_total_bytes: number;
|
||||
storage_used_bytes: number;
|
||||
storage_total_bytes: number;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
openresty_connections: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficReport {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
status_codes_json: string;
|
||||
top_domains_json: string;
|
||||
source_countries_json: string;
|
||||
}
|
||||
|
||||
export interface NodeTrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface NodeCapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeNetworkTrendPoint {
|
||||
bucket_started_at: string;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDiskIOTrendPoint {
|
||||
bucket_started_at: string;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDistributionItem {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficDistributions {
|
||||
status_codes: NodeDistributionItem[];
|
||||
top_domains: NodeDistributionItem[];
|
||||
source_countries: NodeDistributionItem[];
|
||||
}
|
||||
|
||||
export interface NodeTrafficSummary {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
unique_visitor_count: number;
|
||||
error_count: number;
|
||||
estimated_qps: number;
|
||||
error_rate_percent: number;
|
||||
}
|
||||
|
||||
export interface NodeHealthSummary {
|
||||
active_alerts: number;
|
||||
critical_alerts: number;
|
||||
warning_alerts: number;
|
||||
info_alerts: number;
|
||||
resolved_alerts: number;
|
||||
has_capacity_risk: boolean;
|
||||
has_traffic_risk: boolean;
|
||||
has_runtime_risk: boolean;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityAnalytics {
|
||||
traffic: NodeTrafficSummary;
|
||||
distributions: NodeTrafficDistributions;
|
||||
health: NodeHealthSummary;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityTrends {
|
||||
traffic_24h: NodeTrafficTrendPoint[];
|
||||
capacity_24h: NodeCapacityTrendPoint[];
|
||||
network_24h: NodeNetworkTrendPoint[];
|
||||
disk_io_24h: NodeDiskIOTrendPoint[];
|
||||
}
|
||||
|
||||
export interface NodeHealthEvent {
|
||||
event_type: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
message: string;
|
||||
first_triggered_at: string;
|
||||
last_triggered_at: string;
|
||||
reported_at: string;
|
||||
resolved_at?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeObservability {
|
||||
node_id: string;
|
||||
profile: NodeSystemProfile | null;
|
||||
metric_snapshots: NodeMetricSnapshot[];
|
||||
traffic_reports: NodeTrafficReport[];
|
||||
health_events: NodeHealthEvent[];
|
||||
analytics: NodeObservabilityAnalytics;
|
||||
trends: NodeObservabilityTrends;
|
||||
}
|
||||
|
||||
@@ -1,161 +1,169 @@
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
return Boolean(value) && !String(value).startsWith('0001-01-01');
|
||||
}
|
||||
|
||||
export function getNodeStatusVariant(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
export function getNodeStatusLabel(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return '在线';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return '待接入';
|
||||
}
|
||||
|
||||
return '离线';
|
||||
}
|
||||
|
||||
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return '成功';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
|
||||
return '暂无';
|
||||
}
|
||||
|
||||
export function getUpdateMode(node: NodeItem) {
|
||||
if (node.update_requested) {
|
||||
if (node.update_channel === 'preview') {
|
||||
return { label: '等待预览更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
return { label: '等待更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
if (node.auto_update_enabled) {
|
||||
return { label: '自动', variant: 'success' as const };
|
||||
}
|
||||
|
||||
return { label: '手动', variant: 'info' as const };
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return '健康';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return '异常';
|
||||
}
|
||||
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function parseVersionParts(version: string) {
|
||||
const normalized = version.trim().replace(/^v/i, '');
|
||||
if (!normalized || normalized.toLowerCase() === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.split('.').map((segment) => {
|
||||
const matched = segment.trim().match(/^\d+/);
|
||||
return matched ? Number.parseInt(matched[0], 10) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isOlderVersion(current: string, target: string) {
|
||||
const currentParts = parseVersionParts(current);
|
||||
const targetParts = parseVersionParts(target);
|
||||
if (!currentParts || !targetParts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxLength = Math.max(currentParts.length, targetParts.length);
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const currentPart = currentParts[index] ?? 0;
|
||||
const targetPart = targetParts[index] ?? 0;
|
||||
if (currentPart < targetPart) {
|
||||
return true;
|
||||
}
|
||||
if (currentPart > targetPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowManualUpdate(
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
) {
|
||||
const normalizedServerVersion = serverVersion.trim();
|
||||
const normalizedAgentVersion = agentVersion.trim();
|
||||
|
||||
if (
|
||||
!normalizedServerVersion ||
|
||||
normalizedServerVersion.toLowerCase() === 'dev' ||
|
||||
!normalizedAgentVersion ||
|
||||
normalizedAgentVersion.toLowerCase() === 'unknown'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
|
||||
}
|
||||
|
||||
export function getServerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
||||
return [
|
||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
` --agent-token ${agentToken}`,
|
||||
].join('\n');
|
||||
}
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
return Boolean(value) && !String(value).startsWith('0001-01-01');
|
||||
}
|
||||
|
||||
export function getNodeStatusVariant(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
export function getNodeStatusLabel(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return '在线';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return '待接入';
|
||||
}
|
||||
|
||||
return '离线';
|
||||
}
|
||||
|
||||
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return '成功';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return '警告';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
|
||||
return '暂无';
|
||||
}
|
||||
|
||||
export function getUpdateMode(node: NodeItem) {
|
||||
if (node.update_requested) {
|
||||
if (node.update_channel === 'preview') {
|
||||
return { label: '等待预览更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
return { label: '等待更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
if (node.auto_update_enabled) {
|
||||
return { label: '自动', variant: 'success' as const };
|
||||
}
|
||||
|
||||
return { label: '手动', variant: 'info' as const };
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return '健康';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return '异常';
|
||||
}
|
||||
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function parseVersionParts(version: string) {
|
||||
const normalized = version.trim().replace(/^v/i, '');
|
||||
if (!normalized || normalized.toLowerCase() === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.split('.').map((segment) => {
|
||||
const matched = segment.trim().match(/^\d+/);
|
||||
return matched ? Number.parseInt(matched[0], 10) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isOlderVersion(current: string, target: string) {
|
||||
const currentParts = parseVersionParts(current);
|
||||
const targetParts = parseVersionParts(target);
|
||||
if (!currentParts || !targetParts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxLength = Math.max(currentParts.length, targetParts.length);
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const currentPart = currentParts[index] ?? 0;
|
||||
const targetPart = targetParts[index] ?? 0;
|
||||
if (currentPart < targetPart) {
|
||||
return true;
|
||||
}
|
||||
if (currentPart > targetPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowManualUpdate(
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
) {
|
||||
const normalizedServerVersion = serverVersion.trim();
|
||||
const normalizedAgentVersion = agentVersion.trim();
|
||||
|
||||
if (
|
||||
!normalizedServerVersion ||
|
||||
normalizedServerVersion.toLowerCase() === 'dev' ||
|
||||
!normalizedAgentVersion ||
|
||||
normalizedAgentVersion.toLowerCase() === 'unknown'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
|
||||
}
|
||||
|
||||
export function getServerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
||||
return [
|
||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
` --agent-token ${agentToken}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const defaultPerformanceFields = {
|
||||
OpenRestyGzipEnabled: true,
|
||||
OpenRestyGzipMinLength: '1024',
|
||||
OpenRestyGzipCompLevel: '5',
|
||||
OpenRestyResolvers: '',
|
||||
OpenRestyCacheEnabled: false,
|
||||
OpenRestyCachePath: '',
|
||||
OpenRestyCacheLevels: '1:2',
|
||||
@@ -103,6 +104,8 @@ const performanceFieldTooltips: Record<string, string> = {
|
||||
gzip_min_length:
|
||||
'只有响应体超过该字节数时才会启用 gzip,避免对极小响应做无意义压缩。',
|
||||
gzip_comp_level: 'gzip 压缩等级,1 更省 CPU,9 压缩更高但更耗 CPU。',
|
||||
resolvers:
|
||||
'可选填写运行时 DNS 解析器 IP,支持逗号、空格或换行分隔;留空时不额外生成 resolver 指令。',
|
||||
proxy_cache_path: '缓存目录路径,对应 proxy_cache_path 指令中的磁盘位置。',
|
||||
levels: '缓存目录层级,例如 1:2,可控制缓存文件的目录分布。',
|
||||
inactive: '缓存对象在未命中访问时的失活时间,例如 30m。',
|
||||
@@ -241,6 +244,7 @@ export function PerformancePage() {
|
||||
OpenRestyGzipEnabled: toBoolean(optionMap.OpenRestyGzipEnabled, true),
|
||||
OpenRestyGzipMinLength: optionMap.OpenRestyGzipMinLength ?? '1024',
|
||||
OpenRestyGzipCompLevel: optionMap.OpenRestyGzipCompLevel ?? '5',
|
||||
OpenRestyResolvers: optionMap.OpenRestyResolvers ?? '',
|
||||
OpenRestyCacheEnabled: toBoolean(optionMap.OpenRestyCacheEnabled, false),
|
||||
OpenRestyCachePath: optionMap.OpenRestyCachePath ?? '',
|
||||
OpenRestyCacheLevels: optionMap.OpenRestyCacheLevels ?? '1:2',
|
||||
@@ -420,6 +424,7 @@ export function PerformancePage() {
|
||||
'OpenRestyProxyReadTimeout',
|
||||
performanceFields.OpenRestyProxyReadTimeout.trim(),
|
||||
],
|
||||
['OpenRestyResolvers', performanceFields.OpenRestyResolvers.trim()],
|
||||
[
|
||||
'OpenRestyWebsocketEnabled',
|
||||
String(performanceFields.OpenRestyWebsocketEnabled),
|
||||
@@ -938,6 +943,23 @@ export function PerformancePage() {
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="resolver"
|
||||
tooltip={performanceFieldTooltips.resolvers}
|
||||
hint="留空时走 OpenResty 默认行为;填写时请使用 DNS 服务器 IP。"
|
||||
>
|
||||
<ResourceTextarea
|
||||
value={performanceFields.OpenRestyResolvers}
|
||||
onChange={(event) =>
|
||||
setPerformanceFields((previous) => ({
|
||||
...previous,
|
||||
OpenRestyResolvers: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="例如:10.0.0.2, 1.1.1.1"
|
||||
rows={3}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="websocket"
|
||||
tooltip={performanceFieldTooltips.websocket}
|
||||
|
||||
@@ -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/');
|
||||
|
||||
@@ -13,8 +13,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 }),
|
||||
});
|
||||
}
|
||||
@@ -41,8 +41,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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -88,87 +88,87 @@ describe('DashboardOverview', () => {
|
||||
},
|
||||
distributions: {
|
||||
source_countries: [
|
||||
{ key: 'CN', value: 440 },
|
||||
{ key: 'US', value: 320 },
|
||||
{ key: 'SG', value: 140 },
|
||||
['CN', 440],
|
||||
['US', 320],
|
||||
['SG', 140],
|
||||
],
|
||||
status_codes: [
|
||||
{ key: '200', value: 820 },
|
||||
{ key: '502', value: 24 },
|
||||
{ key: '500', value: 12 },
|
||||
['200', 820],
|
||||
['502', 24],
|
||||
['500', 12],
|
||||
],
|
||||
top_domains: [
|
||||
{ key: 'app.example.com', value: 580 },
|
||||
{ key: 'api.example.com', value: 220 },
|
||||
['app.example.com', 580],
|
||||
['api.example.com', 220],
|
||||
],
|
||||
},
|
||||
trends: {
|
||||
traffic_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
request_count: index * 10,
|
||||
error_count: index,
|
||||
unique_visitor_count: index * 3,
|
||||
})),
|
||||
capacity_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
average_cpu_usage_percent: index,
|
||||
average_memory_usage_percent: index + 10,
|
||||
reported_nodes: 2,
|
||||
})),
|
||||
network_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
network_rx_bytes: index * 100,
|
||||
network_tx_bytes: index * 120,
|
||||
openresty_rx_bytes: index * 140,
|
||||
openresty_tx_bytes: index * 160,
|
||||
reported_nodes: 2,
|
||||
})),
|
||||
disk_io_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
disk_read_bytes: index * 50,
|
||||
disk_write_bytes: index * 70,
|
||||
reported_nodes: 2,
|
||||
})),
|
||||
traffic_24h: Array.from({ length: 24 }, (_, index) => [
|
||||
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
index * 10,
|
||||
index,
|
||||
index * 3,
|
||||
]),
|
||||
capacity_24h: Array.from({ length: 24 }, (_, index) => [
|
||||
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
index,
|
||||
index + 10,
|
||||
2,
|
||||
]),
|
||||
network_24h: Array.from({ length: 24 }, (_, index) => [
|
||||
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
index * 100,
|
||||
index * 120,
|
||||
index * 140,
|
||||
index * 160,
|
||||
2,
|
||||
]),
|
||||
disk_io_24h: Array.from({ length: 24 }, (_, index) => [
|
||||
`2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
index * 50,
|
||||
index * 70,
|
||||
2,
|
||||
]),
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
node_id: 'node-a',
|
||||
name: 'edge-a',
|
||||
geo_name: 'Shanghai',
|
||||
geo_latitude: 31.2304,
|
||||
geo_longitude: 121.4737,
|
||||
status: 'online',
|
||||
openresty_status: 'healthy',
|
||||
current_version: '20260314-001',
|
||||
last_seen_at: '2026-03-14T08:00:00Z',
|
||||
active_event_count: 0,
|
||||
cpu_usage_percent: 45,
|
||||
memory_usage_percent: 50,
|
||||
storage_usage_percent: 60,
|
||||
request_count: 600,
|
||||
error_count: 6,
|
||||
unique_visitor_count: 120,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
node_id: 'node-b',
|
||||
name: 'edge-b',
|
||||
geo_name: 'San Francisco',
|
||||
geo_latitude: 37.7749,
|
||||
geo_longitude: -122.4194,
|
||||
status: 'online',
|
||||
openresty_status: 'unhealthy',
|
||||
current_version: '20260313-001',
|
||||
last_seen_at: '2026-03-14T08:00:00Z',
|
||||
active_event_count: 2,
|
||||
cpu_usage_percent: 92,
|
||||
memory_usage_percent: 88,
|
||||
storage_usage_percent: 95,
|
||||
request_count: 300,
|
||||
error_count: 30,
|
||||
unique_visitor_count: 80,
|
||||
},
|
||||
[
|
||||
1,
|
||||
'node-a',
|
||||
'edge-a',
|
||||
'Shanghai',
|
||||
31.2304,
|
||||
121.4737,
|
||||
'online',
|
||||
'healthy',
|
||||
'20260314-001',
|
||||
'2026-03-14T08:00:00Z',
|
||||
0,
|
||||
45,
|
||||
50,
|
||||
60,
|
||||
600,
|
||||
6,
|
||||
120,
|
||||
],
|
||||
[
|
||||
2,
|
||||
'node-b',
|
||||
'edge-b',
|
||||
'San Francisco',
|
||||
37.7749,
|
||||
-122.4194,
|
||||
'online',
|
||||
'unhealthy',
|
||||
'20260313-001',
|
||||
'2026-03-14T08:00:00Z',
|
||||
2,
|
||||
92,
|
||||
88,
|
||||
95,
|
||||
300,
|
||||
30,
|
||||
80,
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -183,7 +183,6 @@ describe('DashboardOverview', () => {
|
||||
renderDashboardOverview();
|
||||
|
||||
expect(await screen.findByText('全球态势板')).toBeInTheDocument();
|
||||
expect(await screen.findByText('系统健康摘要')).toBeInTheDocument();
|
||||
expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument();
|
||||
expect(await screen.findByText('来源分布')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Top Domain')).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user