mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 894745d43a | |||
| 39d54c2fe4 | |||
| fdadd76945 | |||
| 6e109fd3f7 | |||
| f14ba66a11 | |||
| 6b1d2e8af9 | |||
| a0fff76fcb | |||
| 4fa8f073a3 | |||
| a6787ac30d | |||
| 2c87254bb3 | |||
| 1fd4b22b9c | |||
| be9744abc6 | |||
| afd891f0f6 | |||
| edd31da527 | |||
| 7b9377eb21 | |||
| dc72c78b7f | |||
| 9eeccb5fc6 | |||
| a1b3204204 | |||
| 8737e146d1 | |||
| ae72f2da9a | |||
| f26fcd028e | |||
| dd49b2777d | |||
| 891cb7b9c1 | |||
| 007b1d8929 | |||
| 782304012c | |||
| 4945b8b44f | |||
| 1fbe156a7c | |||
| c844f4c784 | |||
| 67197220ae | |||
| 0cb4e06b11 | |||
| c84d5bd540 | |||
| 51a875ab50 | |||
| ed38aa1d79 | |||
| 9ced0eb6f0 |
@@ -1,5 +1 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 赞赏支持
|
||||
url: https://iamazing.cn/page/reward
|
||||
about: 请作者喝杯咖啡,以激励作者持续开发
|
||||
blank_issues_enabled: false
|
||||
+2
-1
@@ -46,4 +46,5 @@ go.work.sum
|
||||
*.log
|
||||
|
||||
.DS_Store
|
||||
.codex-cache
|
||||
.codex-cache
|
||||
/.gomodcache/
|
||||
|
||||
@@ -11,16 +11,13 @@
|
||||
3. [docs/development-plan.md](./docs/development-plan.md)
|
||||
作用:理解当前开发阶段、实施顺序、阶段目标和验收标准。
|
||||
|
||||
4. [docs/frontend-revamp-plan.md](./docs/frontend-revamp-plan.md)
|
||||
作用:理解当前前端从 CRA + Semantic UI 迁移到 Next.js + Tailwind CSS + NextUI 的专项改造目标、实施阶段和风险边界。
|
||||
|
||||
5. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
|
||||
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
|
||||
作用:理解新版前端的技术选型、目录分层、组件规范、请求层、状态管理、样式和测试约束。
|
||||
|
||||
6. [docs/deployment.md](./docs/deployment.md)
|
||||
5. [docs/deployment.md](./docs/deployment.md)
|
||||
作用:理解当前的部署方式和联调步骤,确保开发过程中产出的功能能够成功部署和验证。
|
||||
|
||||
7. [docs/app-config.md](./docs/app-config.md)
|
||||
6. [docs/app-config.md](./docs/app-config.md)
|
||||
作用:系统启动时支持的环境变量和配置项说明,确保开发过程中新增的配置项能够正确使用和文档化。
|
||||
|
||||
|
||||
@@ -29,7 +26,7 @@
|
||||
* 如果实现内容超出 `docs/design.md` 的范围,先修改设计文档,再继续编码。
|
||||
* 如果实现方式违反 `docs/development-guidelines.md`,应优先调整方案,而不是绕过规范。
|
||||
* 如果需求与当前开发阶段冲突,优先遵守 `docs/development-plan.md` 的阶段顺序。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-revamp-plan.md` 与 `docs/frontend-development-guidelines.md`。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-development-guidelines.md`。
|
||||
|
||||
|
||||
## 文档维护要求
|
||||
@@ -40,6 +37,5 @@
|
||||
* 产品范围或系统边界变化:更新 `docs/design.md`
|
||||
* 开发约束、代码规范、接口约定变化:更新 `docs/development-guidelines.md`
|
||||
* 阶段目标、顺序、验收标准变化:更新 `docs/development-plan.md`
|
||||
* 前端技术栈、迁移阶段、页面范围变化:更新 `docs/frontend-revamp-plan.md`
|
||||
* 前端目录分层、组件规范、样式体系、测试基线变化:更新 `docs/frontend-development-guidelines.md`
|
||||
* 环境变量或配置项变化:更新 `docs/app-config.md`
|
||||
|
||||
@@ -112,12 +112,9 @@ services:
|
||||
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:
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -199,18 +196,6 @@ cd openflare_agent
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
## 文档导航
|
||||
|
||||
建议按以下顺序阅读:
|
||||
|
||||
1. [docs/design.md](./docs/design.md)
|
||||
2. [docs/development-guidelines.md](./docs/development-guidelines.md)
|
||||
3. [docs/development-plan.md](./docs/development-plan.md)
|
||||
4. [docs/frontend-revamp-plan.md](./docs/frontend-revamp-plan.md)
|
||||
5. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
|
||||
6. [docs/deployment.md](./docs/deployment.md)
|
||||
7. [docs/app-config.md](./docs/app-config.md)
|
||||
|
||||
## 管理端与接口
|
||||
|
||||
管理端当前覆盖:
|
||||
|
||||
+15
-3
@@ -57,12 +57,19 @@ go run . --port 3000 --log-dir ./logs
|
||||
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
|
||||
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
|
||||
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
|
||||
|
||||
说明:
|
||||
|
||||
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据
|
||||
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1;管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录
|
||||
|
||||
### 1.4 OpenResty 参数
|
||||
|
||||
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
|
||||
@@ -76,14 +83,18 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
* `OpenRestyProxyReadTimeout`
|
||||
* `OpenRestyProxyBufferingEnabled`
|
||||
* `OpenRestyGzipEnabled`
|
||||
* `OpenRestyResolvers`
|
||||
* `OpenRestyCacheEnabled`
|
||||
* `OpenRestyCachePath`
|
||||
* `OpenRestyCacheMaxSize`
|
||||
|
||||
这类参数必须以结构化方式校验、保存并参与版本渲染。
|
||||
|
||||
* `OpenRestyResolvers` 由管理端性能页面维护,支持填写多个 DNS 服务器 IP;留空时不额外生成 `resolver` 指令。
|
||||
* 管理端不再暴露 `resolver` 配置;规则上游统一渲染为 named `upstream` 并启用 keepalive,单上游如带 base path 或 query,会在 `proxy_pass` 中补回原始 URI。
|
||||
* 多上游仍要求每个上游都为纯 `scheme://host[:port]`,且同一规则内协议一致,避免在负载均衡模式下引入不可预测的 URI 差异。
|
||||
* `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定,不再默认对所有规则开启缓存。
|
||||
* 默认缓存 Key 为 `$scheme$host$request_uri`,更贴近代理域名维度;如需按其他维度命中,可在性能页显式覆盖。
|
||||
* 默认 `keepalive_timeout` 为 `20` 秒,默认 `proxy_connect_timeout` 为 `3` 秒,优先兼顾资源占用与回源失败切换速度。
|
||||
* 默认事件模型为 `epoll`,并默认开启 `multi_accept`;HTTPS 监听默认使用独立 `http2 on;` 指令,避免新版 Nginx/OpenResty 对 `listen ... http2` 的弃用告警。
|
||||
### 1.5 前端构建环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
@@ -120,7 +131,7 @@ Agent 当前支持:
|
||||
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
|
||||
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
|
||||
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测,优先选择公网 IPv4;仅无公网地址时退回可用内网地址 |
|
||||
| `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 |
|
||||
| `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` |
|
||||
| `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` |
|
||||
@@ -144,6 +155,7 @@ Agent 当前支持:
|
||||
* `agent_token` 与 `discovery_token` 不能同时为空
|
||||
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串
|
||||
* 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式
|
||||
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址
|
||||
|
||||
## 3. 维护要求
|
||||
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
|
||||
### 1.2 Agent
|
||||
|
||||
* Go 1.23+
|
||||
* Go 1.24+
|
||||
* 对 Agent 数据目录有写权限
|
||||
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
|
||||
* Docker 模式下具备 Docker 执行权限
|
||||
@@ -107,7 +107,7 @@ docker compose up -d
|
||||
如需在本地重新生成文档:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
|
||||
+16
-25
@@ -9,6 +9,8 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
当前稳定能力包括:
|
||||
|
||||
* 反代规则管理
|
||||
* 网站级配置与多域名绑定
|
||||
* 源站管理与复用
|
||||
* 配置预览、发布、激活与回滚
|
||||
* Agent 注册、心跳、同步、应用结果上报
|
||||
* OpenResty 主配置模板、性能参数与缓存参数托管
|
||||
@@ -23,28 +25,6 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
* Server 保存配置与状态,不直接 SSH 管理节点
|
||||
* Agent 是节点侧唯一受控落地入口
|
||||
|
||||
## 2. 范围边界
|
||||
|
||||
当前明确不做:
|
||||
|
||||
* 多租户
|
||||
* CDN SaaS 化能力
|
||||
* GeoDNS、全球调度、智能选路
|
||||
* WAF、Bot 管理、限流平台化
|
||||
* 灰度百分比发布、按节点差异化配置
|
||||
* 对象存储、消息队列、Prometheus、ClickHouse、Kafka 等前置基础设施
|
||||
* 通用日志检索平台、APM、调用链系统、任意 BI 报表
|
||||
* 证书自动签发与自动续期
|
||||
* 平台化抽象对象,如 `zone`、`origin_pool`、`policy`、`deployment`
|
||||
|
||||
边界补充:
|
||||
|
||||
* OpenResty 代理缓存只作为当前反代链路优化能力存在,不扩展为独立缓存产品
|
||||
* 主配置文件由 Server 统一渲染并由 Agent 受控写入,不支持节点侧手工编辑后回传合并
|
||||
* 节点观测聚焦运营与运维排障所需的摘要、趋势和受控窗口数据,不提供长期日志托管
|
||||
|
||||
新增能力如果超出上述边界,先更新本文档,再进入实现。
|
||||
|
||||
## 3. 技术基线
|
||||
|
||||
### 3.1 Server
|
||||
@@ -105,6 +85,7 @@ Origin
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `node_system_profiles`
|
||||
@@ -119,10 +100,18 @@ Origin
|
||||
|
||||
稳定约束:
|
||||
|
||||
* 一个域名只对应一个 `origin_url`
|
||||
* `proxy_routes` 从“单域名规则”升级为“网站配置”聚合对象;一条记录对应一个网站,可绑定一个或多个域名,并共享一组站点级配置
|
||||
* `proxy_routes.site_name` 是网站的业务唯一标识;新建时默认取 `domains[0]`,后续允许独立维护,不随域名改动自动重写
|
||||
* `proxy_routes.domains` 至少包含一个域名,且 `domains[0]` 作为主域名;任一域名全局只能属于一个 `proxy_routes`
|
||||
* 为兼容历史数据,迁移期可保留 `proxy_routes.domain` 作为 `domains[0]` 的镜像字段,但业务读写与后续扩展必须以 `site_name` + `domains` 为准
|
||||
* `origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略
|
||||
* `proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照
|
||||
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
|
||||
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
|
||||
* `proxy_routes.domain` 必须唯一
|
||||
* `origin_url` 必须为合法 `http://` 或 `https://`
|
||||
* 网站级流量限制、反向代理、HTTPS 与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置
|
||||
* 发布渲染时必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散
|
||||
* 所有上游地址都必须为合法 `http://` 或 `https://`
|
||||
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`
|
||||
* 全局同时只能有一个激活版本
|
||||
* 回滚通过重新激活旧版本实现
|
||||
@@ -171,6 +160,7 @@ Origin
|
||||
* 主配置、路由配置、证书与 Lua 资源写入
|
||||
* 执行 `openresty -t` / `openresty -s reload`
|
||||
* 失败回滚
|
||||
* 对已失败并回退的目标版本做本地熔断,直到控制面出现新的激活版本
|
||||
* 节点观测采集与结果上报
|
||||
|
||||
### 7.3 `openflare_server/web`
|
||||
@@ -186,3 +176,4 @@ Origin
|
||||
* 产品范围或系统边界变化时更新本文档
|
||||
* 已完成阶段不再以“版本计划”形式回填
|
||||
* 新阶段开始前,先补设计,再进入实现
|
||||
* 涉及网站级规则改造的详细需求与实施顺序,见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
`openflare_agent` 继续作为 Go 单体程序:
|
||||
|
||||
* Go 1.23+
|
||||
* Go 1.24+
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* `openresty_path` 优先
|
||||
@@ -99,6 +99,7 @@
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `node_system_profiles`
|
||||
@@ -115,8 +116,15 @@
|
||||
通用约束:
|
||||
|
||||
* 不新增平台化对象,除非设计文档明确要求
|
||||
* `proxy_routes` 维持一条域名对应一个 `origin_url`
|
||||
* `origins` 仅作为可复用源站地址目录,字段保持轻量;协议、端口、路径与查询参数继续归属具体 `proxy_routes`
|
||||
* `proxy_routes` 以“网站配置”作为聚合边界,必须包含唯一 `site_name` 与非空 `domains` 列表;数据库内部 `id` 可继续作为技术主键,但不能替代 `site_name` 的业务唯一性
|
||||
* `proxy_routes.domains` 中的每个域名都必须全局唯一;列表第一项视为主域名,创建时若未显式填写 `site_name`,则默认使用主域名
|
||||
* `proxy_routes` 继续允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
|
||||
* 迁移期如保留遗留 `domain` 字段,只能作为 `domains[0]` 的兼容镜像;新代码不得继续以该字段作为唯一业务输入
|
||||
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照
|
||||
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
|
||||
* 流量限制、反向代理、HTTPS 与缓存配置当前都归属站点级 `proxy_routes`,同一网站内不拆分域名级差异配置
|
||||
* `config_versions` 必须保存完整快照与渲染结果
|
||||
* 全局同时只能有一个激活版本
|
||||
* 回滚通过重新激活旧版本实现
|
||||
@@ -125,6 +133,18 @@
|
||||
* 快照与聚合结果采用追加式模型,不覆盖历史
|
||||
* 原始访问明细必须有受控保留策略
|
||||
|
||||
### 3.1 数据库版本与迁移
|
||||
|
||||
* 任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号
|
||||
* 数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库
|
||||
* 每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法
|
||||
* 迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录
|
||||
* 新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本
|
||||
* 空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本
|
||||
* 数据库版本元数据属于内部控制信息,必须保存在独立内部表中,不能混入业务配置表
|
||||
* 如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录
|
||||
* 涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试
|
||||
|
||||
## 4. API 与鉴权规范
|
||||
|
||||
### 4.1 API
|
||||
@@ -191,6 +211,7 @@ Agent 必须满足:
|
||||
* 写入新配置后以运行态恢复为目标执行激活,Docker 模式优先重建容器并确认容器保持运行
|
||||
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty
|
||||
* 回滚后 OpenResty 恢复正常时上报警告;回滚后仍无法恢复运行时上报失败
|
||||
* 某个目标 `version + checksum` 一旦应用失败并回退,Agent 必须在本地状态中阻断该目标的重复应用;只有远端激活版本或 checksum 发生变化时,才允许再次尝试
|
||||
|
||||
## 6. 测试与交付要求
|
||||
|
||||
|
||||
@@ -48,3 +48,14 @@
|
||||
* 部署与配置变动:更新 `README.md`、`docs/deployment.md`、`docs/app-config.md`
|
||||
|
||||
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文件。
|
||||
|
||||
## 6. 当前专项计划
|
||||
|
||||
已确认需要推进“网站级规则与配置界面改造”专项,详细需求、实施顺序与验收标准见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)。
|
||||
|
||||
本专项的执行顺序固定为:
|
||||
|
||||
1. 先完成数据模型与配置渲染兼容方案
|
||||
2. 再调整接口、校验与版本 diff 语义
|
||||
3. 然后改造规则列表与网站配置子页面
|
||||
4. 最后补齐迁移、回归测试与文档联动
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
* 默认使用 App Router
|
||||
* 前端必须支持 `light`、`dark`、`system` 三种主题模式
|
||||
|
||||
禁止:
|
||||
|
||||
* 引入 Semantic UI
|
||||
* 新增大型 UI 框架破坏现有组件基线
|
||||
* 使用 jQuery 风格 DOM 操作
|
||||
|
||||
## 2. 目录与分层
|
||||
|
||||
@@ -131,24 +126,3 @@ tests/
|
||||
* 同时支持 `light`、`dark`、`system`
|
||||
* 用户选择必须持久化
|
||||
* 首屏尽量避免主题闪烁
|
||||
|
||||
## 7. 测试与交付
|
||||
|
||||
每个页面至少具备:
|
||||
|
||||
* 加载态
|
||||
* 空态
|
||||
* 错误态
|
||||
* 成功反馈
|
||||
|
||||
测试要求:
|
||||
|
||||
* 公共工具、类型转换、主题逻辑补单元测试
|
||||
* 关键页面交互补组件测试
|
||||
* 核心主链路补 Playwright 或等效联调验证
|
||||
|
||||
交付要求:
|
||||
|
||||
* 构建产物保持可静态导出
|
||||
* 构建结果可被 Go Server 托管
|
||||
* 新页面默认通过亮色与暗色模式验收
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# 网站配置改造需求与开发计划
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前规则模块以“一个域名对应一条规则”为中心,已经支持单域名绑定一个或多个上游,但无法表达“多个域名共享同一套站点配置”的场景。
|
||||
|
||||
现阶段已经出现以下真实需求:
|
||||
|
||||
* 多个域名指向同一站点,并共享反向代理、HTTPS、缓存等设置
|
||||
* 后续希望围绕“网站”继续叠加更多功能,而不是持续在规则列表中堆积字段
|
||||
* 现有抽屉式编辑界面已经不适合承载更复杂的配置结构
|
||||
|
||||
因此,本轮改造将 `proxy_routes` 从“单域名规则”升级为“网站配置”视角,并引入独立的配置子页面。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本轮改造的目标如下:
|
||||
|
||||
* 支持一个网站绑定多个域名
|
||||
* 支持一个网站绑定一个或多个上游
|
||||
* 引入 `site_name` 作为网站业务唯一标识
|
||||
* 将原列表页的“编辑”操作替换为“配置”,进入独立子页面管理
|
||||
* 将网站配置拆分为更清晰的功能分区,为后续扩展预留结构
|
||||
|
||||
## 3. 本轮范围
|
||||
|
||||
本轮仅覆盖以下站点级配置能力:
|
||||
|
||||
* 域名设置
|
||||
* 流量限制
|
||||
* 反向代理
|
||||
* HTTPS
|
||||
* 缓存
|
||||
|
||||
## 4. 核心模型要求
|
||||
|
||||
### 4.1 网站标识
|
||||
|
||||
* `site_name` 为网站业务唯一标识
|
||||
* 新建网站时,若用户未输入 `site_name`,默认取域名列表第一项
|
||||
* `site_name` 在首次生成后允许独立编辑,不随域名变更自动同步,避免影响引用、跳转和审计
|
||||
* 数据库内部主键可以继续使用现有数值 `id`,但业务层必须校验 `site_name` 唯一性
|
||||
|
||||
### 4.2 域名列表
|
||||
|
||||
* 网站的域名字段改为 `domains` 列表
|
||||
* `domains` 至少包含一个有效域名
|
||||
* `domains[0]` 视为主域名,用于列表摘要、默认展示和兼容历史逻辑
|
||||
* 同一网站内域名不能重复
|
||||
* 任一域名在全局只能属于一个网站
|
||||
* 域名列表需要支持新增、删除和调整顺序
|
||||
|
||||
### 4.3 历史兼容
|
||||
|
||||
* 存量单域名数据迁移后应自动转换为:
|
||||
`site_name = domain`
|
||||
`domains = [domain]`
|
||||
* 若迁移期保留旧 `domain` 字段,该字段仅作为 `domains[0]` 的兼容镜像,不再作为主要业务输入
|
||||
* 版本渲染、差异预览、接口返回和前端展示都应逐步以 `site_name + domains` 为准
|
||||
|
||||
## 5. 功能需求
|
||||
|
||||
### 5.1 列表页改造
|
||||
|
||||
规则列表改造为“网站列表”视图,要求如下:
|
||||
|
||||
* 保留当前列表页入口,但展示对象改为网站
|
||||
* 原“编辑”按钮替换为“配置”按钮
|
||||
* 点击“配置”进入网站配置子页面
|
||||
* 列表项至少展示:
|
||||
`site_name`
|
||||
主域名
|
||||
域名数量
|
||||
上游摘要
|
||||
HTTPS/缓存/启用状态摘要
|
||||
* 删除、发布等现有高风险操作仍保留明确确认
|
||||
|
||||
### 5.2 网站配置子页面
|
||||
|
||||
网站配置采用左右布局:
|
||||
|
||||
* 左侧为菜单栏,用于切换配置分区
|
||||
* 右侧为当前分区的设置面板
|
||||
* 默认进入“域名设置”分区
|
||||
* 建议基于 App Router 子路由或稳定的 tab 路由参数实现,保证可直接访问和刷新恢复
|
||||
|
||||
建议左侧菜单项固定为:
|
||||
|
||||
1. 域名设置
|
||||
2. 流量限制
|
||||
3. 反向代理
|
||||
4. HTTPS
|
||||
5. 缓存
|
||||
|
||||
为降低跨分区校验干扰,每个分区应支持独立保存与反馈;若采用统一保存,也必须提供未保存修改提示。
|
||||
|
||||
### 5.3 域名设置
|
||||
|
||||
域名设置分区负责维护网站身份与域名列表,要求如下:
|
||||
|
||||
* 可编辑 `site_name`
|
||||
* 可维护 `domains` 列表
|
||||
* 可新增、删除、排序域名
|
||||
* 明确提示第一项为主域名
|
||||
* 保存前校验:
|
||||
`site_name` 非空且唯一
|
||||
`domains` 非空
|
||||
每个域名格式合法
|
||||
域名在当前站点内不重复
|
||||
域名在全局不与其他网站冲突
|
||||
|
||||
### 5.4 流量限制
|
||||
|
||||
流量限制分区用于配置站点级限流,第一期要求覆盖以下字段:
|
||||
|
||||
* `limit_conn perserver`
|
||||
* `limit_conn perip`
|
||||
* `limit_rate`
|
||||
|
||||
要求如下:
|
||||
|
||||
* 采用结构化字段存储,不允许直接录入原始 Nginx 片段
|
||||
* `limit_conn perserver` 与 `limit_conn perip` 为整数;空值或 `0` 视为未启用
|
||||
* `limit_rate` 采用人类可读格式录入,例如 `512k`、`1m`
|
||||
* 保存前进行格式校验,并在页面中提供示例说明
|
||||
* 配置发布后渲染为对应的 OpenResty/Nginx 指令
|
||||
|
||||
### 5.5 反向代理
|
||||
|
||||
反向代理分区负责维护网站回源配置,要求如下:
|
||||
|
||||
* 支持一个或多个上游
|
||||
* 至少保留一个上游
|
||||
* 支持维护回源主机名 `origin_host`
|
||||
* 继续兼容当前单上游带 path/query、多上游做负载均衡的模式
|
||||
* 若复用 `origins` 目录,只作为地址候选来源,不改变网站配置为主的编辑模型
|
||||
|
||||
建议继续保留当前兼容约束:
|
||||
|
||||
* 单上游可附带 path/query
|
||||
* 多上游模式下,上游项保持 `scheme://host[:port]` 形式
|
||||
* 同一网站的多个上游在多上游模式下维持统一协议,降低渲染复杂度
|
||||
|
||||
### 5.6 HTTPS
|
||||
|
||||
HTTPS 分区负责维护站点级 TLS 行为,要求如下:
|
||||
|
||||
* 支持开启或关闭 HTTPS
|
||||
* 支持选择证书
|
||||
* 支持保留现有 `HTTP -> HTTPS` 跳转能力
|
||||
* 当 HTTPS 开启时必须明确证书来源
|
||||
* 应校验证书是否覆盖当前网站的全部域名;若无法覆盖,应阻止保存或给出不可忽略的错误提示
|
||||
|
||||
### 5.7 缓存
|
||||
|
||||
缓存分区负责维护站点级缓存策略,要求如下:
|
||||
|
||||
* 支持开启或关闭缓存
|
||||
* 支持多种缓存策略
|
||||
* 第一阶段至少兼容当前已存在的策略:
|
||||
`url`
|
||||
`suffix`
|
||||
`path_prefix`
|
||||
`path_exact`
|
||||
* 缓存规则继续采用结构化配置,不直接暴露原始 Nginx 片段
|
||||
* 保持当前安全绕过逻辑,不因界面改造改变默认缓存边界
|
||||
|
||||
## 6. 接口与渲染要求
|
||||
|
||||
* 列表接口需要返回 `site_name`、`domains`、主域名、状态摘要等字段
|
||||
* 详情接口需要按分区所需字段返回完整站点配置
|
||||
* 更新接口需要支持按分区或按网站整体更新,但服务端必须统一做跨字段校验
|
||||
* 配置 diff 不再只关注单个域名变更,还要能识别:
|
||||
网站新增/删除
|
||||
域名列表变更
|
||||
站点级配置变更
|
||||
* 发布渲染时,同一网站的全部域名必须落入同一份站点配置上下文中
|
||||
|
||||
## 7. 前端实现要求
|
||||
|
||||
* 列表页负责导航与摘要,不再承载完整编辑表单
|
||||
* 网站配置子页面中的每个分区表单继续遵循 `React Hook Form + Zod`
|
||||
* API 请求统一收敛在 `lib/api/`
|
||||
* 站点级数据查询与缓存继续使用 TanStack Query
|
||||
* 左侧菜单切换时需要明确处理未保存状态,避免无提示丢失修改
|
||||
* 页面至少覆盖加载态、空态、错误态和保存成功反馈
|
||||
|
||||
## 8. 数据迁移要求
|
||||
|
||||
实施前必须准备显式数据库迁移与校验逻辑,至少包含:
|
||||
|
||||
1. 新增 `site_name` 与 `domains` 存储结构
|
||||
2. 将旧数据从单域名回填到站点结构
|
||||
3. 为 `site_name` 建立唯一约束
|
||||
4. 为域名唯一性建立可校验约束
|
||||
5. 对迁移结果做一致性校验
|
||||
|
||||
迁移失败时,启动流程必须中止,不允许带半迁移状态继续运行。
|
||||
|
||||
## 9. 开发计划
|
||||
|
||||
### 阶段一:模型与渲染改造
|
||||
|
||||
目标:
|
||||
|
||||
* 定义网站级 `proxy_routes` 数据结构
|
||||
* 完成存量数据迁移
|
||||
* 调整配置渲染与发布链路,支持多域名同站点输出
|
||||
|
||||
交付物:
|
||||
|
||||
* 数据库迁移
|
||||
* model/service 调整
|
||||
* 配置渲染兼容实现
|
||||
* 迁移与渲染测试
|
||||
|
||||
### 阶段二:接口与校验改造
|
||||
|
||||
目标:
|
||||
|
||||
* 更新列表、详情、创建、更新接口的数据结构
|
||||
* 引入 `site_name`、`domains`、流量限制等字段校验
|
||||
* 调整版本 diff 与发布预览语义
|
||||
|
||||
交付物:
|
||||
|
||||
* API 契约更新
|
||||
* 服务端参数校验与错误消息
|
||||
* diff/preview 适配
|
||||
* 接口回归测试
|
||||
|
||||
### 阶段三:前端网站列表与配置子页面
|
||||
|
||||
目标:
|
||||
|
||||
* 将规则列表切换为网站列表
|
||||
* 用“配置”按钮替代“编辑”按钮
|
||||
* 落地左右布局的网站配置子页面与五个分区
|
||||
|
||||
交付物:
|
||||
|
||||
* 列表页 UI 改造
|
||||
* 子页面路由与布局
|
||||
* 域名设置、流量限制、反向代理、HTTPS、缓存五个分区
|
||||
* 前端交互与表单测试
|
||||
|
||||
### 阶段四:联调、发布验证与文档收口
|
||||
|
||||
目标:
|
||||
|
||||
* 验证从创建网站到发布配置的全链路
|
||||
* 验证 Agent 拉取、应用与回滚不受影响
|
||||
* 收口文档与测试
|
||||
|
||||
交付物:
|
||||
|
||||
* 联调记录
|
||||
* 发布/回滚回归验证
|
||||
* 文档同步更新
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
满足以下条件后,本专项可视为完成:
|
||||
|
||||
* 可以创建一个网站,并绑定多个域名
|
||||
* 一个网站可以绑定单个或多个上游
|
||||
* `site_name` 唯一,且创建时默认取第一个域名
|
||||
* 原列表页已用“配置”按钮替代“编辑”按钮
|
||||
* 网站配置子页面已经采用左侧菜单、右侧设置的布局
|
||||
* 五个分区均可独立完成基本配置与保存
|
||||
* 发布后的渲染结果可正确覆盖同一网站的全部域名
|
||||
* Agent 同步、应用、回滚链路不被破坏
|
||||
* 迁移、接口、渲染与前端关键路径均有对应测试或等效回归验证
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "89d0efbf7bcc8fa53fe48889dce4d045",
|
||||
"agent_token": "2380de64b00e99093e16590beb91e1a0",
|
||||
"data_dir": "./data",
|
||||
"openresty_container_name": "openflare-openresty",
|
||||
"openresty_docker_image": "openresty/openresty:alpine",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
module openflare-agent
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
require openflare v0.0.0
|
||||
|
||||
replace openflare => ../openflare_server
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
@@ -331,6 +332,8 @@ func detectNodeIP() string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
bestIP := ""
|
||||
bestPriority := -1
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
@@ -344,11 +347,27 @@ func detectNodeIP() string {
|
||||
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
ipv4 := ipNet.IP.To4()
|
||||
if ipv4 != nil {
|
||||
return ipv4.String()
|
||||
ipv4 := normalizeIPv4(ipNet.IP)
|
||||
priority := nodeIPPriority(ipv4)
|
||||
if priority > bestPriority {
|
||||
bestIP = ipv4.String()
|
||||
bestPriority = priority
|
||||
}
|
||||
if bestPriority == 2 {
|
||||
return bestIP
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return bestIP
|
||||
}
|
||||
|
||||
func normalizeIPv4(ip net.IP) net.IP {
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
return ip.To4()
|
||||
}
|
||||
|
||||
func nodeIPPriority(ip net.IP) int {
|
||||
return iputil.Score(ip)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -326,3 +327,49 @@ func TestInitialAuthToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeIPPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "public ipv4 preferred",
|
||||
ip: "8.8.8.8",
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "private ipv4 fallback",
|
||||
ip: "10.0.0.8",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "link local ignored",
|
||||
ip: "169.254.1.10",
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "loopback ignored",
|
||||
ip: "127.0.0.1",
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "nil ignored",
|
||||
ip: "",
|
||||
expected: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var parsed net.IP
|
||||
if tt.ip != "" {
|
||||
parsed = net.ParseIP(tt.ip)
|
||||
}
|
||||
if got := nodeIPPriority(parsed); got != tt.expected {
|
||||
t.Fatalf("unexpected priority for %q: got %d want %d", tt.ip, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,18 +327,28 @@ type trafficCountItem struct {
|
||||
value int64
|
||||
}
|
||||
|
||||
const accessLogPathMaxRunes = 100
|
||||
|
||||
func normalizeAccessLogPath(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||
return trimmed
|
||||
return truncateAccessLogPath(trimmed)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return trimmed
|
||||
return truncateAccessLogPath(trimmed)
|
||||
}
|
||||
return "/" + trimmed
|
||||
return truncateAccessLogPath("/" + trimmed)
|
||||
}
|
||||
|
||||
func truncateAccessLogPath(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= accessLogPathMaxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:accessLogPathMaxRunes])
|
||||
}
|
||||
|
||||
func topCounts(values map[string]int64, limit int) map[string]int64 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package observability
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"openflare-agent/internal/config"
|
||||
@@ -108,6 +109,31 @@ func TestBuildTrafficObservabilityReturnsAccessLogs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrafficObservabilityTruncatesLongAccessLogPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routeConfigPath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
|
||||
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(routeConfigPath), "openflare_access.log")
|
||||
longPath := "/" + strings.Repeat("a", 140)
|
||||
content := []byte(
|
||||
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"path\":\"" + longPath + "\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n",
|
||||
)
|
||||
if err := os.WriteFile(logPath, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
_, accessLogs, _ := BuildTrafficObservability(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
|
||||
if len(accessLogs) != 1 {
|
||||
t.Fatalf("expected one access log, got %+v", accessLogs)
|
||||
}
|
||||
if got := len([]rune(accessLogs[0].Path)); got != accessLogPathMaxRunes {
|
||||
t.Fatalf("expected truncated path length %d, got %d (%q)", accessLogPathMaxRunes, got, accessLogs[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routeConfigPath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
|
||||
|
||||
@@ -13,6 +13,9 @@ type Snapshot struct {
|
||||
NodeID string `json:"node_id"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
CurrentChecksum string `json:"current_checksum"`
|
||||
BlockedVersion string `json:"blocked_version"`
|
||||
BlockedChecksum string `json:"blocked_checksum"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
LastError string `json:"last_error"`
|
||||
OpenrestyStatus string `json:"openresty_status"`
|
||||
OpenrestyMessage string `json:"openresty_message"`
|
||||
|
||||
@@ -105,13 +105,27 @@ func (s *Service) sync(ctx context.Context, startup bool, target *protocol.Activ
|
||||
}
|
||||
snapshot.CurrentVersion = target.Version
|
||||
snapshot.CurrentChecksum = target.Checksum
|
||||
clearBlockedTarget(snapshot)
|
||||
snapshot.LastError = ""
|
||||
slog.Debug("sync finished without changes", "mode", mode, "version", target.Version)
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
if isBlockedTarget(snapshot, target.Version, target.Checksum) {
|
||||
slog.Warn("skipping blocked config version after previous failed apply", "mode", mode, "version", target.Version, "checksum", target.Checksum)
|
||||
if startup {
|
||||
if err = s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hasBlockedTarget(snapshot) {
|
||||
clearBlockedTarget(snapshot)
|
||||
}
|
||||
if snapshot.CurrentVersion == target.Version && snapshot.CurrentChecksum == target.Checksum && !startup {
|
||||
slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum)
|
||||
return nil
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
|
||||
config, err := s.client.GetActiveConfig(ctx)
|
||||
@@ -139,6 +153,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
}
|
||||
snapshot.CurrentVersion = config.Version
|
||||
snapshot.CurrentChecksum = config.Checksum
|
||||
clearBlockedTarget(snapshot)
|
||||
snapshot.LastError = ""
|
||||
slog.Debug("sync finished without changes", "mode", mode, "version", config.Version)
|
||||
return s.stateStore.Save(snapshot)
|
||||
@@ -146,9 +161,22 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
if target != nil && (target.Version != config.Version || target.Checksum != config.Checksum) {
|
||||
slog.Warn("active config changed between heartbeat and fetch", "heartbeat_version", target.Version, "heartbeat_checksum", target.Checksum, "fetched_version", config.Version, "fetched_checksum", config.Checksum)
|
||||
}
|
||||
if isBlockedTarget(snapshot, config.Version, config.Checksum) {
|
||||
slog.Warn("skipping blocked config after fetch because the same version previously failed", "mode", mode, "version", config.Version, "checksum", config.Checksum)
|
||||
if startup {
|
||||
if err := s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hasBlockedTarget(snapshot) {
|
||||
clearBlockedTarget(snapshot)
|
||||
}
|
||||
if snapshot.CurrentVersion == config.Version && snapshot.CurrentChecksum == config.Checksum && !startup {
|
||||
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
|
||||
return nil
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
routeConfig := config.RouteConfig
|
||||
if routeConfig == "" {
|
||||
@@ -172,6 +200,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version)
|
||||
snapshot.CurrentVersion = config.Version
|
||||
snapshot.CurrentChecksum = config.Checksum
|
||||
clearBlockedTarget(snapshot)
|
||||
snapshot.LastError = ""
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = ""
|
||||
@@ -184,6 +213,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
message = "apply rolled back to previous config"
|
||||
}
|
||||
slog.Warn("openresty config apply rolled back", "mode", mode, "version", config.Version, "message", message)
|
||||
markBlockedTarget(snapshot, config.Version, config.Checksum, message)
|
||||
snapshot.LastError = message
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = message
|
||||
@@ -193,6 +223,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
message = "openresty apply failed"
|
||||
}
|
||||
slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "message", message)
|
||||
markBlockedTarget(snapshot, config.Version, config.Checksum, message)
|
||||
snapshot.LastError = message
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = message
|
||||
@@ -230,6 +261,56 @@ func outcomeError(version string, message string) error {
|
||||
return fmt.Errorf("apply version %s failed: %s", version, trimmed)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRuntimeForCurrentConfig(ctx context.Context, mode string, snapshot *state.Snapshot, currentChecksum string) error {
|
||||
if strings.TrimSpace(currentChecksum) == "" {
|
||||
slog.Warn("blocked config cannot be retried and no local checksum is available for runtime recovery", "mode", mode, "blocked_version", snapshot.BlockedVersion)
|
||||
return nil
|
||||
}
|
||||
slog.Info("ensuring runtime with current local config while active target remains blocked", "mode", mode, "current_version", snapshot.CurrentVersion, "current_checksum", currentChecksum, "blocked_version", snapshot.BlockedVersion)
|
||||
if err := s.nginxManager.EnsureRuntime(ctx, true); err != nil {
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
return err
|
||||
}
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
if strings.TrimSpace(snapshot.OpenrestyMessage) == strings.TrimSpace(snapshot.BlockedReason) {
|
||||
snapshot.OpenrestyMessage = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markBlockedTarget(snapshot *state.Snapshot, version string, checksum string, reason string) {
|
||||
if snapshot == nil {
|
||||
return
|
||||
}
|
||||
snapshot.BlockedVersion = strings.TrimSpace(version)
|
||||
snapshot.BlockedChecksum = strings.TrimSpace(checksum)
|
||||
snapshot.BlockedReason = strings.TrimSpace(reason)
|
||||
}
|
||||
|
||||
func clearBlockedTarget(snapshot *state.Snapshot) {
|
||||
if snapshot == nil {
|
||||
return
|
||||
}
|
||||
snapshot.BlockedVersion = ""
|
||||
snapshot.BlockedChecksum = ""
|
||||
snapshot.BlockedReason = ""
|
||||
}
|
||||
|
||||
func hasBlockedTarget(snapshot *state.Snapshot) bool {
|
||||
return snapshot != nil && (strings.TrimSpace(snapshot.BlockedVersion) != "" || strings.TrimSpace(snapshot.BlockedChecksum) != "")
|
||||
}
|
||||
|
||||
func isBlockedTarget(snapshot *state.Snapshot, version string, checksum string) bool {
|
||||
if snapshot == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(snapshot.BlockedVersion) == strings.TrimSpace(version) &&
|
||||
strings.TrimSpace(snapshot.BlockedChecksum) == strings.TrimSpace(checksum) &&
|
||||
(strings.TrimSpace(version) != "" || strings.TrimSpace(checksum) != "")
|
||||
}
|
||||
|
||||
func checksumString(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -203,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.BlockedVersion != "20260309-002" || snapshot.BlockedChecksum != "checksum-2" {
|
||||
t.Fatalf("expected failed target version to be blocked, got %+v", snapshot)
|
||||
}
|
||||
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusUnhealthy {
|
||||
t.Fatalf("expected unhealthy openresty status, got %q", snapshot.OpenrestyStatus)
|
||||
}
|
||||
@@ -267,6 +270,9 @@ func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
|
||||
if snapshot.CurrentVersion != "20260309-001" || snapshot.CurrentChecksum != "checksum-1" {
|
||||
t.Fatal("expected warning apply to keep previous version state")
|
||||
}
|
||||
if snapshot.BlockedVersion != "20260309-002" || snapshot.BlockedChecksum != "checksum-2" {
|
||||
t.Fatalf("expected rolled-back target version to be blocked, got %+v", snapshot)
|
||||
}
|
||||
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy {
|
||||
t.Fatalf("expected healthy openresty after rollback, got %q", snapshot.OpenrestyStatus)
|
||||
}
|
||||
@@ -368,6 +374,165 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-006",
|
||||
Checksum: "checksum-6",
|
||||
MainConfig: "worker_processes 6;",
|
||||
RouteConfig: "server { listen 86; }",
|
||||
RenderedConfig: "server { listen 86; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
nodeID, err := stateStore.EnsureNodeID()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||
}
|
||||
if err = stateStore.Save(&state.Snapshot{
|
||||
NodeID: nodeID,
|
||||
CurrentVersion: "20260309-005",
|
||||
CurrentChecksum: "checksum-5",
|
||||
BlockedVersion: "20260309-006",
|
||||
BlockedChecksum: "checksum-6",
|
||||
BlockedReason: "apply failed, rolled back to previous config",
|
||||
LastError: "apply failed, rolled back to previous config",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to seed state: %v", err)
|
||||
}
|
||||
|
||||
manager := &fakeManager{currentChecksum: "checksum-5"}
|
||||
service := New(client, manager, stateStore)
|
||||
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
|
||||
Version: "20260309-006",
|
||||
Checksum: "checksum-6",
|
||||
}); err != nil {
|
||||
t.Fatalf("expected blocked version to be skipped, got %v", err)
|
||||
}
|
||||
if client.fetchCalls != 0 {
|
||||
t.Fatalf("expected blocked version to skip fetch, got %d", client.fetchCalls)
|
||||
}
|
||||
if len(manager.applyMainContents) != 0 {
|
||||
t.Fatal("expected blocked version to skip apply")
|
||||
}
|
||||
if len(client.reports) != 0 {
|
||||
t.Fatal("expected blocked version to skip reporting duplicate apply result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
MainConfig: "worker_processes 7;",
|
||||
RouteConfig: "server { listen 87; }",
|
||||
RenderedConfig: "server { listen 87; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
nodeID, err := stateStore.EnsureNodeID()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||
}
|
||||
if err = stateStore.Save(&state.Snapshot{
|
||||
NodeID: nodeID,
|
||||
CurrentVersion: "20260309-005",
|
||||
CurrentChecksum: "checksum-5",
|
||||
BlockedVersion: "20260309-007",
|
||||
BlockedChecksum: "checksum-7",
|
||||
BlockedReason: "apply failed, rolled back to previous config",
|
||||
OpenrestyStatus: protocol.OpenrestyStatusUnhealthy,
|
||||
OpenrestyMessage: "apply failed, rolled back to previous config",
|
||||
LastError: "apply failed, rolled back to previous config",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to seed state: %v", err)
|
||||
}
|
||||
|
||||
manager := &fakeManager{currentChecksum: "checksum-5"}
|
||||
service := New(client, manager, stateStore)
|
||||
if err = service.SyncOnStartup(context.Background(), &protocol.ActiveConfigMeta{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
}); err != nil {
|
||||
t.Fatalf("expected blocked startup target to be skipped, got %v", err)
|
||||
}
|
||||
if len(manager.ensureCalls) != 1 || !manager.ensureCalls[0] {
|
||||
t.Fatal("expected startup skip to ensure runtime with current local config")
|
||||
}
|
||||
if client.fetchCalls != 0 {
|
||||
t.Fatalf("expected blocked startup target to skip fetch, got %d", client.fetchCalls)
|
||||
}
|
||||
if len(client.reports) != 0 {
|
||||
t.Fatal("expected blocked startup target to skip duplicate apply report")
|
||||
}
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load state: %v", err)
|
||||
}
|
||||
if snapshot.BlockedVersion != "20260309-007" || snapshot.BlockedChecksum != "checksum-7" {
|
||||
t.Fatalf("expected blocked target to remain recorded, got %+v", snapshot)
|
||||
}
|
||||
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy {
|
||||
t.Fatalf("expected startup runtime recovery to mark openresty healthy, got %q", snapshot.OpenrestyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-008",
|
||||
Checksum: "checksum-8",
|
||||
MainConfig: "worker_processes 8;",
|
||||
RouteConfig: "server { listen 88; }",
|
||||
RenderedConfig: "server { listen 88; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
nodeID, err := stateStore.EnsureNodeID()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||
}
|
||||
if err = stateStore.Save(&state.Snapshot{
|
||||
NodeID: nodeID,
|
||||
CurrentVersion: "20260309-005",
|
||||
CurrentChecksum: "checksum-5",
|
||||
BlockedVersion: "20260309-007",
|
||||
BlockedChecksum: "checksum-7",
|
||||
BlockedReason: "apply failed, rolled back to previous config",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to seed state: %v", err)
|
||||
}
|
||||
|
||||
manager := &fakeManager{}
|
||||
service := New(client, manager, stateStore)
|
||||
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
|
||||
Version: "20260309-008",
|
||||
Checksum: "checksum-8",
|
||||
}); err != nil {
|
||||
t.Fatalf("expected new target version to be applied, got %v", err)
|
||||
}
|
||||
if client.fetchCalls != 1 {
|
||||
t.Fatalf("expected new target to trigger fetch, got %d", client.fetchCalls)
|
||||
}
|
||||
if len(manager.applyMainContents) != 1 {
|
||||
t.Fatal("expected new target to trigger apply")
|
||||
}
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load state: %v", err)
|
||||
}
|
||||
if snapshot.BlockedVersion != "" || snapshot.BlockedChecksum != "" {
|
||||
t.Fatalf("expected blocked target to be cleared after new version succeeds, got %+v", snapshot)
|
||||
}
|
||||
if snapshot.CurrentVersion != "20260309-008" || snapshot.CurrentChecksum != "checksum-8" {
|
||||
t.Fatalf("expected current version to move to new target, got %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
|
||||
@@ -55,21 +55,23 @@ var NodeOfflineThreshold = 2 * time.Minute
|
||||
var AgentHeartbeatInterval = 10000 // milliseconds
|
||||
var AgentUpdateRepo = "Rain-kl/OpenFlare"
|
||||
var GeoIPProvider = "ipinfo"
|
||||
var DatabaseAutoCleanupEnabled = false
|
||||
var DatabaseAutoCleanupRetentionDays = 30
|
||||
|
||||
// V5 OpenResty performance settings (hot-reloadable via Option table)
|
||||
var OpenRestyWorkerProcesses = "auto"
|
||||
var OpenRestyWorkerConnections = 4096
|
||||
var OpenRestyWorkerRlimitNofile = 65535
|
||||
var OpenRestyEventsUse = ""
|
||||
var OpenRestyEventsMultiAcceptEnabled = false
|
||||
var OpenRestyKeepaliveTimeout = 65
|
||||
var OpenRestyEventsUse = "epoll"
|
||||
var OpenRestyEventsMultiAcceptEnabled = true
|
||||
var OpenRestyKeepaliveTimeout = 20
|
||||
var OpenRestyKeepaliveRequests = 1000
|
||||
var OpenRestyClientHeaderTimeout = 15
|
||||
var OpenRestyClientBodyTimeout = 15
|
||||
var OpenRestyClientMaxBodySize = "64m"
|
||||
var OpenRestyLargeClientHeaderBuffers = "4 16k"
|
||||
var OpenRestySendTimeout = 30
|
||||
var OpenRestyProxyConnectTimeout = 5
|
||||
var OpenRestyProxyConnectTimeout = 3
|
||||
var OpenRestyProxySendTimeout = 60
|
||||
var OpenRestyProxyReadTimeout = 60
|
||||
var OpenRestyWebsocketEnabled = true
|
||||
@@ -81,13 +83,12 @@ var OpenRestyProxyBusyBuffersSize = "64k"
|
||||
var OpenRestyGzipEnabled = true
|
||||
var OpenRestyGzipMinLength = 1024
|
||||
var OpenRestyGzipCompLevel = 5
|
||||
var OpenRestyResolvers = ""
|
||||
var OpenRestyCacheEnabled = false
|
||||
var OpenRestyCachePath = ""
|
||||
var OpenRestyCacheLevels = "1:2"
|
||||
var OpenRestyCacheInactive = "30m"
|
||||
var OpenRestyCacheMaxSize = "1g"
|
||||
var OpenRestyCacheKeyTemplate = "$scheme$proxy_host$request_uri"
|
||||
var OpenRestyCacheKeyTemplate = "$scheme$host$request_uri"
|
||||
var OpenRestyCacheLockEnabled = true
|
||||
var OpenRestyCacheLockTimeout = "5s"
|
||||
var OpenRestyCacheUseStale = "error timeout updating http_500 http_502 http_503 http_504"
|
||||
@@ -103,7 +104,7 @@ events {
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
|
||||
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
|
||||
access_log {{OpenRestyAccessLogPath}} openflare_json;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
@@ -126,7 +127,7 @@ http {
|
||||
gzip {{OpenRestyGzip}};
|
||||
gzip_min_length {{OpenRestyGzipMinLength}};
|
||||
gzip_comp_level {{OpenRestyGzipCompLevel}};
|
||||
{{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
|
||||
{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ func AgentRegister(c *gin.Context) {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
|
||||
var (
|
||||
result *service.AgentRegistrationResponse
|
||||
@@ -57,6 +58,7 @@ func AgentHeartbeat(c *gin.Context) {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
|
||||
authNode, ok := c.Get("agent_node")
|
||||
if !ok {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetConfigVersions godoc
|
||||
@@ -30,6 +31,39 @@ func GetConfigVersions(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetConfigVersion godoc
|
||||
// @Summary Get config version detail
|
||||
// @Tags ConfigVersions
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Version ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/{id} [get]
|
||||
func GetConfigVersion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid id",
|
||||
})
|
||||
return
|
||||
}
|
||||
version, err := service.GetConfigVersionDetail(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": version,
|
||||
})
|
||||
}
|
||||
|
||||
// GetActiveConfigVersion godoc
|
||||
// @Summary Get active config version
|
||||
// @Tags ConfigVersions
|
||||
@@ -137,7 +171,7 @@ func ActivateConfigVersion(c *gin.Context) {
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
"message": "invalid id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,29 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type dashboardOverviewPayload struct {
|
||||
GeneratedAt any `json:"generated_at"`
|
||||
Summary service.DashboardSummary `json:"summary"`
|
||||
Traffic service.DashboardTraffic `json:"traffic"`
|
||||
Capacity service.DashboardCapacity `json:"capacity"`
|
||||
Distributions dashboardDistributionsPayload `json:"distributions"`
|
||||
Trends dashboardTrendsPayload `json:"trends"`
|
||||
Nodes [][]any `json:"nodes"`
|
||||
}
|
||||
|
||||
type dashboardDistributionsPayload struct {
|
||||
StatusCodes [][]any `json:"status_codes"`
|
||||
TopDomains [][]any `json:"top_domains"`
|
||||
SourceCountries [][]any `json:"source_countries"`
|
||||
}
|
||||
|
||||
type dashboardTrendsPayload struct {
|
||||
Traffic24h [][]any `json:"traffic_24h"`
|
||||
Capacity24h [][]any `json:"capacity_24h"`
|
||||
Network24h [][]any `json:"network_24h"`
|
||||
DiskIO24h [][]any `json:"disk_io_24h"`
|
||||
}
|
||||
|
||||
// GetDashboardOverview godoc
|
||||
// @Summary Get dashboard overview
|
||||
// @Tags Dashboard
|
||||
@@ -20,5 +43,130 @@ func GetDashboardOverview(c *gin.Context) {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, view)
|
||||
respondSuccess(c, compressDashboardOverview(view))
|
||||
}
|
||||
|
||||
func compressDashboardOverview(view *service.DashboardOverviewView) *dashboardOverviewPayload {
|
||||
if view == nil {
|
||||
return &dashboardOverviewPayload{
|
||||
Distributions: dashboardDistributionsPayload{
|
||||
StatusCodes: [][]any{},
|
||||
TopDomains: [][]any{},
|
||||
SourceCountries: [][]any{},
|
||||
},
|
||||
Trends: dashboardTrendsPayload{
|
||||
Traffic24h: [][]any{},
|
||||
Capacity24h: [][]any{},
|
||||
Network24h: [][]any{},
|
||||
DiskIO24h: [][]any{},
|
||||
},
|
||||
Nodes: [][]any{},
|
||||
}
|
||||
}
|
||||
return &dashboardOverviewPayload{
|
||||
GeneratedAt: view.GeneratedAt,
|
||||
Summary: view.Summary,
|
||||
Traffic: view.Traffic,
|
||||
Capacity: view.Capacity,
|
||||
Distributions: dashboardDistributionsPayload{
|
||||
StatusCodes: compressDistributionItems(view.Distributions.StatusCodes),
|
||||
TopDomains: compressDistributionItems(view.Distributions.TopDomains),
|
||||
SourceCountries: compressDistributionItems(view.Distributions.SourceCountries),
|
||||
},
|
||||
Trends: dashboardTrendsPayload{
|
||||
Traffic24h: compressTrafficTrendPoints(view.Trends.Traffic24h),
|
||||
Capacity24h: compressCapacityTrendPoints(view.Trends.Capacity24h),
|
||||
Network24h: compressNetworkTrendPoints(view.Trends.Network24h),
|
||||
DiskIO24h: compressDiskIOTrendPoints(view.Trends.DiskIO24h),
|
||||
},
|
||||
Nodes: compressDashboardNodes(view.Nodes),
|
||||
}
|
||||
}
|
||||
|
||||
func compressDistributionItems(items []service.DistributionItem) [][]any {
|
||||
rows := make([][]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []any{item.Key, item.Value})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func compressTrafficTrendPoints(points []service.TrafficTrendPoint) [][]any {
|
||||
rows := make([][]any, 0, len(points))
|
||||
for _, point := range points {
|
||||
rows = append(rows, []any{
|
||||
point.BucketStartedAt,
|
||||
point.RequestCount,
|
||||
point.ErrorCount,
|
||||
point.UniqueVisitorCount,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func compressCapacityTrendPoints(points []service.CapacityTrendPoint) [][]any {
|
||||
rows := make([][]any, 0, len(points))
|
||||
for _, point := range points {
|
||||
rows = append(rows, []any{
|
||||
point.BucketStartedAt,
|
||||
point.AverageCPUUsagePercent,
|
||||
point.AverageMemoryUsagePercent,
|
||||
point.ReportedNodes,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func compressNetworkTrendPoints(points []service.NetworkTrendPoint) [][]any {
|
||||
rows := make([][]any, 0, len(points))
|
||||
for _, point := range points {
|
||||
rows = append(rows, []any{
|
||||
point.BucketStartedAt,
|
||||
point.NetworkRxBytes,
|
||||
point.NetworkTxBytes,
|
||||
point.OpenrestyRxBytes,
|
||||
point.OpenrestyTxBytes,
|
||||
point.ReportedNodes,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func compressDiskIOTrendPoints(points []service.DiskIOTrendPoint) [][]any {
|
||||
rows := make([][]any, 0, len(points))
|
||||
for _, point := range points {
|
||||
rows = append(rows, []any{
|
||||
point.BucketStartedAt,
|
||||
point.DiskReadBytes,
|
||||
point.DiskWriteBytes,
|
||||
point.ReportedNodes,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func compressDashboardNodes(nodes []service.DashboardNodeHealth) [][]any {
|
||||
rows := make([][]any, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
rows = append(rows, []any{
|
||||
node.ID,
|
||||
node.NodeID,
|
||||
node.Name,
|
||||
node.GeoName,
|
||||
node.GeoLatitude,
|
||||
node.GeoLongitude,
|
||||
node.Status,
|
||||
node.OpenrestyStatus,
|
||||
node.CurrentVersion,
|
||||
node.LastSeenAt,
|
||||
node.ActiveEventCount,
|
||||
node.CPUUsagePercent,
|
||||
node.MemoryUsagePercent,
|
||||
node.StorageUsagePercent,
|
||||
node.RequestCount,
|
||||
node.ErrorCount,
|
||||
node.UniqueVisitorCount,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CleanupDatabaseObservability godoc
|
||||
// @Summary Cleanup observability tables
|
||||
// @Tags Options
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/option/database/cleanup [post]
|
||||
func CleanupDatabaseObservability(c *gin.Context) {
|
||||
var input service.DatabaseCleanupInput
|
||||
if err := decodeOptionalJSONBody(c.Request.Body, &input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
result, err := service.CleanupDatabaseObservability(input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
@@ -247,3 +247,27 @@ func GetNodeObservability(c *gin.Context) {
|
||||
}
|
||||
respondSuccess(c, view)
|
||||
}
|
||||
|
||||
// CleanupNodeHealthEvents godoc
|
||||
// @Summary Cleanup node health events
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Node ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/observability/cleanup [post]
|
||||
func CleanupNodeHealthEvents(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := service.CleanupNodeHealthEvents(uint(id))
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
@@ -74,6 +73,21 @@ func validateGeoIPOption(key string, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseCleanupOption(key string, value string) error {
|
||||
switch key {
|
||||
case "DatabaseAutoCleanupEnabled":
|
||||
return validateBooleanOption(key, value)
|
||||
case "DatabaseAutoCleanupRetentionDays":
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue < 1 {
|
||||
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateOpenRestyOption(key string, value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
|
||||
@@ -115,15 +129,7 @@ func validateOpenRestyOption(key string, value string) error {
|
||||
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
|
||||
return fmt.Errorf("%s 已废弃,不再支持配置 resolver", key)
|
||||
case "OpenRestyEventsMultiAcceptEnabled",
|
||||
"OpenRestyWebsocketEnabled",
|
||||
"OpenRestyProxyRequestBufferingEnabled",
|
||||
@@ -187,12 +193,6 @@ 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
|
||||
@@ -286,6 +286,13 @@ func UpdateOption(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if err = validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = model.UpdateOption(option.Key, option.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -14,6 +14,7 @@ func TestValidateOpenRestyOption(t *testing.T) {
|
||||
{name: "worker processes invalid", key: "OpenRestyWorkerProcesses", value: "0", wantErr: true},
|
||||
{name: "events use empty", key: "OpenRestyEventsUse", value: ""},
|
||||
{name: "events use invalid", key: "OpenRestyEventsUse", value: "io_uring", wantErr: true},
|
||||
{name: "resolvers deprecated", key: "OpenRestyResolvers", value: "1.1.1.1", wantErr: true},
|
||||
{name: "proxy buffers valid", key: "OpenRestyProxyBuffers", value: "16 16k"},
|
||||
{name: "proxy buffers invalid", key: "OpenRestyProxyBuffers", value: "16x16k", wantErr: true},
|
||||
{name: "cache max size valid", key: "OpenRestyCacheMaxSize", value: "2g"},
|
||||
@@ -32,9 +33,6 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetOrigins(c *gin.Context) {
|
||||
origins, err := service.ListOrigins()
|
||||
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": origins,
|
||||
})
|
||||
}
|
||||
|
||||
func GetOrigin(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": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
origin, err := service.GetOriginDetail(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": origin,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateOrigin(c *gin.Context) {
|
||||
var input service.OriginInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
origin, err := service.CreateOrigin(input)
|
||||
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": origin,
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateOrigin(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": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
var input service.OriginInput
|
||||
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
origin, err := service.UpdateOrigin(uint(id), input)
|
||||
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": origin,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteOrigin(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": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err = service.DeleteOrigin(uint(id)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
+1175
-180
File diff suppressed because it is too large
Load Diff
+1174
-181
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,12 @@
|
||||
basePath: /
|
||||
definitions:
|
||||
controller.geoIPLookupRequest:
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
type: object
|
||||
model.Option:
|
||||
properties:
|
||||
key:
|
||||
@@ -7,12 +14,94 @@ definitions:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
service.AgentBufferedObservabilityRecord:
|
||||
properties:
|
||||
access_logs:
|
||||
items:
|
||||
$ref: '#/definitions/service.AgentNodeAccessLog'
|
||||
type: array
|
||||
snapshot:
|
||||
$ref: '#/definitions/service.AgentNodeMetricSnapshot'
|
||||
traffic_report:
|
||||
$ref: '#/definitions/service.AgentNodeTrafficReport'
|
||||
window_started_at_unix:
|
||||
type: integer
|
||||
type: object
|
||||
service.AgentNodeAccessLog:
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
logged_at_unix:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
remote_addr:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
type: object
|
||||
service.AgentNodeHealthEvent:
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
severity:
|
||||
type: string
|
||||
triggered_at_unix:
|
||||
type: integer
|
||||
type: object
|
||||
service.AgentNodeMetricSnapshot:
|
||||
properties:
|
||||
captured_at_unix:
|
||||
type: integer
|
||||
cpu_usage_percent:
|
||||
type: number
|
||||
disk_read_bytes:
|
||||
type: integer
|
||||
disk_write_bytes:
|
||||
type: integer
|
||||
memory_total_bytes:
|
||||
type: integer
|
||||
memory_used_bytes:
|
||||
type: integer
|
||||
network_rx_bytes:
|
||||
type: integer
|
||||
network_tx_bytes:
|
||||
type: integer
|
||||
openresty_connections:
|
||||
type: integer
|
||||
openresty_rx_bytes:
|
||||
type: integer
|
||||
openresty_tx_bytes:
|
||||
type: integer
|
||||
storage_total_bytes:
|
||||
type: integer
|
||||
storage_used_bytes:
|
||||
type: integer
|
||||
type: object
|
||||
service.AgentNodePayload:
|
||||
properties:
|
||||
access_logs:
|
||||
items:
|
||||
$ref: '#/definitions/service.AgentNodeAccessLog'
|
||||
type: array
|
||||
agent_version:
|
||||
type: string
|
||||
buffered_observability:
|
||||
items:
|
||||
$ref: '#/definitions/service.AgentBufferedObservabilityRecord'
|
||||
type: array
|
||||
current_version:
|
||||
type: string
|
||||
health_events:
|
||||
items:
|
||||
$ref: '#/definitions/service.AgentNodeHealthEvent'
|
||||
type: array
|
||||
ip:
|
||||
type: string
|
||||
last_error:
|
||||
@@ -27,15 +116,79 @@ definitions:
|
||||
type: string
|
||||
openresty_status:
|
||||
type: string
|
||||
profile:
|
||||
$ref: '#/definitions/service.AgentNodeSystemProfile'
|
||||
snapshot:
|
||||
$ref: '#/definitions/service.AgentNodeMetricSnapshot'
|
||||
traffic_report:
|
||||
$ref: '#/definitions/service.AgentNodeTrafficReport'
|
||||
type: object
|
||||
service.AgentNodeSystemProfile:
|
||||
properties:
|
||||
architecture:
|
||||
type: string
|
||||
cpu_cores:
|
||||
type: integer
|
||||
cpu_model:
|
||||
type: string
|
||||
hostname:
|
||||
type: string
|
||||
kernel_version:
|
||||
type: string
|
||||
os_name:
|
||||
type: string
|
||||
os_version:
|
||||
type: string
|
||||
reported_at_unix:
|
||||
type: integer
|
||||
total_disk_bytes:
|
||||
type: integer
|
||||
total_memory_bytes:
|
||||
type: integer
|
||||
uptime_seconds:
|
||||
type: integer
|
||||
type: object
|
||||
service.AgentNodeTrafficReport:
|
||||
properties:
|
||||
error_count:
|
||||
type: integer
|
||||
request_count:
|
||||
type: integer
|
||||
source_countries:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
type: object
|
||||
status_codes:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
type: object
|
||||
top_domains:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
type: object
|
||||
unique_visitor_count:
|
||||
type: integer
|
||||
window_ended_at_unix:
|
||||
type: integer
|
||||
window_started_at_unix:
|
||||
type: integer
|
||||
type: object
|
||||
service.ApplyLogPayload:
|
||||
properties:
|
||||
checksum:
|
||||
type: string
|
||||
main_config_checksum:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
node_id:
|
||||
type: string
|
||||
result:
|
||||
type: string
|
||||
route_config_checksum:
|
||||
type: string
|
||||
support_file_count:
|
||||
type: integer
|
||||
version:
|
||||
type: string
|
||||
type: object
|
||||
@@ -54,6 +207,16 @@ definitions:
|
||||
properties:
|
||||
auto_update_enabled:
|
||||
type: boolean
|
||||
geo_latitude:
|
||||
type: number
|
||||
geo_longitude:
|
||||
type: number
|
||||
geo_manual_override:
|
||||
type: boolean
|
||||
geo_name:
|
||||
type: string
|
||||
ip:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
@@ -66,6 +229,14 @@ definitions:
|
||||
type: object
|
||||
service.ProxyRouteInput:
|
||||
properties:
|
||||
cache_enabled:
|
||||
type: boolean
|
||||
cache_policy:
|
||||
type: string
|
||||
cache_rules:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
cert_id:
|
||||
type: integer
|
||||
custom_headers:
|
||||
@@ -78,14 +249,18 @@ definitions:
|
||||
type: boolean
|
||||
enabled:
|
||||
type: boolean
|
||||
origin_url:
|
||||
type: string
|
||||
origin_host:
|
||||
type: string
|
||||
origin_url:
|
||||
type: string
|
||||
redirect_http:
|
||||
type: boolean
|
||||
remark:
|
||||
type: string
|
||||
upstreams:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
service.TLSCertificateInput:
|
||||
properties:
|
||||
@@ -104,6 +279,204 @@ info:
|
||||
title: OpenFlare Server API
|
||||
version: "3.0"
|
||||
paths:
|
||||
/api/access-logs/:
|
||||
get:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: query
|
||||
name: node_id
|
||||
type: string
|
||||
- description: Remote address
|
||||
in: query
|
||||
name: remote_addr
|
||||
type: string
|
||||
- description: Host
|
||||
in: query
|
||||
name: host
|
||||
type: string
|
||||
- description: Path
|
||||
in: query
|
||||
name: path
|
||||
type: string
|
||||
- description: Page index
|
||||
in: query
|
||||
name: p
|
||||
type: integer
|
||||
- description: Page size
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: Sort by
|
||||
in: query
|
||||
name: sort_by
|
||||
type: string
|
||||
- description: Sort order
|
||||
in: query
|
||||
name: sort_order
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List access logs
|
||||
tags:
|
||||
- AccessLogs
|
||||
/api/access-logs/cleanup:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Cleanup access logs by retention days
|
||||
tags:
|
||||
- AccessLogs
|
||||
/api/access-logs/folds:
|
||||
get:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: query
|
||||
name: node_id
|
||||
type: string
|
||||
- description: Remote address
|
||||
in: query
|
||||
name: remote_addr
|
||||
type: string
|
||||
- description: Host
|
||||
in: query
|
||||
name: host
|
||||
type: string
|
||||
- description: Path
|
||||
in: query
|
||||
name: path
|
||||
type: string
|
||||
- description: Page index
|
||||
in: query
|
||||
name: p
|
||||
type: integer
|
||||
- description: Page size
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: Sort by
|
||||
in: query
|
||||
name: sort_by
|
||||
type: string
|
||||
- description: Sort order
|
||||
in: query
|
||||
name: sort_order
|
||||
type: string
|
||||
- description: Fold minutes
|
||||
in: query
|
||||
name: fold_minutes
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List folded access logs
|
||||
tags:
|
||||
- AccessLogs
|
||||
/api/access-logs/ip-summary:
|
||||
get:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: query
|
||||
name: node_id
|
||||
type: string
|
||||
- description: Remote address
|
||||
in: query
|
||||
name: remote_addr
|
||||
type: string
|
||||
- description: Host
|
||||
in: query
|
||||
name: host
|
||||
type: string
|
||||
- description: Page index
|
||||
in: query
|
||||
name: p
|
||||
type: integer
|
||||
- description: Page size
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: Sort by
|
||||
in: query
|
||||
name: sort_by
|
||||
type: string
|
||||
- description: Sort order
|
||||
in: query
|
||||
name: sort_order
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List access log IP summaries
|
||||
tags:
|
||||
- AccessLogs
|
||||
/api/access-logs/ip-summary/trend:
|
||||
get:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: query
|
||||
name: node_id
|
||||
type: string
|
||||
- description: Remote address
|
||||
in: query
|
||||
name: remote_addr
|
||||
required: true
|
||||
type: string
|
||||
- description: Host
|
||||
in: query
|
||||
name: host
|
||||
type: string
|
||||
- description: Hours
|
||||
in: query
|
||||
name: hours
|
||||
type: integer
|
||||
- description: Bucket minutes
|
||||
in: query
|
||||
name: bucket_minutes
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get access log IP trend
|
||||
tags:
|
||||
- AccessLogs
|
||||
/api/agent/apply-logs:
|
||||
post:
|
||||
consumes:
|
||||
@@ -226,6 +599,23 @@ paths:
|
||||
summary: List apply logs
|
||||
tags:
|
||||
- ApplyLogs
|
||||
/api/apply-logs/cleanup:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Cleanup apply logs
|
||||
tags:
|
||||
- ApplyLogs
|
||||
/api/config-versions/:
|
||||
get:
|
||||
produces:
|
||||
@@ -241,8 +631,34 @@ paths:
|
||||
summary: List config versions
|
||||
tags:
|
||||
- ConfigVersions
|
||||
/api/config-versions/{id}:
|
||||
get:
|
||||
parameters:
|
||||
- description: Version ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get config version detail
|
||||
tags:
|
||||
- ConfigVersions
|
||||
/api/config-versions/{id}/activate:
|
||||
put:
|
||||
post:
|
||||
parameters:
|
||||
- description: Version ID
|
||||
in: path
|
||||
@@ -327,6 +743,26 @@ paths:
|
||||
summary: Publish a new config version
|
||||
tags:
|
||||
- ConfigVersions
|
||||
/api/dashboard/overview:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get dashboard overview
|
||||
tags:
|
||||
- Dashboard
|
||||
/api/managed-domains/:
|
||||
get:
|
||||
produces:
|
||||
@@ -370,8 +806,8 @@ paths:
|
||||
summary: Create managed domain
|
||||
tags:
|
||||
- ManagedDomains
|
||||
/api/managed-domains/{id}:
|
||||
delete:
|
||||
/api/managed-domains/{id}/delete:
|
||||
post:
|
||||
parameters:
|
||||
- description: Managed domain ID
|
||||
in: path
|
||||
@@ -396,7 +832,8 @@ paths:
|
||||
summary: Delete managed domain
|
||||
tags:
|
||||
- ManagedDomains
|
||||
put:
|
||||
/api/managed-domains/{id}/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
@@ -493,65 +930,6 @@ paths:
|
||||
summary: Create node
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/{id}:
|
||||
delete:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete node
|
||||
tags:
|
||||
- Nodes
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Node payload
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/service.NodeInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update node
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/{id}/agent-release:
|
||||
get:
|
||||
parameters:
|
||||
@@ -608,6 +986,92 @@ paths:
|
||||
summary: Request agent self-update on node
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/{id}/delete:
|
||||
post:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete node
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/{id}/observability:
|
||||
get:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Lookback window in hours
|
||||
in: query
|
||||
name: hours
|
||||
type: integer
|
||||
- description: Max records per section
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get node observability details
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/{id}/observability/cleanup:
|
||||
post:
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Cleanup node health events
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/{id}/openresty-restart:
|
||||
post:
|
||||
parameters:
|
||||
@@ -634,6 +1098,40 @@ paths:
|
||||
summary: Request openresty restart on node
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/{id}/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Node ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Node payload
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/service.NodeInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update node
|
||||
tags:
|
||||
- Nodes
|
||||
/api/nodes/bootstrap-token:
|
||||
get:
|
||||
produces:
|
||||
@@ -677,7 +1175,52 @@ paths:
|
||||
summary: List editable options
|
||||
tags:
|
||||
- Options
|
||||
put:
|
||||
/api/option/database/cleanup:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Cleanup observability tables
|
||||
tags:
|
||||
- Options
|
||||
/api/option/geoip/lookup:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: GeoIP lookup payload
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/controller.geoIPLookupRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Test GeoIP lookup
|
||||
tags:
|
||||
- Options
|
||||
/api/option/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
@@ -746,8 +1289,8 @@ paths:
|
||||
summary: Create proxy route
|
||||
tags:
|
||||
- ProxyRoutes
|
||||
/api/proxy-routes/{id}:
|
||||
delete:
|
||||
/api/proxy-routes/{id}/delete:
|
||||
post:
|
||||
parameters:
|
||||
- description: Route ID
|
||||
in: path
|
||||
@@ -772,7 +1315,8 @@ paths:
|
||||
summary: Delete proxy route
|
||||
tags:
|
||||
- ProxyRoutes
|
||||
put:
|
||||
/api/proxy-routes/{id}/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
@@ -862,7 +1406,59 @@ paths:
|
||||
tags:
|
||||
- TLSCertificates
|
||||
/api/tls-certificates/{id}:
|
||||
delete:
|
||||
get:
|
||||
parameters:
|
||||
- description: Certificate ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get TLS certificate detail
|
||||
tags:
|
||||
- TLSCertificates
|
||||
/api/tls-certificates/{id}/content:
|
||||
get:
|
||||
parameters:
|
||||
- description: Certificate ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get TLS certificate PEM content
|
||||
tags:
|
||||
- TLSCertificates
|
||||
/api/tls-certificates/{id}/delete:
|
||||
post:
|
||||
parameters:
|
||||
- description: Certificate ID
|
||||
in: path
|
||||
@@ -887,6 +1483,40 @@ paths:
|
||||
summary: Delete TLS certificate
|
||||
tags:
|
||||
- TLSCertificates
|
||||
/api/tls-certificates/{id}/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Certificate ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: TLS certificate payload
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/service.TLSCertificateInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update TLS certificate from PEM
|
||||
tags:
|
||||
- TLSCertificates
|
||||
/api/tls-certificates/import-file:
|
||||
post:
|
||||
consumes:
|
||||
@@ -942,6 +1572,12 @@ paths:
|
||||
summary: Get latest GitHub release
|
||||
tags:
|
||||
- Update
|
||||
/api/update/logs/ws:
|
||||
get:
|
||||
responses: {}
|
||||
summary: Stream server upgrade logs over websocket
|
||||
tags:
|
||||
- Update
|
||||
/api/update/manual-upgrade:
|
||||
post:
|
||||
consumes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module openflare
|
||||
|
||||
// +heroku goVersion go1.18
|
||||
// +heroku goVersion go1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.8.12
|
||||
github.com/swaggo/swag v1.16.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/net v0.47.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
@@ -28,6 +28,7 @@ require (
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
@@ -58,6 +59,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -67,6 +69,7 @@ require (
|
||||
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/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
@@ -74,6 +77,7 @@ require (
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/sharding v0.6.2 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
|
||||
+12
-2
@@ -6,6 +6,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
@@ -76,6 +78,7 @@ github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn
|
||||
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-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
@@ -126,6 +129,8 @@ 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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE=
|
||||
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
@@ -157,6 +162,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -174,8 +180,8 @@ github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w=
|
||||
github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
@@ -190,6 +196,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
@@ -253,6 +261,8 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
|
||||
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"openflare/middleware"
|
||||
"openflare/model"
|
||||
"openflare/router"
|
||||
"openflare/service"
|
||||
"openflare/utils/geoip"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -67,6 +69,9 @@ func main() {
|
||||
// Initialize options
|
||||
model.InitOptionMap()
|
||||
geoip.InitGeoIP()
|
||||
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
|
||||
defer cancelBackgroundTasks()
|
||||
service.StartDatabaseAutoCleanupScheduler(backgroundCtx)
|
||||
|
||||
// Initialize HTTP server
|
||||
server := gin.Default()
|
||||
|
||||
@@ -17,7 +17,7 @@ type ApplyLog struct {
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
Version string `json:"version" gorm:"size:32;not null"`
|
||||
Result string `json:"result" gorm:"size:32;not null"`
|
||||
Message string `json:"message" gorm:"size:1024"`
|
||||
Message string `json:"message" gorm:"type:text"`
|
||||
Checksum string `json:"checksum" gorm:"size:64;not null;default:''"`
|
||||
MainConfigChecksum string `json:"main_config_checksum" gorm:"size:64;not null;default:''"`
|
||||
RouteConfigChecksum string `json:"route_config_checksum" gorm:"size:64;not null;default:''"`
|
||||
|
||||
@@ -2,6 +2,15 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ConfigVersionSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigVersion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
|
||||
@@ -15,8 +24,11 @@ type ConfigVersion struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ListConfigVersions() (versions []*ConfigVersion, err error) {
|
||||
err = DB.Order("id desc").Find(&versions).Error
|
||||
func ListConfigVersionSummaries() (versions []*ConfigVersionSummary, err error) {
|
||||
err = DB.Model(&ConfigVersion{}).
|
||||
Select("id", "version", "checksum", "is_active", "created_by", "created_at").
|
||||
Order("id desc").
|
||||
Find(&versions).Error
|
||||
return versions, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 4
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
type DatabaseSchemaVersion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Version int `json:"version" gorm:"not null"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DatabaseSchemaVersion) TableName() string {
|
||||
return "database_schema_versions"
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func registeredModels() []any {
|
||||
&File{},
|
||||
&User{},
|
||||
&Option{},
|
||||
&Origin{},
|
||||
&ProxyRoute{},
|
||||
&ConfigVersion{},
|
||||
&Node{},
|
||||
@@ -41,6 +42,12 @@ func registeredModels() []any {
|
||||
}
|
||||
}
|
||||
|
||||
func schemaMetadataModels() []any {
|
||||
return []any{
|
||||
&DatabaseSchemaVersion{},
|
||||
}
|
||||
}
|
||||
|
||||
func buildDBModels() ([]dbModel, error) {
|
||||
models := registeredModels()
|
||||
result := make([]dbModel, 0, len(models))
|
||||
@@ -61,16 +68,6 @@ func buildDBModels() ([]dbModel, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func migrateProxyRouteEnableHTTPSColumn(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&ProxyRoute{}) {
|
||||
return nil
|
||||
}
|
||||
if db.Migrator().HasColumn(&ProxyRoute{}, "enable_https") || !db.Migrator().HasColumn(&ProxyRoute{}, "enable_http_s") {
|
||||
return nil
|
||||
}
|
||||
return db.Migrator().RenameColumn(&ProxyRoute{}, "enable_http_s", "enable_https")
|
||||
}
|
||||
|
||||
func createRootAccountIfNeed() error {
|
||||
var user User
|
||||
//if user.Status != common.UserStatusEnabled {
|
||||
@@ -99,17 +96,13 @@ func CountTable(tableName string) (num int64) {
|
||||
|
||||
func openDatabase() (*gorm.DB, string, error) {
|
||||
if common.SQLDSN != "" {
|
||||
db, err := gorm.Open(postgres.Open(common.SQLDSN), &gorm.Config{
|
||||
PrepareStmt: true,
|
||||
})
|
||||
db, err := gorm.Open(postgres.Open(common.SQLDSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return db, "postgres", nil
|
||||
}
|
||||
db, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
|
||||
PrepareStmt: true,
|
||||
})
|
||||
db, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -127,9 +120,31 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
|
||||
for _, item := range registeredModels() {
|
||||
models, err := buildDBModels()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, item := range models {
|
||||
if isShardedObservabilityTable(item.tableName) {
|
||||
for _, table := range observabilityShardTables(item.tableName) {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table(table).Limit(1).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !db.Migrator().HasTable(item.value) {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(item).Limit(1).Count(&count).Error; err != nil {
|
||||
if err := db.Model(item.value).Limit(1).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
@@ -238,8 +253,17 @@ func migrateTableData(source *gorm.DB, target *gorm.DB, item dbModel) error {
|
||||
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)
|
||||
if isShardedObservabilityTable(item.tableName) {
|
||||
for index := 0; index < batchLen; index++ {
|
||||
record := batchPtr.Elem().Index(index)
|
||||
if err := target.Create(record.Addr().Interface()).Error; err != nil {
|
||||
return fmt.Errorf("write target sharded table %s failed: %w", item.tableName, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := target.Create(batchPtr.Interface()).Error; err != nil {
|
||||
return fmt.Errorf("write target table %s failed: %w", item.tableName, err)
|
||||
}
|
||||
}
|
||||
migrated += int64(batchLen)
|
||||
offset += batchLen
|
||||
@@ -266,13 +290,10 @@ func InitDB() (err error) {
|
||||
os.Exit(1)
|
||||
}
|
||||
DB = db
|
||||
if err = migrateProxyRouteEnableHTTPSColumn(db); err != nil {
|
||||
if err = registerSharding(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = autoMigrateAll(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = migrateSQLiteDataIfNeeded(db, backend); err != nil {
|
||||
if err = ensureDatabaseSchemaUpToDate(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return createRootAccountIfNeed()
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
||||
func openBareTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
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)
|
||||
@@ -28,6 +27,16 @@ func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db := openBareTestSQLiteDB(t, name)
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
t.Fatalf("auto migrate db: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func findDBModelByTableName(t *testing.T, tableName string) dbModel {
|
||||
t.Helper()
|
||||
|
||||
@@ -121,3 +130,362 @@ func TestMigrateTableDataCopiesRows(t *testing.T) {
|
||||
t.Fatalf("unexpected migrated option value: %s", gotOption.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShardingAutoMigratesShardTables(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "sharded.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
t.Fatalf("auto migrate db: %v", err)
|
||||
}
|
||||
|
||||
for _, table := range []string{
|
||||
"node_metric_snapshots_00",
|
||||
"node_metric_snapshots_09",
|
||||
"node_request_reports_00",
|
||||
"node_request_reports_09",
|
||||
"node_access_logs_00",
|
||||
"node_access_logs_09",
|
||||
} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
t.Fatalf("expected sharded table %s to exist", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testing.T) {
|
||||
db := openTestSQLiteDB(t, "legacy-health-events.db")
|
||||
|
||||
if err := db.Exec("ALTER TABLE node_health_events ADD COLUMN raw_json TEXT").Error; err != nil {
|
||||
t.Fatalf("add raw_json column: %v", err)
|
||||
}
|
||||
rawJSON, err := json.Marshal(map[string]any{
|
||||
"event_type": "sync_error",
|
||||
"metadata": map[string]string{
|
||||
"reason": "checksum_mismatch",
|
||||
"scope": "routes",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal raw json: %v", err)
|
||||
}
|
||||
event := &NodeHealthEvent{
|
||||
NodeID: "node-legacy",
|
||||
EventType: "sync_error",
|
||||
Severity: "warning",
|
||||
Status: "active",
|
||||
Message: "checksum mismatch",
|
||||
FirstTriggeredAt: time.Now().Add(-time.Minute),
|
||||
LastTriggeredAt: time.Now(),
|
||||
ReportedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(event).Error; err != nil {
|
||||
t.Fatalf("create health event: %v", err)
|
||||
}
|
||||
if err := db.Exec("UPDATE node_health_events SET raw_json = ? WHERE id = ?", string(rawJSON), event.ID).Error; err != nil {
|
||||
t.Fatalf("seed legacy raw_json: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateObservabilityLegacyColumns(db); err != nil {
|
||||
t.Fatalf("migrateObservabilityLegacyColumns: %v", err)
|
||||
}
|
||||
|
||||
var got NodeHealthEvent
|
||||
if err := db.First(&got, event.ID).Error; err != nil {
|
||||
t.Fatalf("query health event: %v", err)
|
||||
}
|
||||
if got.MetadataJSON == "" {
|
||||
t.Fatal("expected metadata_json to be backfilled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateInitializesFreshDatabase(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "fresh-schema.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected database schema version to be recorded")
|
||||
}
|
||||
if version != currentDatabaseSchemaVersion {
|
||||
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-schema.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
t.Fatalf("auto migrate db: %v", err)
|
||||
}
|
||||
if err := db.Create(&User{
|
||||
Username: "legacy",
|
||||
Password: "secret",
|
||||
DisplayName: "Legacy User",
|
||||
Role: 1,
|
||||
Status: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed legacy user: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected legacy database to gain a schema version record")
|
||||
}
|
||||
if version != currentDatabaseSchemaVersion {
|
||||
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateMigratesObservabilityShardsToID(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-observability-shards.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
t.Fatalf("auto migrate db: %v", err)
|
||||
}
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
t.Fatalf("auto migrate schema metadata: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := db.Table("node_metric_snapshots_00").Create(&NodeMetricSnapshot{
|
||||
ID: 1,
|
||||
NodeID: "node-a",
|
||||
CapturedAt: now.Add(-2 * time.Minute),
|
||||
CPUUsagePercent: 22,
|
||||
MemoryUsedBytes: 2,
|
||||
MemoryTotalBytes: 8,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed metric snapshot shard 00: %v", err)
|
||||
}
|
||||
if err := db.Table("node_metric_snapshots_01").Create(&NodeMetricSnapshot{
|
||||
ID: 1,
|
||||
NodeID: "node-b",
|
||||
CapturedAt: now.Add(-time.Minute),
|
||||
CPUUsagePercent: 44,
|
||||
MemoryUsedBytes: 4,
|
||||
MemoryTotalBytes: 8,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed metric snapshot shard 01: %v", err)
|
||||
}
|
||||
if err := db.Table("node_request_reports_00").Create(&NodeRequestReport{
|
||||
ID: 1,
|
||||
NodeID: "node-a",
|
||||
WindowStartedAt: now.Add(-3 * time.Minute),
|
||||
WindowEndedAt: now.Add(-2 * time.Minute),
|
||||
RequestCount: 12,
|
||||
ErrorCount: 1,
|
||||
UniqueVisitorCount: 6,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed request report shard 00: %v", err)
|
||||
}
|
||||
if err := db.Table("node_request_reports_01").Create(&NodeRequestReport{
|
||||
ID: 1,
|
||||
NodeID: "node-b",
|
||||
WindowStartedAt: now.Add(-2 * time.Minute),
|
||||
WindowEndedAt: now.Add(-time.Minute),
|
||||
RequestCount: 21,
|
||||
ErrorCount: 2,
|
||||
UniqueVisitorCount: 9,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed request report shard 01: %v", err)
|
||||
}
|
||||
if err := db.Table("node_access_logs_00").Create(&NodeAccessLog{
|
||||
ID: 1,
|
||||
NodeID: "node-a",
|
||||
LoggedAt: now.Add(-90 * time.Second),
|
||||
RemoteAddr: "203.0.113.10",
|
||||
Host: "a.example.com",
|
||||
Path: "/alpha",
|
||||
StatusCode: 200,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed access log shard 00: %v", err)
|
||||
}
|
||||
if err := db.Table("node_access_logs_01").Create(&NodeAccessLog{
|
||||
ID: 1,
|
||||
NodeID: "node-b",
|
||||
LoggedAt: now.Add(-60 * time.Second),
|
||||
RemoteAddr: "203.0.113.11",
|
||||
Host: "b.example.com",
|
||||
Path: "/beta",
|
||||
StatusCode: 502,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed access log shard 01: %v", err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 2); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
previousDB := DB
|
||||
DB = db
|
||||
t.Cleanup(func() {
|
||||
DB = previousDB
|
||||
})
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected migrated database to keep schema version record")
|
||||
}
|
||||
if version != currentDatabaseSchemaVersion {
|
||||
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if db.Migrator().HasTable(legacyTable) {
|
||||
t.Fatalf("expected legacy shard table %s to be removed", legacyTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshots, err := ListMetricSnapshotsSince(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
|
||||
}
|
||||
if len(snapshots) != 2 {
|
||||
t.Fatalf("expected 2 migrated metric snapshots, got %+v", snapshots)
|
||||
}
|
||||
reports, err := ListRequestReportsSince(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRequestReportsSince failed: %v", err)
|
||||
}
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("expected 2 migrated request reports, got %+v", reports)
|
||||
}
|
||||
logs, err := ListNodeAccessLogs(NodeAccessLogQuery{Page: 0, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNodeAccessLogs failed: %v", err)
|
||||
}
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("expected 2 migrated access logs, got %+v", logs)
|
||||
}
|
||||
|
||||
seenSnapshotIDs := make(map[uint]struct{}, len(snapshots))
|
||||
for _, item := range snapshots {
|
||||
if item == nil || item.ID == 0 {
|
||||
t.Fatalf("expected migrated metric snapshot to have a new non-zero id: %+v", item)
|
||||
}
|
||||
if _, exists := seenSnapshotIDs[item.ID]; exists {
|
||||
t.Fatalf("expected migrated metric snapshot ids to be unique, got duplicate %d", item.ID)
|
||||
}
|
||||
seenSnapshotIDs[item.ID] = struct{}{}
|
||||
targetTable := observabilityShardTableForID("node_metric_snapshots", item.ID)
|
||||
var count int64
|
||||
if err := db.Table(targetTable).Where("id = ?", item.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count migrated metric snapshot in target shard: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected migrated metric snapshot id %d to be stored in %s", item.ID, targetTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-origins.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := applyCurrentSchema(db, "sqlite"); err != nil {
|
||||
t.Fatalf("applyCurrentSchema: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
route := &ProxyRoute{
|
||||
Domain: "app.example.com",
|
||||
OriginURL: "https://origin-a.internal:8443/api",
|
||||
Upstreams: `["https://origin-a.internal:8443/api"]`,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(route).Error; err != nil {
|
||||
t.Fatalf("seed proxy route: %v", err)
|
||||
}
|
||||
if err := db.Exec(`DELETE FROM origins`).Error; err != nil {
|
||||
t.Fatalf("clear origins: %v", err)
|
||||
}
|
||||
if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Update("origin_id", nil).Error; err != nil {
|
||||
t.Fatalf("clear route origin_id: %v", err)
|
||||
}
|
||||
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
t.Fatalf("backfillOriginsFromProxyRoutes: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&Origin{}) {
|
||||
t.Fatal("expected origins table to exist")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") {
|
||||
t.Fatal("expected proxy_routes.origin_id column to exist")
|
||||
}
|
||||
|
||||
reloadedRoute := &ProxyRoute{}
|
||||
if err := db.First(reloadedRoute, route.ID).Error; err != nil {
|
||||
t.Fatalf("query proxy route: %v", err)
|
||||
}
|
||||
if reloadedRoute.OriginID == nil || *reloadedRoute.OriginID == 0 {
|
||||
t.Fatal("expected migrated route to be linked to a backfilled origin")
|
||||
}
|
||||
|
||||
origin := &Origin{}
|
||||
if err := db.First(origin, *reloadedRoute.OriginID).Error; err != nil {
|
||||
t.Fatalf("query origin: %v", err)
|
||||
}
|
||||
if origin.Address != "origin-a.internal" {
|
||||
t.Fatalf("unexpected backfilled origin address: %s", origin.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "failed-validation.db")
|
||||
|
||||
err := runDatabaseSchemaMigration(db, "sqlite", databaseSchemaMigration{
|
||||
fromVersion: legacyDatabaseSchemaVersion,
|
||||
toVersion: currentDatabaseSchemaVersion,
|
||||
migrate: func(tx *gorm.DB, backend string) error {
|
||||
return autoMigrateSchemaMetadata(tx)
|
||||
},
|
||||
validate: func(tx *gorm.DB, backend string) error {
|
||||
return gorm.ErrInvalidDB
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected migration validation to fail")
|
||||
}
|
||||
|
||||
_, exists, loadErr := loadDatabaseSchemaVersion(db)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("loadDatabaseSchemaVersion: %v", loadErr)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("expected schema version to remain unset after failed validation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type databaseSchemaMigration struct {
|
||||
fromVersion int
|
||||
toVersion int
|
||||
migrate func(db *gorm.DB, backend string) error
|
||||
validate func(db *gorm.DB, backend string) error
|
||||
}
|
||||
|
||||
func autoMigrateSchemaMetadata(db *gorm.DB) error {
|
||||
for _, item := range schemaMetadataModels() {
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateProxyRouteEnableHTTPSColumn(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&ProxyRoute{}) {
|
||||
return nil
|
||||
}
|
||||
if db.Migrator().HasColumn(&ProxyRoute{}, "enable_https") || !db.Migrator().HasColumn(&ProxyRoute{}, "enable_http_s") {
|
||||
return nil
|
||||
}
|
||||
return db.Migrator().RenameColumn(&ProxyRoute{}, "enable_http_s", "enable_https")
|
||||
}
|
||||
|
||||
func migrateTextColumns(db *gorm.DB, backend string) error {
|
||||
if backend != "postgres" {
|
||||
return nil
|
||||
}
|
||||
type textColumn struct {
|
||||
model any
|
||||
table string
|
||||
column string
|
||||
}
|
||||
columns := []textColumn{
|
||||
{model: &Node{}, table: "nodes", column: "openresty_message"},
|
||||
{model: &Node{}, table: "nodes", column: "last_error"},
|
||||
{model: &ApplyLog{}, table: "apply_logs", column: "message"},
|
||||
{model: &NodeHealthEvent{}, table: "node_health_events", column: "message"},
|
||||
}
|
||||
for _, item := range columns {
|
||||
if !db.Migrator().HasTable(item.model) || !db.Migrator().HasColumn(item.model, item.column) {
|
||||
continue
|
||||
}
|
||||
sql := fmt.Sprintf(`ALTER TABLE "%s" ALTER COLUMN "%s" TYPE text`, item.table, item.column)
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("migrate column %s.%s to text failed: %w", item.table, item.column, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateObservabilityLegacyColumns(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&NodeHealthEvent{}) || !db.Migrator().HasColumn(&NodeHealthEvent{}, "raw_json") {
|
||||
return nil
|
||||
}
|
||||
type legacyHealthEventRaw struct {
|
||||
ID uint
|
||||
RawJSON string
|
||||
MetadataJSON string
|
||||
}
|
||||
type legacyHealthEventPayload struct {
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
var rows []legacyHealthEventRaw
|
||||
if err := db.Model(&NodeHealthEvent{}).
|
||||
Select("id, raw_json, metadata_json").
|
||||
Where("raw_json <> '' AND (metadata_json IS NULL OR metadata_json = '')").
|
||||
Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("query legacy node health event raw_json failed: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
var payload legacyHealthEventPayload
|
||||
if err := json.Unmarshal([]byte(row.RawJSON), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(payload.Metadata) == 0 {
|
||||
continue
|
||||
}
|
||||
metadataJSON, err := json.Marshal(payload.Metadata)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&NodeHealthEvent{}).
|
||||
Where("id = ?", row.ID).
|
||||
Update("metadata_json", string(metadataJSON)).Error; err != nil {
|
||||
return fmt.Errorf("migrate node health event metadata_json failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyCurrentSchema(db *gorm.DB, backend string) error {
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateProxyRouteEnableHTTPSColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateTextColumns(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateObservabilityLegacyColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
|
||||
if db == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
|
||||
return 0, false, nil
|
||||
}
|
||||
var state DatabaseSchemaVersion
|
||||
err := db.Where("id = ?", databaseSchemaVersionRowID).First(&state).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return state.Version, true, nil
|
||||
}
|
||||
|
||||
func saveDatabaseSchemaVersion(db *gorm.DB, version int) error {
|
||||
return db.Save(&DatabaseSchemaVersion{
|
||||
ID: databaseSchemaVersionRowID,
|
||||
Version: version,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV2(db *gorm.DB, backend string) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
|
||||
return fmt.Errorf("table %s is missing", (&DatabaseSchemaVersion{}).TableName())
|
||||
}
|
||||
models, err := buildDBModels()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range models {
|
||||
if isShardedObservabilityTable(item.tableName) {
|
||||
for _, table := range observabilityShardTables(item.tableName) {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
return fmt.Errorf("sharded table %s is missing", table)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !db.Migrator().HasTable(item.value) {
|
||||
return fmt.Errorf("table %s is missing", item.tableName)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasColumn(&NodeHealthEvent{}, "metadata_json") {
|
||||
return fmt.Errorf("column node_health_events.metadata_json is missing")
|
||||
}
|
||||
_ = backend
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV3(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV2(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if db.Migrator().HasTable(legacyTable) {
|
||||
return fmt.Errorf("legacy sharded table %s still exists", legacyTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV4(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV3(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasTable(&Origin{}) {
|
||||
return fmt.Errorf("table origins is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") {
|
||||
return fmt.Errorf("column proxy_routes.origin_id is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameLegacyObservabilityShardTables(db *gorm.DB) error {
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if db.Migrator().HasTable(legacyTable) {
|
||||
return fmt.Errorf("legacy sharded table %s already exists", legacyTable)
|
||||
}
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if err := db.Migrator().RenameTable(table, legacyTable); err != nil {
|
||||
return fmt.Errorf("rename sharded table %s to %s failed: %w", table, legacyTable, err)
|
||||
}
|
||||
if err := dropLegacyObservabilitySecondaryIndexes(db, legacyTable); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyObservabilitySecondaryIndexes(db *gorm.DB, table string) error {
|
||||
db = sessionIgnoringSharding(db)
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
backend := baseDialector(db).Name()
|
||||
indexes := make([]string, 0)
|
||||
switch backend {
|
||||
case "sqlite":
|
||||
if err := db.Raw(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name LIKE 'idx_%'`,
|
||||
table,
|
||||
).Scan(&indexes).Error; err != nil {
|
||||
return fmt.Errorf("list indexes for %s failed: %w", table, err)
|
||||
}
|
||||
case "postgres":
|
||||
if err := db.Raw(
|
||||
`SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND tablename = ? AND indexname LIKE 'idx_%'`,
|
||||
table,
|
||||
).Scan(&indexes).Error; err != nil {
|
||||
return fmt.Errorf("list indexes for %s failed: %w", table, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported database backend %s", backend)
|
||||
}
|
||||
for _, indexName := range indexes {
|
||||
if err := db.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, indexName)).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy index %s failed: %w", indexName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoMigrateObservabilityShardTables(db *gorm.DB) error {
|
||||
db = sessionIgnoringSharding(db)
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
dialector := baseDialector(db)
|
||||
if dialector == nil {
|
||||
return fmt.Errorf("database dialector is nil")
|
||||
}
|
||||
type shardedTable struct {
|
||||
model any
|
||||
base string
|
||||
}
|
||||
tables := []shardedTable{
|
||||
{model: &NodeMetricSnapshot{}, base: "node_metric_snapshots"},
|
||||
{model: &NodeRequestReport{}, base: "node_request_reports"},
|
||||
{model: &NodeAccessLog{}, base: "node_access_logs"},
|
||||
}
|
||||
for _, item := range tables {
|
||||
for _, table := range observabilityShardTables(item.base) {
|
||||
tx := db.Table(table)
|
||||
if err := dialector.Migrator(tx).AutoMigrate(item.model); err != nil {
|
||||
return fmt.Errorf("auto migrate sharded table %s failed: %w", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyObservabilityShardTables(db *gorm.DB) error {
|
||||
db = sessionIgnoringSharding(db)
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`DROP TABLE IF EXISTS "%s"`, legacyTable)).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyNodeMetricSnapshots(db *gorm.DB) error {
|
||||
for _, table := range observabilityShardTables("node_metric_snapshots") {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
var lastSeenID uint
|
||||
for {
|
||||
var rows []NodeMetricSnapshot
|
||||
query := db.Table(legacyTable).Order("id ASC").Limit(500)
|
||||
if lastSeenID > 0 {
|
||||
query = query.Where("id > ?", lastSeenID)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
lastSeenID = rows[len(rows)-1].ID
|
||||
grouped := make(map[string][]NodeMetricSnapshot, observabilityShardCount)
|
||||
for index := range rows {
|
||||
rows[index].ID = 0
|
||||
if err := assignObservabilityID(&rows[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
targetTable := observabilityShardTableForID("node_metric_snapshots", rows[index].ID)
|
||||
grouped[targetTable] = append(grouped[targetTable], rows[index])
|
||||
}
|
||||
for targetTable, batch := range grouped {
|
||||
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
|
||||
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyNodeRequestReports(db *gorm.DB) error {
|
||||
for _, table := range observabilityShardTables("node_request_reports") {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
var lastSeenID uint
|
||||
for {
|
||||
var rows []NodeRequestReport
|
||||
query := db.Table(legacyTable).Order("id ASC").Limit(500)
|
||||
if lastSeenID > 0 {
|
||||
query = query.Where("id > ?", lastSeenID)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
lastSeenID = rows[len(rows)-1].ID
|
||||
grouped := make(map[string][]NodeRequestReport, observabilityShardCount)
|
||||
for index := range rows {
|
||||
rows[index].ID = 0
|
||||
if err := assignObservabilityID(&rows[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
targetTable := observabilityShardTableForID("node_request_reports", rows[index].ID)
|
||||
grouped[targetTable] = append(grouped[targetTable], rows[index])
|
||||
}
|
||||
for targetTable, batch := range grouped {
|
||||
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
|
||||
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyNodeAccessLogs(db *gorm.DB) error {
|
||||
for _, table := range observabilityShardTables("node_access_logs") {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
var lastSeenID uint
|
||||
for {
|
||||
var rows []NodeAccessLog
|
||||
query := db.Table(legacyTable).Order("id ASC").Limit(500)
|
||||
if lastSeenID > 0 {
|
||||
query = query.Where("id > ?", lastSeenID)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
lastSeenID = rows[len(rows)-1].ID
|
||||
grouped := make(map[string][]NodeAccessLog, observabilityShardCount)
|
||||
for index := range rows {
|
||||
rows[index].ID = 0
|
||||
if err := assignObservabilityID(&rows[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
targetTable := observabilityShardTableForID("node_access_logs", rows[index].ID)
|
||||
grouped[targetTable] = append(grouped[targetTable], rows[index])
|
||||
}
|
||||
for targetTable, batch := range grouped {
|
||||
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
|
||||
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOriginAddressForMigration(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func extractOriginAddressForMigration(rawURL string) string {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeOriginAddressForMigration(parsed.Hostname())
|
||||
}
|
||||
|
||||
func backfillOriginsFromProxyRoutes(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
if !db.Migrator().HasTable(&Origin{}) || !db.Migrator().HasTable(&ProxyRoute{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var routes []ProxyRoute
|
||||
if err := db.Order("id asc").Find(&routes).Error; err != nil {
|
||||
return fmt.Errorf("list proxy routes for origin backfill failed: %w", err)
|
||||
}
|
||||
|
||||
type originSeed struct {
|
||||
ID uint
|
||||
Address string
|
||||
}
|
||||
|
||||
originByAddress := make(map[string]originSeed)
|
||||
var origins []Origin
|
||||
if err := db.Order("id asc").Find(&origins).Error; err != nil {
|
||||
return fmt.Errorf("list origins for backfill failed: %w", err)
|
||||
}
|
||||
for _, origin := range origins {
|
||||
address := normalizeOriginAddressForMigration(origin.Address)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
originByAddress[address] = originSeed{ID: origin.ID, Address: address}
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
address := extractOriginAddressForMigration(route.OriginURL)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
origin, ok := originByAddress[address]
|
||||
if !ok {
|
||||
name := address
|
||||
if ip := net.ParseIP(address); ip != nil {
|
||||
name = ip.String()
|
||||
}
|
||||
record := Origin{
|
||||
Name: name,
|
||||
Address: address,
|
||||
Remark: "",
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
return fmt.Errorf("create origin for address %s failed: %w", address, err)
|
||||
}
|
||||
origin = originSeed{ID: record.ID, Address: address}
|
||||
originByAddress[address] = origin
|
||||
}
|
||||
if route.OriginID != nil && *route.OriginID == origin.ID {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&ProxyRoute{}).
|
||||
Where("id = ?", route.ID).
|
||||
Update("origin_id", origin.ID).Error; err != nil {
|
||||
return fmt.Errorf("backfill proxy route %d origin_id failed: %w", route.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV2 upgrades the legacy schema to the first versioned schema by
|
||||
// creating schema metadata, applying the current tables, and backfilling
|
||||
// compatibility columns.
|
||||
func migrateV2(db *gorm.DB, backend string) error {
|
||||
return applyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
// migrateV3 upgrades observability shard tables from legacy ID layout to the
|
||||
// current ID-sharded layout and migrates existing shard data into the new tables.
|
||||
func migrateV3(db *gorm.DB, backend string) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
_ = backend
|
||||
if err := renameLegacyObservabilityShardTables(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := autoMigrateObservabilityShardTables(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyNodeMetricSnapshots(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyNodeRequestReports(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyNodeAccessLogs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return dropLegacyObservabilityShardTables(db)
|
||||
}
|
||||
|
||||
// migrateV4 introduces the origins schema and backfills proxy route origin
|
||||
// references from existing origin_url values.
|
||||
func migrateV4(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillOriginsFromProxyRoutes(db)
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
return []databaseSchemaMigration{
|
||||
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
|
||||
{fromVersion: 2, toVersion: 3, migrate: migrateV3, validate: validateDatabaseSchemaV3},
|
||||
{fromVersion: 3, toVersion: 4, migrate: migrateV4, validate: validateDatabaseSchemaV4},
|
||||
}
|
||||
}
|
||||
|
||||
func databaseSchemaMigrationMap() map[int]databaseSchemaMigration {
|
||||
migrations := make(map[int]databaseSchemaMigration, len(databaseSchemaMigrations()))
|
||||
for _, item := range databaseSchemaMigrations() {
|
||||
migrations[item.fromVersion] = item
|
||||
}
|
||||
return migrations
|
||||
}
|
||||
|
||||
func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseSchemaMigration) error {
|
||||
if backend == "sqlite" {
|
||||
if err := migration.migrate(db, backend); err != nil {
|
||||
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
|
||||
}
|
||||
if err := migration.validate(db, backend); err != nil {
|
||||
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, migration.toVersion); err != nil {
|
||||
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migration.migrate(tx, backend); err != nil {
|
||||
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
|
||||
}
|
||||
if err := migration.validate(tx, backend); err != nil {
|
||||
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(tx, migration.toVersion); err != nil {
|
||||
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error {
|
||||
if version > currentDatabaseSchemaVersion {
|
||||
return fmt.Errorf("database schema version %d is newer than application version %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
if version == currentDatabaseSchemaVersion {
|
||||
return nil
|
||||
}
|
||||
migrationMap := databaseSchemaMigrationMap()
|
||||
for version < currentDatabaseSchemaVersion {
|
||||
migration, ok := migrationMap[version]
|
||||
if !ok {
|
||||
return fmt.Errorf("database schema migration from v%d is not defined", version)
|
||||
}
|
||||
if err := runDatabaseSchemaMigration(db, backend, migration); err != nil {
|
||||
return err
|
||||
}
|
||||
version = migration.toVersion
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateSQLiteDataIfNeeded(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDatabaseSchemaV4(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
|
||||
}
|
||||
|
||||
func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
|
||||
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return upgradeDatabaseSchema(db, backend, version)
|
||||
}
|
||||
empty, err := isDatabaseEmpty(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
return initializeFreshDatabaseSchema(db, backend)
|
||||
}
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return upgradeDatabaseSchema(db, backend, legacyDatabaseSchemaVersion)
|
||||
}
|
||||
@@ -20,11 +20,11 @@ type Node struct {
|
||||
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
||||
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
||||
OpenrestyStatus string `json:"openresty_status" gorm:"size:16;not null;default:'unknown'"`
|
||||
OpenrestyMessage string `json:"openresty_message" gorm:"size:2048"`
|
||||
OpenrestyMessage string `json:"openresty_message" gorm:"type:text"`
|
||||
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
||||
CurrentVersion string `json:"current_version" gorm:"size:32"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
LastError string `json:"last_error" gorm:"size:1024"`
|
||||
LastError string `json:"last_error" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -10,14 +11,13 @@ import (
|
||||
|
||||
type NodeAccessLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
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"`
|
||||
NodeID string `json:"node_id" gorm:"index:,composite:node_logged_at,priority:1;size:64;not null"`
|
||||
LoggedAt time.Time `json:"logged_at" gorm:"index;index:,composite:node_logged_at,priority:2"`
|
||||
RemoteAddr string `json:"remote_addr" gorm:"index;size:128"`
|
||||
Region string `json:"region" gorm:"size:128"`
|
||||
Host string `json:"host" gorm:"index:idx_node_access_logs_host;size:255"`
|
||||
Host string `json:"host" gorm:"index;size:255"`
|
||||
Path string `json:"path" gorm:"size:2048"`
|
||||
StatusCode int `json:"status_code" gorm:"index:idx_node_access_logs_status_code"`
|
||||
RawJSON string `json:"raw_json" gorm:"type:text"`
|
||||
StatusCode int `json:"status_code" gorm:"index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -92,85 +92,92 @@ type NodeAccessLogTrendPointRow struct {
|
||||
RequestCount int64 `json:"request_count"`
|
||||
}
|
||||
|
||||
func (log *NodeAccessLog) BeforeCreate(tx *gorm.DB) error {
|
||||
return assignObservabilityID(&log.ID)
|
||||
}
|
||||
|
||||
func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err error) {
|
||||
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
|
||||
all, err := listNodeAccessLogsAcrossShards(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end := paginateBounds(len(all), query.Page, query.PageSize)
|
||||
if start >= len(all) {
|
||||
return []*NodeAccessLog{}, nil
|
||||
}
|
||||
return all[start:end], 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 {
|
||||
all, err := listNodeAccessLogsAcrossShards(query)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
distinctQuery := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query).
|
||||
Where("remote_addr <> ''").
|
||||
Distinct("remote_addr")
|
||||
if err = distinctQuery.Count(&totalIPs).Error; err != nil {
|
||||
return 0, 0, err
|
||||
ips := make(map[string]struct{}, len(all))
|
||||
for _, item := range all {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(item.RemoteAddr)
|
||||
if trimmed != "" {
|
||||
ips[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
return totalRecords, totalIPs, nil
|
||||
return int64(len(all)), int64(len(ips)), nil
|
||||
}
|
||||
|
||||
func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (items []*NodeAccessLogRegionCount, err error) {
|
||||
query := DB.Model(&NodeAccessLog{}).
|
||||
Select("region as region, count(*) as count").
|
||||
Where("region <> ''")
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
|
||||
NodeID: nodeID,
|
||||
Since: since,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("logged_at >= ?", since)
|
||||
counts := make(map[string]int64)
|
||||
for _, item := range logs {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
region := strings.TrimSpace(item.Region)
|
||||
if region == "" {
|
||||
continue
|
||||
}
|
||||
counts[region]++
|
||||
}
|
||||
query = query.Group("region").Order("count desc, region asc")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
items = make([]*NodeAccessLogRegionCount, 0, len(counts))
|
||||
for region, count := range counts {
|
||||
items = append(items, &NodeAccessLogRegionCount{
|
||||
Region: region,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
err = query.Scan(&items).Error
|
||||
return items, err
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
if items[i].Count == items[j].Count {
|
||||
return items[i].Region < items[j].Region
|
||||
}
|
||||
return items[i].Count > items[j].Count
|
||||
})
|
||||
if limit > 0 && len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func ListNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (items []*NodeAccessLogBucketRow, err error) {
|
||||
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
|
||||
rows, err := buildNodeAccessLogBucketRows(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end := paginateBounds(len(rows), query.Page, query.PageSize)
|
||||
if start >= len(rows) {
|
||||
return []*NodeAccessLogBucketRow{}, nil
|
||||
}
|
||||
return rows[start:end], nil
|
||||
}
|
||||
|
||||
func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err error) {
|
||||
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
|
||||
rows, err := buildNodeAccessLogBucketRows(query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -178,39 +185,19 @@ func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err
|
||||
}
|
||||
|
||||
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
|
||||
rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end := paginateBounds(len(rows), query.Page, query.PageSize)
|
||||
if start >= len(rows) {
|
||||
return []*NodeAccessLogIPSummaryRow{}, nil
|
||||
}
|
||||
return rows[start:end], nil
|
||||
}
|
||||
|
||||
func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int64, err error) {
|
||||
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
|
||||
rows, err := buildNodeAccessLogIPSummaryRows(query, time.Time{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -218,23 +205,82 @@ func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int
|
||||
}
|
||||
|
||||
func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAccessLogTrendPointRow, err error) {
|
||||
bucketExpr := accessLogBucketEpochExpr(query.BucketMinutes)
|
||||
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
|
||||
logs, err := listNodeAccessLogsAcrossShards(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
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteAddr := strings.TrimSpace(query.RemoteAddr)
|
||||
if remoteAddr == "" {
|
||||
return []*NodeAccessLogTrendPointRow{}, nil
|
||||
}
|
||||
buckets := make(map[int64]int64)
|
||||
for _, item := range logs {
|
||||
if item == nil || strings.TrimSpace(item.RemoteAddr) != remoteAddr {
|
||||
continue
|
||||
}
|
||||
bucketEpoch := bucketEpochForTime(item.LoggedAt, query.BucketMinutes)
|
||||
buckets[bucketEpoch]++
|
||||
}
|
||||
items = make([]*NodeAccessLogTrendPointRow, 0, len(buckets))
|
||||
for bucketEpoch, requestCount := range buckets {
|
||||
items = append(items, &NodeAccessLogTrendPointRow{
|
||||
BucketEpoch: bucketEpoch,
|
||||
RequestCount: requestCount,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
return items[i].BucketEpoch < items[j].BucketEpoch
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) {
|
||||
result := DB.Where("logged_at < ?", before).Delete(&NodeAccessLog{})
|
||||
return result.RowsAffected, result.Error
|
||||
return deleteAcrossShards(DB, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("logged_at < ?", before)
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAllNodeAccessLogs(db *gorm.DB) (deleted int64, err error) {
|
||||
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, nil)
|
||||
}
|
||||
|
||||
func NodeAccessLogExists(db *gorm.DB, record *NodeAccessLog) (bool, error) {
|
||||
if record == nil {
|
||||
return false, nil
|
||||
}
|
||||
db = normalizeShardedDB(db)
|
||||
for _, table := range observabilityShardTables("node_access_logs") {
|
||||
var count int64
|
||||
if err := db.Table(table).
|
||||
Where(
|
||||
"node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?",
|
||||
record.NodeID,
|
||||
record.LoggedAt,
|
||||
record.RemoteAddr,
|
||||
record.Host,
|
||||
record.Path,
|
||||
record.StatusCode,
|
||||
).
|
||||
Limit(1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func DeleteNodeAccessLogsByNodeBefore(db *gorm.DB, nodeID string, before time.Time) (deleted int64, err error) {
|
||||
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("node_id = ? AND logged_at < ?", nodeID, before)
|
||||
})
|
||||
}
|
||||
|
||||
func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
||||
@@ -244,6 +290,10 @@ func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
||||
if db.Statement == nil || db.Statement.Model == nil {
|
||||
db = db.Model(&NodeAccessLog{})
|
||||
}
|
||||
return applyNodeAccessLogFilters(db, query)
|
||||
}
|
||||
|
||||
func applyNodeAccessLogFilters(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
||||
if trimmed := strings.TrimSpace(query.NodeID); trimmed != "" {
|
||||
db = db.Where("node_id LIKE ?", "%"+trimmed+"%")
|
||||
}
|
||||
@@ -262,6 +312,297 @@ func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func listNodeAccessLogsAcrossShards(query NodeAccessLogQuery) ([]*NodeAccessLog, error) {
|
||||
items, err := queryAcrossShards("node_access_logs", func(tx *gorm.DB) ([]*NodeAccessLog, error) {
|
||||
var shardRows []*NodeAccessLog
|
||||
if err := applyNodeAccessLogFilters(tx, query).Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sortNodeAccessLogs(items, query.SortBy, query.SortOrder)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildNodeAccessLogBucketRows(query NodeAccessLogBucketQuery) ([]*NodeAccessLogBucketRow, error) {
|
||||
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Path: query.Path,
|
||||
Since: query.Since,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type bucketAccumulator struct {
|
||||
requestCount int64
|
||||
uniqueIPs map[string]struct{}
|
||||
uniqueHosts map[string]struct{}
|
||||
successCount int64
|
||||
clientErrorCount int64
|
||||
serverErrorCount int64
|
||||
}
|
||||
accumulators := make(map[int64]*bucketAccumulator)
|
||||
for _, item := range logs {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
bucketEpoch := bucketEpochForTime(item.LoggedAt, query.FoldMinutes)
|
||||
accumulator := accumulators[bucketEpoch]
|
||||
if accumulator == nil {
|
||||
accumulator = &bucketAccumulator{
|
||||
uniqueIPs: make(map[string]struct{}),
|
||||
uniqueHosts: make(map[string]struct{}),
|
||||
}
|
||||
accumulators[bucketEpoch] = accumulator
|
||||
}
|
||||
accumulator.requestCount++
|
||||
if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" {
|
||||
accumulator.uniqueIPs[trimmed] = struct{}{}
|
||||
}
|
||||
if trimmed := strings.TrimSpace(item.Host); trimmed != "" {
|
||||
accumulator.uniqueHosts[trimmed] = struct{}{}
|
||||
}
|
||||
switch {
|
||||
case item.StatusCode < 400:
|
||||
accumulator.successCount++
|
||||
case item.StatusCode < 500:
|
||||
accumulator.clientErrorCount++
|
||||
default:
|
||||
accumulator.serverErrorCount++
|
||||
}
|
||||
}
|
||||
rows := make([]*NodeAccessLogBucketRow, 0, len(accumulators))
|
||||
for bucketEpoch, accumulator := range accumulators {
|
||||
rows = append(rows, &NodeAccessLogBucketRow{
|
||||
BucketEpoch: bucketEpoch,
|
||||
RequestCount: accumulator.requestCount,
|
||||
UniqueIPCount: int64(len(accumulator.uniqueIPs)),
|
||||
UniqueHostCount: int64(len(accumulator.uniqueHosts)),
|
||||
SuccessCount: accumulator.successCount,
|
||||
ClientErrorCount: accumulator.clientErrorCount,
|
||||
ServerErrorCount: accumulator.serverErrorCount,
|
||||
})
|
||||
}
|
||||
sortNodeAccessLogBucketRows(rows, query.SortBy, query.SortOrder)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) {
|
||||
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Since: query.Since,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type accumulator struct {
|
||||
totalRequests int64
|
||||
recentRequests int64
|
||||
lastSeenAt time.Time
|
||||
}
|
||||
accumulators := make(map[string]*accumulator)
|
||||
for _, item := range logs {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
remoteAddr := strings.TrimSpace(item.RemoteAddr)
|
||||
if remoteAddr == "" {
|
||||
continue
|
||||
}
|
||||
acc := accumulators[remoteAddr]
|
||||
if acc == nil {
|
||||
acc = &accumulator{}
|
||||
accumulators[remoteAddr] = acc
|
||||
}
|
||||
acc.totalRequests++
|
||||
if !recentSince.IsZero() && !item.LoggedAt.Before(recentSince) {
|
||||
acc.recentRequests++
|
||||
}
|
||||
if item.LoggedAt.After(acc.lastSeenAt) {
|
||||
acc.lastSeenAt = item.LoggedAt
|
||||
}
|
||||
}
|
||||
rows := make([]*NodeAccessLogIPSummaryRow, 0, len(accumulators))
|
||||
for remoteAddr, acc := range accumulators {
|
||||
rows = append(rows, &NodeAccessLogIPSummaryRow{
|
||||
RemoteAddr: remoteAddr,
|
||||
TotalRequests: acc.totalRequests,
|
||||
RecentRequests: acc.recentRequests,
|
||||
LastSeenEpoch: acc.lastSeenAt.Unix(),
|
||||
})
|
||||
}
|
||||
sortNodeAccessLogIPSummaryRows(rows, query.SortBy, query.SortOrder)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) {
|
||||
desc := normalizeSortOrder(sortOrder) != "asc"
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
left := items[i]
|
||||
right := items[j]
|
||||
if left == nil || right == nil {
|
||||
return left != nil
|
||||
}
|
||||
var compare int
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "status_code":
|
||||
compare = compareInt(left.StatusCode, right.StatusCode)
|
||||
case "remote_addr":
|
||||
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
|
||||
case "host":
|
||||
compare = strings.Compare(left.Host, right.Host)
|
||||
case "path":
|
||||
compare = strings.Compare(left.Path, right.Path)
|
||||
default:
|
||||
compare = compareTime(left.LoggedAt, right.LoggedAt)
|
||||
}
|
||||
if compare == 0 {
|
||||
compare = compareTime(left.LoggedAt, right.LoggedAt)
|
||||
}
|
||||
if compare == 0 {
|
||||
compare = compareUint(left.ID, right.ID)
|
||||
}
|
||||
if desc {
|
||||
return compare > 0
|
||||
}
|
||||
return compare < 0
|
||||
})
|
||||
}
|
||||
|
||||
func sortNodeAccessLogBucketRows(items []*NodeAccessLogBucketRow, sortBy string, sortOrder string) {
|
||||
desc := normalizeSortOrder(sortOrder) != "asc"
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
left := items[i]
|
||||
right := items[j]
|
||||
if left == nil || right == nil {
|
||||
return left != nil
|
||||
}
|
||||
var compare int
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "request_count":
|
||||
compare = compareInt64(left.RequestCount, right.RequestCount)
|
||||
default:
|
||||
compare = compareInt64(left.BucketEpoch, right.BucketEpoch)
|
||||
}
|
||||
if compare == 0 {
|
||||
compare = compareInt64(left.BucketEpoch, right.BucketEpoch)
|
||||
}
|
||||
if desc {
|
||||
return compare > 0
|
||||
}
|
||||
return compare < 0
|
||||
})
|
||||
}
|
||||
|
||||
func sortNodeAccessLogIPSummaryRows(items []*NodeAccessLogIPSummaryRow, sortBy string, sortOrder string) {
|
||||
desc := normalizeSortOrder(sortOrder) != "asc"
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
left := items[i]
|
||||
right := items[j]
|
||||
if left == nil || right == nil {
|
||||
return left != nil
|
||||
}
|
||||
var compare int
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "recent_requests":
|
||||
compare = compareInt64(left.RecentRequests, right.RecentRequests)
|
||||
case "last_seen_at":
|
||||
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
|
||||
case "remote_addr":
|
||||
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
|
||||
default:
|
||||
compare = compareInt64(left.TotalRequests, right.TotalRequests)
|
||||
}
|
||||
if compare == 0 {
|
||||
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
|
||||
}
|
||||
if compare == 0 {
|
||||
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
|
||||
}
|
||||
if desc {
|
||||
return compare > 0
|
||||
}
|
||||
return compare < 0
|
||||
})
|
||||
}
|
||||
|
||||
func paginateBounds(total int, page int, pageSize int) (int, int) {
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
return 0, total
|
||||
}
|
||||
start := page * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func bucketEpochForTime(value time.Time, bucketMinutes int) int64 {
|
||||
bucketSeconds := int64(bucketMinutes * 60)
|
||||
if bucketSeconds <= 0 {
|
||||
bucketSeconds = 180
|
||||
}
|
||||
return (value.UTC().Unix() / bucketSeconds) * bucketSeconds
|
||||
}
|
||||
|
||||
func compareTime(left time.Time, right time.Time) int {
|
||||
switch {
|
||||
case left.After(right):
|
||||
return 1
|
||||
case left.Before(right):
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func compareInt(left int, right int) int {
|
||||
switch {
|
||||
case left > right:
|
||||
return 1
|
||||
case left < right:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func compareInt64(left int64, right int64) int {
|
||||
switch {
|
||||
case left > right:
|
||||
return 1
|
||||
case left < right:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func compareUint(left uint, right uint) int {
|
||||
switch {
|
||||
case left > right:
|
||||
return 1
|
||||
case left < right:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func buildNodeAccessLogSortClause(sortBy string, sortOrder string) string {
|
||||
column := "logged_at"
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
|
||||
@@ -8,12 +8,12 @@ type NodeHealthEvent struct {
|
||||
EventType string `json:"event_type" gorm:"index;size:64;not null"`
|
||||
Severity string `json:"severity" gorm:"size:16;not null"`
|
||||
Status string `json:"status" gorm:"index;size:16;not null"`
|
||||
Message string `json:"message" gorm:"size:2048"`
|
||||
Message string `json:"message" gorm:"type:text"`
|
||||
FirstTriggeredAt time.Time `json:"first_triggered_at" gorm:"index"`
|
||||
LastTriggeredAt time.Time `json:"last_triggered_at" gorm:"index"`
|
||||
ReportedAt time.Time `json:"reported_at" gorm:"index"`
|
||||
ResolvedAt *time.Time `json:"resolved_at" gorm:"index"`
|
||||
RawJSON string `json:"raw_json" gorm:"type:text"`
|
||||
MetadataJSON string `json:"metadata_json" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -40,3 +40,8 @@ func ListActiveNodeHealthEvents() (events []*NodeHealthEvent, err error) {
|
||||
err = DB.Where("status = ?", "active").Order("last_triggered_at desc").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func DeleteNodeHealthEvents(nodeID string) (deleted int64, err error) {
|
||||
result := DB.Where("node_id = ?", nodeID).Delete(&NodeHealthEvent{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NodeMetricSnapshot struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
@@ -18,31 +23,94 @@ type NodeMetricSnapshot struct {
|
||||
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
|
||||
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
|
||||
OpenrestyConnections int64 `json:"openresty_connections"`
|
||||
RawJSON string `json:"raw_json" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (snapshot *NodeMetricSnapshot) BeforeCreate(tx *gorm.DB) error {
|
||||
return assignObservabilityID(&snapshot.ID)
|
||||
}
|
||||
|
||||
func (snapshot *NodeMetricSnapshot) Insert() error {
|
||||
return DB.Create(snapshot).Error
|
||||
}
|
||||
|
||||
func ListNodeMetricSnapshots(nodeID string, since time.Time, limit int) (snapshots []*NodeMetricSnapshot, err error) {
|
||||
query := DB.Where("node_id = ?", nodeID).Order("captured_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) {
|
||||
var shardRows []*NodeMetricSnapshot
|
||||
query := tx.Order("captured_at desc, id desc")
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
}
|
||||
if err := query.Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
sort.Slice(rows, func(i int, j int) bool {
|
||||
if rows[i].CapturedAt.Equal(rows[j].CapturedAt) {
|
||||
return rows[i].ID > rows[j].ID
|
||||
}
|
||||
return rows[i].CapturedAt.After(rows[j].CapturedAt)
|
||||
})
|
||||
if limit > 0 && len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
}
|
||||
err = query.Find(&snapshots).Error
|
||||
return snapshots, err
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ListMetricSnapshotsSince(since time.Time) (snapshots []*NodeMetricSnapshot, err error) {
|
||||
query := DB.Order("captured_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) {
|
||||
var shardRows []*NodeMetricSnapshot
|
||||
query := tx.Order("captured_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
}
|
||||
if err := query.Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = query.Find(&snapshots).Error
|
||||
return snapshots, err
|
||||
sort.Slice(rows, func(i int, j int) bool {
|
||||
if rows[i].CapturedAt.Equal(rows[j].CapturedAt) {
|
||||
return rows[i].ID > rows[j].ID
|
||||
}
|
||||
return rows[i].CapturedAt.After(rows[j].CapturedAt)
|
||||
})
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func NodeMetricSnapshotExists(db *gorm.DB, nodeID string, capturedAt time.Time) (bool, error) {
|
||||
db = normalizeShardedDB(db)
|
||||
for _, table := range observabilityShardTables("node_metric_snapshots") {
|
||||
var count int64
|
||||
if err := db.Table(table).
|
||||
Where("node_id = ? AND captured_at = ?", nodeID, capturedAt).
|
||||
Limit(1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func DeleteNodeMetricSnapshotsBefore(db *gorm.DB, before time.Time) (int64, error) {
|
||||
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("captured_at < ?", before)
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAllNodeMetricSnapshots(db *gorm.DB) (int64, error) {
|
||||
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, nil)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NodeRequestReport struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
@@ -13,31 +18,94 @@ type NodeRequestReport struct {
|
||||
StatusCodesJSON string `json:"status_codes_json" gorm:"type:text"`
|
||||
TopDomainsJSON string `json:"top_domains_json" gorm:"type:text"`
|
||||
SourceCountriesJSON string `json:"source_countries_json" gorm:"type:text"`
|
||||
RawJSON string `json:"raw_json" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (report *NodeRequestReport) BeforeCreate(tx *gorm.DB) error {
|
||||
return assignObservabilityID(&report.ID)
|
||||
}
|
||||
|
||||
func (report *NodeRequestReport) Insert() error {
|
||||
return DB.Create(report).Error
|
||||
}
|
||||
|
||||
func ListNodeRequestReports(nodeID string, since time.Time, limit int) (reports []*NodeRequestReport, err error) {
|
||||
query := DB.Where("node_id = ?", nodeID).Order("window_ended_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("window_ended_at >= ?", since)
|
||||
rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) {
|
||||
var shardRows []*NodeRequestReport
|
||||
query := tx.Order("window_ended_at desc, id desc")
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("window_ended_at >= ?", since)
|
||||
}
|
||||
if err := query.Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
sort.Slice(rows, func(i int, j int) bool {
|
||||
if rows[i].WindowEndedAt.Equal(rows[j].WindowEndedAt) {
|
||||
return rows[i].ID > rows[j].ID
|
||||
}
|
||||
return rows[i].WindowEndedAt.After(rows[j].WindowEndedAt)
|
||||
})
|
||||
if limit > 0 && len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
}
|
||||
err = query.Find(&reports).Error
|
||||
return reports, err
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ListRequestReportsSince(since time.Time) (reports []*NodeRequestReport, err error) {
|
||||
query := DB.Order("window_ended_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("window_ended_at >= ?", since)
|
||||
rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) {
|
||||
var shardRows []*NodeRequestReport
|
||||
query := tx.Order("window_ended_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("window_ended_at >= ?", since)
|
||||
}
|
||||
if err := query.Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = query.Find(&reports).Error
|
||||
return reports, err
|
||||
sort.Slice(rows, func(i int, j int) bool {
|
||||
if rows[i].WindowEndedAt.Equal(rows[j].WindowEndedAt) {
|
||||
return rows[i].ID > rows[j].ID
|
||||
}
|
||||
return rows[i].WindowEndedAt.After(rows[j].WindowEndedAt)
|
||||
})
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func NodeRequestReportExists(db *gorm.DB, nodeID string, windowStartedAt time.Time, windowEndedAt time.Time) (bool, error) {
|
||||
db = normalizeShardedDB(db)
|
||||
for _, table := range observabilityShardTables("node_request_reports") {
|
||||
var count int64
|
||||
if err := db.Table(table).
|
||||
Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, windowStartedAt, windowEndedAt).
|
||||
Limit(1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func DeleteNodeRequestReportsBefore(db *gorm.DB, before time.Time) (int64, error) {
|
||||
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("window_ended_at < ?", before)
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAllNodeRequestReports(db *gorm.DB) (int64, error) {
|
||||
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, nil)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ type NodeSystemProfile struct {
|
||||
TotalDiskBytes int64 `json:"total_disk_bytes"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
ReportedAt time.Time `json:"reported_at" gorm:"index"`
|
||||
RawJSON string `json:"raw_json" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -49,7 +48,6 @@ func UpsertNodeSystemProfile(profile *NodeSystemProfile) error {
|
||||
"total_disk_bytes",
|
||||
"uptime_seconds",
|
||||
"reported_at",
|
||||
"raw_json",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(profile).Error
|
||||
|
||||
@@ -56,6 +56,8 @@ func InitOptionMap() {
|
||||
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
|
||||
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
|
||||
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
|
||||
common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled)
|
||||
common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays)
|
||||
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
|
||||
common.OptionMap["OpenRestyWorkerConnections"] = strconv.Itoa(common.OpenRestyWorkerConnections)
|
||||
common.OptionMap["OpenRestyWorkerRlimitNofile"] = strconv.Itoa(common.OpenRestyWorkerRlimitNofile)
|
||||
@@ -80,7 +82,6 @@ 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
|
||||
@@ -132,6 +133,11 @@ func updateOptionMap(key string, value string) {
|
||||
common.OptionMap = make(map[string]string)
|
||||
}
|
||||
common.OptionMap[key] = value
|
||||
if key == "OpenRestyResolvers" {
|
||||
delete(common.OptionMap, key)
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(key, "Permission") {
|
||||
intValue, _ := strconv.Atoi(value)
|
||||
switch key {
|
||||
@@ -215,6 +221,12 @@ func updateOptionMap(key string, value string) {
|
||||
common.GeoIPProvider = value
|
||||
shouldRefreshGeoIP = true
|
||||
}
|
||||
case "DatabaseAutoCleanupEnabled":
|
||||
common.DatabaseAutoCleanupEnabled = value == "true"
|
||||
case "DatabaseAutoCleanupRetentionDays":
|
||||
if v, err := strconv.Atoi(value); err == nil && v >= 1 {
|
||||
common.DatabaseAutoCleanupRetentionDays = v
|
||||
}
|
||||
case "OpenRestyWorkerProcesses":
|
||||
if strings.TrimSpace(value) != "" {
|
||||
common.OpenRestyWorkerProcesses = value
|
||||
@@ -299,8 +311,6 @@ 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":
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Origin struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"size:255;not null"`
|
||||
Address string `json:"address" gorm:"uniqueIndex;size:255;not null"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OriginRouteCount struct {
|
||||
OriginID uint `json:"origin_id"`
|
||||
RouteCount int64 `json:"route_count"`
|
||||
}
|
||||
|
||||
func ListOrigins() (origins []*Origin, err error) {
|
||||
err = DB.Order("id desc").Find(&origins).Error
|
||||
return origins, err
|
||||
}
|
||||
|
||||
func GetOriginByID(id uint) (*Origin, error) {
|
||||
origin := &Origin{}
|
||||
err := DB.First(origin, id).Error
|
||||
return origin, err
|
||||
}
|
||||
|
||||
func GetOriginByAddress(address string) (*Origin, error) {
|
||||
origin := &Origin{}
|
||||
err := DB.Where("address = ?", address).First(origin).Error
|
||||
return origin, err
|
||||
}
|
||||
|
||||
func ListOriginRouteCounts() ([]OriginRouteCount, error) {
|
||||
result := make([]OriginRouteCount, 0)
|
||||
err := DB.Model(&ProxyRoute{}).
|
||||
Select("origin_id, COUNT(*) AS route_count").
|
||||
Where("origin_id IS NOT NULL").
|
||||
Group("origin_id").
|
||||
Scan(&result).Error
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (origin *Origin) Insert() error {
|
||||
return DB.Create(origin).Error
|
||||
}
|
||||
|
||||
func (origin *Origin) Update() error {
|
||||
return DB.Save(origin).Error
|
||||
}
|
||||
|
||||
func (origin *Origin) Delete() error {
|
||||
return DB.Delete(origin).Error
|
||||
}
|
||||
@@ -5,12 +5,17 @@ import "time"
|
||||
type ProxyRoute struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||
OriginID *uint `json:"origin_id" gorm:"index"`
|
||||
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
||||
OriginHost string `json:"origin_host" gorm:"size:255"`
|
||||
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
|
||||
CertID *uint `json:"cert_id"`
|
||||
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
|
||||
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
|
||||
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
|
||||
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
|
||||
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -33,6 +38,11 @@ func GetProxyRouteByID(id uint) (*ProxyRoute, error) {
|
||||
return route, err
|
||||
}
|
||||
|
||||
func ListProxyRoutesByOriginID(originID uint) (routes []*ProxyRoute, err error) {
|
||||
err = DB.Where("origin_id = ?", originID).Order("id desc").Find(&routes).Error
|
||||
return routes, err
|
||||
}
|
||||
|
||||
func (route *ProxyRoute) Insert() error {
|
||||
return DB.Create(route).Error
|
||||
}
|
||||
@@ -40,12 +50,17 @@ func (route *ProxyRoute) Insert() error {
|
||||
func (route *ProxyRoute) Update() error {
|
||||
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
|
||||
"domain": route.Domain,
|
||||
"origin_id": route.OriginID,
|
||||
"origin_url": route.OriginURL,
|
||||
"origin_host": route.OriginHost,
|
||||
"upstreams": route.Upstreams,
|
||||
"enabled": route.Enabled,
|
||||
"enable_https": route.EnableHTTPS,
|
||||
"cert_id": route.CertID,
|
||||
"redirect_http": route.RedirectHTTP,
|
||||
"cache_enabled": route.CacheEnabled,
|
||||
"cache_policy": route.CachePolicy,
|
||||
"cache_rules": route.CacheRules,
|
||||
"custom_headers": route.CustomHeaders,
|
||||
"remark": route.Remark,
|
||||
}).Error
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/sharding"
|
||||
)
|
||||
|
||||
const observabilityShardCount = 10
|
||||
|
||||
var (
|
||||
observabilityIDNode *snowflake.Node
|
||||
observabilityIDNodeErr error
|
||||
observabilityIDNodeOnce sync.Once
|
||||
)
|
||||
|
||||
func registerSharding(db *gorm.DB, backend string) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_ = backend
|
||||
if err := db.Use(sharding.Register(sharding.Config{
|
||||
ShardingKey: "id",
|
||||
NumberOfShards: observabilityShardCount,
|
||||
ShardingAlgorithm: func(value any) (string, error) {
|
||||
return observabilityShardSuffixForValue(value)
|
||||
},
|
||||
ShardingAlgorithmByPrimaryKey: func(id int64) string {
|
||||
return observabilityShardSuffixForInt64(id)
|
||||
},
|
||||
PrimaryKeyGenerator: sharding.PKCustom,
|
||||
PrimaryKeyGeneratorFn: func(tableIdx int64) int64 {
|
||||
return 0
|
||||
},
|
||||
}, shardedObservabilityTables()...)); err != nil {
|
||||
return fmt.Errorf("register observability sharding failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shardedObservabilityTables() []any {
|
||||
return []any{
|
||||
&NodeMetricSnapshot{},
|
||||
&NodeRequestReport{},
|
||||
&NodeAccessLog{},
|
||||
}
|
||||
}
|
||||
|
||||
func shardedObservabilityBaseTables() []string {
|
||||
return []string{
|
||||
"node_metric_snapshots",
|
||||
"node_request_reports",
|
||||
"node_access_logs",
|
||||
}
|
||||
}
|
||||
|
||||
func isShardedObservabilityTable(tableName string) bool {
|
||||
switch strings.TrimSpace(tableName) {
|
||||
case "node_metric_snapshots", "node_request_reports", "node_access_logs":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func observabilityShardTables(baseTable string) []string {
|
||||
tables := make([]string, 0, observabilityShardCount)
|
||||
for _, suffix := range observabilityShardSuffixes() {
|
||||
tables = append(tables, baseTable+suffix)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
func observabilityShardSuffixes() []string {
|
||||
suffixes := make([]string, 0, observabilityShardCount)
|
||||
for index := 0; index < observabilityShardCount; index++ {
|
||||
suffixes = append(suffixes, fmt.Sprintf("_%02d", index))
|
||||
}
|
||||
return suffixes
|
||||
}
|
||||
|
||||
func observabilityShardSuffixForID(id uint) string {
|
||||
return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount))
|
||||
}
|
||||
|
||||
func observabilityShardSuffixForInt64(id int64) string {
|
||||
if id < 0 {
|
||||
id = -id
|
||||
}
|
||||
return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount))
|
||||
}
|
||||
|
||||
func observabilityShardSuffixForValue(value any) (string, error) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return observabilityShardSuffixForInt64(int64(typed)), nil
|
||||
case int8:
|
||||
return observabilityShardSuffixForInt64(int64(typed)), nil
|
||||
case int16:
|
||||
return observabilityShardSuffixForInt64(int64(typed)), nil
|
||||
case int32:
|
||||
return observabilityShardSuffixForInt64(int64(typed)), nil
|
||||
case int64:
|
||||
return observabilityShardSuffixForInt64(typed), nil
|
||||
case uint:
|
||||
return observabilityShardSuffixForID(typed), nil
|
||||
case uint8:
|
||||
return observabilityShardSuffixForID(uint(typed)), nil
|
||||
case uint16:
|
||||
return observabilityShardSuffixForID(uint(typed)), nil
|
||||
case uint32:
|
||||
return observabilityShardSuffixForID(uint(typed)), nil
|
||||
case uint64:
|
||||
return fmt.Sprintf("_%02d", typed%uint64(observabilityShardCount)), nil
|
||||
case string:
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid sharding id %q", typed)
|
||||
}
|
||||
return fmt.Sprintf("_%02d", id%uint64(observabilityShardCount)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported observability sharding value type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func observabilityShardTableForID(baseTable string, id uint) string {
|
||||
return baseTable + observabilityShardSuffixForID(id)
|
||||
}
|
||||
|
||||
func legacyObservabilityShardTableName(tableName string) string {
|
||||
return tableName + "_legacy_v2_to_v3"
|
||||
}
|
||||
|
||||
func normalizeShardedDB(db *gorm.DB) *gorm.DB {
|
||||
if db != nil {
|
||||
return db
|
||||
}
|
||||
return DB
|
||||
}
|
||||
|
||||
func sessionIgnoringSharding(db *gorm.DB) *gorm.DB {
|
||||
db = normalizeShardedDB(db)
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return db.Session(&gorm.Session{}).Set(sharding.ShardingIgnoreStoreKey, true)
|
||||
}
|
||||
|
||||
func baseDialector(db *gorm.DB) gorm.Dialector {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if dialector, ok := db.Dialector.(sharding.ShardingDialector); ok {
|
||||
return dialector.Dialector
|
||||
}
|
||||
return db.Dialector
|
||||
}
|
||||
|
||||
func nextObservabilityID() (uint, error) {
|
||||
observabilityIDNodeOnce.Do(func() {
|
||||
observabilityIDNode, observabilityIDNodeErr = snowflake.NewNode(0)
|
||||
})
|
||||
if observabilityIDNodeErr != nil {
|
||||
return 0, observabilityIDNodeErr
|
||||
}
|
||||
id := observabilityIDNode.Generate().Int64()
|
||||
if id <= 0 {
|
||||
return 0, fmt.Errorf("generated invalid observability id %d", id)
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func assignObservabilityID(id *uint) error {
|
||||
if id == nil || *id != 0 {
|
||||
return nil
|
||||
}
|
||||
generated, err := nextObservabilityID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*id = generated
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryAcrossShards[T any](baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) {
|
||||
return queryAcrossShardsWithDB(DB, baseTable, query)
|
||||
}
|
||||
|
||||
func queryAcrossShardsWithDB[T any](db *gorm.DB, baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) {
|
||||
items := make([]T, 0)
|
||||
db = normalizeShardedDB(db)
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
rows, err := query(db.Table(table))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, rows...)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func deleteAcrossShards(db *gorm.DB, baseTable string, model any, apply func(tx *gorm.DB) *gorm.DB) (int64, error) {
|
||||
db = normalizeShardedDB(db)
|
||||
var deleted int64
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
tx := db.Table(table)
|
||||
if apply != nil {
|
||||
tx = apply(tx)
|
||||
} else {
|
||||
tx = tx.Session(&gorm.Session{AllowGlobalUpdate: true})
|
||||
}
|
||||
result := tx.Delete(model)
|
||||
if result.Error != nil {
|
||||
return deleted, result.Error
|
||||
}
|
||||
deleted += result.RowsAffected
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func sortShardRows[T any](items []T, less func(left T, right T) bool) {
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
return less(items[i], items[j])
|
||||
})
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
optionRoute.GET("/", controller.GetOptions)
|
||||
optionRoute.POST("/update", controller.UpdateOption)
|
||||
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
||||
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
|
||||
}
|
||||
updateRoute := apiRouter.Group("/update")
|
||||
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||
@@ -81,6 +82,15 @@ func SetApiRouter(router *gin.Engine) {
|
||||
proxyRoute.POST("/:id/update", controller.UpdateProxyRoute)
|
||||
proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute)
|
||||
}
|
||||
originRoute := apiRouter.Group("/origins")
|
||||
originRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
originRoute.GET("/", controller.GetOrigins)
|
||||
originRoute.GET("/:id", controller.GetOrigin)
|
||||
originRoute.POST("/", controller.CreateOrigin)
|
||||
originRoute.POST("/:id/update", controller.UpdateOrigin)
|
||||
originRoute.POST("/:id/delete", controller.DeleteOrigin)
|
||||
}
|
||||
managedDomainRoute := apiRouter.Group("/managed-domains")
|
||||
managedDomainRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
@@ -108,6 +118,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
configVersionRoute.GET("/active", controller.GetActiveConfigVersion)
|
||||
configVersionRoute.GET("/preview", controller.PreviewConfigVersion)
|
||||
configVersionRoute.GET("/diff", controller.DiffConfigVersion)
|
||||
configVersionRoute.GET("/:id", controller.GetConfigVersion)
|
||||
configVersionRoute.POST("/publish", controller.PublishConfigVersion)
|
||||
configVersionRoute.POST("/:id/activate", controller.ActivateConfigVersion)
|
||||
}
|
||||
@@ -125,6 +136,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
nodeRoute.POST("/", controller.CreateNode)
|
||||
nodeRoute.GET("/:id/agent-release", controller.GetNodeAgentRelease)
|
||||
nodeRoute.GET("/:id/observability", controller.GetNodeObservability)
|
||||
nodeRoute.POST("/:id/observability/cleanup", controller.CleanupNodeHealthEvents)
|
||||
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
|
||||
nodeRoute.POST("/:id/openresty-restart", controller.RequestNodeOpenrestyRestart)
|
||||
nodeRoute.POST("/:id/update", controller.UpdateNode)
|
||||
|
||||
@@ -44,11 +44,15 @@ func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
token := prepareRootToken(t)
|
||||
|
||||
createBody := map[string]any{
|
||||
"domain": "app.example.com",
|
||||
"origin_url": "https://origin-a.internal",
|
||||
"origin_host": "origin-a.internal",
|
||||
"enabled": true,
|
||||
"remark": "primary route",
|
||||
"domain": "app.example.com",
|
||||
"origin_url": "https://10.0.0.11:8443",
|
||||
"upstreams": []string{"https://10.0.0.12:8443"},
|
||||
"origin_host": "origin-a.internal",
|
||||
"enabled": true,
|
||||
"cache_enabled": true,
|
||||
"cache_policy": "path_prefix",
|
||||
"cache_rules": []string{"/assets", "/static"},
|
||||
"remark": "primary route",
|
||||
}
|
||||
resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody)
|
||||
var createdRoute model.ProxyRoute
|
||||
@@ -59,6 +63,15 @@ func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
if createdRoute.OriginHost != "origin-a.internal" {
|
||||
t.Fatalf("unexpected created route origin host: %s", createdRoute.OriginHost)
|
||||
}
|
||||
if !createdRoute.CacheEnabled || createdRoute.CachePolicy != "path_prefix" {
|
||||
t.Fatalf("expected route cache settings to persist, got %+v", createdRoute)
|
||||
}
|
||||
if !strings.Contains(createdRoute.Upstreams, "10.0.0.12:8443") {
|
||||
t.Fatalf("expected route upstream list to persist, got %s", createdRoute.Upstreams)
|
||||
}
|
||||
if !strings.Contains(createdRoute.CacheRules, "/assets") {
|
||||
t.Fatalf("expected route cache rules to persist, got %s", createdRoute.CacheRules)
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
|
||||
var routes []model.ProxyRoute
|
||||
@@ -103,21 +116,31 @@ func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
initialRendered := version1.RenderedConfig
|
||||
|
||||
updateBody := map[string]any{
|
||||
"domain": "app.example.com",
|
||||
"origin_url": "https://origin-b.internal",
|
||||
"origin_host": "origin-b.internal",
|
||||
"enabled": true,
|
||||
"remark": "updated route",
|
||||
"domain": "app.example.com",
|
||||
"origin_url": "https://10.0.0.21:8443",
|
||||
"upstreams": []string{"https://10.0.0.22:8443"},
|
||||
"origin_host": "origin-b.internal",
|
||||
"enabled": true,
|
||||
"cache_enabled": true,
|
||||
"cache_policy": "path_exact",
|
||||
"cache_rules": []string{"/robots.txt"},
|
||||
"remark": "updated route",
|
||||
}
|
||||
routePath := "/api/proxy-routes/" + toString(createdRoute.ID)
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPost, routePath+"/update", updateBody)
|
||||
decodeResponseData(t, resp, &createdRoute)
|
||||
if createdRoute.OriginURL != "https://origin-b.internal" {
|
||||
if createdRoute.OriginURL != "https://10.0.0.21:8443" {
|
||||
t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL)
|
||||
}
|
||||
if createdRoute.OriginHost != "origin-b.internal" {
|
||||
t.Fatalf("unexpected updated route origin host: %s", createdRoute.OriginHost)
|
||||
}
|
||||
if createdRoute.CachePolicy != "path_exact" || !strings.Contains(createdRoute.CacheRules, "/robots.txt") {
|
||||
t.Fatalf("expected updated route cache rules to persist, got %+v", createdRoute)
|
||||
}
|
||||
if !strings.Contains(createdRoute.Upstreams, "10.0.0.22:8443") {
|
||||
t.Fatalf("expected updated route upstream list to persist, got %s", createdRoute.Upstreams)
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
|
||||
var version2 model.ConfigVersion
|
||||
@@ -127,11 +150,33 @@ func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/", nil)
|
||||
var versions []model.ConfigVersion
|
||||
var versions []map[string]any
|
||||
decodeResponseData(t, resp, &versions)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions, got %d", len(versions))
|
||||
}
|
||||
if _, ok := versions[0]["snapshot_json"]; ok {
|
||||
t.Fatal("expected config version list to omit snapshot_json")
|
||||
}
|
||||
if _, ok := versions[0]["main_config"]; ok {
|
||||
t.Fatal("expected config version list to omit main_config")
|
||||
}
|
||||
if _, ok := versions[0]["rendered_config"]; ok {
|
||||
t.Fatal("expected config version list to omit rendered_config")
|
||||
}
|
||||
if _, ok := versions[0]["support_files_json"]; ok {
|
||||
t.Fatal("expected config version list to omit support_files_json")
|
||||
}
|
||||
|
||||
detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/"+toString(version2.ID), nil)
|
||||
var versionDetail model.ConfigVersion
|
||||
decodeResponseData(t, detailResp, &versionDetail)
|
||||
if versionDetail.ID != version2.ID {
|
||||
t.Fatalf("expected config version detail %d, got %d", version2.ID, versionDetail.ID)
|
||||
}
|
||||
if versionDetail.SnapshotJSON == "" || versionDetail.MainConfig == "" || versionDetail.RenderedConfig == "" {
|
||||
t.Fatal("expected config version detail endpoint to include full payload")
|
||||
}
|
||||
|
||||
activeResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/active", nil)
|
||||
var activeVersion model.ConfigVersion
|
||||
@@ -305,7 +350,10 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
|
||||
t.Fatal("expected active config to render managed main config")
|
||||
}
|
||||
if !strings.Contains(version.RenderedConfig, "listen 443 ssl;") {
|
||||
t.Fatal("expected active config to render https listener")
|
||||
t.Fatal("expected active config to render https ssl listener")
|
||||
}
|
||||
if !strings.Contains(version.RenderedConfig, "http2 on;") {
|
||||
t.Fatal("expected active config to render dedicated http2 directive")
|
||||
}
|
||||
if !strings.Contains(version.RenderedConfig, "return 301 https://$host$request_uri;") {
|
||||
t.Fatal("expected active config to render redirect server")
|
||||
|
||||
@@ -170,7 +170,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
createRouteAndPublishVersion(t, engine, adminToken)
|
||||
|
||||
dashboardResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/dashboard/overview", nil)
|
||||
var dashboard service.DashboardOverviewView
|
||||
var dashboard struct {
|
||||
Summary service.DashboardSummary `json:"summary"`
|
||||
}
|
||||
decodeResponseData(t, dashboardResp, &dashboard)
|
||||
if dashboard.Summary.TotalNodes != 0 {
|
||||
t.Fatalf("expected empty dashboard node summary before node registration, got %+v", dashboard.Summary)
|
||||
@@ -210,10 +212,10 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
}
|
||||
resp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
|
||||
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload, "198.51.100.10:1234")
|
||||
var registeredNode model.Node
|
||||
decodeResponseData(t, resp, ®isteredNode)
|
||||
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
if registeredNode.IP != "198.51.100.10" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
t.Fatal("expected heartbeat to update node metadata")
|
||||
}
|
||||
if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy {
|
||||
@@ -279,12 +281,41 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
t.Fatal("expected node list to expose openresty message")
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&model.NodeHealthEvent{
|
||||
NodeID: createdNode.NodeID,
|
||||
EventType: "openresty_down",
|
||||
Severity: service.NodeHealthSeverityCritical,
|
||||
Status: service.NodeHealthEventStatusActive,
|
||||
Message: "docker run openresty failed: bind 80 already allocated",
|
||||
FirstTriggeredAt: time.Now().Add(-2 * time.Minute),
|
||||
LastTriggeredAt: time.Now().Add(-time.Minute),
|
||||
ReportedAt: time.Now().Add(-time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to insert node health event: %v", err)
|
||||
}
|
||||
|
||||
observabilityResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/"+toString(createdNode.ID)+"/observability?hours=24&limit=20", nil)
|
||||
var observability service.NodeObservabilityView
|
||||
decodeResponseData(t, observabilityResp, &observability)
|
||||
if observability.NodeID != createdNode.NodeID {
|
||||
t.Fatalf("expected observability response for node %s, got %s", createdNode.NodeID, observability.NodeID)
|
||||
}
|
||||
if len(observability.HealthEvents) != 1 {
|
||||
t.Fatalf("expected observability response to include health events, got %+v", observability.HealthEvents)
|
||||
}
|
||||
|
||||
cleanupHealthResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/observability/cleanup", nil)
|
||||
var cleanupHealthResult service.NodeHealthEventCleanupResult
|
||||
decodeResponseData(t, cleanupHealthResp, &cleanupHealthResult)
|
||||
if cleanupHealthResult.NodeID != createdNode.NodeID || cleanupHealthResult.DeletedCount != 1 {
|
||||
t.Fatalf("unexpected node health cleanup result: %+v", cleanupHealthResult)
|
||||
}
|
||||
|
||||
observabilityAfterCleanupResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/"+toString(createdNode.ID)+"/observability?hours=24&limit=20", nil)
|
||||
decodeResponseData(t, observabilityAfterCleanupResp, &observability)
|
||||
if len(observability.HealthEvents) != 0 {
|
||||
t.Fatalf("expected health events to be cleaned up, got %+v", observability.HealthEvents)
|
||||
}
|
||||
|
||||
restartResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/openresty-restart", nil)
|
||||
decodeResponseData(t, restartResp, &createdNode)
|
||||
@@ -299,6 +330,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload))
|
||||
restartHeartbeatReq.Header.Set("Content-Type", "application/json")
|
||||
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
|
||||
restartHeartbeatReq.RemoteAddr = "198.51.100.10:1234"
|
||||
restartHeartbeatRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq)
|
||||
if restartHeartbeatRecorder.Code != http.StatusOK {
|
||||
@@ -502,7 +534,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
t.Fatal("expected global discovery token to be available")
|
||||
}
|
||||
|
||||
resp := performAgentJSONRequestWithToken(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
|
||||
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
|
||||
"node_id": "local-node-id",
|
||||
"name": "bulk-edge-1",
|
||||
"ip": "10.0.0.18",
|
||||
@@ -510,7 +542,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
"nginx_version": "1.25.5",
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
})
|
||||
}, "203.0.113.18:4321")
|
||||
var registration service.AgentRegistrationResponse
|
||||
decodeResponseData(t, resp, ®istration)
|
||||
if registration.AgentToken == "" || registration.NodeID == "" {
|
||||
@@ -526,9 +558,16 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline {
|
||||
t.Fatal("expected discovered node to be created online with issued agent token")
|
||||
}
|
||||
if nodes[0].IP != "203.0.113.18" {
|
||||
t.Fatalf("expected discovered node to keep public source ip, got %s", nodes[0].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
return performAgentJSONRequestWithTokenAndRemote(t, engine, token, method, path, body, "")
|
||||
}
|
||||
|
||||
func performAgentJSONRequestWithTokenAndRemote(t *testing.T, engine http.Handler, token string, method string, path string, body any, remoteAddr string) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
@@ -542,6 +581,9 @@ func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token s
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if remoteAddr != "" {
|
||||
req.RemoteAddr = remoteAddr
|
||||
}
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"openflare/model"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -60,9 +61,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
|
||||
StatusCode: 200,
|
||||
},
|
||||
}
|
||||
if err := model.DB.Create(&logs).Error; err != nil {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
seedNodeAccessLogs(t, logs)
|
||||
|
||||
result, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 2})
|
||||
if err != nil {
|
||||
@@ -121,9 +120,7 @@ func TestListAccessLogsUsesDefaultPageSize(t *testing.T) {
|
||||
StatusCode: 200,
|
||||
})
|
||||
}
|
||||
if err := model.DB.Create(&logs).Error; err != nil {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
seedNodeAccessLogs(t, logs)
|
||||
|
||||
result, err := ListAccessLogs(AccessLogQuery{})
|
||||
if err != nil {
|
||||
@@ -143,7 +140,7 @@ func TestListAccessLogsUsesDefaultPageSize(t *testing.T) {
|
||||
func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
now := time.Date(2026, 3, 19, 8, 12, 30, 0, time.UTC)
|
||||
if err := model.DB.Create(&model.Node{
|
||||
NodeID: "node-folded",
|
||||
Name: "edge-folded",
|
||||
@@ -176,9 +173,7 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
|
||||
StatusCode: 404,
|
||||
},
|
||||
}
|
||||
if err := model.DB.Create(&logs).Error; err != nil {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
seedNodeAccessLogs(t, logs)
|
||||
|
||||
folded, err := ListFoldedAccessLogs(AccessLogQuery{
|
||||
NodeID: "node-folded",
|
||||
@@ -221,7 +216,7 @@ func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := model.DB.Create([]*model.NodeAccessLog{
|
||||
seedNodeAccessLogs(t, []*model.NodeAccessLog{
|
||||
{
|
||||
NodeID: "node-cleanup",
|
||||
LoggedAt: now.Add(-10 * 24 * time.Hour),
|
||||
@@ -238,9 +233,7 @@ func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
|
||||
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 {
|
||||
@@ -258,3 +251,45 @@ func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
|
||||
t.Fatalf("unexpected remaining logs after cleanup: %+v", remaining.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistNodeAccessLogsTruncatesLongPath(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
longPath := "/" + strings.Repeat("a", 140)
|
||||
reportedAt := time.Now().UTC()
|
||||
if err := persistNodeAccessLogs(model.DB, "node-truncate", []AgentNodeAccessLog{
|
||||
{
|
||||
LoggedAtUnix: reportedAt.Unix(),
|
||||
RemoteAddr: "203.0.113.10",
|
||||
Host: "truncate.example.com",
|
||||
Path: longPath,
|
||||
StatusCode: 200,
|
||||
},
|
||||
}, reportedAt); err != nil {
|
||||
t.Fatalf("persistNodeAccessLogs failed: %v", err)
|
||||
}
|
||||
|
||||
logs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{
|
||||
NodeID: "node-truncate",
|
||||
Page: 0,
|
||||
PageSize: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNodeAccessLogs failed: %v", err)
|
||||
}
|
||||
if len(logs) != 1 {
|
||||
t.Fatalf("expected one stored log, got %+v", logs)
|
||||
}
|
||||
if got := len([]rune(logs[0].Path)); got != nodeAccessLogPathMaxLength {
|
||||
t.Fatalf("expected truncated path length %d, got %d (%q)", nodeAccessLogPathMaxLength, got, logs[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func seedNodeAccessLogs(t *testing.T, logs []*model.NodeAccessLog) {
|
||||
t.Helper()
|
||||
for _, item := range logs {
|
||||
if err := model.DB.Create(item).Error; err != nil {
|
||||
t.Fatalf("failed to seed access log: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
|
||||
payload.Checksum = strings.TrimSpace(payload.Checksum)
|
||||
payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum)
|
||||
payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum)
|
||||
payload.Message = truncateForDatabase(payload.Message, 16000)
|
||||
if payload.NodeID == "" {
|
||||
return nil, errors.New("node_id 不能为空")
|
||||
}
|
||||
@@ -339,6 +340,17 @@ func ListNodeViews() ([]*NodeView, error) {
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func truncateForDatabase(value string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
const (
|
||||
defaultApplyLogPageSize = 20
|
||||
maxApplyLogPageSize = 200
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -37,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"`
|
||||
@@ -57,14 +61,32 @@ type snapshotRoute struct {
|
||||
Domain string `json:"domain"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
OriginHost string `json:"origin_host,omitempty"`
|
||||
Upstreams []string `json:"upstreams,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
CertID *uint `json:"cert_id,omitempty"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
CacheEnabled bool `json:"cache_enabled"`
|
||||
CachePolicy string `json:"cache_policy,omitempty"`
|
||||
CacheRules []string `json:"cache_rules,omitempty"`
|
||||
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
}
|
||||
|
||||
type routeCacheConfig struct {
|
||||
Enabled bool
|
||||
Policy string
|
||||
Rules []string
|
||||
}
|
||||
|
||||
type routeUpstreamConfig struct {
|
||||
Name string
|
||||
Scheme string
|
||||
ProxyPassURI string
|
||||
Servers []string
|
||||
UsesNamedUpstream bool
|
||||
}
|
||||
|
||||
type openRestyConfigSnapshot struct {
|
||||
WorkerProcesses string `json:"worker_processes"`
|
||||
WorkerConnections int `json:"worker_connections"`
|
||||
@@ -90,7 +112,6 @@ 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"`
|
||||
@@ -132,6 +153,8 @@ var requiredMainConfigTemplatePlaceholders = []string{
|
||||
"{{OpenRestyWorkerProcesses}}",
|
||||
"{{OpenRestyWorkerConnections}}",
|
||||
"{{OpenRestyWorkerRlimitNofile}}",
|
||||
"{{OpenRestyConnectionUpgradeMap}}",
|
||||
"{{OpenRestyDefaultServerBlock}}",
|
||||
"{{OpenRestyAccessLogPath}}",
|
||||
"{{OpenRestyEventsUseDirective}}",
|
||||
"{{OpenRestyEventsMultiAcceptDirective}}",
|
||||
@@ -153,16 +176,19 @@ 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()
|
||||
}
|
||||
|
||||
@@ -381,14 +407,26 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
|
||||
}
|
||||
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
|
||||
}
|
||||
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
|
||||
}
|
||||
items = append(items, snapshotRoute{
|
||||
Domain: route.Domain,
|
||||
OriginURL: route.OriginURL,
|
||||
OriginHost: route.OriginHost,
|
||||
Upstreams: upstreams,
|
||||
Enabled: route.Enabled,
|
||||
EnableHTTPS: route.EnableHTTPS,
|
||||
CertID: route.CertID,
|
||||
RedirectHTTP: route.RedirectHTTP,
|
||||
CacheEnabled: route.CacheEnabled,
|
||||
CachePolicy: route.CachePolicy,
|
||||
CacheRules: cacheRules,
|
||||
CustomHeaders: customHeaders,
|
||||
Remark: route.Remark,
|
||||
})
|
||||
@@ -425,14 +463,40 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
|
||||
if err == nil {
|
||||
routes[index].CustomHeaders = normalizedHeaders
|
||||
}
|
||||
normalizedUpstreams, err := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams)
|
||||
if err == nil {
|
||||
routes[index].OriginURL = normalizedUpstreams[0]
|
||||
routes[index].Upstreams = normalizedUpstreams
|
||||
}
|
||||
normalizedCacheRules, err := normalizeCacheRules(routes[index].CacheEnabled, routes[index].CachePolicy, routes[index].CacheRules)
|
||||
if err == nil {
|
||||
routes[index].CachePolicy = normalizeCachePolicy(routes[index].CacheEnabled, routes[index].CachePolicy)
|
||||
routes[index].CacheRules = normalizedCacheRules
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
|
||||
if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) {
|
||||
if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) {
|
||||
return false
|
||||
}
|
||||
if len(left.Upstreams) != len(right.Upstreams) {
|
||||
return false
|
||||
}
|
||||
for index := range left.Upstreams {
|
||||
if left.Upstreams[index] != right.Upstreams[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(left.CacheRules) != len(right.CacheRules) {
|
||||
return false
|
||||
}
|
||||
for index := range left.CacheRules {
|
||||
if left.CacheRules[index] != right.CacheRules[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(left.CustomHeaders) != len(right.CustomHeaders) {
|
||||
return false
|
||||
}
|
||||
@@ -470,7 +534,6 @@ func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot {
|
||||
GzipEnabled: common.OpenRestyGzipEnabled,
|
||||
GzipMinLength: common.OpenRestyGzipMinLength,
|
||||
GzipCompLevel: common.OpenRestyGzipCompLevel,
|
||||
Resolvers: common.OpenRestyResolvers,
|
||||
CacheEnabled: common.OpenRestyCacheEnabled,
|
||||
CachePath: common.OpenRestyCachePath,
|
||||
CacheLevels: common.OpenRestyCacheLevels,
|
||||
@@ -532,7 +595,6 @@ 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)
|
||||
@@ -579,7 +641,6 @@ func openRestyOptionKeys() []string {
|
||||
"OpenRestyGzipEnabled",
|
||||
"OpenRestyGzipMinLength",
|
||||
"OpenRestyGzipCompLevel",
|
||||
"OpenRestyResolvers",
|
||||
"OpenRestyCacheEnabled",
|
||||
"OpenRestyCachePath",
|
||||
"OpenRestyCacheLevels",
|
||||
@@ -601,8 +662,25 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
|
||||
}
|
||||
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
|
||||
}
|
||||
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
|
||||
}
|
||||
cacheConfig := routeCacheConfig{
|
||||
Enabled: route.CacheEnabled,
|
||||
Policy: route.CachePolicy,
|
||||
Rules: cacheRules,
|
||||
}
|
||||
upstreamConfig := buildRouteUpstreamConfig(route, upstreams)
|
||||
if upstreamConfig.UsesNamedUpstream {
|
||||
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
||||
}
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
|
||||
continue
|
||||
}
|
||||
if route.CertID == nil || *route.CertID == 0 {
|
||||
@@ -619,9 +697,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
if route.RedirectHTTP {
|
||||
builder.WriteString(renderHTTPRedirectServer(route.Domain))
|
||||
} else {
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
|
||||
}
|
||||
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cfg))
|
||||
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, upstreamConfig, cfg))
|
||||
}
|
||||
return builder.String(), dedupeSupportFiles(supportFiles), nil
|
||||
}
|
||||
@@ -656,6 +734,8 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
|
||||
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
|
||||
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
|
||||
"{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
|
||||
"{{OpenRestyConnectionUpgradeMap}}", renderConnectionUpgradeMap(),
|
||||
"{{OpenRestyDefaultServerBlock}}", renderDefaultServerBlock(),
|
||||
"{{OpenRestyAccessLogPath}}", nginxAccessLogPlaceholder,
|
||||
"{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
|
||||
"{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"),
|
||||
@@ -677,7 +757,7 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
|
||||
"{{OpenRestyGzip}}", onOff(cfg.GzipEnabled),
|
||||
"{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength),
|
||||
"{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel),
|
||||
"{{OpenRestyResolverDirective}}", renderResolverDirective(cfg.Resolvers),
|
||||
"{{OpenRestyResolverDirective}}", "",
|
||||
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg),
|
||||
"{{OpenRestyRouteConfigInclude}}", nginxRouteConfigPlaceholder,
|
||||
)
|
||||
@@ -756,25 +836,29 @@ func nextVersionNumber(now time.Time) (string, error) {
|
||||
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
|
||||
}
|
||||
|
||||
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, 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 renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
|
||||
}
|
||||
|
||||
func renderHTTPRedirectServer(domain string) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n return 301 https://$host$request_uri;\n}\n\n", domain, renderExactHostGuard(domain))
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", domain)
|
||||
}
|
||||
|
||||
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cfg openRestyConfigSnapshot) string {
|
||||
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
|
||||
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
|
||||
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s%s }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL, cfg))
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
|
||||
}
|
||||
|
||||
func renderExactHostGuard(domain string) string {
|
||||
return fmt.Sprintf(" if ($host != %q) {\n return 404;\n }\n", domain)
|
||||
func renderConnectionUpgradeMap() string {
|
||||
return " map $http_upgrade $connection_upgrade {\n default upgrade;\n '' \"\";\n }\n\n"
|
||||
}
|
||||
|
||||
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput) string {
|
||||
func renderDefaultServerBlock() string {
|
||||
return " server {\n listen 80 default_server;\n server_name _;\n\n return 404;\n }\n\n"
|
||||
}
|
||||
|
||||
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, upstreamConfig routeUpstreamConfig) string {
|
||||
var builder strings.Builder
|
||||
if strings.TrimSpace(originHost) != "" {
|
||||
builder.WriteString(fmt.Sprintf(" proxy_set_header Host %s;\n", quoteNginxHeaderValue(originHost)))
|
||||
@@ -790,75 +874,181 @@ func renderProxyHeaderBlock(originURL string, originHost string, customHeaders [
|
||||
builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
|
||||
if common.OpenRestyWebsocketEnabled {
|
||||
builder.WriteString(" proxy_http_version 1.1;\n")
|
||||
builder.WriteString(" proxy_set_header Connection $connection_upgrade;\n")
|
||||
builder.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
||||
builder.WriteString(" proxy_set_header Connection $http_connection;\n")
|
||||
} else if upstreamConfig.UsesNamedUpstream {
|
||||
builder.WriteString(" proxy_http_version 1.1;\n")
|
||||
builder.WriteString(" proxy_set_header Connection \"\";\n")
|
||||
}
|
||||
for _, header := range customHeaders {
|
||||
builder.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", header.Key, quoteNginxHeaderValue(header.Value)))
|
||||
}
|
||||
if common.OpenRestyCacheEnabled {
|
||||
builder.WriteString(" proxy_cache openflare_cache;\n")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderProxyPassBlock(originURL string, cfg openRestyConfigSnapshot) string {
|
||||
func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg openRestyConfigSnapshot) string {
|
||||
if !cfg.CacheEnabled || !cacheConfig.Enabled {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(" set $openflare_skip_cache 0;\n")
|
||||
builder.WriteString(" if ($request_method != GET) {\n set $openflare_skip_cache 1;\n }\n")
|
||||
builder.WriteString(" if ($http_authorization != \"\") {\n set $openflare_skip_cache 1;\n }\n")
|
||||
builder.WriteString(" if ($http_cookie ~* \"(session|sess|token|auth|jwt|logged_in|remember|laravel_session|connect\\\\.sid|_session)\") {\n set $openflare_skip_cache 1;\n }\n")
|
||||
builder.WriteString(" if ($http_cache_control ~* \"(no-cache|no-store|private)\") {\n set $openflare_skip_cache 1;\n }\n")
|
||||
if policyCondition := renderRouteCachePolicyCondition(cacheConfig); policyCondition != "" {
|
||||
builder.WriteString(policyCondition)
|
||||
}
|
||||
builder.WriteString(" proxy_cache openflare_cache;\n")
|
||||
builder.WriteString(" proxy_cache_methods GET;\n")
|
||||
builder.WriteString(" proxy_cache_bypass $openflare_skip_cache;\n")
|
||||
builder.WriteString(" proxy_no_cache $openflare_skip_cache;\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string {
|
||||
switch cacheConfig.Policy {
|
||||
case proxyRouteCachePolicySuffix:
|
||||
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(cacheConfig.Rules)))
|
||||
case proxyRouteCachePolicyPathPrefix:
|
||||
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathPrefixMatchPattern(cacheConfig.Rules)))
|
||||
case proxyRouteCachePolicyPathExact:
|
||||
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathExactMatchPattern(cacheConfig.Rules)))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildSuffixMatchPattern(rules []string) string {
|
||||
parts := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
parts = append(parts, regexp.QuoteMeta(rule))
|
||||
}
|
||||
return fmt.Sprintf("\\.(?:%s)$", strings.Join(parts, "|"))
|
||||
}
|
||||
|
||||
func buildPathPrefixMatchPattern(rules []string) string {
|
||||
parts := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
trimmed := strings.TrimRight(rule, "/")
|
||||
if trimmed == "" {
|
||||
trimmed = "/"
|
||||
}
|
||||
if trimmed == "/" {
|
||||
parts = append(parts, "/")
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s(?:/|$)", regexp.QuoteMeta(trimmed)))
|
||||
}
|
||||
return fmt.Sprintf("^(?:%s)", strings.Join(parts, "|"))
|
||||
}
|
||||
|
||||
func buildPathExactMatchPattern(rules []string) string {
|
||||
parts := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
parts = append(parts, regexp.QuoteMeta(rule))
|
||||
}
|
||||
return fmt.Sprintf("^(?:%s)$", strings.Join(parts, "|"))
|
||||
}
|
||||
|
||||
func renderProxyPassBlock(originURL string, upstreamConfig routeUpstreamConfig) string {
|
||||
parsed, err := url.Parse(originURL)
|
||||
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
|
||||
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
|
||||
}
|
||||
if !shouldUseRuntimeResolver(originURL, cfg.Resolvers) {
|
||||
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
|
||||
if upstreamConfig.UsesNamedUpstream {
|
||||
return fmt.Sprintf(" proxy_pass %s://%s%s;\n", upstreamConfig.Scheme, upstreamConfig.Name, upstreamConfig.ProxyPassURI)
|
||||
}
|
||||
upstreamURL := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
basePath := strings.TrimRight(parsed.EscapedPath(), "/")
|
||||
if basePath == "" || basePath == "." {
|
||||
basePath = ""
|
||||
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
|
||||
}
|
||||
|
||||
func buildRouteUpstreamConfig(route *model.ProxyRoute, upstreams []string) routeUpstreamConfig {
|
||||
if len(upstreams) == 0 {
|
||||
return routeUpstreamConfig{}
|
||||
}
|
||||
if parsed.RawQuery != "" {
|
||||
if basePath == "" {
|
||||
basePath = "/"
|
||||
if len(upstreams) == 1 {
|
||||
parsed, err := url.Parse(strings.TrimSpace(upstreams[0]))
|
||||
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
|
||||
return routeUpstreamConfig{}
|
||||
}
|
||||
return routeUpstreamConfig{
|
||||
Name: buildRouteUpstreamName(route),
|
||||
Scheme: parsed.Scheme,
|
||||
ProxyPassURI: buildUpstreamProxyPassURI(parsed),
|
||||
Servers: []string{parsed.Host},
|
||||
UsesNamedUpstream: true,
|
||||
}
|
||||
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()
|
||||
servers := make([]string, 0, len(upstreams))
|
||||
var scheme string
|
||||
for _, upstream := range upstreams {
|
||||
parsed, err := url.Parse(strings.TrimSpace(upstream))
|
||||
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
|
||||
return routeUpstreamConfig{}
|
||||
}
|
||||
if strings.TrimSpace(parsed.EscapedPath()) != "" && strings.TrimSpace(parsed.EscapedPath()) != "/" {
|
||||
return routeUpstreamConfig{}
|
||||
}
|
||||
if parsed.RawQuery != "" {
|
||||
return routeUpstreamConfig{}
|
||||
}
|
||||
if scheme == "" {
|
||||
scheme = parsed.Scheme
|
||||
} else if scheme != parsed.Scheme {
|
||||
return routeUpstreamConfig{}
|
||||
}
|
||||
servers = append(servers, parsed.Host)
|
||||
}
|
||||
return routeUpstreamConfig{
|
||||
Name: buildRouteUpstreamName(route),
|
||||
Scheme: scheme,
|
||||
Servers: servers,
|
||||
UsesNamedUpstream: true,
|
||||
}
|
||||
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 {
|
||||
func buildUpstreamProxyPassURI(parsed *url.URL) string {
|
||||
if parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
|
||||
path := parsed.EscapedPath()
|
||||
if path == "/" {
|
||||
path = ""
|
||||
}
|
||||
if parsed.RawQuery == "" {
|
||||
return path
|
||||
}
|
||||
return fmt.Sprintf("%s?%s", path, parsed.RawQuery)
|
||||
}
|
||||
|
||||
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 buildRouteUpstreamName(route *model.ProxyRoute) string {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r + ('a' - 'A')
|
||||
case r >= '0' && r <= '9':
|
||||
return r
|
||||
default:
|
||||
return '_'
|
||||
}
|
||||
}, route.Domain)
|
||||
sanitized = strings.Trim(sanitized, "_")
|
||||
if sanitized == "" {
|
||||
sanitized = "backend"
|
||||
}
|
||||
return fmt.Sprintf("backend_%s_%d", sanitized, route.ID)
|
||||
}
|
||||
|
||||
func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name))
|
||||
for _, server := range upstreamConfig.Servers {
|
||||
builder.WriteString(fmt.Sprintf(" server %s max_fails=3 fail_timeout=10s;\n", server))
|
||||
}
|
||||
builder.WriteString(" keepalive 128;\n}\n\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func resolveUpstreamServerName(originURL string, originHost string) string {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DatabaseCleanupTargetAccessLogs = "node_access_logs"
|
||||
DatabaseCleanupTargetMetricSnapshots = "node_metric_snapshots"
|
||||
DatabaseCleanupTargetRequestReports = "node_request_reports"
|
||||
)
|
||||
|
||||
var databaseCleanupTargets = map[string]string{
|
||||
DatabaseCleanupTargetAccessLogs: "访问日志",
|
||||
DatabaseCleanupTargetMetricSnapshots: "性能快照",
|
||||
DatabaseCleanupTargetRequestReports: "请求聚合",
|
||||
}
|
||||
|
||||
type DatabaseCleanupInput struct {
|
||||
Target string `json:"target"`
|
||||
RetentionDays *int `json:"retention_days"`
|
||||
}
|
||||
|
||||
type DatabaseCleanupResult struct {
|
||||
Target string `json:"target"`
|
||||
TargetLabel string `json:"target_label"`
|
||||
DeletedCount int64 `json:"deleted_count"`
|
||||
DeleteAll bool `json:"delete_all"`
|
||||
RetentionDays *int `json:"retention_days,omitempty"`
|
||||
Cutoff *time.Time `json:"cutoff,omitempty"`
|
||||
}
|
||||
|
||||
type DatabaseAutoCleanupSummary struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
ExecutedAt time.Time `json:"executed_at"`
|
||||
Results []DatabaseCleanupResult `json:"results"`
|
||||
}
|
||||
|
||||
func CleanupDatabaseObservability(input DatabaseCleanupInput) (*DatabaseCleanupResult, error) {
|
||||
target := strings.TrimSpace(input.Target)
|
||||
targetLabel, ok := databaseCleanupTargets[target]
|
||||
if !ok {
|
||||
return nil, errors.New("unsupported cleanup target")
|
||||
}
|
||||
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
|
||||
return nil, errors.New("retention_days 必须为大于 0 的整数")
|
||||
}
|
||||
|
||||
result := &DatabaseCleanupResult{
|
||||
Target: target,
|
||||
TargetLabel: targetLabel,
|
||||
DeleteAll: input.RetentionDays == nil,
|
||||
}
|
||||
|
||||
if input.RetentionDays == nil {
|
||||
deleted, err := deleteAllObservabilityRows(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.DeletedCount = deleted
|
||||
return result, nil
|
||||
}
|
||||
|
||||
retentionDays := *input.RetentionDays
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(retentionDays) * 24 * time.Hour)
|
||||
deleted, err := deleteObservabilityRowsBefore(target, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.DeletedCount = deleted
|
||||
result.RetentionDays = &retentionDays
|
||||
result.Cutoff = &cutoff
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func RunDatabaseAutoCleanupOnce(now time.Time) (*DatabaseAutoCleanupSummary, error) {
|
||||
if !common.DatabaseAutoCleanupEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
if common.DatabaseAutoCleanupRetentionDays < 1 {
|
||||
return nil, fmt.Errorf("database auto cleanup retention_days must be at least 1")
|
||||
}
|
||||
|
||||
retentionDays := common.DatabaseAutoCleanupRetentionDays
|
||||
results := make([]DatabaseCleanupResult, 0, len(databaseCleanupTargets))
|
||||
for _, target := range []string{
|
||||
DatabaseCleanupTargetAccessLogs,
|
||||
DatabaseCleanupTargetMetricSnapshots,
|
||||
DatabaseCleanupTargetRequestReports,
|
||||
} {
|
||||
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
|
||||
Target: target,
|
||||
RetentionDays: &retentionDays,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
return &DatabaseAutoCleanupSummary{
|
||||
RetentionDays: retentionDays,
|
||||
ExecutedAt: now.UTC(),
|
||||
Results: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func StartDatabaseAutoCleanupScheduler(ctx context.Context) {
|
||||
go func() {
|
||||
for {
|
||||
wait := time.Until(nextDatabaseAutoCleanupTime(time.Now()))
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
summary, err := RunDatabaseAutoCleanupOnce(time.Now())
|
||||
if err != nil {
|
||||
slog.Error("database auto cleanup failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if summary == nil {
|
||||
continue
|
||||
}
|
||||
totalDeleted := int64(0)
|
||||
for _, item := range summary.Results {
|
||||
totalDeleted += item.DeletedCount
|
||||
}
|
||||
slog.Info(
|
||||
"database auto cleanup completed",
|
||||
"retention_days",
|
||||
summary.RetentionDays,
|
||||
"deleted_count",
|
||||
totalDeleted,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func nextDatabaseAutoCleanupTime(now time.Time) time.Time {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func deleteAllObservabilityRows(target string) (int64, error) {
|
||||
switch target {
|
||||
case DatabaseCleanupTargetAccessLogs:
|
||||
return model.DeleteAllNodeAccessLogs(nil)
|
||||
case DatabaseCleanupTargetMetricSnapshots:
|
||||
return model.DeleteAllNodeMetricSnapshots(nil)
|
||||
case DatabaseCleanupTargetRequestReports:
|
||||
return model.DeleteAllNodeRequestReports(nil)
|
||||
default:
|
||||
return 0, errors.New("unsupported cleanup target")
|
||||
}
|
||||
}
|
||||
|
||||
func deleteObservabilityRowsBefore(target string, cutoff time.Time) (int64, error) {
|
||||
switch target {
|
||||
case DatabaseCleanupTargetAccessLogs:
|
||||
return model.DeleteNodeAccessLogsBefore(cutoff)
|
||||
case DatabaseCleanupTargetMetricSnapshots:
|
||||
return model.DeleteNodeMetricSnapshotsBefore(nil, cutoff)
|
||||
case DatabaseCleanupTargetRequestReports:
|
||||
return model.DeleteNodeRequestReportsBefore(nil, cutoff)
|
||||
default:
|
||||
return 0, errors.New("unsupported cleanup target")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCleanupDatabaseObservabilityDeletesTargetedRows(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := model.DB.Create(&model.NodeMetricSnapshot{
|
||||
NodeID: "node-a",
|
||||
CapturedAt: now.Add(-10 * 24 * time.Hour),
|
||||
CPUUsagePercent: 10,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed old metric snapshot: %v", err)
|
||||
}
|
||||
if err := model.DB.Create(&model.NodeMetricSnapshot{
|
||||
NodeID: "node-a",
|
||||
CapturedAt: now.Add(-12 * time.Hour),
|
||||
CPUUsagePercent: 20,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed recent metric snapshot: %v", err)
|
||||
}
|
||||
|
||||
retentionDays := 7
|
||||
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
|
||||
Target: DatabaseCleanupTargetMetricSnapshots,
|
||||
RetentionDays: &retentionDays,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
|
||||
}
|
||||
if result.DeleteAll {
|
||||
t.Fatal("expected retention cleanup instead of delete_all")
|
||||
}
|
||||
if result.DeletedCount != 1 {
|
||||
t.Fatalf("expected 1 deleted row, got %+v", result)
|
||||
}
|
||||
|
||||
rows, err := model.ListMetricSnapshotsSince(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].CPUUsagePercent != 20 {
|
||||
t.Fatalf("unexpected remaining metric snapshots: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupDatabaseObservabilityDeletesAllRowsWhenRetentionMissing(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := model.DB.Create(&model.NodeAccessLog{
|
||||
NodeID: "node-a",
|
||||
LoggedAt: now.Add(-3 * time.Hour),
|
||||
RemoteAddr: "203.0.113.1",
|
||||
Host: "example.com",
|
||||
Path: "/one",
|
||||
StatusCode: 200,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed first access log: %v", err)
|
||||
}
|
||||
if err := model.DB.Create(&model.NodeAccessLog{
|
||||
NodeID: "node-a",
|
||||
LoggedAt: now.Add(-2 * time.Hour),
|
||||
RemoteAddr: "203.0.113.2",
|
||||
Host: "example.com",
|
||||
Path: "/two",
|
||||
StatusCode: 502,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed second access log: %v", err)
|
||||
}
|
||||
|
||||
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
|
||||
Target: DatabaseCleanupTargetAccessLogs,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
|
||||
}
|
||||
if !result.DeleteAll || result.DeletedCount != 2 {
|
||||
t.Fatalf("unexpected delete-all result: %+v", result)
|
||||
}
|
||||
|
||||
rows, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNodeAccessLogs failed: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected all access logs deleted, got %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDatabaseAutoCleanupOnceDeletesAllObservabilityTargets(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := model.DB.Create(&model.NodeAccessLog{
|
||||
NodeID: "node-a",
|
||||
LoggedAt: now.Add(-48 * time.Hour),
|
||||
RemoteAddr: "203.0.113.10",
|
||||
Host: "example.com",
|
||||
Path: "/access",
|
||||
StatusCode: 200,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed access log: %v", err)
|
||||
}
|
||||
if err := model.DB.Create(&model.NodeMetricSnapshot{
|
||||
NodeID: "node-a",
|
||||
CapturedAt: now.Add(-48 * time.Hour),
|
||||
CPUUsagePercent: 10,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed metric snapshot: %v", err)
|
||||
}
|
||||
if err := model.DB.Create(&model.NodeRequestReport{
|
||||
NodeID: "node-a",
|
||||
WindowStartedAt: now.Add(-49 * time.Hour),
|
||||
WindowEndedAt: now.Add(-48 * time.Hour),
|
||||
RequestCount: 15,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed request report: %v", err)
|
||||
}
|
||||
|
||||
previousEnabled := common.DatabaseAutoCleanupEnabled
|
||||
previousRetentionDays := common.DatabaseAutoCleanupRetentionDays
|
||||
common.DatabaseAutoCleanupEnabled = true
|
||||
common.DatabaseAutoCleanupRetentionDays = 1
|
||||
t.Cleanup(func() {
|
||||
common.DatabaseAutoCleanupEnabled = previousEnabled
|
||||
common.DatabaseAutoCleanupRetentionDays = previousRetentionDays
|
||||
})
|
||||
|
||||
summary, err := RunDatabaseAutoCleanupOnce(now)
|
||||
if err != nil {
|
||||
t.Fatalf("RunDatabaseAutoCleanupOnce failed: %v", err)
|
||||
}
|
||||
if summary == nil || len(summary.Results) != 3 {
|
||||
t.Fatalf("unexpected auto cleanup summary: %+v", summary)
|
||||
}
|
||||
|
||||
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNodeAccessLogs failed: %v", err)
|
||||
}
|
||||
if len(accessLogs) != 0 {
|
||||
t.Fatalf("expected auto cleanup to delete access logs, got %+v", accessLogs)
|
||||
}
|
||||
metricSnapshots, err := model.ListMetricSnapshotsSince(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
|
||||
}
|
||||
if len(metricSnapshots) != 0 {
|
||||
t.Fatalf("expected auto cleanup to delete metric snapshots, got %+v", metricSnapshots)
|
||||
}
|
||||
requestReports, err := model.ListRequestReportsSince(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRequestReportsSince failed: %v", err)
|
||||
}
|
||||
if len(requestReports) != 0 {
|
||||
t.Fatalf("expected auto cleanup to delete request reports, got %+v", requestReports)
|
||||
}
|
||||
}
|
||||
@@ -66,14 +66,29 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
|
||||
if strings.Contains(result.Version.MainConfig, "resolver ") {
|
||||
t.Fatal("expected main config to omit resolver directive when no resolvers are configured")
|
||||
}
|
||||
if !strings.Contains(result.Version.MainConfig, "use epoll;") {
|
||||
t.Fatal("expected main config to default to epoll event model")
|
||||
}
|
||||
if !strings.Contains(result.Version.MainConfig, "multi_accept on;") {
|
||||
t.Fatal("expected main config to default multi_accept to on")
|
||||
}
|
||||
if !strings.Contains(result.Version.MainConfig, "keepalive_timeout 20;") {
|
||||
t.Fatal("expected main config to default keepalive_timeout to 20")
|
||||
}
|
||||
if !strings.Contains(result.Version.MainConfig, "proxy_connect_timeout 3;") {
|
||||
t.Fatal("expected main config to default proxy_connect_timeout to 3")
|
||||
}
|
||||
if strings.Contains(result.Version.MainConfig, "allow 127.0.0.1;") {
|
||||
t.Fatal("expected main config to avoid hard-coded allow rules on observability server")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
|
||||
t.Fatal("expected rendered config to include https server block")
|
||||
t.Fatal("expected rendered config to include https ssl listener")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) {
|
||||
t.Fatal("expected rendered config to reject unmatched host headers with 404")
|
||||
if !strings.Contains(result.Version.RenderedConfig, "http2 on;") {
|
||||
t.Fatal("expected rendered config to enable http2 with dedicated directive")
|
||||
}
|
||||
if strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) {
|
||||
t.Fatal("expected rendered config to avoid per-route host guard")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "return 301 https://$host$request_uri;") {
|
||||
t.Fatal("expected rendered config to include http redirect")
|
||||
@@ -138,14 +153,184 @@ func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) {
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Upgrade $http_upgrade;") {
|
||||
t.Fatal("expected rendered config to forward websocket upgrade header")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Connection $http_connection;") {
|
||||
t.Fatal("expected rendered config to forward websocket connection header")
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Connection $connection_upgrade;") {
|
||||
t.Fatal("expected rendered config to use normalized websocket connection header")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "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, "upstream backend_custom_example_com_1 {") {
|
||||
t.Fatal("expected hostname origin to render named upstream")
|
||||
}
|
||||
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")
|
||||
if !strings.Contains(result.Version.RenderedConfig, "server origin.internal max_fails=3 fail_timeout=10s;") {
|
||||
t.Fatal("expected hostname origin to render upstream server entry")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "keepalive 128;") {
|
||||
t.Fatal("expected named upstream to enable keepalive")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_custom_example_com_1;") {
|
||||
t.Fatal("expected hostname origin to proxy through named upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProxyRouteRejectsCachePolicyWithoutRules(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "cache.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
CacheEnabled: true,
|
||||
CachePolicy: proxyRouteCachePolicySuffix,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "至少填写一个后缀") {
|
||||
t.Fatalf("expected cache rule validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionRendersRouteLevelCachePolicy(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
if err := model.UpdateOption("OpenRestyCacheEnabled", "true"); err != nil {
|
||||
t.Fatalf("UpdateOption OpenRestyCacheEnabled failed: %v", err)
|
||||
}
|
||||
if err := model.UpdateOption("OpenRestyCachePath", "/var/cache/openresty/openflare"); err != nil {
|
||||
t.Fatalf("UpdateOption OpenRestyCachePath failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "static.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
CacheEnabled: true,
|
||||
CachePolicy: proxyRouteCachePolicySuffix,
|
||||
CacheRules: []string{"jpg", ".css", "js"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute cached failed: %v", err)
|
||||
}
|
||||
_, err = CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "nocache.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute uncached failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := PublishConfigVersion("root")
|
||||
if err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Version.MainConfig, "proxy_cache_path /var/cache/openresty/openflare") {
|
||||
t.Fatal("expected main config to include cache zone when cache infra is enabled")
|
||||
}
|
||||
if !strings.Contains(result.Version.MainConfig, `proxy_cache_key "$scheme$host$request_uri";`) {
|
||||
t.Fatal("expected main config to default cache key to host dimension")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_methods GET;") {
|
||||
t.Fatal("expected rendered config to only cache GET requests")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_bypass $openflare_skip_cache;") {
|
||||
t.Fatal("expected rendered config to bypass cache when request is unsafe")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_no_cache $openflare_skip_cache;") {
|
||||
t.Fatal("expected rendered config to avoid storing unsafe requests in cache")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "if ($http_authorization != \"\")") {
|
||||
t.Fatal("expected rendered config to bypass authenticated requests")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "if ($request_method != GET)") {
|
||||
t.Fatal("expected rendered config to bypass non-GET requests")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "if ($uri !~* \"\\\\.(?:jpg|css|js)$\")") {
|
||||
t.Fatal("expected rendered config to render suffix cache matching rule")
|
||||
}
|
||||
if strings.Count(result.Version.RenderedConfig, "proxy_cache openflare_cache;") != 1 {
|
||||
t.Fatal("expected only cache-enabled route to include proxy_cache directive")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_static_example_com_1;") {
|
||||
t.Fatal("expected cache-enabled hostname route to proxy through named upstream")
|
||||
}
|
||||
if !strings.Contains(result.Version.SnapshotJSON, `"cache_enabled":true`) {
|
||||
t.Fatal("expected snapshot to include route cache toggle")
|
||||
}
|
||||
if !strings.Contains(result.Version.SnapshotJSON, `"cache_policy":"suffix"`) {
|
||||
t.Fatal("expected snapshot to include route cache policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "lb.example.com",
|
||||
OriginURL: "http://10.0.0.11:39010",
|
||||
Upstreams: []string{"http://10.0.0.12:39010", "http://10.0.0.13:39010"},
|
||||
Enabled: true,
|
||||
OriginHost: "lb.example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(route.Upstreams, "10.0.0.12:39010") {
|
||||
t.Fatalf("expected route upstreams to persist, got %s", route.Upstreams)
|
||||
}
|
||||
|
||||
result, err := PublishConfigVersion("root")
|
||||
if err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_lb_example_com_1 {") {
|
||||
t.Fatal("expected rendered config to define upstream block for load balancing route")
|
||||
}
|
||||
if strings.Count(result.Version.RenderedConfig, "max_fails=3 fail_timeout=10s;") < 3 {
|
||||
t.Fatal("expected rendered config to include every upstream server")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.11:39010 max_fails=3 fail_timeout=10s;") {
|
||||
t.Fatal("expected rendered config to include primary upstream server")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.12:39010 max_fails=3 fail_timeout=10s;") {
|
||||
t.Fatal("expected rendered config to include secondary upstream server")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.13:39010 max_fails=3 fail_timeout=10s;") {
|
||||
t.Fatal("expected rendered config to include tertiary upstream server")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_lb_example_com_1;") {
|
||||
t.Fatal("expected rendered config to proxy through load balancing upstream")
|
||||
}
|
||||
if !strings.Contains(result.Version.SnapshotJSON, `"upstreams":["http://10.0.0.11:39010","http://10.0.0.12:39010","http://10.0.0.13:39010"]`) {
|
||||
t.Fatal("expected snapshot to include upstream list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionRendersHostnameLoadBalancingUpstream(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "hostname-lb.example.com",
|
||||
OriginURL: "http://c1:39010",
|
||||
Upstreams: []string{"http://c2:39010"},
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := PublishConfigVersion("root")
|
||||
if err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_hostname_lb_example_com_1 {") {
|
||||
t.Fatal("expected hostname load balancing route to define named upstream")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "server c1:39010 max_fails=3 fail_timeout=10s;") {
|
||||
t.Fatal("expected rendered config to include primary hostname upstream")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "server c2:39010 max_fails=3 fail_timeout=10s;") {
|
||||
t.Fatal("expected rendered config to include secondary hostname upstream")
|
||||
}
|
||||
if strings.Contains(result.Version.RenderedConfig, " resolve ") {
|
||||
t.Fatal("expected hostname upstreams to avoid resolver-based server parameters")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_hostname_lb_example_com_1;") {
|
||||
t.Fatal("expected hostname load balancing route to proxy through named upstream")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,22 +360,46 @@ 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.RenderedConfig, "upstream backend_git_arctel_de_1 {") {
|
||||
t.Fatal("expected hostname origin to render named upstream")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_git_arctel_de_1;") {
|
||||
t.Fatal("expected rendered config to proxy through named upstream for hostname origin")
|
||||
}
|
||||
if !strings.Contains(result.Version.SnapshotJSON, `"origin_host":"git.arctel.net"`) {
|
||||
t.Fatal("expected snapshot to include origin_host override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionUsesRuntimeResolverWhenConfigured(t *testing.T) {
|
||||
func TestPublishConfigVersionUsesNamedUpstreamForOriginBasePath(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/api/",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := PublishConfigVersion("root")
|
||||
if err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "upstream backend_resolver_example_com_1 {") {
|
||||
t.Fatal("expected hostname origin with base path to still render named upstream")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_resolver_example_com_1/api/;") {
|
||||
t.Fatal("expected rendered config to preserve base path while proxying through named upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionUsesNamedUpstreamForHostnameOrigins(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "resolver-upstream.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
})
|
||||
@@ -202,18 +411,18 @@ func TestPublishConfigVersionUsesRuntimeResolverWhenConfigured(t *testing.T) {
|
||||
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, "upstream backend_resolver_upstream_example_com_1 {") {
|
||||
t.Fatal("expected rendered config to define named upstream for hostname origin")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, `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, "server origin.internal max_fails=3 fail_timeout=10s;") {
|
||||
t.Fatal("expected rendered config to include hostname upstream server entry")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass $openflare_upstream$request_uri;") {
|
||||
t.Fatal("expected rendered config to proxy via runtime-resolved upstream variable when resolvers are configured")
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_resolver_upstream_example_com_1;") {
|
||||
t.Fatal("expected rendered config to proxy through named upstream for hostname origin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionKeepsDirectProxyPassForIPOrigins(t *testing.T) {
|
||||
func TestPublishConfigVersionUsesNamedUpstreamForIPOrigins(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
@@ -229,8 +438,11 @@ func TestPublishConfigVersionKeepsDirectProxyPassForIPOrigins(t *testing.T) {
|
||||
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, "upstream backend_ip_origin_example_com_1 {") {
|
||||
t.Fatal("expected rendered config to define named upstream for static IP origins")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_ip_origin_example_com_1;") {
|
||||
t.Fatal("expected rendered config to proxy through named upstream for IP origin")
|
||||
}
|
||||
if strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "http://10.0.0.8:8080"`) {
|
||||
t.Fatal("expected rendered config to avoid runtime resolver variables for IP origin")
|
||||
@@ -239,7 +451,6 @@ func TestPublishConfigVersionKeepsDirectProxyPassForIPOrigins(t *testing.T) {
|
||||
|
||||
func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "ws-off.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
@@ -256,15 +467,15 @@ func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewConfigVersion failed: %v", err)
|
||||
}
|
||||
if strings.Contains(preview.RenderedConfig, "proxy_http_version 1.1;") {
|
||||
t.Fatal("expected preview config to omit websocket proxy_http_version when disabled")
|
||||
if !strings.Contains(preview.RenderedConfig, "proxy_http_version 1.1;") {
|
||||
t.Fatal("expected preview config to keep HTTP/1.1 proxying for named upstream keepalive")
|
||||
}
|
||||
if !strings.Contains(preview.RenderedConfig, `proxy_set_header Connection "";`) {
|
||||
t.Fatal("expected preview config to clear connection header when websocket upgrades are disabled")
|
||||
}
|
||||
if strings.Contains(preview.RenderedConfig, "proxy_set_header Upgrade $http_upgrade;") {
|
||||
t.Fatal("expected preview config to omit websocket upgrade header when disabled")
|
||||
}
|
||||
if strings.Contains(preview.RenderedConfig, "proxy_set_header Connection $http_connection;") {
|
||||
t.Fatal("expected preview config to omit websocket connection header when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewAndDiffConfigVersion(t *testing.T) {
|
||||
@@ -411,7 +622,7 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderConfigRejectsUnknownSubdomainHosts(t *testing.T) {
|
||||
func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
@@ -431,11 +642,17 @@ func TestRenderConfigRejectsUnknownSubdomainHosts(t *testing.T) {
|
||||
if !strings.Contains(preview.RenderedConfig, `server_name git.arctel.net;`) {
|
||||
t.Fatal("expected rendered config to include exact server_name")
|
||||
}
|
||||
if !strings.Contains(preview.RenderedConfig, `if ($host != "git.arctel.net") {`) {
|
||||
t.Fatal("expected rendered config to guard against unknown subdomain host matches")
|
||||
if strings.Contains(preview.RenderedConfig, `if ($host != "git.arctel.net") {`) {
|
||||
t.Fatal("expected rendered config to avoid per-route host guard")
|
||||
}
|
||||
if !strings.Contains(preview.RenderedConfig, "return 404;") {
|
||||
t.Fatal("expected rendered config to return 404 when host does not exactly match route domain")
|
||||
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
|
||||
t.Fatal("expected preview main config to include default http server")
|
||||
}
|
||||
if !strings.Contains(preview.MainConfig, "server_name _;") {
|
||||
t.Fatal("expected preview main config to include default server_name")
|
||||
}
|
||||
if !strings.Contains(preview.MainConfig, "return 404;") {
|
||||
t.Fatal("expected preview main config to return 404 for unmatched hosts")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +700,12 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
|
||||
if !strings.Contains(preview.MainConfig, "access_log __OPENFLARE_ACCESS_LOG__ openflare_json;") {
|
||||
t.Fatal("expected preview main config to preserve managed access log placeholder")
|
||||
}
|
||||
if !strings.Contains(preview.MainConfig, "map $http_upgrade $connection_upgrade {") {
|
||||
t.Fatal("expected preview main config to preserve managed websocket upgrade map")
|
||||
}
|
||||
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
|
||||
t.Fatal("expected preview main config to preserve managed default server block")
|
||||
}
|
||||
|
||||
invalidTemplate := strings.ReplaceAll(
|
||||
common.OpenRestyMainConfigTemplate,
|
||||
@@ -501,6 +724,15 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
|
||||
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
|
||||
t.Fatal("expected template without managed access log placeholder to fail validation")
|
||||
}
|
||||
|
||||
invalidTemplate = strings.ReplaceAll(
|
||||
common.OpenRestyMainConfigTemplate,
|
||||
"{{OpenRestyConnectionUpgradeMap}}",
|
||||
"",
|
||||
)
|
||||
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
|
||||
t.Fatal("expected template without managed websocket upgrade map placeholder to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRestyCommonRequestOptionsRender(t *testing.T) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/utils/geoip"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -332,6 +333,29 @@ func cloneCoordinate(value *float64) *float64 {
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func ResolveReportedNodeIP(reportedIP string, remoteAddr string) string {
|
||||
reported := iputil.NormalizeIP(reportedIP)
|
||||
remote := iputil.NormalizeRemoteAddr(remoteAddr)
|
||||
if reported == "" {
|
||||
return remote
|
||||
}
|
||||
if !shouldPreferRemoteNodeIP(reported) {
|
||||
return reported
|
||||
}
|
||||
if isPublicNodeIP(remote) {
|
||||
return remote
|
||||
}
|
||||
return reported
|
||||
}
|
||||
|
||||
func shouldPreferRemoteNodeIP(ip string) bool {
|
||||
return !isPublicNodeIP(ip)
|
||||
}
|
||||
|
||||
func isPublicNodeIP(raw string) bool {
|
||||
return iputil.IsPublicString(raw)
|
||||
}
|
||||
|
||||
func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo {
|
||||
currentVersion := strings.TrimSpace(node.AgentVersion)
|
||||
view := &NodeAgentReleaseInfo{
|
||||
@@ -418,9 +442,9 @@ func normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload {
|
||||
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
||||
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
||||
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||
payload.LastError = strings.TrimSpace(payload.LastError)
|
||||
payload.LastError = truncateForDatabase(payload.LastError, 16000)
|
||||
payload.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
|
||||
payload.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
|
||||
payload.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -428,6 +452,9 @@ func validateAgentNodePayload(payload AgentNodePayload) error {
|
||||
if payload.IP == "" {
|
||||
return errors.New("ip 不能为空")
|
||||
}
|
||||
if net.ParseIP(payload.IP) == nil {
|
||||
return errors.New("ip 格式无效")
|
||||
}
|
||||
if payload.AgentVersion == "" {
|
||||
return errors.New("agent_version 不能为空")
|
||||
}
|
||||
@@ -444,11 +471,11 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b
|
||||
node.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
||||
node.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
||||
node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
|
||||
node.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
|
||||
node.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000)
|
||||
node.Status = NodeStatusOnline
|
||||
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||
node.LastSeenAt = time.Now()
|
||||
node.LastError = strings.TrimSpace(payload.LastError)
|
||||
node.LastError = truncateForDatabase(payload.LastError, 16000)
|
||||
if !node.GeoManualOverride {
|
||||
applyGeoInfoFromIP(node, node.IP)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ type NodeObservabilityTrends struct {
|
||||
DiskIO24h []DiskIOTrendPoint `json:"disk_io_24h"`
|
||||
}
|
||||
|
||||
type NodeHealthEventCleanupResult struct {
|
||||
NodeID string `json:"node_id"`
|
||||
DeletedCount int64 `json:"deleted_count"`
|
||||
}
|
||||
|
||||
func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) {
|
||||
now := time.Now()
|
||||
node, err := model.GetNodeByID(id)
|
||||
@@ -105,6 +110,21 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
|
||||
}, nil
|
||||
}
|
||||
|
||||
func CleanupNodeHealthEvents(id uint) (*NodeHealthEventCleanupResult, error) {
|
||||
node, err := model.GetNodeByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deletedCount, err := model.DeleteNodeHealthEvents(node.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &NodeHealthEventCleanupResult{
|
||||
NodeID: node.NodeID,
|
||||
DeletedCount: deletedCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func latestMetricSnapshot(snapshots []*model.NodeMetricSnapshot) *model.NodeMetricSnapshot {
|
||||
for _, snapshot := range snapshots {
|
||||
if snapshot != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -327,6 +328,27 @@ func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportedNodeIPPrefersPublicRemoteAddr(t *testing.T) {
|
||||
resolved := ResolveReportedNodeIP("10.0.0.8", "198.51.100.20:9000")
|
||||
if resolved != "198.51.100.20" {
|
||||
t.Fatalf("expected public remote ip to override private reported ip, got %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportedNodeIPKeepsPublicReportedAddr(t *testing.T) {
|
||||
resolved := ResolveReportedNodeIP("8.8.8.8", "198.51.100.20:9000")
|
||||
if resolved != "8.8.8.8" {
|
||||
t.Fatalf("expected reported public ip to be preserved, got %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportedNodeIPKeepsPrivateReportedAddrWhenRemoteIsPrivate(t *testing.T) {
|
||||
resolved := ResolveReportedNodeIP("10.0.0.8", "172.16.1.10:9000")
|
||||
if resolved != "10.0.0.8" {
|
||||
t.Fatalf("expected private reported ip to be preserved when remote is also private, got %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
withFakeGeoIPProvider(t, &geoip.GeoInfo{
|
||||
@@ -677,6 +699,9 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
|
||||
Severity: NodeHealthSeverityCritical,
|
||||
Message: "reload failed",
|
||||
TriggeredAtUnix: time.Now().Add(-2 * time.Minute).Unix(),
|
||||
Metadata: map[string]string{
|
||||
"source": "runtime",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -731,6 +756,16 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
|
||||
if len(events) != 1 || events[0].EventType != "openresty_unhealthy" {
|
||||
t.Fatalf("unexpected active health events: %+v", events)
|
||||
}
|
||||
if events[0].MetadataJSON == "" {
|
||||
t.Fatal("expected metadata_json to persist")
|
||||
}
|
||||
var metadata map[string]string
|
||||
if err := json.Unmarshal([]byte(events[0].MetadataJSON), &metadata); err != nil {
|
||||
t.Fatalf("expected metadata_json to be valid json: %v", err)
|
||||
}
|
||||
if metadata["source"] != "runtime" {
|
||||
t.Fatalf("unexpected metadata json: %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
|
||||
@@ -868,6 +903,15 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
|
||||
TopDomains: map[string]int64{"edge.example.com": 40},
|
||||
SourceCountries: map[string]int64{"CN": 20},
|
||||
},
|
||||
AccessLogs: []AgentNodeAccessLog{
|
||||
{
|
||||
LoggedAtUnix: now.Add(-110 * time.Second).Unix(),
|
||||
RemoteAddr: "203.0.113.21",
|
||||
Host: "edge.example.com",
|
||||
Path: "/buffered",
|
||||
StatusCode: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -889,6 +933,18 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("expected replay dedupe to keep report count stable, got %+v", reports)
|
||||
}
|
||||
accessLogs, err = model.ListNodeAccessLogs(model.NodeAccessLogQuery{
|
||||
NodeID: node.NodeID,
|
||||
Since: time.Time{},
|
||||
Page: 0,
|
||||
PageSize: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected node access logs query to succeed after replay: %v", err)
|
||||
}
|
||||
if len(accessLogs) != 1 {
|
||||
t.Fatalf("expected replay dedupe to keep access log count stable, got %+v", accessLogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccessLogsUsesPagination(t *testing.T) {
|
||||
@@ -908,7 +964,7 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := model.DB.Create([]*model.NodeAccessLog{
|
||||
for _, item := range []*model.NodeAccessLog{
|
||||
{
|
||||
NodeID: node.NodeID,
|
||||
LoggedAt: now.Add(-10 * time.Second),
|
||||
@@ -936,8 +992,10 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
|
||||
Path: "/three",
|
||||
StatusCode: 502,
|
||||
},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
} {
|
||||
if err := model.DB.Create(item).Error; err != nil {
|
||||
t.Fatalf("failed to seed access logs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
pageOne, err := ListAccessLogs(AccessLogQuery{
|
||||
@@ -1201,6 +1259,66 @@ func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupNodeHealthEvents(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-health-cleanup",
|
||||
Name: "health-cleanup-edge",
|
||||
IP: "10.0.0.72",
|
||||
AgentToken: "token-health-cleanup",
|
||||
AgentVersion: "v0.6.0",
|
||||
NginxVersion: "1.27.1.2",
|
||||
Status: NodeStatusOnline,
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to insert node: %v", err)
|
||||
}
|
||||
|
||||
resolvedAt := time.Now().Add(-4 * time.Minute)
|
||||
if err := model.DB.Create(&model.NodeHealthEvent{
|
||||
NodeID: node.NodeID,
|
||||
EventType: "sync_error",
|
||||
Severity: NodeHealthSeverityWarning,
|
||||
Status: NodeHealthEventStatusActive,
|
||||
Message: "checksum mismatch",
|
||||
FirstTriggeredAt: time.Now().Add(-2 * time.Minute),
|
||||
LastTriggeredAt: time.Now().Add(-time.Minute),
|
||||
ReportedAt: time.Now().Add(-time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to insert first node health event: %v", err)
|
||||
}
|
||||
if err := model.DB.Create(&model.NodeHealthEvent{
|
||||
NodeID: node.NodeID,
|
||||
EventType: "openresty_down",
|
||||
Severity: NodeHealthSeverityCritical,
|
||||
Status: NodeHealthEventStatusResolved,
|
||||
Message: "openresty exited unexpectedly",
|
||||
FirstTriggeredAt: time.Now().Add(-10 * time.Minute),
|
||||
LastTriggeredAt: time.Now().Add(-5 * time.Minute),
|
||||
ReportedAt: time.Now().Add(-5 * time.Minute),
|
||||
ResolvedAt: &resolvedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to insert second node health event: %v", err)
|
||||
}
|
||||
|
||||
result, err := CleanupNodeHealthEvents(node.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupNodeHealthEvents failed: %v", err)
|
||||
}
|
||||
if result.NodeID != node.NodeID || result.DeletedCount != 2 {
|
||||
t.Fatalf("unexpected cleanup result: %+v", result)
|
||||
}
|
||||
|
||||
events, err := model.ListNodeHealthEvents(node.NodeID, false, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list node health events after cleanup: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("expected node health events to be removed, got %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardOverview(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
@@ -1344,7 +1462,7 @@ func TestGetDashboardOverview(t *testing.T) {
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("failed to insert node b traffic report: %v", err)
|
||||
}
|
||||
if err := model.DB.Create([]*model.NodeAccessLog{
|
||||
for _, item := range []*model.NodeAccessLog{
|
||||
{
|
||||
NodeID: "node-dashboard-a",
|
||||
LoggedAt: now.Add(-30 * time.Minute),
|
||||
@@ -1372,8 +1490,10 @@ func TestGetDashboardOverview(t *testing.T) {
|
||||
Path: "/edge",
|
||||
StatusCode: 502,
|
||||
},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed dashboard access logs: %v", err)
|
||||
} {
|
||||
if err := model.DB.Create(item).Error; err != nil {
|
||||
t.Fatalf("failed to seed dashboard access logs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&model.NodeHealthEvent{
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
NodeHealthSeverityWarning = "warning"
|
||||
NodeHealthSeverityCritical = "critical"
|
||||
nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
|
||||
nodeAccessLogPathMaxLength = 100
|
||||
)
|
||||
|
||||
type AgentNodeSystemProfile struct {
|
||||
@@ -151,7 +152,6 @@ func persistNodeSystemProfile(tx *gorm.DB, nodeID string, profile *AgentNodeSyst
|
||||
TotalDiskBytes: profile.TotalDiskBytes,
|
||||
UptimeSeconds: profile.UptimeSeconds,
|
||||
ReportedAt: timeFromUnix(profile.ReportedAtUnix, reportedAt),
|
||||
RawJSON: marshalJSON(profile),
|
||||
}
|
||||
return tx.Model(&model.NodeSystemProfile{}).Where("node_id = ?", nodeID).Assign(record).FirstOrCreate(record).Error
|
||||
}
|
||||
@@ -175,9 +175,15 @@ func persistNodeMetricSnapshot(tx *gorm.DB, nodeID string, snapshot *AgentNodeMe
|
||||
OpenrestyRxBytes: snapshot.OpenrestyRxBytes,
|
||||
OpenrestyTxBytes: snapshot.OpenrestyTxBytes,
|
||||
OpenrestyConnections: snapshot.OpenrestyConnections,
|
||||
RawJSON: marshalJSON(snapshot),
|
||||
}
|
||||
return tx.Where("node_id = ? AND captured_at = ?", nodeID, record.CapturedAt).Assign(record).FirstOrCreate(record).Error
|
||||
exists, err := model.NodeMetricSnapshotExists(tx, nodeID, record.CapturedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(record).Error
|
||||
}
|
||||
|
||||
func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTrafficReport, reportedAt time.Time) error {
|
||||
@@ -197,9 +203,15 @@ func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTraff
|
||||
StatusCodesJSON: marshalJSON(report.StatusCodes),
|
||||
TopDomainsJSON: marshalJSON(report.TopDomains),
|
||||
SourceCountriesJSON: marshalJSON(report.SourceCountries),
|
||||
RawJSON: marshalJSON(report),
|
||||
}
|
||||
return tx.Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, record.WindowStartedAt, record.WindowEndedAt).Assign(record).FirstOrCreate(record).Error
|
||||
exists, err := model.NodeRequestReportExists(tx, nodeID, record.WindowStartedAt, record.WindowEndedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(record).Error
|
||||
}
|
||||
|
||||
func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog, reportedAt time.Time) error {
|
||||
@@ -220,26 +232,25 @@ func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog
|
||||
RemoteAddr: strings.TrimSpace(item.RemoteAddr),
|
||||
Region: "",
|
||||
Host: strings.TrimSpace(item.Host),
|
||||
Path: strings.TrimSpace(item.Path),
|
||||
Path: truncateForDatabase(strings.TrimSpace(item.Path), nodeAccessLogPathMaxLength),
|
||||
StatusCode: item.StatusCode,
|
||||
RawJSON: marshalJSON(item),
|
||||
}
|
||||
if resolver != nil {
|
||||
record.Region = resolver.Resolve(record.RemoteAddr)
|
||||
}
|
||||
if err := tx.Where(
|
||||
"node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?",
|
||||
nodeID,
|
||||
record.LoggedAt,
|
||||
record.RemoteAddr,
|
||||
record.Host,
|
||||
record.Path,
|
||||
record.StatusCode,
|
||||
).Assign(record).FirstOrCreate(record).Error; err != nil {
|
||||
exists, err := model.NodeAccessLogExists(tx, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Where("node_id = ? AND logged_at < ?", nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow)).Delete(&model.NodeAccessLog{}).Error
|
||||
_, err = model.DeleteNodeAccessLogsByNodeBefore(tx, nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow))
|
||||
return err
|
||||
}
|
||||
|
||||
func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHealthEvent, reportedAt time.Time) error {
|
||||
@@ -271,10 +282,10 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea
|
||||
triggeredAt := timeFromUnix(event.TriggeredAtUnix, reportedAt)
|
||||
if existing, ok := activeByType[eventType]; ok {
|
||||
existing.Severity = event.Severity
|
||||
existing.Message = strings.TrimSpace(event.Message)
|
||||
existing.Message = normalizeHealthEventMessage(event.Message)
|
||||
existing.LastTriggeredAt = triggeredAt
|
||||
existing.ReportedAt = reportedAt
|
||||
existing.RawJSON = marshalJSON(event)
|
||||
existing.MetadataJSON = marshalJSON(event.Metadata)
|
||||
existing.ResolvedAt = nil
|
||||
if err := tx.Save(existing).Error; err != nil {
|
||||
return err
|
||||
@@ -286,11 +297,11 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea
|
||||
EventType: eventType,
|
||||
Severity: event.Severity,
|
||||
Status: NodeHealthEventStatusActive,
|
||||
Message: strings.TrimSpace(event.Message),
|
||||
Message: normalizeHealthEventMessage(event.Message),
|
||||
FirstTriggeredAt: triggeredAt,
|
||||
LastTriggeredAt: triggeredAt,
|
||||
ReportedAt: reportedAt,
|
||||
RawJSON: marshalJSON(event),
|
||||
MetadataJSON: marshalJSON(event.Metadata),
|
||||
}
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return err
|
||||
@@ -330,6 +341,10 @@ func normalizeHealthSeverity(severity string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHealthEventMessage(message string) string {
|
||||
return truncateForDatabase(message, 4096)
|
||||
}
|
||||
|
||||
func timeFromUnix(unixSeconds int64, fallback time.Time) time.Time {
|
||||
if unixSeconds <= 0 {
|
||||
return fallback
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"openflare/model"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OriginInput struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type OriginRouteSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OriginView struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
RouteCount int64 `json:"route_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OriginDetailView struct {
|
||||
OriginView
|
||||
Routes []OriginRouteSummary `json:"routes"`
|
||||
}
|
||||
|
||||
func ListOrigins() ([]OriginView, error) {
|
||||
origins, err := model.ListOrigins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildOriginViews(origins)
|
||||
}
|
||||
|
||||
func GetOriginDetail(id uint) (*OriginDetailView, error) {
|
||||
origin, err := model.GetOriginByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views, err := buildOriginViews([]*model.Origin{origin})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes, err := model.ListProxyRoutesByOriginID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]OriginRouteSummary, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
items = append(items, OriginRouteSummary{
|
||||
ID: route.ID,
|
||||
Domain: route.Domain,
|
||||
OriginURL: route.OriginURL,
|
||||
Enabled: route.Enabled,
|
||||
UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
return items[i].Domain < items[j].Domain
|
||||
})
|
||||
detail := &OriginDetailView{
|
||||
OriginView: views[0],
|
||||
Routes: items,
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func CreateOrigin(input OriginInput) (*model.Origin, error) {
|
||||
origin, err := buildOrigin(nil, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = origin.Insert(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New("源站地址已存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return origin, nil
|
||||
}
|
||||
|
||||
func UpdateOrigin(id uint, input OriginInput) (*model.Origin, error) {
|
||||
origin, err := model.GetOriginByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previousAddress := origin.Address
|
||||
nextOrigin, err := buildOrigin(origin, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(nextOrigin).Error; err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return errors.New("源站地址已存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if previousAddress == nextOrigin.Address {
|
||||
return nil
|
||||
}
|
||||
return updateRoutesForOriginAddress(tx, nextOrigin.ID, nextOrigin.Address)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nextOrigin, nil
|
||||
}
|
||||
|
||||
func DeleteOrigin(id uint) error {
|
||||
routes, err := model.ListProxyRoutesByOriginID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(routes) > 0 {
|
||||
return errors.New("该源站仍被规则引用,无法删除")
|
||||
}
|
||||
origin, err := model.GetOriginByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return origin.Delete()
|
||||
}
|
||||
|
||||
func buildOrigin(existing *model.Origin, input OriginInput) (*model.Origin, error) {
|
||||
address := normalizeOriginAddress(input.Address)
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
existing = &model.Origin{}
|
||||
}
|
||||
existing.Address = address
|
||||
existing.Name = normalizeOriginName(input.Name, address)
|
||||
existing.Remark = strings.TrimSpace(input.Remark)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func getOrCreateOriginByAddress(address string) (*model.Origin, error) {
|
||||
normalizedAddress := normalizeOriginAddress(address)
|
||||
if err := validateOriginAddress(normalizedAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := model.GetOriginByAddress(normalizedAddress)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
origin := &model.Origin{
|
||||
Name: normalizedAddress,
|
||||
Address: normalizedAddress,
|
||||
Remark: "",
|
||||
}
|
||||
if err := origin.Insert(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return model.GetOriginByAddress(normalizedAddress)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return origin, nil
|
||||
}
|
||||
|
||||
func updateRoutesForOriginAddress(tx *gorm.DB, originID uint, address string) error {
|
||||
var routes []*model.ProxyRoute
|
||||
if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil {
|
||||
return fmt.Errorf("query routes for origin update failed: %w", err)
|
||||
}
|
||||
for _, route := range routes {
|
||||
rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err)
|
||||
}
|
||||
upstreams := make([]string, 0)
|
||||
if strings.TrimSpace(route.Upstreams) != "" {
|
||||
if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil {
|
||||
return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err)
|
||||
}
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
upstreams = append(upstreams, rewrittenOriginURL)
|
||||
} else {
|
||||
upstreams[0] = rewrittenOriginURL
|
||||
}
|
||||
upstreamsJSON, err := json.Marshal(upstreams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err)
|
||||
}
|
||||
if err := tx.Model(&model.ProxyRoute{}).
|
||||
Where("id = ?", route.ID).
|
||||
Updates(map[string]any{
|
||||
"origin_url": rewrittenOriginURL,
|
||||
"upstreams": string(upstreamsJSON),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update route %d origin address failed: %w", route.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildOriginViews(origins []*model.Origin) ([]OriginView, error) {
|
||||
countRows, err := model.ListOriginRouteCounts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countMap := make(map[uint]int64, len(countRows))
|
||||
for _, row := range countRows {
|
||||
countMap[row.OriginID] = row.RouteCount
|
||||
}
|
||||
views := make([]OriginView, 0, len(origins))
|
||||
for _, origin := range origins {
|
||||
views = append(views, OriginView{
|
||||
ID: origin.ID,
|
||||
Name: origin.Name,
|
||||
Address: origin.Address,
|
||||
Remark: origin.Remark,
|
||||
RouteCount: countMap[origin.ID],
|
||||
CreatedAt: origin.CreatedAt,
|
||||
UpdatedAt: origin.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func normalizeOriginAddress(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func validateOriginAddress(address string) error {
|
||||
if address == "" {
|
||||
return errors.New("源站地址不能为空")
|
||||
}
|
||||
if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") {
|
||||
return errors.New("源站地址无需包含 IPv6 方括号")
|
||||
}
|
||||
if ip := net.ParseIP(address); ip != nil {
|
||||
return nil
|
||||
}
|
||||
if len(address) > 253 {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
labels := strings.Split(address, ".")
|
||||
for _, label := range labels {
|
||||
if len(label) == 0 || len(label) > 63 {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
for _, r := range label {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||
continue
|
||||
}
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOriginName(name string, address string) string {
|
||||
normalized := strings.TrimSpace(name)
|
||||
if normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func normalizeOriginPort(raw string) (string, error) {
|
||||
port := strings.TrimSpace(raw)
|
||||
if port == "" {
|
||||
return "", errors.New("端口不能为空")
|
||||
}
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return "", errors.New("端口格式不合法")
|
||||
}
|
||||
return strconv.Itoa(value), nil
|
||||
}
|
||||
|
||||
func normalizeOriginScheme(raw string) (string, error) {
|
||||
scheme := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch scheme {
|
||||
case "http", "https":
|
||||
return scheme, nil
|
||||
default:
|
||||
return "", errors.New("源站协议仅支持 http 或 https")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOriginURI(raw string) (string, error) {
|
||||
uri := strings.TrimSpace(raw)
|
||||
if uri == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(uri, "://") {
|
||||
return "", errors.New("源站路径不能包含协议")
|
||||
}
|
||||
if !strings.HasPrefix(uri, "/") && !strings.HasPrefix(uri, "?") {
|
||||
return "", errors.New("源站路径需以 / 或 ? 开头")
|
||||
}
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func formatOriginHost(address string, port string) string {
|
||||
if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") {
|
||||
return net.JoinHostPort(address, port)
|
||||
}
|
||||
return net.JoinHostPort(address, port)
|
||||
}
|
||||
|
||||
func buildOriginURLFromParts(
|
||||
scheme string,
|
||||
address string,
|
||||
port string,
|
||||
uri string,
|
||||
) (string, error) {
|
||||
normalizedScheme, err := normalizeOriginScheme(scheme)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalizedAddress := normalizeOriginAddress(address)
|
||||
if err := validateOriginAddress(normalizedAddress); err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalizedPort, err := normalizeOriginPort(port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalizedURI, err := normalizeOriginURI(uri)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parsed := &url.URL{
|
||||
Scheme: normalizedScheme,
|
||||
Host: formatOriginHost(normalizedAddress, normalizedPort),
|
||||
}
|
||||
if normalizedURI != "" {
|
||||
if strings.HasPrefix(normalizedURI, "?") {
|
||||
parsed.RawQuery = strings.TrimPrefix(normalizedURI, "?")
|
||||
} else {
|
||||
pathQuery := strings.SplitN(normalizedURI, "?", 2)
|
||||
parsed.Path = pathQuery[0]
|
||||
if len(pathQuery) > 1 {
|
||||
parsed.RawQuery = pathQuery[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func extractOriginAddress(rawURL string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("源站地址格式不合法: %w", err)
|
||||
}
|
||||
address := normalizeOriginAddress(parsed.Hostname())
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("源站地址格式不合法: %w", err)
|
||||
}
|
||||
address := normalizeOriginAddress(newAddress)
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
return "", errors.New("源站地址缺少端口")
|
||||
}
|
||||
parsed.Host = formatOriginHost(address, port)
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func splitOriginURL(rawURL string) (scheme string, address string, port string, uri string, err error) {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
scheme = parsed.Scheme
|
||||
address = normalizeOriginAddress(parsed.Hostname())
|
||||
port = parsed.Port()
|
||||
uri = parsed.EscapedPath()
|
||||
if uri == "" {
|
||||
uri = parsed.Path
|
||||
}
|
||||
if parsed.RawQuery != "" {
|
||||
if uri == "" {
|
||||
uri = "?" + parsed.RawQuery
|
||||
} else {
|
||||
uri = uri + "?" + parsed.RawQuery
|
||||
}
|
||||
}
|
||||
return scheme, address, port, uri, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"openflare/model"
|
||||
)
|
||||
|
||||
func TestCreateProxyRouteStructuredOriginAutoCreatesOrigin(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
OriginScheme: "https",
|
||||
OriginAddress: "origin.internal",
|
||||
OriginPort: "8443",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
if route.OriginID == nil || *route.OriginID == 0 {
|
||||
t.Fatal("expected route to be linked with an auto-created origin")
|
||||
}
|
||||
if route.OriginURL != "https://origin.internal:8443" {
|
||||
t.Fatalf("unexpected route origin url: %s", route.OriginURL)
|
||||
}
|
||||
|
||||
origin, err := model.GetOriginByID(*route.OriginID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOriginByID failed: %v", err)
|
||||
}
|
||||
if origin.Address != "origin.internal" {
|
||||
t.Fatalf("unexpected origin address: %s", origin.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOriginRewritesLinkedRouteOriginURL(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
origin, err := CreateOrigin(OriginInput{
|
||||
Name: "primary-origin",
|
||||
Address: "origin-a.internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrigin failed: %v", err)
|
||||
}
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
OriginID: &origin.ID,
|
||||
OriginScheme: "https",
|
||||
OriginPort: "8443",
|
||||
OriginURI: "/api",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
|
||||
updatedOrigin, err := UpdateOrigin(origin.ID, OriginInput{
|
||||
Name: origin.Name,
|
||||
Address: "origin-c.internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateOrigin failed: %v", err)
|
||||
}
|
||||
if updatedOrigin.Address != "origin-c.internal" {
|
||||
t.Fatalf("unexpected updated origin address: %s", updatedOrigin.Address)
|
||||
}
|
||||
|
||||
reloadedRoute, err := model.GetProxyRouteByID(route.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProxyRouteByID failed: %v", err)
|
||||
}
|
||||
if reloadedRoute.OriginURL != "https://origin-c.internal:8443/api" {
|
||||
t.Fatalf("expected route origin url to be rewritten, got %s", reloadedRoute.OriginURL)
|
||||
}
|
||||
if reloadedRoute.Upstreams == "" || reloadedRoute.Upstreams == "[]" {
|
||||
t.Fatalf("expected route upstreams to be preserved, got %s", reloadedRoute.Upstreams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOriginRejectsReferencedOrigin(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
origin, err := CreateOrigin(OriginInput{
|
||||
Address: "origin-a.internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrigin failed: %v", err)
|
||||
}
|
||||
if _, err = CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
OriginID: &origin.ID,
|
||||
OriginScheme: "https",
|
||||
OriginPort: "443",
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
|
||||
if err = DeleteOrigin(origin.ID); err == nil {
|
||||
t.Fatal("expected referenced origin deletion to fail")
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,19 @@ import (
|
||||
"openflare/model"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
const (
|
||||
proxyRouteCachePolicyURL = "url"
|
||||
proxyRouteCachePolicySuffix = "suffix"
|
||||
proxyRouteCachePolicyPathPrefix = "path_prefix"
|
||||
proxyRouteCachePolicyPathExact = "path_exact"
|
||||
)
|
||||
|
||||
type ProxyRouteCustomHeaderInput struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
@@ -18,12 +27,21 @@ type ProxyRouteCustomHeaderInput struct {
|
||||
|
||||
type ProxyRouteInput struct {
|
||||
Domain string `json:"domain"`
|
||||
OriginID *uint `json:"origin_id"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
OriginScheme string `json:"origin_scheme"`
|
||||
OriginAddress string `json:"origin_address"`
|
||||
OriginPort string `json:"origin_port"`
|
||||
OriginURI string `json:"origin_uri"`
|
||||
OriginHost string `json:"origin_host"`
|
||||
Upstreams []string `json:"upstreams"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
CertID *uint `json:"cert_id"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
CacheEnabled bool `json:"cache_enabled"`
|
||||
CachePolicy string `json:"cache_policy"`
|
||||
CacheRules []string `json:"cache_rules"`
|
||||
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
@@ -74,13 +92,33 @@ func DeleteProxyRoute(id uint) error {
|
||||
|
||||
func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) {
|
||||
domain := strings.ToLower(strings.TrimSpace(input.Domain))
|
||||
originURL := strings.TrimSpace(input.OriginURL)
|
||||
originURL, originID, err := resolveProxyRoutePrimaryOrigin(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
originHost := strings.TrimSpace(input.OriginHost)
|
||||
remark := strings.TrimSpace(input.Remark)
|
||||
upstreams, err := normalizeUpstreams(originURL, input.Upstreams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cachePolicy := strings.TrimSpace(input.CachePolicy)
|
||||
cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customHeaders, err := normalizeCustomHeaders(input.CustomHeaders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cacheRulesJSON, err := json.Marshal(cacheRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
upstreamsJSON, err := json.Marshal(upstreams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customHeadersJSON, err := json.Marshal(customHeaders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -91,9 +129,6 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
||||
if strings.Contains(domain, "://") || strings.Contains(domain, "/") {
|
||||
return nil, errors.New("域名格式不合法")
|
||||
}
|
||||
if err := validateOriginURL(originURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateOriginHost(originHost); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -116,17 +151,94 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
||||
route = &model.ProxyRoute{}
|
||||
}
|
||||
route.Domain = domain
|
||||
route.OriginURL = originURL
|
||||
route.OriginID = originID
|
||||
route.OriginURL = upstreams[0]
|
||||
route.OriginHost = originHost
|
||||
route.Upstreams = string(upstreamsJSON)
|
||||
route.Enabled = input.Enabled
|
||||
route.EnableHTTPS = input.EnableHTTPS
|
||||
route.CertID = input.CertID
|
||||
route.RedirectHTTP = input.RedirectHTTP
|
||||
route.CacheEnabled = input.CacheEnabled
|
||||
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
|
||||
route.CacheRules = string(cacheRulesJSON)
|
||||
route.CustomHeaders = string(customHeadersJSON)
|
||||
route.Remark = remark
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error) {
|
||||
if hasStructuredOriginInput(input) {
|
||||
scheme, err := normalizeOriginScheme(input.OriginScheme)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
port, err := normalizeOriginPort(input.OriginPort)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
uri, err := normalizeOriginURI(input.OriginURI)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if input.OriginID != nil && *input.OriginID != 0 {
|
||||
origin, err := model.GetOriginByID(*input.OriginID)
|
||||
if err != nil {
|
||||
return "", nil, errors.New("所选源站不存在")
|
||||
}
|
||||
originURL, err := buildOriginURLFromParts(
|
||||
scheme,
|
||||
origin.Address,
|
||||
port,
|
||||
uri,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return originURL, &origin.ID, nil
|
||||
}
|
||||
|
||||
address := normalizeOriginAddress(input.OriginAddress)
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
originURL, err := buildOriginURLFromParts(scheme, address, port, uri)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
origin, err := getOrCreateOriginByAddress(address)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return originURL, &origin.ID, nil
|
||||
}
|
||||
|
||||
originURL := strings.TrimSpace(input.OriginURL)
|
||||
if originURL == "" {
|
||||
return "", nil, errors.New("源站地址不能为空")
|
||||
}
|
||||
address, err := extractOriginAddress(originURL)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
origin, findErr := model.GetOriginByAddress(address)
|
||||
if findErr == nil {
|
||||
return originURL, &origin.ID, nil
|
||||
}
|
||||
if !errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||
return "", nil, findErr
|
||||
}
|
||||
return originURL, nil, nil
|
||||
}
|
||||
|
||||
func hasStructuredOriginInput(input ProxyRouteInput) bool {
|
||||
return (input.OriginID != nil && *input.OriginID != 0) ||
|
||||
strings.TrimSpace(input.OriginScheme) != "" ||
|
||||
strings.TrimSpace(input.OriginAddress) != "" ||
|
||||
strings.TrimSpace(input.OriginPort) != "" ||
|
||||
strings.TrimSpace(input.OriginURI) != ""
|
||||
}
|
||||
|
||||
func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRouteCustomHeaderInput, error) {
|
||||
if len(headers) == 0 {
|
||||
return []ProxyRouteCustomHeaderInput{}, nil
|
||||
@@ -155,6 +267,59 @@ func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRoute
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) {
|
||||
candidates := make([]string, 0, len(upstreams)+1)
|
||||
if strings.TrimSpace(originURL) != "" {
|
||||
candidates = append(candidates, originURL)
|
||||
}
|
||||
candidates = append(candidates, upstreams...)
|
||||
trimmed := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
item := strings.TrimSpace(candidate)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
trimmed = append(trimmed, item)
|
||||
}
|
||||
unique := make([]string, 0, len(trimmed))
|
||||
seen := make(map[string]struct{}, len(trimmed))
|
||||
for _, item := range trimmed {
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
unique = append(unique, item)
|
||||
}
|
||||
normalized := make([]string, 0, len(unique))
|
||||
var scheme string
|
||||
multiUpstream := len(unique) > 1
|
||||
for _, item := range unique {
|
||||
if err := validateOriginURL(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(item)
|
||||
if err != nil {
|
||||
return nil, errors.New("源站地址格式不合法")
|
||||
}
|
||||
if multiUpstream && parsed.Path != "" && parsed.Path != "/" {
|
||||
return nil, errors.New("多上游模式暂不支持带路径的源站地址")
|
||||
}
|
||||
if multiUpstream && parsed.RawQuery != "" {
|
||||
return nil, errors.New("多上游模式暂不支持带查询参数的源站地址")
|
||||
}
|
||||
if scheme == "" {
|
||||
scheme = parsed.Scheme
|
||||
} else if scheme != parsed.Scheme {
|
||||
return nil, errors.New("同一规则的多个上游必须使用相同协议")
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil, errors.New("至少填写一个上游地址")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
@@ -167,6 +332,120 @@ func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error
|
||||
return normalizeCustomHeaders(headers)
|
||||
}
|
||||
|
||||
func normalizeCachePolicy(enabled bool, raw string) string {
|
||||
if !enabled {
|
||||
return ""
|
||||
}
|
||||
policy := strings.TrimSpace(raw)
|
||||
if policy == "" {
|
||||
return proxyRouteCachePolicyURL
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]string, error) {
|
||||
if !enabled {
|
||||
return []string{}, nil
|
||||
}
|
||||
policy := normalizeCachePolicy(enabled, rawPolicy)
|
||||
switch policy {
|
||||
case proxyRouteCachePolicyURL:
|
||||
return []string{}, nil
|
||||
case proxyRouteCachePolicySuffix:
|
||||
return normalizeCacheSuffixRules(rules)
|
||||
case proxyRouteCachePolicyPathPrefix:
|
||||
return normalizeCachePathRules(rules, true)
|
||||
case proxyRouteCachePolicyPathExact:
|
||||
return normalizeCachePathRules(rules, false)
|
||||
default:
|
||||
return nil, errors.New("缓存策略不支持")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCacheSuffixRules(rules []string) ([]string, error) {
|
||||
normalized := make([]string, 0, len(rules))
|
||||
seen := make(map[string]struct{}, len(rules))
|
||||
for _, rule := range rules {
|
||||
item := strings.TrimSpace(strings.TrimPrefix(rule, "."))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(item, "/\\ \t\r\n") {
|
||||
return nil, errors.New("缓存后缀格式不合法")
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil, errors.New("按后缀缓存时至少填写一个后缀")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeCachePathRules(rules []string, allowPrefix bool) ([]string, error) {
|
||||
normalized := make([]string, 0, len(rules))
|
||||
seen := make(map[string]struct{}, len(rules))
|
||||
for _, rule := range rules {
|
||||
item := strings.TrimSpace(rule)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(item, "/") || strings.Contains(item, "://") || strings.ContainsAny(item, " \t\r\n") {
|
||||
return nil, errors.New("缓存路径规则格式不合法")
|
||||
}
|
||||
if !allowPrefix && strings.HasSuffix(item, "/") && len(item) > 1 {
|
||||
item = strings.TrimRight(item, "/")
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
if allowPrefix {
|
||||
return nil, errors.New("按路径前缀缓存时至少填写一个路径")
|
||||
}
|
||||
return nil, errors.New("按精确路径缓存时至少填写一个路径")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func decodeStoredCacheRules(raw string) ([]string, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
var rules []string
|
||||
if err := json.Unmarshal([]byte(text), &rules); err != nil {
|
||||
return nil, errors.New("缓存规则格式不合法")
|
||||
}
|
||||
normalized := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
item := strings.TrimSpace(rule)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func decodeStoredUpstreams(raw string, fallbackOriginURL string) ([]string, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return normalizeUpstreams(fallbackOriginURL, nil)
|
||||
}
|
||||
var upstreams []string
|
||||
if err := json.Unmarshal([]byte(text), &upstreams); err != nil {
|
||||
return nil, errors.New("上游配置格式不合法")
|
||||
}
|
||||
return normalizeUpstreams(fallbackOriginURL, upstreams)
|
||||
}
|
||||
|
||||
func validateOriginURL(raw string) error {
|
||||
if raw == "" {
|
||||
return errors.New("源站地址不能为空")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeIP(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
ip := net.ParseIP(trimmed)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4.String()
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func NormalizeRemoteAddr(remoteAddr string) string {
|
||||
trimmed := strings.TrimSpace(remoteAddr)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(trimmed); err == nil {
|
||||
return NormalizeIP(host)
|
||||
}
|
||||
return NormalizeIP(trimmed)
|
||||
}
|
||||
|
||||
func IsPublic(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
ip = ipv4
|
||||
}
|
||||
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsPublicString(raw string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
return IsPublic(ip)
|
||||
}
|
||||
|
||||
func Score(ip net.IP) int {
|
||||
if ip == nil {
|
||||
return -1
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
ip = ipv4
|
||||
}
|
||||
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return -1
|
||||
}
|
||||
if IsPublic(ip) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeIP(t *testing.T) {
|
||||
if got := NormalizeIP(" 8.8.8.8 "); got != "8.8.8.8" {
|
||||
t.Fatalf("unexpected normalized ipv4: %q", got)
|
||||
}
|
||||
if got := NormalizeIP("[::1]"); got != "" {
|
||||
t.Fatalf("expected invalid bracketed host to be rejected, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRemoteAddr(t *testing.T) {
|
||||
if got := NormalizeRemoteAddr("203.0.113.10:8443"); got != "203.0.113.10" {
|
||||
t.Fatalf("unexpected remote addr normalization: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublic(t *testing.T) {
|
||||
if !IsPublic(net.ParseIP("8.8.8.8")) {
|
||||
t.Fatal("expected public ip to be detected")
|
||||
}
|
||||
if IsPublic(net.ParseIP("10.0.0.8")) {
|
||||
t.Fatal("expected private ip to be rejected")
|
||||
}
|
||||
if IsPublic(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatal("expected loopback ip to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore(t *testing.T) {
|
||||
if got := Score(net.ParseIP("8.8.8.8")); got != 2 {
|
||||
t.Fatalf("unexpected score for public ip: %d", got)
|
||||
}
|
||||
if got := Score(net.ParseIP("10.0.0.8")); got != 1 {
|
||||
t.Fatalf("unexpected score for private ip: %d", got)
|
||||
}
|
||||
if got := Score(net.ParseIP("127.0.0.1")); got != -1 {
|
||||
t.Fatalf("unexpected score for loopback ip: %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { OriginDetailPage } from '@/features/origins/components/origin-detail-page';
|
||||
|
||||
export default function OriginDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <OriginDetailPage originId={searchParams.get('id') ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OriginsPage } from '@/features/origins/components/origins-page';
|
||||
|
||||
export default function OriginRoute() {
|
||||
return <OriginsPage />;
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
@import '../styles/tokens.css';
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import '../styles/tokens.css';
|
||||
@import 'tailwindcss';
|
||||
@import '@heroui/styles';
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
html {
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--body-background);
|
||||
@@ -28,17 +33,141 @@ body {
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
::selection {
|
||||
background: var(--selection-background);
|
||||
color: var(--selection-foreground);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import type { ReactNode } from 'react';
|
||||
import { AppProviders } from '@/components/providers/app-providers';
|
||||
import { getThemeInitScript } from '@/lib/theme/theme';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
import './globals.css';
|
||||
import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'OpenFlare 控制台',
|
||||
@@ -22,7 +26,7 @@ interface RootLayoutProps {
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang='zh-CN' suppressHydrationWarning>
|
||||
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
|
||||
<body>
|
||||
<Script id='theme-init' strategy='beforeInteractive'>
|
||||
{getThemeInitScript()}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -47,6 +47,13 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
||||
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
|
||||
</svg>
|
||||
);
|
||||
case 'origin':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
|
||||
<circle cx="12" cy="10.1" r="1.9" />
|
||||
</svg>
|
||||
);
|
||||
case 'domain':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type DrawerDirection = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
type DrawerContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
direction: DrawerDirection;
|
||||
};
|
||||
|
||||
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
||||
|
||||
function useDrawerContext() {
|
||||
const context = useContext(DrawerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Drawer components must be used within Drawer.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function renderWithOptionalChild(
|
||||
child: ReactNode,
|
||||
props: Record<string, unknown>,
|
||||
fallback: ReactNode,
|
||||
) {
|
||||
if (isValidElement(child)) {
|
||||
return cloneElement(child as ReactElement, props);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
children,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
direction = 'bottom',
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
direction?: DrawerDirection;
|
||||
title?: string;
|
||||
description?: string;
|
||||
footer?: ReactNode;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const resolvedOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const value = useMemo<DrawerContextValue>(
|
||||
() => ({
|
||||
open: resolvedOpen,
|
||||
direction,
|
||||
setOpen: (nextOpen) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
}),
|
||||
[direction, isControlled, onOpenChange, resolvedOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={value}>
|
||||
{title || description || footer ? (
|
||||
<DrawerContent
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'w-full md:w-[50vw] md:max-w-none',
|
||||
direction === 'right' || direction === 'left' ? '' : 'max-h-[85vh]',
|
||||
)}
|
||||
>
|
||||
<DrawerHeader className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
{title ? <DrawerTitle>{title}</DrawerTitle> : null}
|
||||
{description ? (
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => value.setOpen(false)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="关闭抽屉"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <DrawerFooter>{footer}</DrawerFooter> : null}
|
||||
</DrawerContent>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</DrawerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerTrigger({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(true),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerClose({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerContent({
|
||||
children,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { open, setOpen, direction } = useDrawerContext();
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionClassName =
|
||||
direction === 'right'
|
||||
? 'inset-y-0 right-0 h-full border-l'
|
||||
: direction === 'left'
|
||||
? 'inset-y-0 left-0 h-full border-r'
|
||||
: direction === 'top'
|
||||
? 'inset-x-0 top-0 border-b'
|
||||
: 'inset-x-0 bottom-0 border-t';
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="关闭抽屉"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||
aria-describedby={descriptionId}
|
||||
className={cn(
|
||||
'absolute flex w-full flex-col overflow-hidden border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||
positionClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DrawerMetaContext.Provider value={{ titleId, descriptionId }}>
|
||||
{children}
|
||||
</DrawerMetaContext.Provider>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type DrawerMetaContextValue = {
|
||||
titleId: string;
|
||||
descriptionId: string;
|
||||
};
|
||||
|
||||
const DrawerMetaContext = createContext<DrawerMetaContextValue | null>(null);
|
||||
|
||||
function useDrawerMetaContext() {
|
||||
const context = useContext(DrawerMetaContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Drawer title and description must be used within DrawerContent.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DrawerHeader({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('border-b border-[var(--border-default)] px-6 py-5', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function DrawerFooter({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('border-t border-[var(--border-default)] px-6 py-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const { titleId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<h2
|
||||
id={titleId}
|
||||
className={cn('text-xl font-semibold text-[var(--foreground-primary)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const { descriptionId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={cn('mt-2 text-sm leading-6 text-[var(--foreground-secondary)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.ComponentProps<'label'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type SwitchProps = Omit<React.ComponentProps<'button'>, 'onChange'> & {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
|
||||
const isControlled = checked !== undefined;
|
||||
const isChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const updateChecked = React.useCallback(
|
||||
(nextChecked: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
},
|
||||
[isControlled, onCheckedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isChecked}
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-[var(--control-background-hover)] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--brand-primary)]',
|
||||
className,
|
||||
)}
|
||||
onClick={() => updateChecked(!isChecked)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
updateChecked(!isChecked);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
className="pointer-events-none block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-5"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<'textarea'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,14 +1,23 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionItem,
|
||||
} from '@/features/config-versions/types';
|
||||
|
||||
export function getConfigVersions() {
|
||||
return apiRequest<ConfigVersionItem[]>('/config-versions/');
|
||||
}
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
} from '@/features/config-versions/types';
|
||||
|
||||
export function getConfigVersions() {
|
||||
return apiRequest<ConfigVersionSummary[]>('/config-versions/');
|
||||
}
|
||||
|
||||
export function getConfigVersion(id: number) {
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}`);
|
||||
}
|
||||
|
||||
export function getActiveConfigVersion() {
|
||||
return apiRequest<ConfigVersionDetail>('/config-versions/active');
|
||||
}
|
||||
|
||||
export function getConfigVersionPreview() {
|
||||
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
|
||||
@@ -18,14 +27,14 @@ export function getConfigVersionDiff() {
|
||||
return apiRequest<ConfigDiffResult>('/config-versions/diff');
|
||||
}
|
||||
|
||||
export function publishConfigVersion() {
|
||||
return apiRequest<ConfigVersionItem>('/config-versions/publish', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function activateConfigVersion(id: number) {
|
||||
return apiRequest<ConfigVersionItem>(`/config-versions/${id}/activate`, {
|
||||
export function publishConfigVersion() {
|
||||
return apiRequest<ConfigVersionDetail>('/config-versions/publish', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function activateConfigVersion(id: number) {
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
+39
-9
@@ -1,20 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import type { ConfigVersionItem } from '@/features/config-versions/types';
|
||||
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
|
||||
import type { ConfigVersionSummary } from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function ConfigVersionSnapshotModal({
|
||||
version,
|
||||
onClose,
|
||||
}: {
|
||||
version: ConfigVersionItem | null;
|
||||
version: ConfigVersionSummary | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const versionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'detail', version?.id ?? 0],
|
||||
queryFn: () => {
|
||||
if (!version) {
|
||||
throw new Error('missing config version');
|
||||
}
|
||||
return getConfigVersion(version.id);
|
||||
},
|
||||
enabled: Boolean(version?.id),
|
||||
});
|
||||
const versionDetail = versionDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(version)}
|
||||
@@ -30,7 +51,14 @@ export function ConfigVersionSnapshotModal({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{version ? (
|
||||
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
|
||||
<LoadingState />
|
||||
) : versionDetailQuery.isError ? (
|
||||
<ErrorState
|
||||
title="配置版本详情加载失败"
|
||||
description={getErrorMessage(versionDetailQuery.error)}
|
||||
/>
|
||||
) : versionDetail ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -38,7 +66,7 @@ export function ConfigVersionSnapshotModal({
|
||||
Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{version.checksum}
|
||||
{versionDetail.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -46,7 +74,7 @@ export function ConfigVersionSnapshotModal({
|
||||
创建人
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{version.created_by || '系统'}
|
||||
{versionDetail.created_by || '系统'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -54,7 +82,7 @@ export function ConfigVersionSnapshotModal({
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(version.created_at)}
|
||||
{formatDateTime(versionDetail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,23 +92,25 @@ export function ConfigVersionSnapshotModal({
|
||||
快照 JSON
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.snapshot_json}
|
||||
{versionDetail.snapshot_json}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.main_config}
|
||||
{versionDetail.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{version.rendered_config}
|
||||
{versionDetail.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
activateConfigVersion,
|
||||
getActiveConfigVersion,
|
||||
getConfigVersionDiff,
|
||||
getConfigVersionPreview,
|
||||
getConfigVersions,
|
||||
@@ -22,7 +23,8 @@ import type {
|
||||
ConfigOptionDiffItem,
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionItem,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
SupportFile,
|
||||
} from '@/features/config-versions/types';
|
||||
import {
|
||||
@@ -168,14 +170,20 @@ function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
|
||||
function PublishPreviewCard({
|
||||
preview,
|
||||
diff,
|
||||
activeVersion,
|
||||
activeVersionMeta,
|
||||
activeVersionDetail,
|
||||
isActiveVersionDetailLoading,
|
||||
activeVersionDetailError,
|
||||
isPublishing,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
activeVersion: ConfigVersionItem | null;
|
||||
activeVersionMeta: ConfigVersionSummary | null;
|
||||
activeVersionDetail: ConfigVersionDetail | null;
|
||||
isActiveVersionDetailLoading: boolean;
|
||||
activeVersionDetailError: string | null;
|
||||
isPublishing: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -263,18 +271,29 @@ function PublishPreviewCard({
|
||||
<OptionDiffTable items={diff.changed_option_details} />
|
||||
</div>
|
||||
|
||||
{diff.main_config_changed && activeVersion ? (
|
||||
{diff.main_config_changed && activeVersionMeta ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Current Active Main Config
|
||||
</p>
|
||||
<StatusBadge label={activeVersion.version} variant="info" />
|
||||
<StatusBadge label={activeVersionMeta.version} variant="info" />
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersion.main_config}
|
||||
</CodeBlock>
|
||||
{isActiveVersionDetailLoading ? (
|
||||
<LoadingState />
|
||||
) : activeVersionDetailError ? (
|
||||
<InlineMessage tone="danger" message={activeVersionDetailError} />
|
||||
) : activeVersionDetail ? (
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersionDetail.main_config}
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前激活版本详情暂不可用。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -406,6 +425,14 @@ export function ConfigVersionsPage() {
|
||||
() => versions.find((item) => item.id === selectedVersionId) ?? null,
|
||||
[selectedVersionId, versions],
|
||||
);
|
||||
const shouldLoadActiveVersionDetail = Boolean(
|
||||
activeVersion?.id && publishPreview?.diff.main_config_changed,
|
||||
);
|
||||
const activeVersionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
|
||||
queryFn: getActiveConfigVersion,
|
||||
enabled: shouldLoadActiveVersionDetail,
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: publishConfigVersion,
|
||||
@@ -455,7 +482,7 @@ export function ConfigVersionsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (version: ConfigVersionItem) => {
|
||||
const handleActivate = (version: ConfigVersionSummary) => {
|
||||
if (version.is_active) {
|
||||
return;
|
||||
}
|
||||
@@ -493,7 +520,14 @@ export function ConfigVersionsPage() {
|
||||
<PublishPreviewCard
|
||||
preview={publishPreview.preview}
|
||||
diff={publishPreview.diff}
|
||||
activeVersion={activeVersion}
|
||||
activeVersionMeta={activeVersion}
|
||||
activeVersionDetail={activeVersionDetailQuery.data ?? null}
|
||||
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
|
||||
activeVersionDetailError={
|
||||
activeVersionDetailQuery.isError
|
||||
? getErrorMessage(activeVersionDetailQuery.error)
|
||||
: null
|
||||
}
|
||||
isPublishing={publishMutation.isPending}
|
||||
onConfirm={() => publishMutation.mutate()}
|
||||
onCancel={() => setPublishPreview(null)}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
export interface ConfigVersionItem {
|
||||
export interface ConfigVersionSummary {
|
||||
id: number;
|
||||
version: string;
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
rendered_config: string;
|
||||
support_files_json: string;
|
||||
checksum: string;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigVersionDetail extends ConfigVersionSummary {
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
rendered_config: string;
|
||||
support_files_json: string;
|
||||
}
|
||||
|
||||
export interface SupportFile {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
@@ -1,38 +1,229 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type { DashboardOverview } from '@/features/dashboard/types';
|
||||
import type {
|
||||
CompactCapacityTrendPoint,
|
||||
CompactDashboardNodeHealth,
|
||||
CompactDiskIOTrendPoint,
|
||||
CompactDistributionItem,
|
||||
CompactNetworkTrendPoint,
|
||||
CompactTrafficTrendPoint,
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
DashboardOverview,
|
||||
DashboardOverviewCompact,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
|
||||
function arrayOrEmpty<T>(value: T[] | null | undefined) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function isCompactDistributionItem(
|
||||
value: DistributionItem | CompactDistributionItem,
|
||||
): value is CompactDistributionItem {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactTrafficTrendPoint(
|
||||
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint,
|
||||
): value is CompactTrafficTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactCapacityTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint,
|
||||
): value is CompactCapacityTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactNetworkTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint,
|
||||
): value is CompactNetworkTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDiskIOTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint,
|
||||
): value is CompactDiskIOTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDashboardNode(
|
||||
value: DashboardNodeHealth | CompactDashboardNodeHealth,
|
||||
): value is CompactDashboardNodeHealth {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeDistributionItems(
|
||||
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
|
||||
): DistributionItem[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDistributionItem(item)
|
||||
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTrafficTrendPoints(
|
||||
items:
|
||||
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactTrafficTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
request_count: Number(item[1] ?? 0),
|
||||
error_count: Number(item[2] ?? 0),
|
||||
unique_visitor_count: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapacityTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactCapacityTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
average_cpu_usage_percent: Number(item[1] ?? 0),
|
||||
average_memory_usage_percent: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNetworkTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactNetworkTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
network_rx_bytes: Number(item[1] ?? 0),
|
||||
network_tx_bytes: Number(item[2] ?? 0),
|
||||
openresty_rx_bytes: Number(item[3] ?? 0),
|
||||
openresty_tx_bytes: Number(item[4] ?? 0),
|
||||
reported_nodes: Number(item[5] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiskIOTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDiskIOTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
disk_read_bytes: Number(item[1] ?? 0),
|
||||
disk_write_bytes: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardNodes(
|
||||
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
|
||||
): DashboardNodeHealth[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDashboardNode(item)
|
||||
? {
|
||||
id: Number(item[0] ?? 0),
|
||||
node_id: String(item[1] ?? ''),
|
||||
name: String(item[2] ?? ''),
|
||||
geo_name: String(item[3] ?? ''),
|
||||
geo_latitude:
|
||||
item[4] === null || item[4] === undefined ? null : Number(item[4]),
|
||||
geo_longitude:
|
||||
item[5] === null || item[5] === undefined ? null : Number(item[5]),
|
||||
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
|
||||
openresty_status: (item[7] ??
|
||||
'unknown') as DashboardNodeHealth['openresty_status'],
|
||||
current_version: String(item[8] ?? ''),
|
||||
last_seen_at: String(item[9] ?? ''),
|
||||
active_event_count: Number(item[10] ?? 0),
|
||||
cpu_usage_percent: Number(item[11] ?? 0),
|
||||
memory_usage_percent: Number(item[12] ?? 0),
|
||||
storage_usage_percent: Number(item[13] ?? 0),
|
||||
request_count: Number(item[14] ?? 0),
|
||||
error_count: Number(item[15] ?? 0),
|
||||
unique_visitor_count: Number(item[16] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardOverview(
|
||||
overview: DashboardOverview | null | undefined,
|
||||
overview:
|
||||
| DashboardOverview
|
||||
| DashboardOverviewCompact
|
||||
| null
|
||||
| undefined,
|
||||
): DashboardOverview | null {
|
||||
if (!overview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = (overview.summary ?? {}) as DashboardSummary;
|
||||
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
|
||||
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
|
||||
|
||||
return {
|
||||
...overview,
|
||||
nodes: arrayOrEmpty(overview.nodes),
|
||||
generated_at: String(overview.generated_at ?? ''),
|
||||
summary,
|
||||
traffic,
|
||||
capacity,
|
||||
nodes: normalizeDashboardNodes(overview.nodes),
|
||||
distributions: {
|
||||
...overview.distributions,
|
||||
source_countries: arrayOrEmpty(overview.distributions?.source_countries),
|
||||
status_codes: arrayOrEmpty(overview.distributions?.status_codes),
|
||||
top_domains: arrayOrEmpty(overview.distributions?.top_domains),
|
||||
source_countries: normalizeDistributionItems(
|
||||
overview.distributions?.source_countries,
|
||||
),
|
||||
status_codes: normalizeDistributionItems(overview.distributions?.status_codes),
|
||||
top_domains: normalizeDistributionItems(overview.distributions?.top_domains),
|
||||
},
|
||||
trends: {
|
||||
...overview.trends,
|
||||
traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h),
|
||||
capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h),
|
||||
network_24h: arrayOrEmpty(overview.trends?.network_24h),
|
||||
disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h),
|
||||
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
||||
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
|
||||
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
|
||||
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardOverview() {
|
||||
const overview = await apiRequest<DashboardOverview>('/dashboard/overview');
|
||||
const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>(
|
||||
'/dashboard/overview',
|
||||
);
|
||||
return normalizeDashboardOverview(overview);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface DistributionItem {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type CompactDistributionItem = [string, number];
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
@@ -99,3 +101,53 @@ export interface DashboardOverview {
|
||||
trends: DashboardTrends;
|
||||
nodes: DashboardNodeHealth[];
|
||||
}
|
||||
|
||||
export type CompactTrafficTrendPoint = [string, number, number, number];
|
||||
export type CompactCapacityTrendPoint = [string, number, number, number];
|
||||
export type CompactNetworkTrendPoint = [
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
export type CompactDiskIOTrendPoint = [string, number, number, number];
|
||||
export type CompactDashboardNodeHealth = [
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number | null,
|
||||
number | null,
|
||||
DashboardNodeHealth['status'],
|
||||
DashboardNodeHealth['openresty_status'],
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
export interface DashboardOverviewCompact {
|
||||
generated_at: string;
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
distributions: {
|
||||
status_codes: CompactDistributionItem[];
|
||||
top_domains: CompactDistributionItem[];
|
||||
source_countries: CompactDistributionItem[];
|
||||
};
|
||||
trends: {
|
||||
traffic_24h: CompactTrafficTrendPoint[];
|
||||
capacity_24h: CompactCapacityTrendPoint[];
|
||||
network_24h: CompactNetworkTrendPoint[];
|
||||
disk_io_24h: CompactDiskIOTrendPoint[];
|
||||
};
|
||||
nodes: CompactDashboardNodeHealth[];
|
||||
}
|
||||
|
||||
@@ -85,3 +85,12 @@ export function getNodeObservability(
|
||||
`/nodes/${id}/observability${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanupNodeHealthEvents(id: number) {
|
||||
return apiRequest<{ node_id: string; deleted_count: number }>(
|
||||
`/nodes/${id}/observability/cleanup`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
|
||||
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
|
||||
import type { ConfigVersionItem } from '@/features/config-versions/types';
|
||||
import type { ConfigVersionSummary } from '@/features/config-versions/types';
|
||||
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
|
||||
import {
|
||||
cleanupNodeHealthEvents,
|
||||
deleteNode,
|
||||
getNodeAgentRelease,
|
||||
getNodeObservability,
|
||||
@@ -250,6 +251,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
const [healthEventFilter, setHealthEventFilter] =
|
||||
useState<HealthEventFilter>('all');
|
||||
const [activeTab, setActiveTab] = useState<NodeDetailTab>('dashboard');
|
||||
const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const nodesQuery = useQuery({
|
||||
queryKey: nodesQueryKey,
|
||||
@@ -373,6 +376,27 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
const cleanupHealthEventsMutation = useMutation({
|
||||
mutationFn: () => cleanupNodeHealthEvents(Number(nodeId)),
|
||||
onSuccess: async (result) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message:
|
||||
result.deleted_count > 0
|
||||
? `已清理 ${result.deleted_count} 条健康事件日志。`
|
||||
: '当前没有可清理的健康事件日志。',
|
||||
});
|
||||
setHealthEventCleanupModalOpen(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!node) {
|
||||
return;
|
||||
@@ -416,7 +440,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const activeConfigVersion = useMemo<ConfigVersionItem | null>(() => {
|
||||
const activeConfigVersion = useMemo<ConfigVersionSummary | null>(() => {
|
||||
return (
|
||||
(configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null
|
||||
);
|
||||
@@ -1215,6 +1239,20 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
<AppCard
|
||||
title="健康事件时间线"
|
||||
description="保留活动与已恢复事件,帮助判断问题是持续中、间歇性还是已经恢复。"
|
||||
action={
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={
|
||||
cleanupHealthEventsMutation.isPending ||
|
||||
!observability?.health_events.length
|
||||
}
|
||||
onClick={() => setHealthEventCleanupModalOpen(true)}
|
||||
>
|
||||
{cleanupHealthEventsMutation.isPending
|
||||
? '清理中...'
|
||||
: '清理日志'}
|
||||
</DangerButton>
|
||||
}
|
||||
>
|
||||
{observability?.health_events.length ? (
|
||||
<div className="space-y-4">
|
||||
@@ -1703,6 +1741,49 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
isOpen={isHealthEventCleanupModalOpen}
|
||||
onClose={() => setHealthEventCleanupModalOpen(false)}
|
||||
title="清理健康事件日志"
|
||||
description={
|
||||
node
|
||||
? `确认清理节点“${node.name}”的健康事件时间线吗?已清理的历史记录将立即从当前页面移除,后续只有新的节点上报才会再次出现。`
|
||||
: '确认清理当前节点的健康事件时间线吗?'
|
||||
}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setHealthEventCleanupModalOpen(false)}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={cleanupHealthEventsMutation.isPending}
|
||||
onClick={() => {
|
||||
setFeedback(null);
|
||||
cleanupHealthEventsMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{cleanupHealthEventsMutation.isPending ? '清理中...' : '确认清理'}
|
||||
</DangerButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{cleanupHealthEventsMutation.isError ? (
|
||||
<ErrorState
|
||||
title="健康事件清理失败"
|
||||
description={getErrorMessage(cleanupHealthEventsMutation.error)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。</p>
|
||||
<p>这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。</p>
|
||||
</div>
|
||||
)}
|
||||
</AppModal>
|
||||
|
||||
<AppModal
|
||||
isOpen={isAgentUpdateModalOpen}
|
||||
onClose={() => setIsAgentUpdateModalOpen(false)}
|
||||
|
||||
@@ -183,16 +183,17 @@ export interface NodeObservabilityTrends {
|
||||
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 NodeHealthEvent {
|
||||
event_type: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
message: string;
|
||||
metadata_json?: string;
|
||||
first_triggered_at: string;
|
||||
last_triggered_at: string;
|
||||
reported_at: string;
|
||||
resolved_at?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeObservability {
|
||||
node_id: string;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
OriginDetail,
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
} from '@/features/origins/types';
|
||||
|
||||
export function getOrigins() {
|
||||
return apiRequest<OriginItem[]>('/origins/');
|
||||
}
|
||||
|
||||
export function getOrigin(id: number) {
|
||||
return apiRequest<OriginDetail>(`/origins/${id}`);
|
||||
}
|
||||
|
||||
export function createOrigin(payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>('/origins/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrigin(id: number, payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>(`/origins/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOrigin(id: number) {
|
||||
return apiRequest<void>(`/origins/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { deleteOrigin, getOrigin } from '@/features/origins/api/origins';
|
||||
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
|
||||
import {
|
||||
DangerButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
export function OriginDetailPage({ originId }: { originId: string }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originQuery = useQuery({
|
||||
queryKey: ['origins', originId],
|
||||
queryFn: () => getOrigin(Number(originId)),
|
||||
enabled: originId !== '',
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
router.push('/origin');
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (originQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (originQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="源站详情加载失败"
|
||||
description={
|
||||
originQuery.error instanceof Error
|
||||
? originQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const origin = originQuery.data;
|
||||
if (!origin) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="源站不存在"
|
||||
description="该源站可能已被删除,或当前 ID 无法匹配到源站记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={origin.name}
|
||||
description="源站详情"
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
href="/origin"
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsEditorOpen(true)}
|
||||
>
|
||||
编辑源站
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除源站
|
||||
</DangerButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<AppCard title="源站地址">
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="绑定规则">
|
||||
<div className="space-y-3">
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={origin.route_count > 0 ? 'success' : 'warning'}
|
||||
/>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
编辑地址后,绑定规则的主源站地址会一起更新。
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="创建时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.created_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="更新时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.updated_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard title="备注">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="关联规则"
|
||||
description="展示当前源站作为主源站绑定的规则。"
|
||||
>
|
||||
{origin.routes.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>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<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)]">
|
||||
{origin.routes.map((route) => (
|
||||
<tr key={route.id}>
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{route.domain}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{route.origin_url}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
variant={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(route.updated_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={origin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={() => {
|
||||
setFeedback({ tone: 'success', message: '源站已更新。' });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['origins', origin.id],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { createOrigin, updateOrigin } from '@/features/origins/api/origins';
|
||||
import type {
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
} from '@/features/origins/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const originSchema = z.object({
|
||||
name: z.string().max(255, '源站名不能超过 255 个字符'),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => !/[/?#]/.test(value) && !value.includes('://'),
|
||||
'源站地址格式不合法',
|
||||
),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
type OriginFormValues = z.infer<typeof originSchema>;
|
||||
|
||||
function toPayload(values: OriginFormValues): OriginMutationPayload {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
address: values.address.trim(),
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function toFormValues(origin?: OriginItem | null): OriginFormValues {
|
||||
if (!origin) {
|
||||
return {
|
||||
name: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: origin.name,
|
||||
address: origin.address,
|
||||
remark: origin.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function OriginEditorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
origin,
|
||||
onSaved,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
origin?: OriginItem | null;
|
||||
onSaved?: (origin: OriginItem, mode: 'create' | 'update') => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<OriginFormValues>({
|
||||
resolver: zodResolver(originSchema),
|
||||
defaultValues: toFormValues(origin),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(toFormValues(origin));
|
||||
}, [form, origin, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OriginFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
return origin ? updateOrigin(origin.id, payload) : createOrigin(payload);
|
||||
},
|
||||
onSuccess: async (savedOrigin) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['origins'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
]);
|
||||
onSaved?.(savedOrigin, origin ? 'update' : 'create');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={origin ? '编辑源站' : '新增源站'}
|
||||
description="源站会作为规则里的可复用地址目录,协议和端口仍由规则决定。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="origin-editor-form"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? '保存中...'
|
||||
: origin
|
||||
? '保存修改'
|
||||
: '新增源站'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="origin-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="源站地址"
|
||||
hint="支持 IP、域名或主机名,例如 10.0.0.10、origin.internal。"
|
||||
error={form.formState.errors.address?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="origin.internal"
|
||||
{...form.register('address')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="源站名"
|
||||
hint="可选,留空时默认使用源站地址。"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput placeholder="主站源站" {...form.register('name')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:主站内网入口"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: '请求失败,请稍后重试。'}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { deleteOrigin, getOrigins } from '@/features/origins/api/origins';
|
||||
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
|
||||
import type { OriginItem } from '@/features/origins/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function OriginsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originsQuery = useQuery({
|
||||
queryKey: ['origins'],
|
||||
queryFn: getOrigins,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '源站已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
|
||||
|
||||
const handleDelete = (origin: OriginItem) => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="源站"
|
||||
description="集中维护规则复用的源站地址,减少批量改地址时的重复操作。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(null);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
新增源站
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="源站列表"
|
||||
description="编辑源站地址后,所有引用该源站的规则会同步更新。"
|
||||
>
|
||||
{originsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : originsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="源站列表加载失败"
|
||||
description={
|
||||
originsQuery.error instanceof Error
|
||||
? originsQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
) : origins.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无源站"
|
||||
description="点击右上角“新增源站”开始录入。后续规则可直接复用这些地址。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{origins.map((origin) => (
|
||||
<article
|
||||
key={origin.id}
|
||||
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{origin.name}
|
||||
</h2>
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={
|
||||
origin.route_count > 0 ? 'success' : 'warning'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
|
||||
最后更新:{formatDateTime(origin.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Link
|
||||
href={`/origin/detail?id=${origin.id}`}
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(origin);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(origin)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={editingOrigin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={(origin, mode) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: mode === 'create' ? '源站已创建。' : '源站已更新。',
|
||||
});
|
||||
setEditingOrigin(origin);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface OriginItem {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
route_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginRouteSummary {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_url: string;
|
||||
enabled: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginDetail extends OriginItem {
|
||||
routes: OriginRouteSummary[];
|
||||
}
|
||||
|
||||
export interface OriginMutationPayload {
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
}
|
||||
@@ -34,16 +34,16 @@ const defaultPerformanceFields = {
|
||||
OpenRestyWorkerProcesses: 'auto',
|
||||
OpenRestyWorkerConnections: '4096',
|
||||
OpenRestyWorkerRlimitNofile: '65535',
|
||||
OpenRestyEventsUse: '',
|
||||
OpenRestyEventsMultiAcceptEnabled: false,
|
||||
OpenRestyKeepaliveTimeout: '65',
|
||||
OpenRestyEventsUse: 'epoll',
|
||||
OpenRestyEventsMultiAcceptEnabled: true,
|
||||
OpenRestyKeepaliveTimeout: '20',
|
||||
OpenRestyKeepaliveRequests: '1000',
|
||||
OpenRestyClientHeaderTimeout: '15',
|
||||
OpenRestyClientBodyTimeout: '15',
|
||||
OpenRestyClientMaxBodySize: '64m',
|
||||
OpenRestyLargeClientHeaderBuffers: '4 16k',
|
||||
OpenRestySendTimeout: '30',
|
||||
OpenRestyProxyConnectTimeout: '5',
|
||||
OpenRestyProxyConnectTimeout: '3',
|
||||
OpenRestyProxySendTimeout: '60',
|
||||
OpenRestyProxyReadTimeout: '60',
|
||||
OpenRestyWebsocketEnabled: true,
|
||||
@@ -55,13 +55,12 @@ const defaultPerformanceFields = {
|
||||
OpenRestyGzipEnabled: true,
|
||||
OpenRestyGzipMinLength: '1024',
|
||||
OpenRestyGzipCompLevel: '5',
|
||||
OpenRestyResolvers: '',
|
||||
OpenRestyCacheEnabled: false,
|
||||
OpenRestyCachePath: '',
|
||||
OpenRestyCacheLevels: '1:2',
|
||||
OpenRestyCacheInactive: '30m',
|
||||
OpenRestyCacheMaxSize: '1g',
|
||||
OpenRestyCacheKeyTemplate: '$scheme$proxy_host$request_uri',
|
||||
OpenRestyCacheKeyTemplate: '$scheme$host$request_uri',
|
||||
OpenRestyCacheLockEnabled: true,
|
||||
OpenRestyCacheLockTimeout: '5s',
|
||||
OpenRestyCacheUseStale:
|
||||
@@ -76,9 +75,9 @@ const performanceFieldTooltips: Record<string, string> = {
|
||||
worker_rlimit_nofile:
|
||||
'提升 worker 可打开的文件描述符上限,避免高并发下连接或文件句柄不足。',
|
||||
events_use:
|
||||
'指定事件驱动模型。Linux 常见是 epoll,留空时由 OpenResty 自动选择。',
|
||||
'指定事件驱动模型。默认使用 epoll,Linux 高并发场景通常优先选择它。',
|
||||
multi_accept:
|
||||
'开启后,worker 会尽可能一次接受多个新连接,适合高吞吐接入场景。',
|
||||
'默认开启。worker 会尽可能一次接受多个新连接,适合高吞吐接入场景。',
|
||||
keepalive_timeout: '客户端 Keep-Alive 空闲保持时间,单位秒。',
|
||||
keepalive_requests: '单个长连接允许复用的最大请求数。',
|
||||
client_header_timeout: '读取客户端请求头的超时时间,单位秒。',
|
||||
@@ -104,8 +103,6 @@ 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。',
|
||||
@@ -206,12 +203,12 @@ export function PerformancePage() {
|
||||
optionMap.OpenRestyWorkerConnections ?? '4096',
|
||||
OpenRestyWorkerRlimitNofile:
|
||||
optionMap.OpenRestyWorkerRlimitNofile ?? '65535',
|
||||
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? '',
|
||||
OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? 'epoll',
|
||||
OpenRestyEventsMultiAcceptEnabled: toBoolean(
|
||||
optionMap.OpenRestyEventsMultiAcceptEnabled,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '65',
|
||||
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '20',
|
||||
OpenRestyKeepaliveRequests:
|
||||
optionMap.OpenRestyKeepaliveRequests ?? '1000',
|
||||
OpenRestyClientHeaderTimeout:
|
||||
@@ -222,7 +219,7 @@ export function PerformancePage() {
|
||||
optionMap.OpenRestyLargeClientHeaderBuffers ?? '4 16k',
|
||||
OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30',
|
||||
OpenRestyProxyConnectTimeout:
|
||||
optionMap.OpenRestyProxyConnectTimeout ?? '5',
|
||||
optionMap.OpenRestyProxyConnectTimeout ?? '3',
|
||||
OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60',
|
||||
OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60',
|
||||
OpenRestyWebsocketEnabled: toBoolean(
|
||||
@@ -244,14 +241,13 @@ 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',
|
||||
OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? '30m',
|
||||
OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? '1g',
|
||||
OpenRestyCacheKeyTemplate:
|
||||
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$proxy_host$request_uri',
|
||||
optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$host$request_uri',
|
||||
OpenRestyCacheLockEnabled: toBoolean(
|
||||
optionMap.OpenRestyCacheLockEnabled,
|
||||
true,
|
||||
@@ -424,7 +420,6 @@ export function PerformancePage() {
|
||||
'OpenRestyProxyReadTimeout',
|
||||
performanceFields.OpenRestyProxyReadTimeout.trim(),
|
||||
],
|
||||
['OpenRestyResolvers', performanceFields.OpenRestyResolvers.trim()],
|
||||
[
|
||||
'OpenRestyWebsocketEnabled',
|
||||
String(performanceFields.OpenRestyWebsocketEnabled),
|
||||
@@ -943,23 +938,6 @@ 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}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,66 @@
|
||||
export interface ProxyRouteCustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
redirect_http: boolean;
|
||||
custom_headers: string;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
domain: string;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
redirect_http: boolean;
|
||||
custom_headers: ProxyRouteCustomHeader[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard';
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
}
|
||||
export interface ProxyRouteCustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
upstreams: string;
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
redirect_http: boolean;
|
||||
cache_enabled: boolean;
|
||||
cache_policy: string;
|
||||
cache_rules: string;
|
||||
custom_headers: string;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
domain: string;
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_scheme: 'http' | 'https';
|
||||
origin_address: string;
|
||||
origin_port: string;
|
||||
origin_uri: string;
|
||||
origin_host: string;
|
||||
upstreams: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
redirect_http: boolean;
|
||||
cache_enabled: boolean;
|
||||
cache_policy: string;
|
||||
cache_rules: string[];
|
||||
custom_headers: ProxyRouteCustomHeader[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard';
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ManagedDomainItem } from '@/features/managed-domains/types';
|
||||
|
||||
export function isWildcardManagedDomain(domain: string) {
|
||||
return domain.startsWith('*.');
|
||||
}
|
||||
|
||||
export function buildRouteDomain(
|
||||
managedDomain: string | undefined,
|
||||
subdomainLabel: string,
|
||||
) {
|
||||
if (!managedDomain) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!isWildcardManagedDomain(managedDomain)) {
|
||||
return managedDomain.toLowerCase();
|
||||
}
|
||||
|
||||
const normalizedLabel = subdomainLabel.trim().toLowerCase();
|
||||
if (!normalizedLabel) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${normalizedLabel}.${managedDomain.slice(2).toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function findManagedDomainForRoute(
|
||||
routeDomain: string,
|
||||
managedDomains: ManagedDomainItem[],
|
||||
) {
|
||||
const normalizedRouteDomain = routeDomain.trim().toLowerCase();
|
||||
const exactMatch = managedDomains.find(
|
||||
(item) => item.domain.toLowerCase() === normalizedRouteDomain,
|
||||
);
|
||||
|
||||
if (exactMatch) {
|
||||
return {
|
||||
managedDomainId: exactMatch.domain,
|
||||
subdomainLabel: '',
|
||||
};
|
||||
}
|
||||
|
||||
const wildcardMatch = managedDomains.find((item) => {
|
||||
if (!isWildcardManagedDomain(item.domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const suffix = item.domain.slice(2).toLowerCase();
|
||||
const suffixWithDot = `.${suffix}`;
|
||||
if (!normalizedRouteDomain.endsWith(suffixWithDot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const label = normalizedRouteDomain.slice(
|
||||
0,
|
||||
normalizedRouteDomain.length - suffixWithDot.length,
|
||||
);
|
||||
|
||||
return Boolean(label) && !label.includes('.');
|
||||
});
|
||||
|
||||
if (!wildcardMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
managedDomainId: wildcardMatch.domain,
|
||||
subdomainLabel: normalizedRouteDomain.slice(
|
||||
0,
|
||||
normalizedRouteDomain.length - wildcardMatch.domain.slice(1).length,
|
||||
),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user